commit dbb6302bd1668e865d8de521c96d3bbe4a7e595c Author: meowarex Date: Wed May 20 19:47:33 2026 +1000 Alpha diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2a6eac8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,40 @@ +name: Build Debug App + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: zulu + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.event_name == 'pull_request' }} + + - name: Build with Gradle + working-directory: Manager + run: ./gradlew :app:assembleDebug --stacktrace + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: app + if-no-files-found: error + path: Manager/app/build/outputs/apk/debug/app-debug.apk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4488dcd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +name: Build & Publish Release App + +on: + push: + tags: [ "v*.*.*" ] + +concurrency: + group: "release" + cancel-in-progress: true + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: zulu + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-write-only: true + + - name: Build with Gradle + working-directory: Manager + env: + SIGNING_KEY_ALIAS: ${{ secrets.keyAlias }} + SIGNING_KEY_PASSWORD: ${{ secrets.keyPassword }} + SIGNING_STORE_PASSWORD: ${{ secrets.keystorePassword }} + SIGNING_STORE_FILE: ${{ github.workspace }}/release.keystore + RELEASE: true + run: | + echo "${{ secrets.keystore }}" | base64 -d > ${{ github.workspace }}/release.keystore + ./gradlew :app:packageRelease --stacktrace + rm ${{ github.workspace }}/release.keystore + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: app + if-no-files-found: error + path: Manager/app/build/outputs/apk/release/app-release.apk + + - name: Publish Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${{ github.ref_name }}" + apk_file="rl-mobile-manager-$tag.apk" + mv -T ./Manager/app/build/outputs/apk/release/app-release.apk "./$apk_file" + + gh release create "$tag" \ + --title "$tag" \ + --generate-notes \ + --verify-tag \ + --fail-on-no-commits \ + "./$apk_file" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0100dcc --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Gradle +.gradle/ +build/ +**/build/ +*.apk +*.aab +*.dex + +# Android +local.properties +Manager/app/release/ + +# IDE +.idea/ +*.iml +.DS_Store + +# Keys / secrets +*.keystore +*.jks + +# Reference material (APKs, original source) +Reference/ diff --git a/Manager/.editorconfig b/Manager/.editorconfig new file mode 100644 index 0000000..6735892 --- /dev/null +++ b/Manager/.editorconfig @@ -0,0 +1,46 @@ +# Android Studio: Settings > Editor > Code Style > Enable EditorConfig support + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +max_line_length = 150 +trim_trailing_whitespace = true +insert_final_newline = true +indent_size = 4 +ij_continuation_indent_size = 4 + +[*.java] +ij_java_names_count_to_use_import_on_demand = 3 +ij_java_class_count_to_use_import_on_demand = 3 +ij_java_blank_lines_around_method_in_interface = 0 +ij_java_doc_do_not_wrap_if_one_line = true +ij_java_keep_first_column_comment = false +ij_java_keep_simple_blocks_in_one_line = true +ij_java_keep_simple_classes_in_one_line = true +ij_java_keep_simple_lambdas_in_one_line = true +ij_java_keep_simple_methods_in_one_line = true +ij_java_line_comment_add_space = true +ij_java_line_comment_at_first_column = false +ij_java_spaces_within_array_initializer_braces = true +ij_kotlin_call_parameters_new_line_after_left_paren = false +ij_kotlin_call_parameters_right_paren_on_new_line = false + +[*.kt] +ij_kotlin_name_count_to_use_star_import = 3 +ij_kotlin_name_count_to_use_star_import_for_members = 3 +ij_kotlin_keep_first_column_comment = false +ij_kotlin_line_comment_add_space = true +ij_kotlin_line_comment_at_first_column = false +ij_kotlin_allow_trailing_comma = true + +[*.{xml,yml}] +indent_size = 2 + +[*.properties] +ij_properties_align_group_field_declarations = false +ij_properties_keep_blank_lines = true +ij_properties_key_value_delimiter = equals +ij_properties_spaces_around_key_value_delimiter = false diff --git a/Manager/.gitattributes b/Manager/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/Manager/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/Manager/.github/FUNDING.yml b/Manager/.github/FUNDING.yml new file mode 100644 index 0000000..10e345a --- /dev/null +++ b/Manager/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [ "rushiiMachine" ] diff --git a/Manager/.github/ISSUE_TEMPLATE/blank_issue.yml b/Manager/.github/ISSUE_TEMPLATE/blank_issue.yml new file mode 100644 index 0000000..eb79cce --- /dev/null +++ b/Manager/.github/ISSUE_TEMPLATE/blank_issue.yml @@ -0,0 +1,20 @@ +name: Blank Template +description: Use this template ONLY IF the other templates do not fit! +labels: [] +body: + - type: textarea + id: info-sec + attributes: + label: Tell us all about it. + description: Go nuts, let us know what you're wanting to bring attention to. + placeholder: ... + validations: + required: true + - type: checkboxes + id: agreement-check + attributes: + label: Request Agreement + description: Did you check to make sure this issue you're bringing forward has not already been mentioned? + options: + - label: I did indeed check to make sure the issue is original! + required: true diff --git a/Manager/.github/ISSUE_TEMPLATE/bug_report.yml b/Manager/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..167c909 --- /dev/null +++ b/Manager/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,81 @@ +name: Bug/Crash Report +description: Report a bug or crash in Radiant Lyrics Manager. For issues with Radiant Lyrics or plugins, please use the plugin's repo or our support server. +labels: [bug] +body: + - type: textarea + id: bug-description + attributes: + label: What’s the issue? + description: Describe what happens, when it happens, and where. + placeholder: | + Example: + When I open the settings page and tap on "Plugins", the app crashes with no warning. + validations: + required: true + + - type: textarea + id: expected-behaviour + attributes: + label: What did you expect to happen? + description: Describe the correct or expected behavior. + placeholder: | + Example: + I expected the "Plugins" page to open without crashing. + validations: + required: true + + - type: textarea + id: reproduction-steps + attributes: + label: How can we reproduce it? + description: Provide step-by-step instructions. + placeholder: | + Steps: + 1. Go to Settings + 2. Tap on "Plugins" + 3. App crashes immediately + validations: + required: true + + - type: input + id: android-version + attributes: + label: Android Version + description: What Android version is this happening on? + placeholder: Android 13, or SDK 33 + validations: + required: true + + - type: input + id: device-info + attributes: + label: Device & OS Info + description: Device model and ROM/skin (e.g. One UI, LineageOS, Pixel Experience). + placeholder: Samsung Galaxy S21, One UI 5.1 + validations: + required: true + + - type: textarea + id: install-log + attributes: + label: Installation failure log (if applicable) + description: The log for a failed installation. This can be obtained by going to the Home screen -> Log icon in top right -> most recent log -> save. Paste the log here (between triple backticks ```). + placeholder: | + ``` + Paste your installation log here + ``` + validations: + required: false + + - type: checkboxes + id: agreement-check + attributes: + label: Before Submitting + description: "Please confirm the following:" + options: + - label: I’ve checked that this issue hasn’t already been reported. + required: true + + - type: markdown + attributes: + value: After creating this issue, please upload a file containing a logcat recording your crash in a separate comment! See [here](https://pastebin.com/pNhXwhrd) on how to accomplish this. diff --git a/Manager/.github/ISSUE_TEMPLATE/config.yml b/Manager/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..2bccfc3 --- /dev/null +++ b/Manager/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Radiant Lyrics Support Server + url: https://tidal.gg/EsNDvBaHVU + about: If you need help regarding Radiant Lyrics or plugins for Radiant Lyrics, please join our support server. diff --git a/Manager/.github/ISSUE_TEMPLATE/feature_request.yml b/Manager/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..86037c3 --- /dev/null +++ b/Manager/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,28 @@ +name: Feature Request +description: Create a feature or backport request to be built into Radiant Lyrics Manager. +labels: [enhancement] +body: + - type: textarea + id: feature-basic-description + attributes: + label: What is it that you'd like to see? + description: Also, you'd like to, give us a bit more information on what you'd like this feature to do and/or how you want it to work. + placeholder: I think ... would be a cool feature to add. This would be awesome, thanks! + validations: + required: true + - type: textarea + id: feature-verbose-description + attributes: + label: Go into more detail... + description: If you want to, you can be more descriptive about your request here. Not required. + placeholder: This would be really awesome, but I think if you would do it this way, by doing ..., it'd be cooler. + validations: + required: false + - type: checkboxes + id: agreement-check + attributes: + label: Request Agreement + description: Did you check to make sure your feature has not already been requested? + options: + - label: I did indeed check to make sure my feature request is original! + required: true diff --git a/Manager/.github/assets/manager-preview.png b/Manager/.github/assets/manager-preview.png new file mode 100644 index 0000000..3597c20 Binary files /dev/null and b/Manager/.github/assets/manager-preview.png differ diff --git a/Manager/.github/assets/manager-preview.psd b/Manager/.github/assets/manager-preview.psd new file mode 100644 index 0000000..23d0946 Binary files /dev/null and b/Manager/.github/assets/manager-preview.psd differ diff --git a/Manager/.github/workflows/build.yml b/Manager/.github/workflows/build.yml new file mode 100644 index 0000000..0edb0dc --- /dev/null +++ b/Manager/.github/workflows/build.yml @@ -0,0 +1,39 @@ +name: Build Debug App + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: zulu + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.event_name == 'pull_request' }} + + - name: Build with Gradle + run: ./gradlew :app:assembleDebug :app:assembleStaging --stacktrace + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: app + if-no-files-found: error + path: | + app/build/outputs/apk/debug/app-debug.apk + app/build/outputs/apk/staging/app-staging.apk diff --git a/Manager/.github/workflows/crowdin.yml b/Manager/.github/workflows/crowdin.yml new file mode 100644 index 0000000..51b6091 --- /dev/null +++ b/Manager/.github/workflows/crowdin.yml @@ -0,0 +1,32 @@ +name: Sync Crowdin + +on: + schedule: + - cron: "0 17 * * 6" # "At 17:00 on Saturday." + workflow_dispatch: + +permissions: + contents: write + +jobs: + sync-crowdin: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Crowdin + uses: crowdin/github-action@v2 + with: + config: crowdin.yml + upload_sources: true + upload_translations: true + download_translations: true + push_translations: true + localization_branch_name: main + create_pull_request: false + commit_message: 'chore(l10n): sync translations' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_TOKEN }} diff --git a/Manager/.github/workflows/release.yml b/Manager/.github/workflows/release.yml new file mode 100644 index 0000000..701ea2c --- /dev/null +++ b/Manager/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Build & Publish Release App + +on: + push: + tags: [ "v*.*.*" ] + +concurrency: + group: "release" + cancel-in-progress: true + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: zulu + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-write-only: true + + - name: Build with Gradle + env: + SIGNING_KEY_ALIAS: ${{ secrets.keyAlias }} + SIGNING_KEY_PASSWORD: ${{ secrets.keyPassword }} + SIGNING_STORE_PASSWORD: ${{ secrets.keystorePassword }} + SIGNING_STORE_FILE: ${{ github.workspace }}/release.keystore + RELEASE: true + run: | + echo "${{ secrets.keystore }}" | base64 -d > release.keystore + ./gradlew :app:packageRelease --stacktrace + rm release.keystore + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: app + if-no-files-found: error + path: app/build/outputs/apk/release/app-release.apk + + - name: Publish Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${{ github.ref_name }}" + apk_file="radiant-lyrics-manager-$tag.apk" + mv -T ./app/build/outputs/apk/release/app-release.apk "./$apk_file" + + gh release create "$tag" \ + --title "$tag" \ + --generate-notes \ + --verify-tag \ + --fail-on-no-commits \ + "./$apk_file" diff --git a/Manager/.github/workflows/wrapper-validation.yml b/Manager/.github/workflows/wrapper-validation.yml new file mode 100644 index 0000000..06880cc --- /dev/null +++ b/Manager/.github/workflows/wrapper-validation.yml @@ -0,0 +1,20 @@ +name: "Validate Gradle Wrapper" + +on: + workflow_dispatch: + push: + paths: + - gradle/wrapper/** + - gradlew* + pull_request: + paths: + - gradle/wrapper/** + - gradlew* + +jobs: + validation: + name: "Validate Authenticity of Gradle Wrapper" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: gradle/actions/wrapper-validation@v4 diff --git a/Manager/.gitignore b/Manager/.gitignore new file mode 100644 index 0000000..7d85821 --- /dev/null +++ b/Manager/.gitignore @@ -0,0 +1,10 @@ +.idea +.gradle +.kotlin + +/**/build/ +/local.properties +/captures + +.DS_Store +*.log diff --git a/Manager/LICENSE b/Manager/LICENSE new file mode 100644 index 0000000..95230e3 --- /dev/null +++ b/Manager/LICENSE @@ -0,0 +1,172 @@ +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of +authorship (the "Original Work") whose owner (the "Licensor") has placed the +following licensing notice adjacent to the copyright notice for the Original +Work: + +Licensed under the Open Software License version 3.0 + +1) Grant of Copyright License. Licensor grants You a worldwide, royalty-free, +non-exclusive, sublicensable license, for the duration of the copyright, to do +the following: + + a) to reproduce the Original Work in copies, either alone or as part of a + collective work; + + b) to translate, adapt, alter, transform, modify, or arrange the Original + Work, thereby creating derivative works ("Derivative Works") based upon the + Original Work; + + c) to distribute or communicate copies of the Original Work and Derivative + Works to the public, with the proviso that copies of Original Work or + Derivative Works that You distribute or communicate shall be licensed under + this Open Software License; + + d) to perform the Original Work publicly; and + + e) to display the Original Work publicly. + +2) Grant of Patent License. Licensor grants You a worldwide, royalty-free, +non-exclusive, sublicensable license, under patent claims owned or controlled +by the Licensor that are embodied in the Original Work as furnished by the +Licensor, for the duration of the patents, to make, use, sell, offer for sale, +have made, and import the Original Work and Derivative Works. + +3) Grant of Source Code License. The term "Source Code" means the preferred +form of the Original Work for making modifications to it and all available +documentation describing how to modify the Original Work. Licensor agrees to +provide a machine-readable copy of the Source Code of the Original Work along +with each copy of the Original Work that Licensor distributes. Licensor +reserves the right to satisfy this obligation by placing a machine-readable +copy of the Source Code in an information repository reasonably calculated to +permit inexpensive and convenient access by You for as long as Licensor +continues to distribute the Original Work. + +4) Exclusions From License Grant. Neither the names of Licensor, nor the names +of any contributors to the Original Work, nor any of their trademarks or +service marks, may be used to endorse or promote products derived from this +Original Work without express prior permission of the Licensor. Except as +expressly stated herein, nothing in this License grants any license to +Licensor's trademarks, copyrights, patents, trade secrets or any other +intellectual property. No patent license is granted to make, use, sell, offer +for sale, have made, or import embodiments of any patent claims other than the +licensed claims defined in Section 2. No license is granted to the trademarks +of Licensor even if such marks are included in the Original Work. Nothing in +this License shall be interpreted to prohibit Licensor from licensing under +terms different from this License any Original Work that Licensor otherwise +would have a right to license. + +5) External Deployment. The term "External Deployment" means the use, +distribution, or communication of the Original Work or Derivative Works in any +way such that the Original Work or Derivative Works may be used by anyone +other than You, whether those works are distributed or communicated to those +persons or made available as an application intended for use over a network. +As an express condition for the grants of license hereunder, You must treat +any External Deployment by You of the Original Work or a Derivative Work as a +distribution under section 1(c). + +6) Attribution Rights. You must retain, in the Source Code of any Derivative +Works that You create, all copyright, patent, or trademark notices from the +Source Code of the Original Work, as well as any notices of licensing and any +descriptive text identified therein as an "Attribution Notice." You must cause +the Source Code for any Derivative Works that You create to carry a prominent +Attribution Notice reasonably calculated to inform recipients that You have +modified the Original Work. + +7) Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that +the copyright in and to the Original Work and the patent rights granted herein +by Licensor are owned by the Licensor or are sublicensed to You under the +terms of this License with the permission of the contributor(s) of those +copyrights and patent rights. Except as expressly stated in the immediately +preceding sentence, the Original Work is provided under this License on an "AS +IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without +limitation, the warranties of non-infringement, merchantability or fitness for +a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK +IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this +License. No license to the Original Work is granted by this License except +under this disclaimer. + +8) Limitation of Liability. Under no circumstances and under no legal theory, +whether in tort (including negligence), contract, or otherwise, shall the +Licensor be liable to anyone for any indirect, special, incidental, or +consequential damages of any character arising as a result of this License or +the use of the Original Work including, without limitation, damages for loss +of goodwill, work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses. This limitation of liability shall not +apply to the extent applicable law prohibits such limitation. + +9) Acceptance and Termination. If, at any time, You expressly assented to this +License, that assent indicates your clear and irrevocable acceptance of this +License and all of its terms and conditions. If You distribute or communicate +copies of the Original Work or a Derivative Work, You must make a reasonable +effort under the circumstances to obtain the express assent of recipients to +the terms of this License. This License conditions your rights to undertake +the activities listed in Section 1, including your right to create Derivative +Works based upon the Original Work, and doing so without honoring these terms +and conditions is prohibited by copyright law and international treaty. +Nothing in this License is intended to affect copyright exceptions and +limitations (including "fair use" or "fair dealing"). This License shall +terminate immediately and You may no longer exercise any of the rights granted +to You by this License upon your failure to honor the conditions in Section +1(c). + +10) Termination for Patent Action. This License shall terminate automatically +and You may no longer exercise any of the rights granted to You by this +License as of the date You commence an action, including a cross-claim or +counterclaim, against Licensor or any licensee alleging that the Original Work +infringes a patent. This termination provision shall not apply for an action +alleging patent infringement by combinations of the Original Work with other +software or hardware. + +11) Jurisdiction, Venue and Governing Law. Any action or suit relating to this +License may be brought only in the courts of a jurisdiction wherein the +Licensor resides or in which Licensor conducts its primary business, and under +the laws of that jurisdiction excluding its conflict-of-law provisions. The +application of the United Nations Convention on Contracts for the +International Sale of Goods is expressly excluded. Any use of the Original +Work outside the scope of this License or after its termination shall be +subject to the requirements and penalties of copyright or patent law in the +appropriate jurisdiction. This section shall survive the termination of this +License. + +12) Attorneys' Fees. In any action to enforce the terms of this License or +seeking damages relating thereto, the prevailing party shall be entitled to +recover its costs and expenses, including, without limitation, reasonable +attorneys' fees and costs incurred in connection with such action, including +any appeal of such action. This section shall survive the termination of this +License. + +13) Miscellaneous. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent necessary +to make it enforceable. + +14) Definition of "You" in This License. "You" throughout this License, +whether in upper or lower case, means an individual or a legal entity +exercising rights under, and complying with all of the terms of, this License. +For legal entities, "You" includes any entity that controls, is controlled by, +or is under common control with you. For purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the direction or +management of such entity, whether by contract or otherwise, or (ii) ownership +of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial +ownership of such entity. + +15) Right to Use. You may use the Original Work in all ways not otherwise +restricted or conditioned by this License or by law, and Licensor promises not +to interfere with or be responsible for such uses by You. + +16) Modification of This License. This License is Copyright © 2005 Lawrence +Rosen. Permission is granted to copy, distribute, or communicate this License +without modification. Nothing in this License permits You to modify this +License as applied to the Original Work or to Derivative Works. However, You +may modify the text of this License and copy, distribute or communicate your +modified version (the "Modified License") and apply it to other original works +of authorship subject to the following conditions: (i) You may not indicate in +any way that your Modified License is the "Open Software License" or "OSL" and +you may not use those names in the name of your Modified License; (ii) You +must replace the notice specified in the first paragraph above with the notice +"Licensed under " or with a notice of your own +that is not confusingly similar to the notice in this License; and (iii) You +may not claim that your original works are open source software unless your +Modified License has been approved by Open Source Initiative (OSI) and You +comply with its license review and certification process. diff --git a/Manager/app/build.gradle.kts b/Manager/app/build.gradle.kts new file mode 100644 index 0000000..e305f0a --- /dev/null +++ b/Manager/app/build.gradle.kts @@ -0,0 +1,227 @@ +@file:Suppress("UnstableApiUsage") + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.hiddenApi.refine) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) +} + +val isRelease = System.getenv("RELEASE")?.toBoolean() ?: false +val gitCurrentBranch = providers.execIgnoreCode("git", "symbolic-ref", "--quiet", "--short", "HEAD").takeIf { it.isNotEmpty() } +val gitLatestCommit = providers.execIgnoreCode("git", "rev-parse", "--short", "HEAD") +val gitHasLocalCommits = gitCurrentBranch?.let { branch -> + val remoteBranchExists = providers.execIgnoreCode("git", "ls-remote", "--heads", "origin", branch) + .isNotEmpty() + + remoteBranchExists && providers.execIgnoreCode("git", "log", "origin/$branch..HEAD").isNotEmpty() +} ?: false +val gitHasHasLocalChanges = providers.execIgnoreCode("git", "status", "-s").isNotEmpty() + +android { + namespace = "com.meowarex.rlmobile" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + targetSdk = 36 + versionCode = 10_03_00 + versionName = "1.3.0" + + vectorDrawables { + useSupportLibrary = true + } + + buildConfigField("String", "TAG", "\"RLMobileManager\"") + buildConfigField("String", "SUPPORT_SERVER", "\"\"") // no support server yet + + buildConfigField("String", "PATCHES_REPO_OWNER", "\"meowarex\"") + buildConfigField("String", "PATCHES_REPO_NAME", "\"rl-mobile\"") + + buildConfigField("Boolean", "RELEASE", isRelease.toString()) + buildConfigField("String", "GIT_BRANCH", "\"$gitCurrentBranch\"") + buildConfigField("String", "GIT_COMMIT", "\"$gitLatestCommit\"") + buildConfigField("boolean", "GIT_LOCAL_COMMITS", "$gitHasLocalCommits") + buildConfigField("boolean", "GIT_LOCAL_CHANGES", "$gitHasHasLocalChanges") + } + + signingConfigs { + create("release") { + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true + enableV4Signing = true + keyAlias = System.getenv("SIGNING_KEY_ALIAS") + keyPassword = System.getenv("SIGNING_KEY_PASSWORD") + storeFile = System.getenv("SIGNING_STORE_FILE")?.let(::File) + storePassword = System.getenv("SIGNING_STORE_PASSWORD") + } + } + + buildTypes { + val isRelease = System.getenv("RELEASE")?.toBoolean() ?: false + val hasReleaseSigning = System.getenv("SIGNING_STORE_PASSWORD")?.isNotEmpty() == true + + if (isRelease && !hasReleaseSigning) + error("Missing keystore in a release workflow!") + + release { + isMinifyEnabled = true + isShrinkResources = true + isCrunchPngs = true + signingConfig = signingConfigs.getByName(if (hasReleaseSigning) "release" else "debug") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + + create("staging") { + isMinifyEnabled = true + isShrinkResources = true + isCrunchPngs = true + signingConfig = signingConfigs.getByName("debug") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + androidComponents { + onVariants(selector().withBuildType("release")) { + it.packaging.resources.excludes.apply { + // Debug metadata + add("/**/*.version") + add("/kotlin-tooling-metadata.json") + // Kotlin debugging (https://github.com/Kotlin/kotlinx.coroutines/issues/2274) + add("/DebugProbesKt.bin") + // Reflection symbol list (https://stackoverflow.com/a/41073782/13964629) + add("/**/*.kotlin_builtins") + } + } + } + + packaging { + resources { + // okhttp3 is used by some lib (no cookies so publicsuffixes.gz can be dropped) + excludes += "/okhttp3/**" + + // Remnants of smali/baksmali lib + excludes += "/*.properties" + excludes += "/org/antlr/**" + excludes += "/com/android/tools/smali/**" + excludes += "/org/eclipse/jgit/**" + + // bouncycastle + excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF" + excludes += "/org/bouncycastle/**" + } + jniLibs { + // x86 is dead + excludes += "/lib/x86/*.so" + + // Equivalent of AndroidManifest's extractNativeLibs=false + useLegacyPackaging = false + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + buildFeatures { + buildConfig = true + compose = true + } + + androidResources { + generateLocaleConfig = true + } + + lint { + disable += "ModifierParameter" + disable += "ExtraTranslation" + } +} + +composeCompiler { + // Temporary workaround for https://youtrack.jetbrains.com/projects/KT/issues/KT-83266/ + // Remove once updated to Kotlin 2.3.10 + includeComposeMappingFile.set(false) +} + +kotlin { + compilerOptions { + val reportsDir = layout.buildDirectory.asFile.get() + .resolve("reports").absolutePath + + jvmTarget = JvmTarget.JVM_21 + optIn.addAll( + "androidx.compose.animation.ExperimentalAnimationApi", + "androidx.compose.foundation.ExperimentalFoundationApi", + "androidx.compose.foundation.layout.ExperimentalLayoutApi", + "androidx.compose.material3.ExperimentalMaterial3Api", + "kotlin.time.ExperimentalTime", + "kotlinx.serialization.ExperimentalSerializationApi", + ) + freeCompilerArgs.addAll( + "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${reportsDir}", + "-XXLanguage:+ExplicitBackingFields", + "-XXLanguage:+PropertyParamAnnotationDefaultTargetMode", // @StringRes in field parameters of a class warning + ) + } +} + +tasks.withType { + // Disable warnings about obsolete target version + options.compilerArgs.add("-Xlint:-options") +} + +dependencies { + implementation(libs.bundles.accompanist) + implementation(libs.bundles.androidx) + implementation(libs.bundles.coil) + implementation(libs.bundles.compose) + implementation(libs.bundles.koin) + implementation(libs.bundles.ktor) + implementation(libs.bundles.shizuku) + implementation(libs.bundles.voyager) + + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.runtime.tracing) + + implementation(libs.kotlinx.immutable) + implementation(libs.kotlinx.serialization.json) + + compileOnly(libs.hiddenApi.stub) + implementation(libs.hiddenApi.refine) + implementation(libs.hiddenApi.bypass) + + implementation(libs.apksig) + implementation(libs.axml) + implementation(libs.bouncycastle) + implementation(libs.binaryResources) + implementation(libs.dhizuku.api) + implementation(libs.diff) + implementation(libs.microg) + implementation(libs.smali) + implementation(libs.baksmali) + implementation(libs.compose.pipette) + implementation(libs.compose.shimmer) + implementation(libs.libsu) + implementation(libs.zip) + + coreLibraryDesugaring(libs.desugaring) +} + +fun ProviderFactory.execIgnoreCode(vararg command: String): String = try { + val result = exec { + commandLine = command.toList() + isIgnoreExitValue = true + } + + result.standardOutput.asText.get().trim() +} catch (_: Exception) { + "" +} diff --git a/Manager/app/proguard-rules.pro b/Manager/app/proguard-rules.pro new file mode 100644 index 0000000..d0aeece --- /dev/null +++ b/Manager/app/proguard-rules.pro @@ -0,0 +1,60 @@ +# Keep `Companion` object fields of serializable classes. +# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +# Keep `serializer()` on companion objects (both default and named) of serializable classes. +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep `INSTANCE.serializer()` of serializable objects. +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault + +# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`. +# If you have any, uncomment and replace classes with those containing named companion objects. +-keepattributes InnerClasses # Needed for `getDeclaredClasses`. +-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept. + static <1>$$serializer INSTANCE; +} + +# Keep fields for APK verification, used through reflection +-keepclassmembers public class com.android.apksig.** { + public *; +} + +# Keep class names of patching steps since they're used via reflection +-keepnames class com.meowarex.rlmobile.patcher.steps.** + +# Repackage classes into the top-level. +-repackageclasses + +# Amount of optimization iterations, taken from an SO post +-optimizationpasses 5 +-mergeinterfacesaggressively + +# Broaden access modifiers to increase results during optimization +-allowaccessmodification + +# Suppress missing class warnings +-dontwarn org.slf4j.impl.StaticLoggerBinder + +# Keeps all class and field names for debugging +-keepnames class ** { *; } + +# Preserve the line number information for debugging stack traces. +-keepattributes SourceFile,LineNumberTable diff --git a/Manager/app/src/main/AndroidManifest.xml b/Manager/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0b1977d --- /dev/null +++ b/Manager/app/src/main/AndroidManifest.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/MainActivity.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/MainActivity.kt new file mode 100644 index 0000000..e5b85d2 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/MainActivity.kt @@ -0,0 +1,210 @@ +package com.meowarex.rlmobile + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.animation.core.* +import androidx.compose.runtime.* +import androidx.compose.ui.unit.IntOffset +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.WindowCompat +import androidx.lifecycle.compose.LifecycleResumeEffect +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.* +import cafe.adriel.voyager.transitions.SlideTransition +import com.meowarex.rlmobile.MainActivity.Companion.EXTRA_COMPONENT_TYPE +import com.meowarex.rlmobile.MainActivity.Companion.EXTRA_FILE_PATH +import com.meowarex.rlmobile.MainActivity.Companion.EXTRA_PACKAGE_NAME +import com.meowarex.rlmobile.manager.* +import com.meowarex.rlmobile.patcher.InstallMetadata +import com.meowarex.rlmobile.ui.screens.home.HomeScreen +import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel +import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreen +import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen +import com.meowarex.rlmobile.ui.theme.ManagerTheme +import com.meowarex.rlmobile.ui.widgets.updater.UpdaterDialog +import com.meowarex.rlmobile.util.* +import com.github.diamondminer88.zip.ZipReader +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import java.io.File + +class MainActivity : ComponentActivity() { + private val permissions: PermissionsModel by viewModel() + private val preferences: PreferencesManager by inject() + private val overlays: OverlayManager by inject() + private val paths: PathManager by inject() + private val json: Json by inject() + private val scope = CoroutineScope(Dispatchers.Default) + + override fun onCreate(savedInstanceState: Bundle?) { + WindowCompat.setDecorFitsSystemWindows(window, false) + + installSplashScreen() + + super.onCreate(savedInstanceState) + + setContent { + // Refresh permissions when the activity resumes + LifecycleResumeEffect(Unit) { + permissions.refresh() + + onPauseOrDispose {} + } + + ManagerTheme( + theme = preferences.theme, + dynamicColor = preferences.dynamicColor, + ) { + if (BuildConfig.RELEASE) { + UpdaterDialog() + } + + @OptIn(ExperimentalVoyagerApi::class) + CompositionLocalProvider( + LocalNavigatorSaver provides parcelableNavigatorSaver(), + ) { + Navigator( + screen = HomeScreen(), + onBackPressed = null, + ) { navigator -> + // Open the permissions screen whenever permissions are insufficient + LaunchedEffect(permissions.requiredPermsGranted) { + if (!permissions.requiredPermsGranted) + navigator.pushOnce(PermissionsScreen()) + } + + DisposableEffect(Unit) { + this@MainActivity.intent?.let { handleNewIntent(it, navigator) } + + fun handle(intent: Intent) = handleNewIntent(intent, navigator) + addOnNewIntentListener(::handle) + onDispose { removeOnNewIntentListener(::handle) } + } + + BackHandler { + navigator.back(this@MainActivity) + } + + SlideTransition( + navigator = navigator, + disposeScreenAfterTransitionEnd = true, + animationSpec = spring( + stiffness = Spring.StiffnessMedium, + visibilityThreshold = IntOffset.VisibilityThreshold, + ) + ) + } + + overlays.Overlays() + } + } + } + } + + private fun handleNewIntent(intent: Intent, navigator: Navigator) = scope.launchBlock { + when (intent.action) { + INTENT_REINSTALL -> { + val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: run { + Log.w(BuildConfig.TAG, "Missing $EXTRA_PACKAGE_NAME extra for intent $INTENT_REINSTALL") + return@launchBlock + } + + navigator.push(handleReinstall(packageName)) + } + + INTENT_OPEN_PLUGINS -> { + // TODO: per-install plugins screen + // val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: run { + // Log.w(BuildConfig.TAG, "Missing $EXTRA_PACKAGE_NAME extra for intent $INTENT_REINSTALL") + // return@launchBlock + // } + + navigator.push(PluginsScreen()) + } + + INTENT_IMPORT_COMPONENT -> { + val path = intent.getStringExtra(EXTRA_FILE_PATH) ?: run { + Log.w(BuildConfig.TAG, "Missing $EXTRA_FILE_PATH extra for intent $INTENT_IMPORT_COMPONENT") + mainThread { showToast(R.string.intent_import_component_failure) } + return@launchBlock + } + val componentType = intent.getStringExtra(EXTRA_COMPONENT_TYPE) ?: run { + Log.w(BuildConfig.TAG, "Missing $EXTRA_COMPONENT_TYPE extra for intent $INTENT_IMPORT_COMPONENT") + mainThread { showToast(R.string.intent_import_component_failure) } + return@launchBlock + } + + val file = File("/data/local/tmp", path) + if (!file.exists()) { + Log.w(BuildConfig.TAG, "Intent $INTENT_IMPORT_COMPONENT specified an invalid file!") + mainThread { showToast(R.string.intent_import_component_failure) } + return@launchBlock + } + + val targetDir = when (componentType) { + "injector" -> paths.customInjectorsDir + "patches" -> paths.customPatchesDir + else -> { + Log.w(BuildConfig.TAG, "Extra $EXTRA_COMPONENT_TYPE is not a valid value!") + mainThread { showToast(R.string.intent_import_component_failure) } + return@launchBlock + } + } + + try { + file.copyTo(targetDir.resolve(file.name), overwrite = true) + file.delete() // This most likely silently fails + } catch (e: Exception) { + Log.e(BuildConfig.TAG, "Failed to import custom component", e) + mainThread { showToast(R.string.intent_import_component_failure) } + } + + mainThread { showToast(R.string.intent_import_component_success, file.name) } + } + + else -> { + Log.w(BuildConfig.TAG, "Unhandled intent ${intent.action}") + } + } + } + + private suspend fun handleReinstall(packageName: String): Screen { + val metadata = try { + val applicationInfo = packageManager.getApplicationInfo(packageName, 0) + val metadataFile = ZipReader(applicationInfo.publicSourceDir) + .use { it.openEntry("rlmobile.json")?.read() } + + metadataFile?.let { json.decodeFromStream(it.inputStream()) } + } catch (t: Throwable) { + Log.w(BuildConfig.TAG, "Failed to parse Radiant Lyrics install metadata from package $packageName", t) + mainThread { showToast(R.string.intent_reinstall_fail) } + null + } + + val patchOptions = metadata?.options + ?: PatchOptions.Default.copy(packageName = packageName) + + return PatchingScreen(patchOptions) + } + + companion object { + const val INTENT_REINSTALL = "com.meowarex.rlmobile.REINSTALL" + const val INTENT_OPEN_PLUGINS = "com.meowarex.rlmobile.OPEN_PLUGINS" + const val INTENT_IMPORT_COMPONENT = "com.meowarex.rlmobile.IMPORT_COMPONENT" + + const val EXTRA_PACKAGE_NAME = "rlmobile.packageName" + const val EXTRA_FILE_PATH = "rlmobile.file" + const val EXTRA_COMPONENT_TYPE = "rlmobile.componentType" + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ManagerApplication.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ManagerApplication.kt new file mode 100644 index 0000000..5fb732a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ManagerApplication.kt @@ -0,0 +1,105 @@ +package com.meowarex.rlmobile + +import android.app.Application +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.annotation.DelicateCoilApi +import com.meowarex.rlmobile.di.* +import com.meowarex.rlmobile.installers.dhizuku.DhizukuInstaller +import com.meowarex.rlmobile.installers.intent.IntentInstaller +import com.meowarex.rlmobile.installers.pm.PMInstaller +import com.meowarex.rlmobile.installers.root.RootInstaller +import com.meowarex.rlmobile.installers.shizuku.ShizukuInstaller +import com.meowarex.rlmobile.manager.* +import com.meowarex.rlmobile.manager.download.AndroidDownloadManager +import com.meowarex.rlmobile.manager.download.KtorDownloadManager +import com.meowarex.rlmobile.network.services.* +import com.meowarex.rlmobile.ui.screens.about.AboutModel +import com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsModel +import com.meowarex.rlmobile.ui.screens.home.HomeModel +import com.meowarex.rlmobile.ui.screens.log.LogScreenModel +import com.meowarex.rlmobile.ui.screens.logs.LogsListScreenModel +import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenModel +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsModel +import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel +import com.meowarex.rlmobile.ui.screens.plugins.PluginsModel +import com.meowarex.rlmobile.ui.screens.settings.SettingsModel +import com.meowarex.rlmobile.ui.widgets.updater.UpdaterViewModel +import kotlinx.coroutines.Dispatchers +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin +import org.koin.core.module.dsl.* +import org.koin.dsl.module + +class ManagerApplication : Application() { + override fun onCreate() { + super.onCreate() + + startKoin { + // Android activities & context + androidContext(this@ManagerApplication) + modules(module(createdAtStart = true) { + singleOf(::ActivityProvider) + }) + + // HTTP + modules(module { + single { provideJson() } + single { provideHttpClient() } + }) + + // Services + modules(module { + singleOf(::HttpService) + singleOf(::RadiantLyricsGithubService) + }) + + // UI Models + modules(module { + factoryOf(::HomeModel) + factoryOf(::PluginsModel) + factoryOf(::AboutModel) + factoryOf(::PatchingScreenModel) + factoryOf(::SettingsModel) + factoryOf(::PatchOptionsModel) + factoryOf(::ComponentOptionsModel) + factoryOf(::LogScreenModel) + factoryOf(::LogsListScreenModel) + factoryOf(::PermissionsModel) + viewModelOf(::UpdaterViewModel) + }) + + // Managers + modules(module { + single { providePreferences() } + singleOf(::PathManager) + singleOf(::InstallerManager) + singleOf(::OverlayManager) + singleOf(::InstallLogManager) + + singleOf(::ShizukuManager) + singleOf(::DhizukuManager) + + singleOf(::AndroidDownloadManager) + singleOf(::KtorDownloadManager) + }) + + // Installers + modules(module { + singleOf(::PMInstaller) + singleOf(::RootInstaller) + singleOf(::IntentInstaller) + singleOf(::ShizukuInstaller) + singleOf(::DhizukuInstaller) + }) + } + + // Limit parallel fetching of images using Coil + @OptIn(DelicateCoilApi::class) + SingletonImageLoader.setUnsafe { context -> + ImageLoader.Builder(context) + .fetcherCoroutineContext(Dispatchers.IO.limitedParallelism(5)) + .build() + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/di/ActivityProvider.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/di/ActivityProvider.kt new file mode 100644 index 0000000..b404f7d --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/di/ActivityProvider.kt @@ -0,0 +1,34 @@ +package com.meowarex.rlmobile.di + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import java.util.Objects + +class ActivityProvider(application: Application) { + private var activeActivity: Activity? = null + + /** + * Gets the current active activity as a specific activity or errors otherwise. + */ + @Suppress("UNCHECKED_CAST") + fun get(): T = Objects.requireNonNull(activeActivity, "No active activity cached!") as T + + init { + application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityStarted(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + + override fun onActivityResumed(activity: Activity) { + activeActivity = activity + } + + override fun onActivityDestroyed(activity: Activity) { + activeActivity = null + } + }) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/di/Http.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/di/Http.kt new file mode 100644 index 0000000..8abce47 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/di/Http.kt @@ -0,0 +1,109 @@ +package com.meowarex.rlmobile.di + +import android.app.Application +import com.meowarex.rlmobile.BuildConfig +import io.ktor.client.HttpClient +import io.ktor.client.call.HttpClientCall +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.cache.HttpCache +import io.ktor.client.plugins.cache.storage.FileStorage +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.cookies.HttpCookies +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header +import io.ktor.client.statement.HttpReceivePipeline +import io.ktor.client.statement.HttpResponse +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.json +import io.ktor.util.AttributeKey +import io.ktor.util.date.GMTDate +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.InternalAPI +import kotlinx.serialization.json.Json +import okhttp3.Dns +import org.koin.core.scope.Scope +import java.net.Inet4Address +import java.net.InetAddress +import kotlin.coroutines.CoroutineContext + +@Suppress("UnusedReceiverParameter") +fun Scope.provideJson() = Json { + ignoreUnknownKeys = true +} + +fun Scope.provideHttpClient() = HttpClient(OkHttp) { + val json: Json = get() + val application: Application = get() + + defaultRequest { + header(HttpHeaders.UserAgent, "Radiant Lyrics Manager/${BuildConfig.VERSION_NAME}") + } + + engine { + config { + dns(object : Dns { + override fun lookup(hostname: String): List { + val addresses = Dns.SYSTEM.lookup(hostname) + + // Github's nameservers do not respond to IPv6 requests for raw.githubusercontent.com, + // which causes CIO, Android and OkHTTP to all hang + return if (hostname == "raw.githubusercontent.com") { + addresses.filterIsInstance() + } else { + addresses + } + } + }) + } + } + + install(ContentNegotiation) { + json(json) + } + + install(HttpTimeout) { + socketTimeoutMillis = 30000 + connectTimeoutMillis = 30000 + } + + install(HttpCache) { + val dir = application.cacheDir.resolve("ktor") + publicStorage(FileStorage(dir)) + } + + install(HttpCookies) { + // Default storage is in-memory + } + + // Custom plugin to allow overriding response cache headers, and force caching + install("OverrideCacheControl") { + receivePipeline.intercept(HttpReceivePipeline.Before) { response -> + val customCacheControl = response.call.attributes.getOrNull(CustomCacheControl) + ?: return@intercept + + proceedWith(object : HttpResponse() { + @InternalAPI + override val rawContent: ByteReadChannel = response.rawContent + override val call: HttpClientCall = response.call + override val coroutineContext: CoroutineContext = response.coroutineContext + override val requestTime: GMTDate = response.requestTime + override val responseTime: GMTDate = response.responseTime + override val status: HttpStatusCode = response.status + override val version: HttpProtocolVersion = response.version + + override val headers: Headers = headers { + appendAll(response.headers) + set(HttpHeaders.CacheControl, customCacheControl.toString()) + } + }) + } + } +} + +private val CustomCacheControl: AttributeKey = AttributeKey("CustomCacheControl") + +fun HttpRequestBuilder.cacheControl(cacheControl: CacheControl) { + attributes.put(CustomCacheControl, cacheControl) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/di/Managers.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/di/Managers.kt new file mode 100644 index 0000000..880114e --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/di/Managers.kt @@ -0,0 +1,10 @@ +package com.meowarex.rlmobile.di + +import android.content.Context +import com.meowarex.rlmobile.manager.PreferencesManager +import org.koin.core.scope.Scope + +fun Scope.providePreferences(): PreferencesManager { + val ctx: Context = get() + return PreferencesManager(ctx.getSharedPreferences("preferences", Context.MODE_PRIVATE)) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/Installer.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/Installer.kt new file mode 100644 index 0000000..01bf9e3 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/Installer.kt @@ -0,0 +1,46 @@ +package com.meowarex.rlmobile.installers + +import java.io.File + +/** + * A generic installer interface that manages installing APKs + */ +interface Installer { + /** + * Starts an installation and forgets about it. A toast will be shown if the installation completes successfully. + * @param apks All APKs (including any splits) willed be merged into a single install. + * @param silent If this is an update, then the update will occur without user interaction. + */ + suspend fun install(apks: List, silent: Boolean = true) + + /** + * Starts an installation and waits for it to finish with a result. A toast will be shown for all result states. + * @param apks All APKs (including any splits) willed be merged into a single install. + * @param silent If this is an update, then the update will occur without user interaction. + * @param onProgressUpdate A progress callback invoked by the Android system representing the total progress of the installation. + * This may not be available for all underlying installers. + */ + suspend fun waitInstall( + apks: List, + silent: Boolean = true, + onProgressUpdate: ProgressListener? = null, + ): InstallerResult + + /** + * Triggers an uninstallation and waits for it to complete with a result. A toast will be shown for all result states. + * @param packageName The package name of the target package. + */ + suspend fun waitUninstall(packageName: String): InstallerResult + + /** + * A callback executed from a coroutine called when the Android system returns progress updates + * about a currently running installation session. This callback should finish as soon as possible, + * otherwise it will slow down installation. + */ + fun interface ProgressListener { + /** + * @param progress The current installation progress in a `[0,1]` range. + */ + fun onUpdate(progress: Float) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/InstallerResult.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/InstallerResult.kt new file mode 100644 index 0000000..e14cc5d --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/InstallerResult.kt @@ -0,0 +1,41 @@ +package com.meowarex.rlmobile.installers + +import android.content.Context +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * The state of an APK installation after it has completed and cleaned up. + */ +sealed interface InstallerResult : Parcelable { + /** + * The installation was successfully completed. + */ + @Parcelize + data object Success : InstallerResult + + /** + * This installation was interrupted and the install session has been canceled. + * @param systemTriggered Whether the cancellation happened from the system (ie. clicked cancel on the install prompt) + * Otherwise, this was caused by a coroutine cancellation. + */ + @Parcelize + data class Cancelled(val systemTriggered: Boolean) : InstallerResult + + /** + * This installation encountered an error and has been aborted. + * All implementors should implement [Parcelable]. + */ + abstract class Error : InstallerResult, Parcelable { + /** + * The full internal error representation. + */ + abstract fun getDebugReason(): String + + /** + * Simplified + translatable user facing reason for the failure. + * If null is returned, then the [getDebugReason] will be used instead. + */ + open fun getLocalizedReason(context: Context): String? = null + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/UnknownInstallerError.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/UnknownInstallerError.kt new file mode 100644 index 0000000..67e1da1 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/UnknownInstallerError.kt @@ -0,0 +1,14 @@ +package com.meowarex.rlmobile.installers + +import android.content.Context +import com.meowarex.rlmobile.R +import kotlinx.parcelize.Parcelize + +@Parcelize +data class UnknownInstallerError(val error: Throwable) : InstallerResult.Error() { + override fun getDebugReason() = error.stackTraceToString() + + // No localizations for exceptions, use short message anyway + override fun getLocalizedReason(context: Context) = + error.message ?: context.getString(R.string.install_error_unknown) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/dhizuku/DhizukuInstaller.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/dhizuku/DhizukuInstaller.kt new file mode 100644 index 0000000..028601d --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/dhizuku/DhizukuInstaller.kt @@ -0,0 +1,158 @@ +package com.meowarex.rlmobile.installers.dhizuku + +import android.content.Context +import android.content.pm.* +import com.meowarex.rlmobile.installers.Installer +import com.meowarex.rlmobile.installers.InstallerResult +import com.meowarex.rlmobile.installers.pm.PMUtils +import com.meowarex.rlmobile.manager.DhizukuManager +import com.meowarex.rlmobile.util.HiddenAPI +import com.rosan.dhizuku.api.Dhizuku +import dev.rikka.tools.refine.Refine +import kotlinx.coroutines.suspendCancellableCoroutine +import rikka.shizuku.SystemServiceHelper +import java.io.File +import kotlin.coroutines.resume + +/** + * Uses Dhizuku to remotely invoke the [PackageInstaller] API using device owner. + */ +class DhizukuInstaller( + private val context: Context, + private val dhizuku: DhizukuManager, +) : Installer { + /** + * Gets the Dhizuku API binder for [IPackageInstaller]. + */ + private fun getPackageInstallerBinder(): IPackageInstaller { + HiddenAPI.disable() + + val iPackageManager = IPackageManager.Stub.asInterface( + Dhizuku.binderWrapper(SystemServiceHelper.getSystemService("package")) + ) + val iPackageInstaller = IPackageInstaller.Stub.asInterface( + Dhizuku.binderWrapper(iPackageManager.packageInstaller.asBinder()) + ) + + return iPackageInstaller + } + + /** + * Opens and binds a [PackageInstaller.Session] wrapper through Dhizuku. + */ + fun openSession(sessionId: Int): PackageInstaller.Session { + HiddenAPI.disable() + + val iPackageInstaller = getPackageInstallerBinder() + val iSession = IPackageInstallerSession.Stub.asInterface( + Dhizuku.binderWrapper(iPackageInstaller.openSession(sessionId).asBinder()) + ) + return Refine.unsafeCast(PackageInstallerHidden.SessionHidden(iSession)) + } + + override suspend fun install(apks: List, silent: Boolean) { + if (!dhizuku.requestPermissions()) + throw IllegalStateException("Dhizuku is not available!") + + // Construct install session and create it + val params = PMUtils.createInstallSessionParams(silent = true) + val packageInstaller = PMUtils.getPackageInstaller( + context = context, + iPackageInstaller = getPackageInstallerBinder(), + installerPackageName = Dhizuku.getOwnerPackageName(), + ) + val sessionId = packageInstaller.createSession(params) + + PMUtils.startInstall( + context = context, + session = openSession(sessionId), + sessionId = sessionId, + apks = apks, + relay = false, + ) + } + + override suspend fun waitInstall( + apks: List, + silent: Boolean, + onProgressUpdate: Installer.ProgressListener?, + ): InstallerResult { + if (!dhizuku.requestPermissions()) + throw IllegalStateException("Dhizuku is not available!") + + return suspendCancellableCoroutine { continuation -> + // Construct install session and create it + val params = PMUtils.createInstallSessionParams(silent = true) + val packageInstaller = PMUtils.getPackageInstaller( + context = context, + iPackageInstaller = getPackageInstallerBinder(), + installerPackageName = Dhizuku.getOwnerPackageName(), + ) + val sessionId = packageInstaller.createSession(params) + + // Create and register a result receiver + val relayReceiver = PMUtils.registerRelayReceiver( + context = context, + sessionId = sessionId, + isUninstall = false, + onResult = continuation::resume, + ) + + // Create and register a progress callback + val sessionCallback = onProgressUpdate?.let { onProgressUpdate -> + PMUtils.registerSessionCallback( + sessionId = sessionId, + packageInstaller = packageInstaller, + onProgressUpdate = onProgressUpdate, + ) + } + + // Unregister PMResultReceiver when this coroutine finishes or errors + // Explicitly cancel the install session if it did not finish. + continuation.invokeOnCancellation { + context.unregisterReceiver(relayReceiver) + sessionCallback?.let { packageInstaller.unregisterSessionCallback(it) } + packageInstaller.abandonSession(sessionId) + } + + PMUtils.startInstall( + context = context, + session = openSession(sessionId), + sessionId = sessionId, + apks = apks, + relay = true, + ) + } + } + + override suspend fun waitUninstall(packageName: String): InstallerResult { + if (!dhizuku.requestPermissions()) + throw IllegalStateException("Dhizuku is not available!") + + return suspendCancellableCoroutine { continuation -> + val packageInstaller = PMUtils.getPackageInstaller( + context = context, + iPackageInstaller = getPackageInstallerBinder(), + installerPackageName = Dhizuku.getOwnerPackageName(), + ) + + // Create and register a result receiver + val relayReceiver = PMUtils.registerRelayReceiver( + context = context, + sessionId = -1, + isUninstall = true, + onResult = continuation::resume, + ) + + // Unregister PMResultReceiver when this coroutine finishes or errors + continuation.invokeOnCancellation { + context.unregisterReceiver(relayReceiver) + } + + packageInstaller.uninstall( + /* packageName = */ packageName, + /* statusReceiver = */ PMUtils.createUninstallRelayingIntent(context).intentSender, + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/intent/IntentInstaller.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/intent/IntentInstaller.kt new file mode 100644 index 0000000..96aa112 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/intent/IntentInstaller.kt @@ -0,0 +1,125 @@ +package com.meowarex.rlmobile.installers.intent + +import android.annotation.SuppressLint +import android.content.* +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.FileProvider +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.di.ActivityProvider +import com.meowarex.rlmobile.installers.* +import kotlinx.coroutines.* +import java.io.File +import java.util.UUID +import kotlin.coroutines.resume + +/** + * Launches an (un)installation intent to invoke the system's default app installer. + * This defaults to the package installer in AOSP, however custom installers such as InstallerX + * can intercept install intents if configured to do so. + */ +class IntentInstaller( + private val context: Context, + private val activities: ActivityProvider, +) : Installer { + val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override suspend fun install(apks: List, silent: Boolean) { + coroutineScope.launch { waitInstall(apks, silent) } + } + + @Suppress("DEPRECATION") + @SuppressLint("RequestInstallPackagesPolicy") + override suspend fun waitInstall( + apks: List, + silent: Boolean, + onProgressUpdate: Installer.ProgressListener?, + ): InstallerResult { + val file = apks.singleOrNull() + ?: throw IllegalArgumentException("IntentInstaller only supports installing a single APK") + val fileUri = if (Build.VERSION.SDK_INT >= 24) { + FileProvider.getUriForFile( + /* context = */ context, + /* authority = */ "${BuildConfig.APPLICATION_ID}.provider", + /* file = */ file, + ) + } else { + Uri.fromFile(file) + } + + val intent = Intent(Intent.ACTION_INSTALL_PACKAGE, fileUri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_RETURN_RESULT, true) + .putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, BuildConfig.APPLICATION_ID) + + val resultCode = try { + launchForResultCode(intent) + } catch (_: ActivityNotFoundException) { + return UnsupportedIntentInstallerError(Intent.ACTION_INSTALL_PACKAGE) + } + + return parseResultCode(resultCode) + } + + @Suppress("DEPRECATION") + override suspend fun waitUninstall(packageName: String): InstallerResult { + // Ignore if the package does not exist + try { + context.packageManager.getPackageInfo(packageName, 0) + } catch (_: PackageManager.NameNotFoundException) { + return InstallerResult.Success + } + + val uri = Uri.fromParts("package", packageName, null) + val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, uri) + .putExtra(Intent.EXTRA_RETURN_RESULT, true) + + val resultCode = try { + launchForResultCode(intent) + } catch (_: ActivityNotFoundException) { + return UnsupportedIntentInstallerError(Intent.ACTION_UNINSTALL_PACKAGE) + } + + return parseResultCode(resultCode) + } + + /** + * Parses the result code of an intent that was launched with [Intent.EXTRA_RETURN_RESULT] + * for package installer operations. + */ + private fun parseResultCode(resultCode: Int): InstallerResult { + return when (resultCode) { + AppCompatActivity.RESULT_OK -> InstallerResult.Success + AppCompatActivity.RESULT_CANCELED -> InstallerResult.Cancelled(systemTriggered = true) + AppCompatActivity.RESULT_FIRST_USER -> // This is returned on errors + UnknownInstallerError(IllegalStateException("External installer failed!")) + + else -> UnknownInstallerError(IllegalStateException("External installer returned unknown result code $resultCode")) + } + } + + /** + * Launches an intent activity and captures it's result code. + */ + private suspend fun launchForResultCode(intent: Intent): Int { + val activity = activities.get() + + return suspendCancellableCoroutine { continuation -> + val launcher = activity.activityResultRegistry.register( + key = UUID.randomUUID().toString(), + contract = ActivityResultContracts.StartActivityForResult(), + callback = { continuation.resume(it.resultCode) }, + ) + + continuation.invokeOnCancellation { + launcher.unregister() + } + + launcher.launch(intent) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/intent/UnsupportedIntentInstallerError.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/intent/UnsupportedIntentInstallerError.kt new file mode 100644 index 0000000..c3e68da --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/intent/UnsupportedIntentInstallerError.kt @@ -0,0 +1,15 @@ +package com.meowarex.rlmobile.installers.intent + +import android.content.Context +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.installers.InstallerResult +import kotlinx.parcelize.Parcelize + +@Parcelize +data class UnsupportedIntentInstallerError(private val action: String) : InstallerResult.Error() { + override fun getDebugReason() = "This Android rom does not support $action!" + + override fun getLocalizedReason(context: Context) = + context.getString(R.string.install_error_unhandled_intent, action) +} + diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMInstaller.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMInstaller.kt new file mode 100644 index 0000000..200a188 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMInstaller.kt @@ -0,0 +1,112 @@ +package com.meowarex.rlmobile.installers.pm + +import android.app.Application +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.util.Log +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.installers.Installer +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import kotlin.coroutines.resume + +/** + * Uses the [PackageInstaller] API from the system's [PackageManager] service. + * This installer invokes the API directly from this app's context. + */ +class PMInstaller( + private val context: Application, +) : Installer { + private val _packageInstaller = context.packageManager.packageInstaller + + init { + // Destroy all open sessions that may have not been previously cleaned up due to fatal errors + for (session in _packageInstaller.mySessions) { + Log.d(BuildConfig.TAG, "Deleting old PackageInstaller session ${session.sessionId}") + _packageInstaller.abandonSession(session.sessionId) + } + } + + override suspend fun install(apks: List, silent: Boolean) { + val sessionId = createInstallSession(silent) + + PMUtils.startInstall( + context = context, + session = _packageInstaller.openSession(sessionId), + sessionId = sessionId, + apks = apks, + relay = false, + ) + } + + override suspend fun waitInstall( + apks: List, + silent: Boolean, + onProgressUpdate: Installer.ProgressListener?, + ) = suspendCancellableCoroutine { continuation -> + // Create a new install session + val sessionId = createInstallSession(silent) + + // Create and register a result receiver + val relayReceiver = PMUtils.registerRelayReceiver( + context = context, + sessionId = sessionId, + isUninstall = false, + onResult = continuation::resume, + ) + + // Create and register a progress callback + val sessionCallback = onProgressUpdate?.let { onProgressUpdate -> + PMUtils.registerSessionCallback( + sessionId = sessionId, + packageInstaller = _packageInstaller, + onProgressUpdate = onProgressUpdate, + ) + } + + // Unregister PMResultReceiver when this coroutine finishes or errors + // Explicitly cancel the install session if it did not finish. + continuation.invokeOnCancellation { + context.unregisterReceiver(relayReceiver) + sessionCallback?.let { _packageInstaller.unregisterSessionCallback(it) } + _packageInstaller.abandonSession(sessionId) + } + + PMUtils.startInstall( + context = context, + session = _packageInstaller.openSession(sessionId), + sessionId = sessionId, + apks = apks, + relay = true, + ) + } + + override suspend fun waitUninstall(packageName: String) = suspendCancellableCoroutine { continuation -> + // Create and register a result receiver + val relayReceiver = PMUtils.registerRelayReceiver( + context = context, + sessionId = -1, + isUninstall = true, + onResult = continuation::resume, + ) + + // Unregister PMResultReceiver when this coroutine finishes or errors + continuation.invokeOnCancellation { + context.unregisterReceiver(relayReceiver) + } + + _packageInstaller.uninstall( + /* packageName = */ packageName, + /* statusReceiver = */ PMUtils.createUninstallRelayingIntent(context).intentSender, + ) + } + + /** + * Starts a [PackageInstaller] session with the necessary params. + * @param silent If this is an update, then the update will occur without user interaction. + * @return The open install session id. + */ + private fun createInstallSession(silent: Boolean): Int { + return _packageInstaller.createSession(PMUtils.createInstallSessionParams(silent = silent)) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMInstallerError.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMInstallerError.kt new file mode 100644 index 0000000..e30f85e --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMInstallerError.kt @@ -0,0 +1,41 @@ +package com.meowarex.rlmobile.installers.pm + +import android.content.Context +import android.content.pm.PackageInstaller +import android.os.Parcelable +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.installers.InstallerResult +import kotlinx.parcelize.Parcelize + +/** + * Translates the errors returned by PackageInstaller's [PackageInstaller.EXTRA_STATUS] + * that is captured by a receiver into something human readable. + */ +@Parcelize +data class PMInstallerError(val status: Int) : InstallerResult.Error(), Parcelable { + override fun getDebugReason() = when (status) { + PackageInstaller.STATUS_FAILURE -> "Unknown failure" + PackageInstaller.STATUS_FAILURE_BLOCKED -> "Blocked" + PackageInstaller.STATUS_FAILURE_INVALID -> "Invalid package" + PackageInstaller.STATUS_FAILURE_CONFLICT -> "Package conflict" + PackageInstaller.STATUS_FAILURE_STORAGE -> "Storage error" + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Device incompatibility" + /* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> "Operation timeout" + else -> "Unknown code ($status)" + } + + override fun getLocalizedReason(context: Context): String { + val string = when (status) { + PackageInstaller.STATUS_FAILURE -> R.string.install_error_unknown + PackageInstaller.STATUS_FAILURE_BLOCKED -> R.string.install_error_blocked + PackageInstaller.STATUS_FAILURE_INVALID -> R.string.install_error_invalid + PackageInstaller.STATUS_FAILURE_CONFLICT -> R.string.install_error_conflict + PackageInstaller.STATUS_FAILURE_STORAGE -> R.string.install_error_storage + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> R.string.install_error_incompatible + /* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> R.string.install_error_timeout + else -> R.string.install_error_unknown + } + + return context.getString(string) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMIntentReceiver.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMIntentReceiver.kt new file mode 100644 index 0000000..906c67b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMIntentReceiver.kt @@ -0,0 +1,95 @@ +package com.meowarex.rlmobile.installers.pm + +import android.app.PendingIntent +import android.content.* +import android.content.pm.PackageInstaller +import android.util.Log +import android.widget.Toast +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.installers.InstallerResult +import com.meowarex.rlmobile.installers.UnknownInstallerError + +/** + * This class is used as a callback receiver for [PackageInstaller] events, + * registered as a [PendingIntent]. If [PMIntentReceiver.EXTRA_RELAY_ENABLED] is set to true on + * incoming intents, then the incoming intent will be parsed and relayed as an intent intended for [PMResultReceiver] + * that was registered dynamically with the correct session id, which will handle the relayed result and return + * it as a callback back to the application. + */ +class PMIntentReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val realSessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1) + val expectedSessionId = intent.getIntExtra(EXTRA_SESSION_ID, -1) + + if (realSessionId != expectedSessionId) return + + try { + handleSessionIntent(context, intent, expectedSessionId) + } catch (error: Throwable) { + if (intent.getBooleanExtra(EXTRA_RELAY_ENABLED, false)) { + val relayIntent = Intent(PMResultReceiver.ACTION_RECEIVE_RESULT) + .setPackage(BuildConfig.APPLICATION_ID) + .putExtra(PMResultReceiver.EXTRA_SESSION_ID, expectedSessionId) + .putExtra(PMResultReceiver.EXTRA_RESULT, UnknownInstallerError(error)) + + context.sendBroadcast(relayIntent) + } else { + Log.e(BuildConfig.TAG, "[PMIntentReceiver] Failed to handle intent", error) + } + } + } + + private fun handleSessionIntent(context: Context, intent: Intent, sessionId: Int) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999) + + // Launch the user action intent + if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + @Suppress("DEPRECATION") + val confirmationIntent = intent + .getParcelableExtra(Intent.EXTRA_INTENT)!! + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + context.startActivity(confirmationIntent) + + return + } + + // Handle install result + val installerResult = when (status) { + PackageInstaller.STATUS_SUCCESS -> InstallerResult.Success + PackageInstaller.STATUS_FAILURE_ABORTED -> InstallerResult.Cancelled(systemTriggered = true) + + else -> { + Log.w(BuildConfig.TAG, "PM failed with error code $status") + + if (status <= PackageInstaller.STATUS_SUCCESS) { + // Unknown status code (not an error) + return + } else { + PMInstallerError(status).also { + Toast.makeText( + /* context = */ context, + /* text = */ it.getLocalizedReason(context), + /* duration = */ Toast.LENGTH_LONG + ).show() + } + } + } + } + + // Forward result to PMResultReceiver if relaying is enabled and have a real result + if (intent.getBooleanExtra(EXTRA_RELAY_ENABLED, false)) { + val relayIntent = Intent(PMResultReceiver.ACTION_RECEIVE_RESULT) + .setPackage(BuildConfig.APPLICATION_ID) + .putExtra(PMResultReceiver.EXTRA_SESSION_ID, sessionId) + .putExtra(PMResultReceiver.EXTRA_RESULT, installerResult) + + context.sendBroadcast(relayIntent) + } + } + + companion object { + const val EXTRA_RELAY_ENABLED = "relayEnabled" + const val EXTRA_SESSION_ID = "expectedSessionId" + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMResultReceiver.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMResultReceiver.kt new file mode 100644 index 0000000..0b0db7c --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMResultReceiver.kt @@ -0,0 +1,69 @@ +package com.meowarex.rlmobile.installers.pm + +import android.content.* +import android.content.pm.PackageInstaller +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.installers.InstallerResult +import com.meowarex.rlmobile.installers.UnknownInstallerError +import com.meowarex.rlmobile.util.showToast + +/** + * This receiver is meant to be registered dynamically in combination with [PMIntentReceiver] in order to + * relay parsed [PackageInstaller] results back to the running application. + */ +class PMResultReceiver( + private val sessionId: Int, + private val isUninstall: Boolean, + private val onResult: (InstallerResult) -> Unit, +) : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.getIntExtra(EXTRA_SESSION_ID, -1) != sessionId) return + + try { + when (intent.action) { + ACTION_RECEIVE_RESULT -> handleResult(context, intent) + } + } catch (t: Throwable) { + context.unregisterReceiver(this) + onResult(UnknownInstallerError(t)) + } + } + + private fun handleResult(context: Context, intent: Intent) { + @Suppress("DEPRECATION") + val result = intent.getParcelableExtra(EXTRA_RESULT) ?: return + + // Show toast for successful and aborted sessions + when (result) { + InstallerResult.Success -> { + context.showToast(if (!isUninstall) R.string.installer_install_success else R.string.installer_uninstall_success) + } + + // The reason we don't do this in PMIntentReceiver is we can't tell whether it was + // an old session that for which `abandonSession(...)` was called + is InstallerResult.Cancelled -> { + context.showToast(if (!isUninstall) R.string.installer_install_aborted else R.string.installer_uninstall_aborted) + } + + else -> {} + } + + context.unregisterReceiver(this) + onResult(result) + } + + companion object { + const val ACTION_RECEIVE_INTENT = "com.meowarex.rlmobile.RELAY_PM_INTENT" + const val ACTION_RECEIVE_RESULT = "com.meowarex.rlmobile.RELAY_PM_RESULT" + const val EXTRA_RESULT = "installerResult" + const val EXTRA_SESSION_ID = "sessionId" + + /** + * The intent filter this receiver should be registered with to work properly. + */ + val intentFilter = IntentFilter().apply { + addAction(ACTION_RECEIVE_INTENT) + addAction(ACTION_RECEIVE_RESULT) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMUtils.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMUtils.kt new file mode 100644 index 0000000..9feecfd --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/pm/PMUtils.kt @@ -0,0 +1,233 @@ +package com.meowarex.rlmobile.installers.pm + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.* +import android.content.pm.PackageInstaller.SessionCallback +import android.content.pm.PackageInstaller.SessionParams +import android.content.pm.PackageInstallerHidden.SessionParamsHidden +import android.os.* +import androidx.core.content.ContextCompat +import com.meowarex.rlmobile.installers.Installer +import com.meowarex.rlmobile.installers.InstallerResult +import com.meowarex.rlmobile.util.* +import dev.rikka.tools.refine.Refine +import java.io.File + +/** + * Shared functionality between all types of installers that directly interface with the [PackageInstaller] API. + */ +object PMUtils { + /** + * Gets a binded [PackageInstaller] service wrapper. + * This is used by remote installers such as Shizuku and Dhizuku. + */ + fun getPackageInstaller( + context: Context, + iPackageInstaller: IPackageInstaller, + installerPackageName: String, + ): PackageInstaller { + HiddenAPI.disable() + + val userId = context.getUserId() ?: 0 + + val hiddenPackageInstaller = if (Build.VERSION.SDK_INT >= 31) { + PackageInstallerHidden( + /* installer = */ iPackageInstaller, + /* installerPackageName = */ installerPackageName, + /* installerAttributionTag = */ null, + /* userId = */ userId, + ) + } else if (Build.VERSION.SDK_INT >= 26) { + PackageInstallerHidden( + /* installer = */ iPackageInstaller, + /* installerPackageName = */ installerPackageName, + /* userId = */ userId, + ) + } else { + PackageInstallerHidden( + /* context = */ context, + /* pm = */ context.packageManager, + /* installer = */ iPackageInstaller, + /* installerPackageName = */ installerPackageName, + /* userId = */ userId, + ) + } + return Refine.unsafeCast(hiddenPackageInstaller) + } + + /** + * Creates install sessions params for [PackageInstaller]. + * @param silent If this is an update, then the update will occur without user interaction. + */ + @Suppress("DEPRECATION") + fun createInstallSessionParams(silent: Boolean): SessionParams { + val params = SessionParams(SessionParams.MODE_FULL_INSTALL).apply { + if (Build.VERSION.SDK_INT >= 24) setOriginatingUid(Process.myUid()) + if (Build.VERSION.SDK_INT >= 26) setInstallReason(PackageManager.INSTALL_REASON_USER) + if (Build.VERSION.SDK_INT >= 30) setAutoRevokePermissionsMode(false) + + if (Build.VERSION.SDK_INT >= 31) { + setInstallScenario(PackageManager.INSTALL_SCENARIO_FAST) + + // Allegedly MIUI is not happy with silent installs + if (silent && !isMiui()) { + setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED) + } + } + + if (Build.VERSION.SDK_INT >= 34) setPackageSource(PackageInstaller.PACKAGE_SOURCE_OTHER) + } + + val hiddenParams = Refine.unsafeCast(params) + HiddenAPI.disable() + hiddenParams.installFlags = hiddenParams.installFlags or + PackageManagerHidden.INSTALL_REPLACE_EXISTING or + PackageManagerHidden.INSTALL_ALLOW_TEST or + (if (Build.VERSION.SDK_INT >= 34) PackageManagerHidden.INSTALL_BYPASS_LOW_TARGET_SDK_BLOCK else 0) + + return Refine.unsafeCast(hiddenParams) + } + + /** + * Registers a [PMResultReceiver] to receive results relayed by [PMIntentReceiver], to return the state + * back to the application. + * + * @param context Android context. + * @param sessionId The [PackageInstaller] install session ID to filter for. + * @param isUninstall Whether this operation is an uninstallation. + * @param onResult A callback lambda providing the parsed installation result. + * @return The receiver that was registered. This should be unregistered manually, such as + * upon the cancellation of the registering coroutine. + */ + fun registerRelayReceiver( + context: Context, + sessionId: Int, + isUninstall: Boolean, + onResult: (InstallerResult) -> Unit, + ): PMResultReceiver { + // This will receive parsed data forwarded by PMIntentReceiver + val relayReceiver = PMResultReceiver( + sessionId = sessionId, + isUninstall = isUninstall, + onResult = onResult, + ) + + ContextCompat.registerReceiver( + /* context = */ context, + /* receiver = */ relayReceiver, + /* filter = */ PMResultReceiver.intentFilter, + /* flags = */ ContextCompat.RECEIVER_NOT_EXPORTED, + ) + + return relayReceiver + } + + /** + * Creates a session callback listener that is then registered to receive + * installation progress updates from the system. + */ + fun registerSessionCallback( + sessionId: Int, + packageInstaller: PackageInstaller, + onProgressUpdate: Installer.ProgressListener, + ): SessionCallback { + val callback = object : SessionCallback() { + override fun onActiveChanged(callbackSessionId: Int, active: Boolean) {} + override fun onBadgingChanged(callbackSessionId: Int) {} + override fun onCreated(callbackSessionId: Int) {} + override fun onFinished(callbackSessionId: Int, success: Boolean) {} + + override fun onProgressChanged(callbackSessionId: Int, progress: Float) { + if (sessionId != callbackSessionId) return + + onProgressUpdate.onUpdate(progress) + } + } + + // Register callback to receive invocations on main thread + packageInstaller.registerSessionCallback(callback, Handler(Looper.getMainLooper())) + + return callback + } + + /** + * Start a [PackageInstaller] session for installation. + * @param context Android context + * @param session A newly opened install session to be written to. Ones binded through Shizuku also work. + * @param sessionId The install session ID of [session]. + * @param apks The apks to install + * @param relay Whether to use the [PMResultReceiver] flow. + */ + fun startInstall( + context: Context, + session: PackageInstaller.Session, + sessionId: Int, + apks: List, + relay: Boolean, + ) { + val callbackIntent = Intent(context, PMIntentReceiver::class.java) + .putExtra(PMIntentReceiver.EXTRA_SESSION_ID, sessionId) + .putExtra(PMIntentReceiver.EXTRA_RELAY_ENABLED, relay) + + val pendingIntent = PendingIntent.getBroadcast( + /* context = */ context, + /* requestCode = */ 0, + /* intent = */ callbackIntent, + /* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + session.use { session -> + val bufferSize = 1 * 1024 * 1024 // 1MiB + + for (apkIdx in 0..apks.lastIndex) { + val apk = apks[apkIdx] + val apkSize = apk.length() + val filesProgress = (apkIdx + 1f) / apks.size + + session.openWrite(apk.name, 0, apkSize).use { out -> + apk.inputStream().use { input -> + val buffer = ByteArray(bufferSize) + var bytesCopied: Long = 0 + var bytes = input.read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + + val apkProgress = bytes.toFloat() / apkSize + session.setStagingProgress(apkProgress * filesProgress) + + bytes = input.read(buffer) + } + } + session.fsync(out) + } + } + + @SuppressLint("RequestInstallPackagesPolicy") + session.commit(pendingIntent.intentSender) + } + } + + /** + * Creates an uninstallation callback [PendingIntent] that will forward events + * to the relaying [PMIntentReceiver]. These events can be captured by registering [PMResultReceiver] + * through [PMUtils.registerRelayReceiver]. + */ + fun createUninstallRelayingIntent(context: Context): PendingIntent { + // FIXME: Conflicting pending intents when multiple simultaneous uninstalls are happening. + // The extras will end up being merged into one pending intent, with only the newest one working. + val callbackIntent = Intent(context, PMIntentReceiver::class.java) + .putExtra(PMIntentReceiver.EXTRA_SESSION_ID, -1) + .putExtra(PMIntentReceiver.EXTRA_RELAY_ENABLED, true) + + return PendingIntent.getBroadcast( + /* context = */ context, + /* requestCode = */ 0, + /* intent = */ callbackIntent, + /* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/root/RootInstaller.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/root/RootInstaller.kt new file mode 100644 index 0000000..9d15496 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/root/RootInstaller.kt @@ -0,0 +1,132 @@ +package com.meowarex.rlmobile.installers.root + +import android.content.Context +import com.meowarex.rlmobile.installers.* +import com.meowarex.rlmobile.util.getUserId +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.* +import java.io.File + +// Based on https://gitlab.com/AuroraOSS/AuroraStore/-/blob/master/app/src/main/java/com/aurora/store/data/installer/RootInstaller.kt + +/** + * Installer based on using libsu to invoke `pm` with root. + * + * Errors from this installer will always be [UnknownInstallerError] + * as it is impossible to extract meaningful information from shell installations. + */ +class RootInstaller(private val context: Context) : Installer { + val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private suspend fun executeSU(command: String): List = withContext(Dispatchers.IO) { + val result = Shell.cmd(command).exec() + if (result.code != 0) { + val resultString = "Result code: ${result.code}. Stdout: '${result.out}'. Stderr: '${result.err}'." + val message = "Root command '$command' failed. $resultString" + throw ShellException(message) + } + result.out + } + + /** + * Creates the main root shell and requests root permissions. + * If they are not granted, throw an exception. + */ + private fun obtainRoot() { + Shell.getShell().waitAndClose() + Shell.getShell() + + if (Shell.isAppGrantedRoot() != true) + throw ShellException("Missing root permissions (denied)") + } + + private suspend fun createInstallSession(totalSize: Long): Int { + val userId = context.getUserId()?.toString() ?: "all" + val response = executeSU("pm install-create -i $PLAY_PACKAGE_NAME --user $userId -r -S $totalSize") + val result = response[0] + + val sessionIdMatch = Regex("""\d+""").find(result) + checkNotNull(sessionIdMatch) { "Can't find session id with regex pattern. Output: $result" } + + val sessionId = sessionIdMatch.groups[0] + checkNotNull(sessionId) { "Can't find match group containing the session id. Output: $result" } + + return sessionId.value.toInt() + } + + /** + * Disable ADB install verification (bypass useless Play Protect). + */ + private suspend fun disableAdbVerify() { + executeSU("settings put global verifier_verify_adb_installs 0") + } + + override suspend fun install(apks: List, silent: Boolean) { + coroutineScope.launch { waitInstall(apks, silent) } + } + + override suspend fun waitInstall( + apks: List, + silent: Boolean, + onProgressUpdate: Installer.ProgressListener?, + ): InstallerResult { + val invalidChars = """\W""".toRegex() + for (apk in apks) { + if (hasDangerousCharacter(apk.canonicalPath) || hasDangerousCharacter(apk.name)) + throw IllegalArgumentException("APK path or name has dangerous characters: ${apk.canonicalPath}") + if (apk.nameWithoutExtension.contains(invalidChars)) + throw IllegalArgumentException("APK file name contains invalid characters: ${apk.nameWithoutExtension}") + } + + obtainRoot() + disableAdbVerify() + + val sessionId = createInstallSession( + totalSize = apks.sumOf(File::length), + ) + + return try { + for (apk in apks) { + executeSU("""cat "${apk.canonicalPath}" | pm install-write -S ${apk.length()} $sessionId "${apk.name}"""") + } + executeSU("""pm install-commit $sessionId""") + + InstallerResult.Success + } catch (t: Throwable) { + executeSU("""pm install-abandon $sessionId""") + + UnknownInstallerError(t) + } + } + + override suspend fun waitUninstall(packageName: String): InstallerResult { + if (hasDangerousCharacter(packageName)) + throw IllegalArgumentException("packageName has dangerous characters!") + + obtainRoot() + + return try { + val userFlag = context.getUserId()?.let { "--user $it" } ?: "" + + executeSU("""pm uninstall $userFlag $packageName""") + InstallerResult.Success + } catch (t: Throwable) { + UnknownInstallerError(t) + } + } + + private companion object { + // We spoof Google Play Store to prevent unnecessary checks + const val PLAY_PACKAGE_NAME = "com.android.vending" + + /** + * Checks if [value] has a dangerous character to put in a shell. + * Paths and names should be checked with this. + */ + fun hasDangerousCharacter(value: String): Boolean = dangerousCharacters.containsMatchIn(value) + + private val dangerousCharacters = """[`'()|<>*$&?!#:;{}\s"\[\]\\]""".toRegex() + } +} + +private class ShellException(message: String) : Exception(message) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/shizuku/ShizukuInstaller.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/shizuku/ShizukuInstaller.kt new file mode 100644 index 0000000..af9ca8b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/shizuku/ShizukuInstaller.kt @@ -0,0 +1,171 @@ +package com.meowarex.rlmobile.installers.shizuku + +import android.content.Context +import android.content.pm.* +import com.meowarex.rlmobile.installers.Installer +import com.meowarex.rlmobile.installers.InstallerResult +import com.meowarex.rlmobile.installers.pm.PMUtils +import com.meowarex.rlmobile.manager.ShizukuManager +import com.meowarex.rlmobile.util.HiddenAPI +import dev.rikka.tools.refine.Refine +import kotlinx.coroutines.suspendCancellableCoroutine +import rikka.shizuku.ShizukuBinderWrapper +import rikka.shizuku.SystemServiceHelper +import java.io.File +import kotlin.coroutines.resume + +// Based on https://github.com/Tobi823/ffupdater/blob/9830452fe1cb3b77b28175833c68118a63d5ca69/ffupdater/src/main/java/de/marmaro/krt/ffupdater/installer/impl/ShizukuInstaller.kt + +/** + * The package name of Google Play Store. + * We spoof our installer to this when installing through Shizuku to prevent + * potentially unnecessary scans/checks. + */ +private const val PLAY_PACKAGE_NAME = "com.android.vending" + +/** + * Uses Shizuku to remotely invoke the [PackageInstaller] API from ADB. + */ +class ShizukuInstaller( + private val context: Context, + private val shizuku: ShizukuManager, +) : Installer { + /** + * Gets the Shizuku API binder for [IPackageInstaller]. + */ + private fun getPackageInstallerBinder(): IPackageInstaller { + HiddenAPI.disable() + + val iPackageManager = IPackageManager.Stub.asInterface( + ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package")) + ) + val iPackageInstaller = IPackageInstaller.Stub.asInterface( + ShizukuBinderWrapper(iPackageManager.packageInstaller.asBinder()) + ) + + return iPackageInstaller + } + + /** + * Opens and binds a [PackageInstaller.Session] wrapper through Shizuku. + */ + fun openSession(sessionId: Int): PackageInstaller.Session { + HiddenAPI.disable() + + val iPackageInstaller = getPackageInstallerBinder() + val iSession = IPackageInstallerSession.Stub.asInterface( + ShizukuBinderWrapper(iPackageInstaller.openSession(sessionId).asBinder()) + ) + return Refine.unsafeCast(PackageInstallerHidden.SessionHidden(iSession)) + } + + override suspend fun install(apks: List, silent: Boolean) { + if (!shizuku.requestPermissions()) + throw IllegalStateException("Shizuku is not available!") + + ShizukuSettingsWrapper.disableAdbVerify(context) + + // Construct install session and create it + val params = PMUtils.createInstallSessionParams(silent = true) + val packageInstaller = PMUtils.getPackageInstaller( + context = context, + iPackageInstaller = getPackageInstallerBinder(), + installerPackageName = PLAY_PACKAGE_NAME, + ) + val sessionId = packageInstaller.createSession(params) + + PMUtils.startInstall( + context = context, + session = openSession(sessionId), + sessionId = sessionId, + apks = apks, + relay = false, + ) + } + + override suspend fun waitInstall( + apks: List, + silent: Boolean, + onProgressUpdate: Installer.ProgressListener?, + ): InstallerResult { + if (!shizuku.requestPermissions()) + throw IllegalStateException("Shizuku is not available!") + + ShizukuSettingsWrapper.disableAdbVerify(context) + + return suspendCancellableCoroutine { continuation -> + // Construct install session and create it + val params = PMUtils.createInstallSessionParams(silent = true) + val packageInstaller = PMUtils.getPackageInstaller( + context = context, + iPackageInstaller = getPackageInstallerBinder(), + installerPackageName = PLAY_PACKAGE_NAME, + ) + val sessionId = packageInstaller.createSession(params) + + // Create and register a result receiver + val relayReceiver = PMUtils.registerRelayReceiver( + context = context, + sessionId = sessionId, + isUninstall = false, + onResult = continuation::resume, + ) + + // Create and register a progress callback + val sessionCallback = onProgressUpdate?.let { onProgressUpdate -> + PMUtils.registerSessionCallback( + sessionId = sessionId, + packageInstaller = packageInstaller, + onProgressUpdate = onProgressUpdate, + ) + } + + // Unregister PMResultReceiver when this coroutine finishes or errors + // Explicitly cancel the install session if it did not finish. + continuation.invokeOnCancellation { + context.unregisterReceiver(relayReceiver) + sessionCallback?.let { packageInstaller.unregisterSessionCallback(it) } + packageInstaller.abandonSession(sessionId) + } + + PMUtils.startInstall( + context = context, + session = openSession(sessionId), + sessionId = sessionId, + apks = apks, + relay = true, + ) + } + } + + override suspend fun waitUninstall(packageName: String): InstallerResult { + if (!shizuku.requestPermissions()) + throw IllegalStateException("Shizuku is not available!") + + return suspendCancellableCoroutine { continuation -> + val packageInstaller = PMUtils.getPackageInstaller( + context = context, + iPackageInstaller = getPackageInstallerBinder(), + installerPackageName = PLAY_PACKAGE_NAME, + ) + + // Create and register a result receiver + val relayReceiver = PMUtils.registerRelayReceiver( + context = context, + sessionId = -1, + isUninstall = true, + onResult = continuation::resume, + ) + + // Unregister PMResultReceiver when this coroutine finishes or errors + continuation.invokeOnCancellation { + context.unregisterReceiver(relayReceiver) + } + + packageInstaller.uninstall( + /* packageName = */ packageName, + /* statusReceiver = */ PMUtils.createUninstallRelayingIntent(context).intentSender, + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/shizuku/ShizukuSettingsWrapper.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/shizuku/ShizukuSettingsWrapper.kt new file mode 100644 index 0000000..04462dd --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/installers/shizuku/ShizukuSettingsWrapper.kt @@ -0,0 +1,65 @@ +package com.meowarex.rlmobile.installers.shizuku + +import android.content.* +import android.os.* +import android.provider.Settings +import androidx.annotation.RequiresApi +import com.meowarex.rlmobile.util.HiddenAPI +import rikka.shizuku.Shizuku +import rikka.shizuku.ShizukuBinderWrapper + +// Based on https://github.com/vvb2060/PackageInstaller/blob/3d113a5e000c62a712e6165cb75cbca63fb912aa/app/src/main/java/io/github/vvb2060/packageinstaller/model/Hook.kt + +/** + * Wraps writing to Android's global settings through Shizuku. + * This allows accessing Android secure settings. + */ +object ShizukuSettingsWrapper { + /** + * Disable ADB install verification (bypass useless Play Protect). + */ + fun disableAdbVerify(context: Context) { + val settingName = "verifier_verify_adb_installs" + val enabled = Settings.Global.getInt(context.contentResolver, settingName, 1) != 0 + if (enabled) { + wrapGlobalSettings { + val contextWrapper = ShizukuContext(context) + val cr = object : ContentResolver(contextWrapper) {} + Settings.Global.putInt(cr, settingName, 0) + } + } + } + + private fun wrapGlobalSettings(callback: () -> Unit) { + HiddenAPI.disable() + + val holder = Settings.Global::class.java.getDeclaredField("sProviderHolder") + .apply { isAccessible = true } + .get(null) + val provider = holder::class.java.getDeclaredField("mContentProvider") + .apply { isAccessible = true } + .get(holder) + + val remoteField = provider::class.java.getDeclaredField("mRemote") + .apply { isAccessible = true } + + val originalBinder = remoteField.get(provider) as IBinder + remoteField.set(provider, ShizukuBinderWrapper(originalBinder)) + callback() + remoteField.set(provider, originalBinder) + } + + private class ShizukuContext(context: Context) : ContextWrapper(context) { + override fun getOpPackageName(): String = "com.android.shell" + + @RequiresApi(Build.VERSION_CODES.S) + override fun getAttributionSource(): AttributionSource { + val builder = AttributionSource.Builder(Shizuku.getUid()) + .setPackageName("com.android.shell") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + builder.setPid(Process.INVALID_PID) + } + return builder.build() + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/DhizukuManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/DhizukuManager.kt new file mode 100644 index 0000000..d53afb3 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/DhizukuManager.kt @@ -0,0 +1,74 @@ +package com.meowarex.rlmobile.manager + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.util.showToast +import com.rosan.dhizuku.api.Dhizuku +import com.rosan.dhizuku.api.DhizukuRequestPermissionListener +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume + +/** + * Handles setting up Dhizuku and obtaining permissions. + */ +class DhizukuManager(private val context: Context) { + private var dhizukuPermissionLock = Mutex() + private val dhizukuAvailable = AtomicBoolean(false) + + /** + * Determines whether Dhizuku is available and the binder has been retrieved. + */ + fun dhizukuAvailable(): Boolean { + // Dhziuku requires at least Android 8.0 + if (Build.VERSION.SDK_INT < 26) return false + + if (!dhizukuAvailable.get()) { + return Dhizuku.init(context) + .also(dhizukuAvailable::set) + } + return true + } + + /** + * Checks whether Dhizuku permissions have been granted to this app. + */ + fun checkPermissions(): Boolean { + if (!dhizukuAvailable()) return false + + return Dhizuku.isPermissionGranted() + } + + /** + * Requests and waits for Dhizuku permissions if they have not already been granted. + */ + suspend fun requestPermissions(): Boolean { + if (!dhizukuAvailable()) return false + + // Lock and check if the previous holder already obtained permissions + dhizukuPermissionLock.lock() + try { + if (checkPermissions()) { + dhizukuPermissionLock.unlock() + return true + } + } catch (_: Exception) { + dhizukuPermissionLock.unlock() + } + + return suspendCancellableCoroutine { continuation -> + Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() { + override fun onRequestPermission(grantResult: Int) { + if (grantResult != PackageManager.PERMISSION_GRANTED) + context.showToast(R.string.permissions_dhizuku_denied) + + continuation.resume(grantResult == PackageManager.PERMISSION_GRANTED) + dhizukuPermissionLock.unlock() + } + }) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/InstallLogManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/InstallLogManager.kt new file mode 100644 index 0000000..7d24747 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/InstallLogManager.kt @@ -0,0 +1,188 @@ +package com.meowarex.rlmobile.manager + +import android.annotation.SuppressLint +import android.app.Application +import android.os.Build +import android.os.storage.StorageManager +import android.util.Log +import androidx.compose.runtime.Immutable +import androidx.core.content.getSystemService +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import com.meowarex.rlmobile.util.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.time.Duration +import kotlin.time.Instant + +/** + * Central manager for storing all attempted installations and + * their associated logs/crashes (not including manager crashes themselves). + */ +class InstallLogManager( + private val application: Application, + private val prefs: PreferencesManager, + private val json: Json, +) { + val logsDir = application.filesDir.resolve("install-logs").apply { mkdir() } + + /** + * Lists all the install data entries that exist on disk, sorted decreasing by + * the file creation date. + * @return List of installation ids, most recent installation first. + */ + fun fetchInstallDataEntries(): List { + val files = logsDir.listFiles { it.extension == "json" } ?: emptyArray() + + return files + .sortedByDescending { it.lastModified() } + .map { it.nameWithoutExtension } + } + + /** + * Loads the install log from disk, if it exists. + */ + fun fetchInstallData(id: String): InstallLogData? { + val path = logsDir.resolve("$id.json") + if (!path.exists()) return null + + return try { + json.decodeFromStream(path.inputStream()) + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to open install log $id", t) + null + } + } + + fun deleteAllEntries() { + logsDir.deleteRecursively() + logsDir.mkdir() + } + + /** + * Writes an install log entry to disk. + */ + suspend fun storeInstallData( + id: String, + installDate: Instant, + installDuration: Duration, + options: PatchOptions, + log: String, + error: Throwable?, + ) { + val path = logsDir.resolve("$id.json") + + val data = InstallLogData( + id = id, + installDate = installDate, + installDuration = installDuration, + installOptions = options, + environmentInfo = getEnvironmentInfo(), + installationLog = log, + errorStacktrace = error?.let { Log.getStackTraceString(it).trimEnd() }, + ) + + try { + path.writeText(json.encodeToString(data)) + } catch (e: IOException) { + Log.e(BuildConfig.TAG, "Failed to write log to disk", e) + } + } + + /** + * Creates a list of details about the current installation environment. + */ + @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants") + @SuppressLint("UsableSpace") + suspend fun getEnvironmentInfo(): String { + val storageManager = application.getSystemService()!! + + val buildType = when { + BuildConfig.RELEASE -> "(Release)" + BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS -> "(Changes present)" + else -> "" + } + val soc = if (Build.VERSION.SDK_INT >= 31) (Build.SOC_MANUFACTURER + ' ' + Build.SOC_MODEL) else "Unavailable" + val playProtect = when (application.isPlayProtectEnabled()) { + null -> "Unavailable" + true -> "Enabled" + false -> "Disabled" + } + + val diskFreeSize = application.filesDir.usableSpace + val cacheQuotaSize = if (Build.VERSION.SDK_INT >= 26) { + storageManager.getCacheQuotaBytes(storageManager.getUuidForPath(application.cacheDir)) + } else { + 0L + } + + return """ + Radiant Lyrics Manager v${BuildConfig.VERSION_NAME} + Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} $buildType + Developer mode: ${if (prefs.devMode) "On" else "Off"} + External storage: ${if (prefs.devMode || prefs.keepPatchedApks) "Yes" else "No"} + + Disk Free: ${diskFreeSize.formatShortFileSize()} + Cache Quota: ${cacheQuotaSize.formatShortFileSize()} + + Android API: ${Build.VERSION.SDK_INT} + Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()} + ROM: Android ${Build.VERSION.RELEASE} (Patch ${Build.VERSION.SECURITY_PATCH}) + Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE}) + Emulator: ${if (IS_PROBABLY_EMULATOR) "Yes" else "No"} (guess) + Play Protect: $playProtect + SOC: $soc + """.trimIndent() + } +} + +@Immutable +@Serializable +data class InstallLogData( + val id: String, + val installDate: Instant, + val installDuration: Duration, + val installOptions: PatchOptions, + val environmentInfo: String, + val installationLog: String, + val errorStacktrace: String?, +) { + val isError: Boolean + get() = errorStacktrace != null + + fun getFormattedInstallDate(): String { + @SuppressLint("SimpleDateFormat") + return SimpleDateFormat("yyyy-MM-dd HH-mm-ss", Locale.ENGLISH) + .format(Date(installDate.toEpochMilliseconds())) + } + + fun getLogFileContents(): String = buildString { + appendLine("////////////////// Environment Info //////////////////") + appendLine(environmentInfo) + + append("\n\n") + appendLine("////////////////// Installation Info //////////////////") + appendLine() + append("Install ID: ") + appendLine(id) + append("Install time: ") + appendLine(getFormattedInstallDate()) + append("Result: ") + appendLine(if (isError) "Failure" else "Success") + + append("\n\n") + appendLine("////////////////// Error Stacktrace //////////////////") + appendLine() + appendLine(errorStacktrace ?: "None") + + append("\n\n") + appendLine("////////////////// Installation Log //////////////////") + appendLine() + appendLine(installationLog) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/InstallerManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/InstallerManager.kt new file mode 100644 index 0000000..ea3ba33 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/InstallerManager.kt @@ -0,0 +1,64 @@ +package com.meowarex.rlmobile.manager + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.installers.Installer +import com.meowarex.rlmobile.installers.dhizuku.DhizukuInstaller +import com.meowarex.rlmobile.installers.intent.IntentInstaller +import com.meowarex.rlmobile.installers.pm.PMInstaller +import com.meowarex.rlmobile.installers.root.RootInstaller +import com.meowarex.rlmobile.installers.shizuku.ShizukuInstaller +import org.koin.core.annotation.KoinInternalApi +import org.koin.core.component.KoinComponent +import kotlin.reflect.KClass + +/** + * Handle providing the correct install manager based on preferences. + */ +class InstallerManager( + private val prefs: PreferencesManager, +) : KoinComponent { + fun getActiveInstaller(): Installer = + getInstaller(prefs.installer) + + @OptIn(KoinInternalApi::class) + fun getInstaller(type: InstallerSetting): Installer = + getKoin().scopeRegistry.rootScope.get(clazz = type.installerClass) +} + +enum class InstallerSetting(val installerClass: KClass) { + PackageInstaller(PMInstaller::class), + Root(RootInstaller::class), + Intent(IntentInstaller::class), + Shizuku(ShizukuInstaller::class), + Dhizuku(DhizukuInstaller::class); + + @Composable + fun title() = when (this) { + PackageInstaller -> stringResource(R.string.installer_pm) + Root -> stringResource(R.string.installer_root) + Intent -> stringResource(R.string.installer_intent) + Shizuku -> stringResource(R.string.installer_shizuku) + Dhizuku -> stringResource(R.string.installer_dhizuku) + } + + @Composable + fun description() = when (this) { + PackageInstaller -> stringResource(R.string.installer_pm_desc) + Root -> stringResource(R.string.installer_root_desc) + Intent -> stringResource(R.string.installer_intent_desc) + Shizuku -> stringResource(R.string.installer_shizuku_desc) + Dhizuku -> stringResource(R.string.installer_dhizuku_desc) + } + + @Composable + fun icon() = when (this) { + PackageInstaller -> painterResource(R.drawable.ic_android) + Root -> painterResource(R.drawable.ic_hashtag) + Intent -> painterResource(R.drawable.ic_launch) + Shizuku -> painterResource(R.drawable.ic_shizuku) + Dhizuku -> painterResource(R.drawable.ic_dhizuku) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/OverlayManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/OverlayManager.kt new file mode 100644 index 0000000..5def93b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/OverlayManager.kt @@ -0,0 +1,72 @@ +package com.meowarex.rlmobile.manager + +import androidx.compose.runtime.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlin.coroutines.resume + +typealias ResultComposable = @Composable (onResult: (R) -> Unit) -> Unit + +/** + * This is used to display dialogs on top of the current activity at any point in the code. + * The main use case for this is dialogs for which a result is needed during patching steps. + * + * The only other alternative to this setup is binding `Flow`s from the steps back to the patching screen model + * and then displaying them in the UI, which is too much boilerplate. + */ +@Stable +class OverlayManager { + private val coroutineScope = CoroutineScope(Dispatchers.Main) + SupervisorJob() + private var overlays = mutableStateListOf>() + private var overlayResults = MutableSharedFlow, Any?>>(extraBufferCapacity = 5) + + /** + * Display all the currently queued overlays. + */ + @Composable + fun Overlays() { + for (composable in overlays) { + key(System.identityHashCode(composable)) { + val composable by rememberUpdatedState(composable) + + composable { result -> + if (!overlayResults.tryEmit(composable to result)) + error("overlayResults flow full!") + + overlays -= composable + } + } + } + } + + /** + * Adds a composable to the overlay stack which will be displayed over the top of any content. + * + * This content will be displayed until the `onResult` callback is called, + * after which this method will finish suspending with the result from the invoked callback. + * + * If the coroutine scope this method was called in gets cancelled, then the overlay will be + * removed and no result will be returned (cancelled). + */ + @Suppress("UNCHECKED_CAST") + suspend fun startComposableForResult(composable: ResultComposable): R { + return suspendCancellableCoroutine { continuation -> + val job = overlayResults + .filter { (c, _) -> c === composable } + .onEach { (_, result) -> continuation.resume(result as R) } + .cancellable() + .launchIn(coroutineScope) + + continuation.invokeOnCancellation { + coroutineScope.launch { + overlays -= composable + job.cancel() + } + } + + coroutineScope.launch { + overlays += composable + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PathManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PathManager.kt new file mode 100644 index 0000000..ab95de5 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PathManager.kt @@ -0,0 +1,53 @@ +package com.meowarex.rlmobile.manager + +import android.app.Application +import android.os.Environment +import com.meowarex.rlmobile.network.utils.SemVer +import java.io.File + +class PathManager( + private val context: Application, +) { + val rlMobileDir = Environment.getExternalStorageDirectory().resolve("RadiantLyrics") + + val pluginsDir = rlMobileDir.resolve("plugins") + + val coreSettingsFile = rlMobileDir.resolve("settings/RadiantLyrics.json") + + val legacyKeystoreFile = rlMobileDir.resolve("ks.keystore") + + val keystoreFile = context.filesDir.resolve("rlmobile.keystore") + + val patchingDir = context.filesDir.resolve("patching") + + val patchingDownloadDir = patchingDir.resolve("downloads") + + val cacheDownloadDir = context.cacheDir.resolve("downloads") + + val customComponentsDir = patchingDir.resolve("custom") + + val customInjectorsDir = customComponentsDir.resolve("injector") + + val customPatchesDir = customComponentsDir.resolve("patches") + + val patchingWorkingDir = patchingDir.resolve("patched") + + val patchedApk = patchingWorkingDir.resolve("patched.apk") + + fun clearCache() { + for (dir in arrayOf(patchingDir, cacheDownloadDir, context.cacheDir)) + dir.deleteRecursively() + } + + fun cachedTidalApk(version: Int, split: String = "base"): File = patchingDownloadDir + .resolve("tidal/$version") + .resolve("$split.apk") + + fun cachedSmaliPatches(version: SemVer) = patchingDownloadDir + .resolve("patches") + .resolve("$version.zip") + + fun customInjectors() = customInjectorsDir.listFiles()?.asList() ?: emptyList() + + fun customSmaliPatches() = customPatchesDir.listFiles()?.asList() ?: emptyList() +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PreferencesManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PreferencesManager.kt new file mode 100644 index 0000000..5fdfab7 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PreferencesManager.kt @@ -0,0 +1,17 @@ +package com.meowarex.rlmobile.manager + +import android.content.SharedPreferences +import androidx.compose.runtime.Stable +import com.meowarex.rlmobile.manager.base.BasePreferenceManager +import com.meowarex.rlmobile.ui.theme.Theme + +@Stable +class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager(preferences) { + var theme by enumPreference("theme", Theme.System) + var dynamicColor by booleanPreference("dynamic_color", true) + var devMode by booleanPreference("dev_mode", false) + var installer by enumPreference("installer", InstallerSetting.PackageInstaller) + var keepPatchedApks by booleanPreference("keep_patched_apks", false) + var showNetworkWarning by booleanPreference("show_network_warning", true) + var showPlayProtectWarning by booleanPreference("show_play_protect_warning", true) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/ShizukuManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/ShizukuManager.kt new file mode 100644 index 0000000..f0f6277 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/ShizukuManager.kt @@ -0,0 +1,96 @@ +package com.meowarex.rlmobile.manager + +import android.content.Context +import android.content.pm.PackageManager +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.util.showToast +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import rikka.shizuku.Shizuku +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlin.random.Random + +/** + * Handles setting up Shizuku and obtaining permissions. + */ +class ShizukuManager(private val context: Context) { + private var shizukuPermissionLock = Mutex() + private val shizukuAvailable = AtomicBoolean(false) + + init { + Shizuku.addBinderReceivedListenerSticky { + shizukuAvailable.set(true) + } + Shizuku.addBinderDeadListener { + shizukuAvailable.set(false) + + if (shizukuPermissionLock.isLocked) + shizukuPermissionLock.unlock() + } + } + + /** + * Determines whether Shizuku is available and the binder has been retrieved. + */ + fun shizukuAvailable(): Boolean { + if (!shizukuAvailable.get()) { + return Shizuku.pingBinder() + .also(shizukuAvailable::set) + } + return true + } + + /** + * Checks whether Shizuku permissions have been granted to this app. + */ + fun checkPermissions(): Boolean { + if (!shizukuAvailable()) return false + + // Old shizuku does not have permission checks + if (Shizuku.isPreV11()) return true + + return Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + } + + /** + * Requests and waits for Shizuku permissions if they have not already been granted. + */ + suspend fun requestPermissions(): Boolean { + if (!shizukuAvailable()) return false + + // Lock and check if the previous holder already obtained permissions + shizukuPermissionLock.lock() + try { + if (checkPermissions()) { + shizukuPermissionLock.unlock() + return true + } + } catch (_: Exception) { + shizukuPermissionLock.unlock() + } + + return suspendCancellableCoroutine { continuation -> + val currentRequestCode = Random.nextInt() + val onPermissionRequestResult = + Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> + if (requestCode != currentRequestCode) + return@OnRequestPermissionResultListener + + if (grantResult == PackageManager.PERMISSION_DENIED) + context.showToast(R.string.permissions_shizuku_denied) + + continuation.resume(grantResult == PackageManager.PERMISSION_GRANTED) + shizukuPermissionLock.unlock() + } + + continuation.invokeOnCancellation { + Shizuku.removeRequestPermissionResultListener(onPermissionRequestResult) + shizukuPermissionLock.unlock() + } + + Shizuku.addRequestPermissionResultListener(onPermissionRequestResult) + Shizuku.requestPermission(currentRequestCode) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/base/BasePreferencesManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/base/BasePreferencesManager.kt new file mode 100644 index 0000000..a207ec3 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/base/BasePreferencesManager.kt @@ -0,0 +1,99 @@ +package com.meowarex.rlmobile.manager.base + +import android.content.SharedPreferences +import androidx.compose.runtime.* +import androidx.core.content.edit +import kotlin.reflect.KProperty + +abstract class BasePreferenceManager( + private val prefs: SharedPreferences, +) { + protected fun getString(key: String, defaultValue: String) = prefs.getString(key, defaultValue) ?: defaultValue + private fun getBoolean(key: String, defaultValue: Boolean) = prefs.getBoolean(key, defaultValue) + private fun getInt(key: String, defaultValue: Int) = prefs.getInt(key, defaultValue) + private fun getFloat(key: String, defaultValue: Float) = prefs.getFloat(key, defaultValue) + protected inline fun > getEnum(key: String, defaultValue: E): E { + return try { + enumValueOf(getString(key, defaultValue.name)) + } catch (_: IllegalArgumentException) { + defaultValue + } + } + + protected fun putString(key: String, value: String?) = prefs.edit { putString(key, value) } + private fun putBoolean(key: String, value: Boolean) = prefs.edit { putBoolean(key, value) } + private fun putInt(key: String, value: Int) = prefs.edit { putInt(key, value) } + private fun putFloat(key: String, value: Float) = prefs.edit { putFloat(key, value) } + protected inline fun > putEnum(key: String, value: E) = putString(key, value.name) + + protected class Preference( + private val key: String, + defaultValue: T, + getter: (key: String, defaultValue: T) -> T, + private val setter: (key: String, newValue: T) -> Unit, + ) { + var value by mutableStateOf(getter(key, defaultValue)) + private set + + operator fun getValue(thisRef: Any?, property: KProperty<*>) = value + operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) { + value = newValue + setter(key, newValue) + } + } + + @Suppress("unused") + protected fun stringPreference( + key: String, + defaultValue: String, + ) = Preference( + key = key, + defaultValue = defaultValue, + getter = ::getString, + setter = ::putString + ) + + @Suppress("unused") + protected fun booleanPreference( + key: String, + defaultValue: Boolean, + ) = Preference( + key = key, + defaultValue = defaultValue, + getter = ::getBoolean, + setter = ::putBoolean + ) + + @Suppress("unused") + protected fun intPreference( + key: String, + defaultValue: Int, + ) = Preference( + key = key, + defaultValue = defaultValue, + getter = ::getInt, + setter = ::putInt + ) + + @Suppress("unused") + protected fun floatPreference( + key: String, + defaultValue: Float, + ) = Preference( + key = key, + defaultValue = defaultValue, + getter = ::getFloat, + setter = ::putFloat + ) + + @Suppress("unused") + protected inline fun > enumPreference( + key: String, + defaultValue: E, + ) = Preference( + key = key, + defaultValue = defaultValue, + getter = ::getEnum, + setter = ::putEnum + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/download/AndroidDownloadManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/download/AndroidDownloadManager.kt new file mode 100644 index 0000000..ecdce2e --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/download/AndroidDownloadManager.kt @@ -0,0 +1,153 @@ +package com.meowarex.rlmobile.manager.download + +import android.app.Application +import android.app.DownloadManager +import android.content.Context +import android.database.Cursor +import android.net.Uri +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.download.IDownloadManager.ProgressListener +import com.meowarex.rlmobile.manager.download.IDownloadManager.Result +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import java.io.File +import kotlin.coroutines.cancellation.CancellationException + +/** + * Handle downloading remote urls to a path through the system's [DownloadManager]. + */ +class AndroidDownloadManager(application: Application) : IDownloadManager { + private val downloadManager = application.getSystemService() + ?: throw IllegalStateException("DownloadManager service is not available") + + /** + * Start a cancellable download with the system [IDownloadManager]. + * If the current [CoroutineScope] is cancelled, then the system download will be cancelled within 100ms. + * @param url Remote src url + * @param out Target path to download to. It is assumed that the application has write permissions to this path. + * @param onProgressUpdate An optional [ProgressListener] + */ + override suspend fun download(url: String, out: File, onProgressUpdate: ProgressListener?): Result { + onProgressUpdate?.onUpdate(null) + out.parentFile?.mkdirs() + + // Create and start a download in the system DownloadManager + val downloadId = DownloadManager.Request(url.toUri()) + .setTitle("Radiant Lyrics Manager") + .setDescription("Downloading ${out.name}...") + .setDestinationUri(Uri.fromFile(out)) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) + .addRequestHeader("User-Agent", "Radiant Lyrics Manager/${BuildConfig.VERSION_NAME}") + .let(downloadManager::enqueue) + + // Repeatedly request download state until it is finished + while (true) { + try { + // Hand over control to a suspend function to check for cancellation + // At the same time, delay 100ms to slow down the potentially infinite loop + delay(100) + } catch (_: CancellationException) { + // If the running CoroutineScope has been cancelled, then gracefully cancel download + downloadManager.remove(downloadId) + return Result.Cancelled(systemTriggered = false) + } + + // Request download status + val cursor = DownloadManager.Query() + .setFilterById(downloadId) + .let(downloadManager::query) + + cursor.use { + // No results in cursor, download was cancelled + if (!cursor.moveToFirst()) { + return Result.Cancelled(systemTriggered = true) + } + + val statusColumn = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) + val status = cursor.getInt(statusColumn) + + when (status) { + DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED -> + onProgressUpdate?.onUpdate(null) + + DownloadManager.STATUS_RUNNING -> + onProgressUpdate?.onUpdate(getDownloadProgress(cursor)) + + DownloadManager.STATUS_SUCCESSFUL -> + return Result.Success(out) + + DownloadManager.STATUS_FAILED -> { + val reasonColumn = cursor.getColumnIndex(DownloadManager.COLUMN_REASON) + val reason = cursor.getInt(reasonColumn) + + return Error(reason) + } + + else -> throw Error("Unreachable") + } + } + } + } + + /** + * Get the download progress of the current row in a [DownloadManager.Query]. + * @return Download progress in the range of `[0,1]` + */ + private fun getDownloadProgress(queryCursor: Cursor): Float { + val bytesColumn = queryCursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + val bytes = queryCursor.getLong(bytesColumn) + + val totalBytesColumn = queryCursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + val totalBytes = queryCursor.getLong(totalBytesColumn) + + if (totalBytes <= 0) return 0f + return bytes.toFloat() / totalBytes + } + + /** + * Error returned by the system [DownloadManager]. + * @param reason The reason code returned by the [DownloadManager.COLUMN_REASON] column. + */ + data class Error(val reason: Int) : Result.Error() { + /** + * Convert a [DownloadManager.COLUMN_REASON] code into its name. + */ + override fun getDebugReason(): String = when (reason) { + DownloadManager.ERROR_UNKNOWN -> "Unknown" + DownloadManager.ERROR_FILE_ERROR -> "File Error" + DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP code" + DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP data error" + DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects" + DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient space" + DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Target file's device not found" + DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume" + DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File exists" + /* DownloadManager.ERROR_BLOCKED */ 1010 -> "Network policy block" + else -> "Unknown code ($reason)" + } + + override fun getLocalizedReason(context: Context): String { + val string = when (reason) { // @formatter:off + DownloadManager.ERROR_HTTP_DATA_ERROR, + DownloadManager.ERROR_TOO_MANY_REDIRECTS, + DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> + R.string.downloader_err_response + + DownloadManager.ERROR_INSUFFICIENT_SPACE -> + R.string.downloader_err_storage_space + + DownloadManager.ERROR_FILE_ALREADY_EXISTS -> + R.string.downloader_err_file_exists + + else -> R.string.downloader_err_unknown + } // @formatter:on + + return context.getString(string) + } + + override fun toString(): String = getDebugReason() + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/download/IDownloadManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/download/IDownloadManager.kt new file mode 100644 index 0000000..b13f01a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/download/IDownloadManager.kt @@ -0,0 +1,69 @@ +package com.meowarex.rlmobile.manager.download + +import android.app.DownloadManager +import android.content.Context +import java.io.File + +/** + * Common interface for different implementations of starting and managing the lifetime of downloads. + */ +interface IDownloadManager { + /** + * Start a cancellable download. + * @param url Remote src url + * @param out Target path to download to. It is assumed that the application has write permissions to this path. + * @param onProgressUpdate An optional [ProgressListener] callback. + */ + suspend fun download(url: String, out: File, onProgressUpdate: ProgressListener? = null): Result + + /** + * A callback executed from a coroutine called every 100ms in order to provide + * info about the current download. This should not perform long-running tasks as the delay will be offset. + */ + fun interface ProgressListener { + /** + * @param progress The current download progress in a `[0,1]` range. If null, then the download is either + * paused, pending, or waiting to retry. + */ + fun onUpdate(progress: Float?) + } + + /** + * The state of a download after execution has been completed and the system-level [DownloadManager] has been cleaned up. + */ + sealed interface Result { + /** + * The download succeeded successfully. + * @param file The path that the download was downloaded to. + */ + data class Success(val file: File) : Result + + /** + * This download was interrupted and the in-progress file has been deleted. + * @param systemTriggered Whether the cancellation happened from the system (ie. clicked cancel on the download notification) + * Otherwise, this was caused by a coroutine cancellation. + */ + data class Cancelled(val systemTriggered: Boolean) : Result + + /** + * This download failed to complete due to an error. + */ + abstract class Error : Result { + /** + * The full internal error representation. + */ + abstract fun getDebugReason(): String + + /** + * Simplified + translatable user facing reason for the failure. + * If null is returned, then the [getDebugReason] will be used instead. + */ + open fun getLocalizedReason(context: Context): String? = null + + /** + * Gets the underlying raw error (if available). + */ + open fun getError(): Throwable? = null + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/download/KtorDownloadManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/download/KtorDownloadManager.kt new file mode 100644 index 0000000..bee8628 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/download/KtorDownloadManager.kt @@ -0,0 +1,148 @@ +package com.meowarex.rlmobile.manager.download + +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.storage.StorageManager +import android.util.Log +import androidx.annotation.StringRes +import androidx.core.content.getSystemService +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.download.IDownloadManager.Result +import com.meowarex.rlmobile.patcher.util.InsufficientStorageException +import com.meowarex.rlmobile.util.IS_PROBABLY_EMULATOR +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.prepareGet +import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText +import io.ktor.http.* +import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.CancellationException +import java.io.File +import java.io.IOException +import java.net.SocketTimeoutException + +/** + * Handle downloading remote urls to a path with Ktor. + * This is used as an alternative downloader option due to some bugs with the + * system's DownloadManager that prevents its usage on some emulators and ROMs. + */ +class KtorDownloadManager( + private val http: HttpClient, + private val application: Application, +) : IDownloadManager { + override suspend fun download(url: String, out: File, onProgressUpdate: IDownloadManager.ProgressListener?): Result { + onProgressUpdate?.onUpdate(null) + out.parentFile?.mkdirs() + + val tmpOut = out.resolveSibling(out.name + ".tmp") + + try { + val httpStmt = http.prepareGet(url) { + header(HttpHeaders.CacheControl, "no-cache, no-store") + + // Disable compression due to bug on emulators + // This header cannot be set with Android's DownloadManager + if (IS_PROBABLY_EMULATOR) { + header(HttpHeaders.AcceptEncoding, null) + } + } + + httpStmt.execute { resp -> + if (!resp.status.isSuccess()) { + val body = try { + resp.bodyAsText().take(2048) + } catch (e: Exception) { + Log.e(BuildConfig.TAG, "Failed to read downloader error response", e) + "" + } + + throw DownloadException(url = url, status = resp.status, body = body) + } + + val channel = resp.bodyAsChannel() + val total = resp.contentLength() ?: 0 + var retrieved = 0L + + val buf = ByteArray(1024 * 1024 * 1) + var bufLen: Int + + tmpOut.outputStream().use { stream -> + // Preallocate space for this file + if (total > 0 && Build.VERSION.SDK_INT >= 26) { + val storageManager = application.getSystemService()!! + + try { + storageManager.allocateBytes(stream.fd, total) + } catch (e: IOException) { + throw InsufficientStorageException(e.message) + } + } + + while (!channel.isClosedForRead) { + bufLen = channel.readAvailable(buf) + if (bufLen <= 0) break + + stream.write(buf, 0, bufLen) + stream.flush() + + retrieved += bufLen + + if (total > 0) { + if (retrieved > total) + throw IOException("Total bytes received exceeds header total!") + + onProgressUpdate?.onUpdate(retrieved / total.toFloat()) + } else { + onProgressUpdate?.onUpdate(null) + } + } + } + } + } catch (_: CancellationException) { + tmpOut.delete() + return Result.Cancelled(systemTriggered = false) + } catch (e: DownloadException) { + tmpOut.delete() + return Error( + error = e, + localizedError = R.string.downloader_err_code, + localizedErrorArgs = arrayOf(e.status.value), + ) + } catch (e: SocketTimeoutException) { + tmpOut.delete() + return Error(e, localizedError = R.string.downloader_err_timeout) + } catch (e: InsufficientStorageException) { + tmpOut.delete() + return Error(e, localizedError = R.string.downloader_err_storage_space) + } catch (t: Throwable) { + tmpOut.delete() + return Error(t) + } + + tmpOut.renameTo(out) + return Result.Success(out) + } + + /** + * Wrapper around an exception that occurred from invoking Ktor + */ + class Error( + private val error: Throwable, + @StringRes + private val localizedError: Int? = null, + private val localizedErrorArgs: Array = arrayOf(), + ) : Result.Error() { + override fun toString(): String = error.stackTraceToString() + override fun getDebugReason(): String = error.message ?: "Unknown exception" + override fun getLocalizedReason(context: Context): String? = + localizedError?.let { context.getString(it, *localizedErrorArgs) } + + override fun getError(): Throwable? = error + } + + private class DownloadException(val url: String, val status: HttpStatusCode, val body: String) : + IOException("Failed to download $url, received status code $status, response: $body") +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/Contributor.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/Contributor.kt new file mode 100644 index 0000000..db93a6d --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/Contributor.kt @@ -0,0 +1,23 @@ +package com.meowarex.rlmobile.network.models + +import androidx.compose.runtime.Immutable +import com.meowarex.rlmobile.util.serialization.ImmutableListSerializer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class Contributor( + val username: String, + val avatarUrl: String, + val commits: Int, + @Serializable(with = ImmutableListSerializer::class) + val repositories: ImmutableList, +) { + @Immutable + @Serializable + data class Repository( + val name: String, + val commits: Int, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/GithubRelease.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/GithubRelease.kt new file mode 100644 index 0000000..166c67f --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/GithubRelease.kt @@ -0,0 +1,22 @@ +package com.meowarex.rlmobile.network.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GithubRelease( + @SerialName("created_at") + val createdAt: String, + val assets: List, + @SerialName("tag_name") + val tagName: String, + @SerialName("html_url") + val htmlUrl: String, +) { + @Serializable + data class GithubReleaseAssets( + val name: String, + @SerialName("browser_download_url") + val browserDownloadUrl: String, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/RLBuildInfo.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/RLBuildInfo.kt new file mode 100644 index 0000000..0dccced --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/models/RLBuildInfo.kt @@ -0,0 +1,15 @@ +package com.meowarex.rlmobile.network.models + +import com.meowarex.rlmobile.network.utils.SemVer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RLBuildInfo( + @SerialName("tidalVersionCode") + val tidalVersionCode: Int, + @SerialName("tidalApkUrl") + val tidalApkUrl: String, + @SerialName("patchesVersion") + val patchesVersion: SemVer, +) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/HttpService.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/HttpService.kt new file mode 100644 index 0000000..57c0e44 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/HttpService.kt @@ -0,0 +1,51 @@ +package com.meowarex.rlmobile.network.services + +import android.util.Log +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.network.utils.* +import io.ktor.client.HttpClient +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.request +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +class HttpService( + val json: Json, + val http: HttpClient, +) { + suspend inline fun request( + crossinline builder: HttpRequestBuilder.() -> Unit = {}, + ): ApiResponse = withContext(Dispatchers.IO) request@{ + var body: String? = null + + val response = try { + val response = http.request(builder) + + if (response.status.isSuccess()) { + body = response.bodyAsText() + + if (T::class == String::class) { + return@request ApiResponse.Success(body as T) + } + + ApiResponse.Success(json.decodeFromString(body)) + } else { + body = try { + response.bodyAsText() + } catch (t: Throwable) { + null + } + + Log.e(BuildConfig.TAG, "Failed to fetch: API error, http status: ${response.status}, body: $body") + ApiResponse.Error(ApiError(response.status, body)) + } + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to fetch: error: $t, body: $body") + ApiResponse.Failure(ApiFailure(t, body)) + } + return@request response + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/RLMobileGithubService.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/RLMobileGithubService.kt new file mode 100644 index 0000000..a2eb7df --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/RLMobileGithubService.kt @@ -0,0 +1,54 @@ +package com.meowarex.rlmobile.network.services + +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.network.models.GithubRelease +import com.meowarex.rlmobile.network.models.RLBuildInfo +import com.meowarex.rlmobile.network.utils.ApiResponse +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.http.HttpHeaders + +class RadiantLyricsGithubService( + private val http: HttpService, +) { + /** + * Fetches the latest release from meowarex/rl-mobile to determine current patch + TIDAL versions. + */ + suspend fun getLatestRelease(force: Boolean = false): ApiResponse = + http.request { + url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/releases/latest") + if (force) { + header(HttpHeaders.CacheControl, "no-cache") + } else { + header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60") + } + } + + /** + * Fetches build metadata from the data.json asset in the latest GitHub release. + * The data.json asset URL is obtained from [getLatestRelease]. + */ + suspend fun getBuildInfo(dataJsonUrl: String, force: Boolean = false): ApiResponse = + http.request { + url(dataJsonUrl) + if (force) { + header(HttpHeaders.CacheControl, "no-cache") + } + } + + /** + * Fetches manager self-update releases. + */ + suspend fun getManagerReleases(): ApiResponse> = + http.request { + url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/releases") + header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60") + } + + companion object { + const val REPO_OWNER = BuildConfig.PATCHES_REPO_OWNER + const val REPO_NAME = BuildConfig.PATCHES_REPO_NAME + const val PATCHES_ASSET_NAME = "patches.zip" + const val DATA_JSON_ASSET_NAME = "data.json" + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/ApiResponse.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/ApiResponse.kt new file mode 100644 index 0000000..251cca1 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/ApiResponse.kt @@ -0,0 +1,82 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.meowarex.rlmobile.network.utils + +import io.ktor.http.HttpStatusCode + +sealed interface ApiResponse { + data class Success(val data: T) : ApiResponse + data class Error(val error: ApiError) : ApiResponse + data class Failure(val error: ApiFailure) : ApiResponse +} + +class ApiError(code: HttpStatusCode, body: String?) : Error("HTTP Code $code, Body: $body") + +class ApiFailure(error: Throwable, body: String?) : Error(body, error) + +inline fun ApiResponse.fold( + success: (T) -> R, + error: (ApiError) -> R, + failure: (ApiFailure) -> R, +): R { + return when (this) { + is ApiResponse.Success -> success(this.data) + is ApiResponse.Error -> error(this.error) + is ApiResponse.Failure -> failure(this.error) + } +} + +inline fun ApiResponse.fold( + success: (T) -> R, + fail: (Error) -> R, +): R { + return when (this) { + is ApiResponse.Success -> success(data) + is ApiResponse.Error -> fail(error) + is ApiResponse.Failure -> fail(error) + } +} + +@Suppress("UNCHECKED_CAST") +inline fun ApiResponse.transform(block: (T) -> R): ApiResponse { + return if (this !is ApiResponse.Success) { + // Error and Failure do not use the generic value + this as ApiResponse + } else { + ApiResponse.Success(block(data)) + } +} + +inline fun ApiResponse.getOrThrow(): T { + return fold( + success = { it }, + fail = { throw it } + ) +} + +inline fun ApiResponse.getOrNull(): T? { + return fold( + success = { it }, + fail = { null } + ) +} + +@Suppress("UNCHECKED_CAST") +inline fun ApiResponse.chain(block: (T) -> ApiResponse): ApiResponse { + return if (this !is ApiResponse.Success) { + // Error and Failure do not use the generic value + this as ApiResponse + } else { + block(data) + } +} + +@Suppress("UNCHECKED_CAST") +inline fun ApiResponse.chain(secondary: ApiResponse): ApiResponse { + return if (secondary is ApiResponse.Success) { + secondary + } else { + // Error and Failure do not use the generic value + this as ApiResponse + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/SemVer.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/SemVer.kt new file mode 100644 index 0000000..650200c --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/SemVer.kt @@ -0,0 +1,85 @@ +package com.meowarex.rlmobile.network.utils + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Parses a Semantic version in the format of `v1.0.0` or `1.0.0`. + * This always gets serialized and stringified without the `v` prefix. + */ +@Immutable +@Parcelize +@Serializable(SemVer.Serializer::class) +data class SemVer( + val major: Int, + val minor: Int, + val patch: Int, +) : Comparable, Parcelable { + override fun compareTo(other: SemVer): Int { + var cmp = 0 + if (0 != major.compareTo(other.major).also { cmp = it }) + return cmp + if (0 != minor.compareTo(other.minor).also { cmp = it }) + return cmp + if (0 != patch.compareTo(other.patch).also { cmp = it }) + return cmp + + return 0 + } + + override fun equals(other: Any?): Boolean { + val ver = other as? SemVer + ?: return false + + return ver.major == major && + ver.minor == minor && + ver.patch == patch + } + + override fun toString(): String { + return "$major.$minor.$patch" + } + + override fun hashCode(): Int { + var result = major + result = 31 * result + minor + result = 31 * result + patch + return result + } + + companion object { + fun parse(version: String): SemVer = parseOrNull(version) + ?: throw IllegalArgumentException("Invalid semver string $version") + + fun parseOrNull(version: String): SemVer? { + val parts = version.removePrefix("v").split(".") + + if (parts.size != 3) + return null + + val major = parts[0].toIntOrNull() ?: return null + val minor = parts[1].toIntOrNull() ?: return null + val patch = parts[2].toIntOrNull() ?: return null + + return SemVer(major, minor, patch) + } + } + + object Serializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("SemVer", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder) = + parse(decoder.decodeString()) + + override fun serialize(encoder: Encoder, value: SemVer) { + encoder.encodeString(value.toString()) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/InstallMetadata.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/InstallMetadata.kt new file mode 100644 index 0000000..6b5daf5 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/InstallMetadata.kt @@ -0,0 +1,13 @@ +package com.meowarex.rlmobile.patcher + +import com.meowarex.rlmobile.network.utils.SemVer +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import kotlinx.serialization.Serializable + +@Serializable +data class InstallMetadata( + val customManager: Boolean, + val managerVersion: SemVer, + val patchesVersion: SemVer, + val options: PatchOptions, +) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/StepRunner.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/StepRunner.kt new file mode 100644 index 0000000..c741ae0 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/StepRunner.kt @@ -0,0 +1,138 @@ +package com.meowarex.rlmobile.patcher + +import android.content.Context +import android.util.Log +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PreferencesManager +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.ui.util.InstallNotifications +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * The minimum time that is required to occur between step switches, to avoid + * quickly switching the step groups in the UI. (very disorienting) + * Larger delay leads to a perception that it's doing more work than it actually is. + */ +private const val MINIMUM_STEP_DELAY: Long = 600L + +/** + * ID used for showing error notifications emanating from this step runner. + */ +private const val ERROR_NOTIF_ID = 200002 + +abstract class StepRunner : KoinComponent { + private val context: Context by inject() + private val preferences: PreferencesManager by inject() + private val paths: com.meowarex.rlmobile.manager.PathManager by inject() + + private fun logApkState(stepName: String) { + val apk = paths.patchedApk + if (!apk.exists()) return + val header = try { + apk.inputStream().use { stream -> + val buf = ByteArray(16) + val n = stream.read(buf).coerceAtLeast(0) + buf.copyOf(n) + } + } catch (t: Throwable) { + log("[apk-check] after $stepName: failed to read header: $t") + return + } + val hex = header.joinToString(" ") { "%02x".format(it) } + log("[apk-check] after $stepName: size=${apk.length()} header=$hex") + } + + /** + * The log history from this runner and all steps. + */ + private val logEntries: MutableList = mutableListOf() + + /** + * The steps to be run, defined by specific step runners. + */ + abstract val steps: ImmutableList + + /** + * Get a step that has already been successfully executed. + * This is used to retrieve previously executed dependency steps from a later step. + * @param completed Only match steps that have finished executing. + */ + inline fun getStep(completed: Boolean = true): T { + val step = steps.asSequence() + .filterIsInstance() + .filter { !completed || it.state.isFinished } + .firstOrNull() + + if (step == null) { + throw IllegalArgumentException("No completed step ${T::class.simpleName} exists in container") + } + + return step + } + + /** + * Adds a log entry to this installation run without any associated log level. + */ + fun log(text: String) { + logEntries += text + Log.i(BuildConfig.TAG, text) + } + + /** + * Combines all the log entries into a single formatted log. + */ + fun getLog(): String = logEntries.joinToString(separator = "\n") + + suspend fun executeAll(): Throwable? { + log("Starting step runner") + log("Registered steps: " + steps.joinToString { it.javaClass.simpleName }) + + for (step in steps) { + val stepName = step.javaClass.simpleName + + log("Running step: $stepName") + val error = step.executeCatching(this@StepRunner) + logApkState(stepName) + + if (error != null) { + log("Failed on step: $stepName after ${step.getDuration()}ms") + maybeShowErrorNotification() + return error + } + + // Skip minimum run time when in dev mode + val duration = step.getDuration() + if (!preferences.devMode && duration < MINIMUM_STEP_DELAY) { + delay(MINIMUM_STEP_DELAY - duration) + } + + log("Completed step: $stepName in ${duration}ms") + } + + log("Successfully finished all steps in ${steps.sumOf { it.getDuration() }}ms") + + return null + } + + /** + * Shows a system notification to notify the user that the installation failed, + * if manager is currently backgrounded while the installation was running. + */ + private fun maybeShowErrorNotification() { + if (ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) + return // Is currently foreground + + InstallNotifications.createNotification( + context = context, + id = ERROR_NOTIF_ID, + title = R.string.notif_install_fail_title, + description = R.string.notif_install_fail_desc, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/TidalPatchRunner.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/TidalPatchRunner.kt new file mode 100644 index 0000000..cf4b4cc --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/TidalPatchRunner.kt @@ -0,0 +1,37 @@ +package com.meowarex.rlmobile.patcher + +import com.meowarex.rlmobile.patcher.steps.download.* +import com.meowarex.rlmobile.patcher.steps.install.* +import com.meowarex.rlmobile.patcher.steps.patch.* +import com.meowarex.rlmobile.patcher.steps.prepare.* +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import kotlinx.collections.immutable.persistentListOf + +class TidalPatchRunner( + options: PatchOptions, +) : StepRunner() { + override val steps = persistentListOf( + // Prepare + FetchInfoStep(), + DowngradeCheckStep(options), + RestoreDownloadsStep(), + + // Download + DownloadTidalStep(), + DownloadPatchesStep(options.customPatches), + CopyDependenciesStep(), + + // Patch + SmaliPatchStep(), + ReorganizeDexStep(), + PatchManifestStep(options), + PatchCertsStep(), + SaveMetadataStep(options), + + // Install + AlignmentStep(), + SigningStep(options), + InstallStep(options), + CleanupStep(), + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/StepGroup.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/StepGroup.kt new file mode 100644 index 0000000..3827630 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/StepGroup.kt @@ -0,0 +1,26 @@ +package com.meowarex.rlmobile.patcher.steps + +import android.os.Parcelable +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import com.meowarex.rlmobile.R +import kotlinx.parcelize.Parcelize + +/** + * A group of steps that is shown under one section in the patching UI. + * This has no functional impact other than organization. + */ +@Immutable +@Parcelize +enum class StepGroup( + /** + * The UI name to display this group as + */ + @get:StringRes + val localizedName: Int, +) : Parcelable { + Prepare(R.string.install_group_prepare), + Download(R.string.install_group_download), + Patch(R.string.install_group_patch), + Install(R.string.install_group_install) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/DownloadStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/DownloadStep.kt new file mode 100644 index 0000000..b268f8c --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/DownloadStep.kt @@ -0,0 +1,131 @@ +package com.meowarex.rlmobile.patcher.steps.base + +import android.content.Context +import android.util.Log +import android.widget.Toast +import androidx.annotation.CallSuper +import androidx.compose.runtime.Stable +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.download.IDownloadManager +import com.meowarex.rlmobile.manager.download.KtorDownloadManager +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File +import kotlin.coroutines.cancellation.CancellationException + +@Stable +abstract class DownloadStep : Step(), KoinComponent { + private val context: Context by inject() + private val downloader: KtorDownloadManager by inject() + + /** + * The version of the file that is/will be downloaded. + * The return type is dynamic as different dependencies have varying version formats. + */ + abstract fun getVersion(container: StepRunner): IVersion + + /** + * The remote url to be downloaded to the [getStoredFile]. + */ + abstract fun getRemoteUrl(container: StepRunner): String + + /** + * Target path to store the download in. If this file already exists at the time + * of execution, then the cached version is used and the step is marked as skipped. + */ + abstract fun getStoredFile(container: StepRunner): File + + /** + * Verify that the download completely successfully without errors. + * @throws Throwable If verification fails. + */ + @CallSuper + open suspend fun verify(container: StepRunner) { + val file = getStoredFile(container) + + if (!file.exists()) + throw Error("Downloaded file is missing!") + if (file.length() <= 0) + throw Error("Downloaded file is empty!") + } + + override val group = StepGroup.Download + + override suspend fun execute(container: StepRunner) { + val version = getVersion(container) + val file = getStoredFile(container) + val url = getRemoteUrl(container) + + container.log("Checking if file cached: ${file.absolutePath}") + if (file.exists()) { + container.log("File exists, verifying...") + + try { + verify(container) + state = StepState.Skipped + container.log("File verified, skipping download") + return + } catch (t: Throwable) { + file.delete() + container.log("Verification error: " + Log.getStackTraceString(t)) + container.log("File failed verification, deleting and redownloading") + } + } + + container.log("Downloading file version: $version at url: $url") + var lastLogProgress = 0f + val result = downloader.download(url, file) { newProgress -> + progress = newProgress ?: -1f + + newProgress?.let { newProgress -> + if (newProgress > lastLogProgress + 0.1f) { + container.log("Download progress: ${(newProgress * 100.0).toPrecision(0)}% after ${getDuration()}ms") + } + @Suppress("AssignedValueIsNeverRead") // incorrect + lastLogProgress = newProgress + } + } + + when (result) { + is IDownloadManager.Result.Cancelled -> { + state = StepState.Error + container.log("Download cancelled!") + } + + is IDownloadManager.Result.Success -> { + container.log("Successfully downloaded file, verifying...") + + try { + verify(container) + } catch (e: CancellationException) { + file.delete() + throw e + } catch (t: Throwable) { + mainThread { context.showToast(R.string.installer_dl_verify_fail) } + container.log("Failed to verify file, deleting...") + file.delete() + throw t + } + + container.log("Verified downloaded file") + } + + is IDownloadManager.Result.Error -> { + withContext(Dispatchers.Main) { + val toastText = result.getLocalizedReason(context) + ?: context.getString(R.string.downloader_err_unknown) + + Toast.makeText(context, toastText, Toast.LENGTH_LONG).show() + } + + container.log("Failed to download file") + throw Error("Failed to download: $result") + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/IDexProvider.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/IDexProvider.kt new file mode 100644 index 0000000..cc04e51 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/IDexProvider.kt @@ -0,0 +1,22 @@ +package com.meowarex.rlmobile.patcher.steps.base + +import com.meowarex.rlmobile.patcher.StepRunner + +interface IDexProvider { + /** + * The priority of the .dex files supplied by [getDexFiles]. + * Higher number leads to a higher overwrite priority. + * .dex files already included in the APK have a priority of `0`. + */ + val dexPriority: Int + + /** + * The amount of files returned by [getDexFiles] + */ + val dexCount: Int + + /** + * Any dex files to be added into the APK. + */ + fun getDexFiles(container: StepRunner): List +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/Step.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/Step.kt new file mode 100644 index 0000000..6eb34db --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/Step.kt @@ -0,0 +1,118 @@ +package com.meowarex.rlmobile.patcher.steps.base + +import androidx.annotation.StringRes +import androidx.compose.runtime.* +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.util.toPrecision +import kotlinx.coroutines.* +import kotlin.time.measureTimedValue + +/** + * A base install process step. Steps are single-use + */ +@Stable +abstract class Step { + /** + * The group this step belongs to. + */ + abstract val group: StepGroup + + /** + * The UI name to display this step as + */ + @get:StringRes + abstract val localizedName: Int + + /** + * Run the step's logic. + * It can be assumed that this is executed in the correct order after other steps. + */ + protected abstract suspend fun execute(container: StepRunner) + + /** + * The current state of this step in the installation process. + */ + var state by mutableStateOf(StepState.Pending) + protected set + + /** + * If the current state is [StepState.Running], then the progress of this step. + * If the progress isn't currently measurable, then this should be set to `-1`. + */ + var progress by mutableFloatStateOf(-1f) + protected set + + private val durationSecs = mutableFloatStateOf(0f) + private var startTime: Long? = null + private var totalTimeMs: Long? = null + + /** + * The total amount of time this step has/was executed for in milliseconds. + * If this step has not started executing then it will return `0`. + */ + fun getDuration(): Long { + // Step hasn't started executing + val startTime = startTime ?: return 0 + + // Step already finished executing + totalTimeMs?.let { return it } + + return System.currentTimeMillis() - startTime + } + + /** + * The live execution time of this step in seconds. + * The value is clamped to a resolution of 10ms updated every 50ms. + */ + @Composable + fun collectDurationAsState(): State { + if (state.isFinished) + return durationSecs + + LaunchedEffect(state) { + while (true) { + durationSecs.floatValue = (getDuration() / 1000.0) + .toPrecision(2).toFloat() + + delay(50) + } + } + + return durationSecs + } + + /** + * Thin wrapper over [execute] but handling errors. + * @return An exception if the step failed to execute. + */ + suspend fun executeCatching(container: StepRunner): Throwable? { + if (state != StepState.Pending) + throw IllegalStateException("Cannot execute a step that has already started") + + state = StepState.Running + startTime = System.currentTimeMillis() + + // Execute this steps logic while timing it + val (error, executionTime) = measureTimedValue { + try { + withContext(Dispatchers.Default) { + execute(container) + } + + if (state != StepState.Skipped) + state = StepState.Success + + null + } catch (t: Throwable) { + state = StepState.Error + t + } + } + + totalTimeMs = executionTime.inWholeMilliseconds + durationSecs.floatValue = executionTime.inWholeMilliseconds / 1000f + + return error + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/StepState.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/StepState.kt new file mode 100644 index 0000000..a018849 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/base/StepState.kt @@ -0,0 +1,12 @@ +package com.meowarex.rlmobile.patcher.steps.base + +enum class StepState { + Pending, + Running, + Success, + Error, + Skipped; + + val isFinished: Boolean + get() = this == Success || this == Skipped || this == Error +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/download/CopyDependenciesStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/download/CopyDependenciesStep.kt new file mode 100644 index 0000000..87ed772 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/download/CopyDependenciesStep.kt @@ -0,0 +1,63 @@ +package com.meowarex.rlmobile.patcher.steps.download + +import android.app.Application +import android.os.Build +import android.os.storage.StorageManager +import androidx.core.content.getSystemService +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.util.InsufficientStorageException +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File +import java.io.IOException + +/** + * Step to duplicate the Tidal APK to be worked on. + */ +class CopyDependenciesStep : Step(), KoinComponent { + private val paths: PathManager by inject() + private val application: Application by inject() + + /** + * The target APK file that will be modified during patching. + */ + val apk: File = paths.patchedApk + + override val group = StepGroup.Download + override val localizedName = R.string.patch_step_copy_deps + + override suspend fun execute(container: StepRunner) { + val srcApk = container.getStep().getStoredFile(container) + + container.log("Clearing patched directory") + if (!paths.patchingWorkingDir.deleteRecursively()) + throw Error("Failed to clear existing patched dir") + + // Preallocate space for file copy and future patching operations + if (Build.VERSION.SDK_INT >= 26) { + val storageManager = application.getSystemService()!! + val targetFileStorageId = storageManager.getUuidForPath(apk) + val fileSize = srcApk.length() + + // We request 3.5x the size of the APK, to give space for the following: + // 1) A copy of the APK + // 2) Modifying the copied APK (whether this is necessary I'm not sure) + // 2) Extracting native libs and other various operations + val allocSize = (fileSize * 3.5).toLong() + + try { + storageManager.allocateBytes(targetFileStorageId, allocSize) + } catch (e: IOException) { + throw InsufficientStorageException(e.message) + } + } + + container.log("Copying patched apk from ${srcApk.absolutePath} to ${apk.absolutePath}") + apk.parentFile!!.mkdirs() + srcApk.copyTo(apk) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/download/DownloadPatchesStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/download/DownloadPatchesStep.kt new file mode 100644 index 0000000..4987b2c --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/download/DownloadPatchesStep.kt @@ -0,0 +1,50 @@ +package com.meowarex.rlmobile.patcher.steps.download + +import androidx.compose.runtime.Stable +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.network.utils.SemVer +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.base.DownloadStep +import com.meowarex.rlmobile.patcher.steps.base.StepState +import com.meowarex.rlmobile.patcher.steps.prepare.FetchInfoStep +import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.FileNotFoundException + +@Stable +class DownloadPatchesStep( + private val custom: PatchComponent?, +) : DownloadStep(), KoinComponent { + private val paths: PathManager by inject() + + override val localizedName = R.string.patch_step_dl_smali + + override fun getRemoteUrl(container: StepRunner) = + container.getStep().patchesAssetUrl + + override fun getVersion(container: StepRunner) = + custom?.version ?: container.getStep().data.patchesVersion + + override fun getStoredFile(container: StepRunner) = + custom?.getFile(paths) ?: paths.cachedSmaliPatches(getVersion(container)) + + override suspend fun execute(container: StepRunner) { + if (custom != null) { + container.log("Using custom patches with version ${custom.version} built ${custom.timestamp}") + + if (!custom.getFile(paths).exists()) { + throw FileNotFoundException( + "Selected custom component does not exist on disk! If this is an update, " + + "updates cannot occur when the originally selected custom component has been deleted." + ) + } + + state = StepState.Skipped + return + } + + super.execute(container) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/download/DownloadTidalStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/download/DownloadTidalStep.kt new file mode 100644 index 0000000..ec73308 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/download/DownloadTidalStep.kt @@ -0,0 +1,79 @@ +package com.meowarex.rlmobile.patcher.steps.download + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Stable +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.base.DownloadStep +import com.meowarex.rlmobile.patcher.steps.prepare.FetchInfoStep +import com.android.apksig.ApkVerifier +import okio.ByteString.Companion.decodeHex +import okio.ByteString.Companion.toByteString +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File + +@Stable +class DownloadTidalStep : DownloadStep(), KoinComponent { + private val paths: PathManager by inject() + + override val localizedName = R.string.patch_step_dl_tidal_apk + + override fun getVersion(container: StepRunner) = + container.getStep().data.tidalVersionCode + + override fun getRemoteUrl(container: StepRunner) = + container.getStep().data.tidalApkUrl + + override fun getStoredFile(container: StepRunner) = + paths.cachedTidalApk(getVersion(container)) + + override suspend fun verify(container: StepRunner) { + super.verify(container) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + container.log("Verifying APK signature") + verifySignature(getStoredFile(container)) + } else { + container.log("Skipping APK signature verification, API level too old") + } + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun verifySignature(apk: File) { + val verifier = ApkVerifier.Builder(apk).build() + val result = try { + verifier.verify() + } catch (e: Exception) { + throw IllegalStateException("Failed to verify APK! It may have been corrupted or tampered with.", e) + } + + if (!result.isVerified) + throw SignatureVerificationException(result.allErrors) + + if (TIDAL_CERTIFICATE_SHA256 != null) { + if (result.signerCertificates.singleOrNull() + ?.let { it.encoded.toByteString().sha256() == TIDAL_CERTIFICATE_SHA256.decodeHex() } != true + ) { + throw VerifyError("Failed to verify TIDAL APK signatures! This is an unoriginal APK that has been tampered with.") + } + } + } + + private companion object { + // TODO: populate with actual TIDAL signing certificate SHA-256 + // Run: apksigner verify --print-certs tidal.apk + val TIDAL_CERTIFICATE_SHA256: String? = null + + fun getStoredFilePath(paths: PathManager, version: Int): File = + paths.cachedTidalApk(version) + } + + private class SignatureVerificationException(errors: List) : Exception( + "Failed to verify APK signatures! " + + "This is an unoriginal APK that has been tampered with. " + + "Verification errors: " + errors.joinToString() + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/install/AlignmentStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/install/AlignmentStep.kt new file mode 100644 index 0000000..5257a59 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/install/AlignmentStep.kt @@ -0,0 +1,122 @@ +package com.meowarex.rlmobile.patcher.steps.install + +import android.os.Build +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep +import com.github.diamondminer88.zip.* +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * Align certain files in the APK to the necessary boundaries. + */ +class AlignmentStep : Step(), KoinComponent { + private val paths: PathManager by inject() + + override val group = StepGroup.Install + override val localizedName = R.string.patch_step_alignment + + override suspend fun execute(container: StepRunner) { + val currentDeviceArch = Build.SUPPORTED_ABIS.first() + val apk = container.getStep().apk + + var resourcesArscBytes: ByteArray? = null + var dexCount: Int = -1 + + // Align resources.arsc due to targeting API 30 for silent install + if (Build.VERSION.SDK_INT >= 30) { + container.log("Extracting resources.arsc to be aligned later") + resourcesArscBytes = ZipReader(apk) + .use { it.openEntry("resources.arsc")?.read() } + ?: throw IllegalArgumentException("APK is missing resources.arsc") + } + + // Align dex files due to using useEmbeddedDex (ref. ManifestPatcher) + if (Build.VERSION.SDK_INT >= 29) { + container.log("Extracting all dex files to be aligned later") + ZipReader(apk).use { zip -> + // Count the amount of dex files currently in the apk + dexCount = zip.entryNames.count { it.endsWith(".dex") } + + // Copy all the dex files that need to be moved out of the apk + for (idx in 0.. + val libPaths = zip.entryNames.filter { it.endsWith(".so") } + + // Extract to disk temporarily + for ((idx, path) in libPaths.withIndex()) { + // Ignore lib architectures that don't match this device + if (!path.startsWith("lib/$currentDeviceArch")) { + container.log("Skipping native lib $path due to incompatible architecture") + continue + } + + // Index is just used as a placeholder id to cache on disk + val bytes = zip.openEntry(path)!!.read() + val file = paths.patchingWorkingDir.resolve("$idx.so") + file.writeBytes(bytes) + container.log("Extracted native lib $file") + } + + libPaths + } + + container.log("Writing entries back aligned") + ZipWriter(apk, /* append = */ true).use { zip -> + // Delete all the unaligned files from APK + container.log("Deleting resources.arsc") + if (resourcesArscBytes != null) + zip.deleteEntry("resources.arsc") + + container.log("Deleting $dexCount dex files") + for (i in 0..().apk + + // If app backgrounded, show notification + if (ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.CREATED) { + InstallNotifications.createNotification( + context = context, + id = READY_NOTIF_ID, + title = R.string.notif_install_ready_title, + description = R.string.notif_install_ready_desc, + ) + + container.log("Waiting until manager is resumed to continue installation") + } + + // Wait until app resumed + ProcessLifecycleOwner.get().lifecycle.withResumed {} + + // Retrieve configured installer + container.log("Retrieving configured installer ${prefs.installer}") + val installer = installers.getActiveInstaller() + + // Show [PlayProtectDialog] and wait until it gets dismissed + if (installer !is ShizukuInstaller && + installer !is RootInstaller + && prefs.showPlayProtectWarning + && !prefs.devMode + && !context.isPackageInstalled(options.packageName) + && context.isPlayProtectEnabled() == true + ) { + container.log("Showing play protect warning dialog") + val neverShowAgain = overlays.startComposableForResult { onResult -> + PlayProtectDialog(onDismiss = onResult) + } + prefs.showPlayProtectWarning = !neverShowAgain + } + + container.log("Installing ${apk.absolutePath}, silent: ${!prefs.devMode}") + var lastProgress = 0f + val result = installer.waitInstall( + apks = listOf(apk), + silent = !prefs.devMode, + onProgressUpdate = { newProgress -> + this@InstallStep.progress = newProgress + + if (newProgress > lastProgress + 0.1f) { + container.log("Install progress: ${(newProgress * 100.0).toPrecision(0)}% after ${getDuration()}ms") + } + @Suppress("AssignedValueIsNeverRead") // incorrect + lastProgress = newProgress + }, + ) + + when (result) { + is InstallerResult.Error -> { + container.log("Installation failed") + throw Error("Failed to install APKs: ${result.getDebugReason()}") + } + + is InstallerResult.Cancelled -> { + // The install screen is automatically closed immediately once cleanup finishes + state = StepState.Skipped + container.log("Installation was cancelled by user") + } + + InstallerResult.Success -> + container.log("Installation successful") + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/install/SigningStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/install/SigningStep.kt new file mode 100644 index 0000000..d6c729f --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/install/SigningStep.kt @@ -0,0 +1,177 @@ +package com.meowarex.rlmobile.patcher.steps.install + +import android.app.Application +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import com.android.apksig.ApkSigner +import com.android.apksig.KeyConfig +import com.github.diamondminer88.zip.ZipReader +import com.github.diamondminer88.zip.ZipWriter +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import org.bouncycastle.cert.X509v3CertificateBuilder +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.ByteArrayOutputStream +import java.security.* +import java.security.cert.X509Certificate +import java.util.* +import kotlin.math.abs +import kotlin.time.Duration.Companion.days + +// TODO: prompt user to uninstall Radiant Lyrics if signing keystore is unavailable/corrupt and CorePatch isn't installed + +/** + * Sign the APK with a keystore generated on-device. + */ +class SigningStep( + private val options: PatchOptions, +) : Step(), KoinComponent { + private val paths: PathManager by inject() + private val context: Application by inject() + + override val group = StepGroup.Install + override val localizedName = R.string.patch_step_signing + + override suspend fun execute(container: StepRunner) { + val apk = container.getStep().apk + val tmpApk = apk.resolveSibling(apk.name + ".tmp") + + container.log("Building signing config and storing keystore") + + val (keystore, keystoreBytes) = getKeystore(options.packageName) + val keyAlias = keystore.aliases().nextElement() + val signingConfig = ApkSigner.SignerConfig.Builder( + /* name = */ "Radiant Lyrics Manager", + /* keyConfig = */ KeyConfig.Jca(keystore.getKey(keyAlias, LEGACY_KEYSTORE_PASSWORD) as PrivateKey), + /* certificates = */ listOf(keystore.getCertificate(keyAlias) as X509Certificate) + ).build() + + ZipWriter(apk, /* append = */ true).use { zip -> + zip.writeEntry("rlmobile.keystore", keystoreBytes) + } + + container.log("Signing apk at ${apk.absolutePath}") + + ApkSigner.Builder(listOf(signingConfig)) + .setV1SigningEnabled(false) // TODO: enable so api <24 devices can work, however zip-alignment breaks + .setV2SigningEnabled(true) + .setV3SigningEnabled(true) + .setInputApk(apk) + .setOutputApk(tmpApk) + .build() + .sign() + + tmpApk.renameTo(apk) + } + + /** + * Attempts to load or generate a signing keystore with various priorities. + * + * 1. If the specified Radiant Lyrics installation for [packageName] exists, + * then attempt to load the signing keystore that was embedded into it. + * 3. If external storage permissions are granted and the legacy keystore exists, + * then move it to Manager's internal storage and use that. + * 2. If Manager has a signing key already stored in internal storage + * (that isn't persisted between reinstallations of Manager) then use that. + * 4. Otherwise, generate a new keystore in Manager's internal storage. + * + * Returns the loaded keystore along with its byte representation. + */ + private fun getKeystore(packageName: String): Pair { + val embeddedKeystoreRaw = try { + val applicationInfo = context.packageManager.getApplicationInfo(packageName, 0) + ZipReader(applicationInfo.publicSourceDir) + .use { it.openEntry("rlmobile.keystore")?.read() } + } catch (_: Exception) { + null + } + + if (embeddedKeystoreRaw != null) { + try { + val keystore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(embeddedKeystoreRaw.inputStream(), LEGACY_KEYSTORE_PASSWORD) + } + return keystore to embeddedKeystoreRaw + } catch (e: Exception) { + throw Exception("Embedded existing signing key is corrupted! Please uninstall the app and retry!", e) + } + } + + try { + if (paths.legacyKeystoreFile.exists()) { + paths.legacyKeystoreFile.copyTo(paths.keystoreFile, overwrite = true) + paths.legacyKeystoreFile.delete() + } + } catch (_: SecurityException) { + // Ignore + } + + if (!paths.keystoreFile.exists()) { + createKeystore(LEGACY_KEYSTORE_PASSWORD) + .also { paths.keystoreFile.writeBytes(it) } + } + + val keystoreBytes = paths.keystoreFile.readBytes() + val keystore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(keystoreBytes.inputStream(), LEGACY_KEYSTORE_PASSWORD) + } + return keystore to keystoreBytes + } + + /** + * Creates a keystore with a new keyset and protects it with a password. + */ + private fun createKeystore(password: CharArray): ByteArray { + // Generate keys + certificate + val keys = KeyPairGenerator.getInstance("RSA").run { + initialize(2048) + generateKeyPair() + } + val signer = JcaContentSignerBuilder("SHA1withRSA") + .build(keys.private) + val certificate = X509v3CertificateBuilder( + /* issuer = */ X500Name("CN=Radiant Lyrics Manager"), + /* serial = */ abs(Random().nextInt()).toBigInteger(), + /* notBefore = */ Date(System.currentTimeMillis() - 365.days.inWholeMilliseconds), + /* notAfter = */ Date(System.currentTimeMillis() + (100 * 365.days.inWholeMilliseconds)), + /* dateLocale = */ Locale.ENGLISH, + /* subject = */ X500Name("CN=Radiant Lyrics Manager"), + /* publicKeyInfo = */ SubjectPublicKeyInfo.getInstance(keys.public.encoded), + ).build(signer) + + val publicKey = JcaX509CertificateConverter().getCertificate(certificate) + val privateKey = keys.private + + val keystoreBytes = ByteArrayOutputStream() + val keystore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, password) + setKeyEntry( + /* alias = */ "alias", + /* key = */ privateKey, + /* password = */ password, + /* chain = */ arrayOf(publicKey), + ) + } + keystore.store(keystoreBytes, password) + return keystoreBytes.toByteArray() + } + + private companion object { + // TODO: Figure out a way to get a unique and private key/identifier that is only available to Manager + // and is persistable across multiple installations. + + /** + * This password was used to secure the old keystore stored in external storage at + * `/storage/emulated/0/Radiant Lyrics/ks.keystore` + */ + val LEGACY_KEYSTORE_PASSWORD = "password".toCharArray() + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/PatchCertsStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/PatchCertsStep.kt new file mode 100644 index 0000000..e1e81bb --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/PatchCertsStep.kt @@ -0,0 +1,230 @@ +package com.meowarex.rlmobile.patcher.steps.patch + +import android.app.Application +import android.os.Build +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.steps.base.StepState +import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep +import com.meowarex.rlmobile.patcher.util.ArscUtil +import com.meowarex.rlmobile.patcher.util.ArscUtil.addResource +import com.meowarex.rlmobile.patcher.util.ArscUtil.getMainArscChunk +import com.meowarex.rlmobile.patcher.util.ArscUtil.getPackageChunk +import com.meowarex.rlmobile.patcher.util.ArscUtil.getResourceFileNames +import com.meowarex.rlmobile.patcher.util.AxmlUtil.getMainAxmlChunk +import com.meowarex.rlmobile.util.find +import com.github.diamondminer88.zip.ZipReader +import com.github.diamondminer88.zip.ZipWriter +import com.google.devrel.gmscore.tools.apk.arsc.* +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File + +/** + * Adds a network security config that manually adds new CA root certificates. + * This is useful for old Android devices that do not have updated root certs. + */ +class PatchCertsStep : Step(), KoinComponent { + private val context: Application by inject() + + override val group = StepGroup.Patch + override val localizedName = R.string.patch_step_patch_certs + + // Manager's (this application) network security config is used as a template + // to inject into Radiant Lyrics, except with the resource ids pointing to certificate files changed + // to new ones injected into the patched app's arsc + override suspend fun execute(container: StepRunner) { + val apk = container.getStep().apk + + if (Build.VERSION.SDK_INT >= 26) { + container.log("Modern device detected, skipping injecting root certs") + state = StepState.Skipped + return + } + + container.log("Parsing resources.arsc") + val arsc = ArscUtil.readArsc(apk) + val resourcesChunk = arsc.getMainArscChunk() + val packageChunk = arsc.getPackageChunk() + + container.log("Creating new raw resources in arsc") + val certificateIds = CERTIFICATES.keys.map { certificateName -> + packageChunk.addResource( + typeName = "raw", + resourceName = certificateName, + configurations = { it.isDefault }, + valueType = BinaryResourceValue.Type.STRING, + valueData = resourcesChunk.stringPool.addString("res/$certificateName.der"), + ) + } + + container.log("Generating new network security config AXML") + val newNetworkSecurityConfigBytes = generateNetworkConfig(certificateIds) + + container.log("Parsing existing AndroidManifest.xml") + val networkSecurityConfigId = getNetworkSecurityConfigResourceId(apk) + val networkSecurityConfigPath = resourcesChunk.getResourceFileNames( + resourceId = networkSecurityConfigId, + configurations = { it.isDefault }, + ).single() + + ZipWriter(apk, /* append = */ true).use { zip -> + zip.deleteEntries(networkSecurityConfigPath, "resources.arsc") + + container.log("Writing new network security config AXML") + zip.writeEntry(networkSecurityConfigPath, newNetworkSecurityConfigBytes) + + container.log("Writing new arsc") + zip.writeEntry("resources.arsc", arsc.toByteArray()) + + for ((name, id) in CERTIFICATES) { + container.log("Writing $name CA certificate to apk") + + val bytes = context.resources.openRawResource(id).use { it.readBytes() } + zip.writeEntry("res/$name.der", bytes) + } + } + } + + /** + * From an APK, read the manifest's `android:networkSecurityConfig` references to a resource. + * This is then used to get the filename of the resource from `resources.arsc`. + */ + fun getNetworkSecurityConfigResourceId(apk: File): BinaryResourceIdentifier { + val manifestBytes = ZipReader(apk).use { + it.openEntry("AndroidManifest.xml")?.read() + } ?: error("APK missing manifest") + val manifest = BinaryResourceFile(manifestBytes) + val mainChunk = manifest.getMainAxmlChunk() + + // Prefetch string indexes to avoid parsing the entire string pool + val networkSecurityConfigStringIdx = mainChunk.stringPool.indexOf("networkSecurityConfig") + val applicationStringIdx = mainChunk.stringPool.indexOf("application") + + val applicationChunk = mainChunk.chunks + .find { it is XmlStartElementChunk && it.nameIndex == applicationStringIdx } as? XmlStartElementChunk + ?: error("Unable to find in manifest") + val networkSecurityConfig = applicationChunk.attributes + .find { it.nameIndex() == networkSecurityConfigStringIdx } + ?: error("Unable to find android:networkSecurityConfig in manifest") + + assert(networkSecurityConfig.typedValue().type() == BinaryResourceValue.Type.REFERENCE) + + return BinaryResourceIdentifier.create(networkSecurityConfig.typedValue().data()) + } + + /** + * This generates a binary AXML representation a network security config similar to the one + * manager uses [R.xml.network_security_config], except with resource IDs generated for the patched APK. + */ + private fun generateNetworkConfig(certificateIds: List): ByteArray { + val axml = BinaryResourceFile(byteArrayOf()) + val xmlChunk = XmlChunk(null) + val strings = StringPoolChunk(xmlChunk) + + axml.appendChunk(xmlChunk) + xmlChunk.appendChunk(strings) + xmlChunk.appendChunk(XmlResourceMapChunk(intArrayOf(), xmlChunk)) + + // Nested chunks + // @formatter:off + val chunkNames = arrayOf("network-security-config", "base-config", "trust-anchors") + for (chunkName in chunkNames) { + xmlChunk.appendChunk(XmlStartElementChunk( + /* namespaceIndex = */ -1, + /* nameIndex = */ strings.addString(chunkName), + /* idIndex = */ -1, + /* classIndex = */ -1, + /* styleIndex = */ -1, + /* attributes = */ emptyList(), + /* parent = */ xmlChunk, + )) + } + + // Allow "system" certificates + run { + val certificateChunk = XmlStartElementChunk( + /* namespaceIndex = */ -1, + /* nameIndex = */ strings.addString("certificates", /* deduplicate = */ true), + /* idIndex = */ -1, + /* classIndex = */ -1, + /* styleIndex = */ -1, + /* attributes = */ listOf(), + /* parent = */ xmlChunk, + ) + certificateChunk.attributes += XmlAttribute( + /* namespaceIndex = */ -1, + /* nameIndex = */ xmlChunk.stringPool.addString("src", /* deduplicate = */ true), + /* rawValueIndex = */ xmlChunk.stringPool.addString("system"), + /* typedValue = */ BinaryResourceValue( + /* type = */ BinaryResourceValue.Type.STRING, + /* data = */ xmlChunk.stringPool.addString("system", /* deduplicate = */ true), + ), + /* parent = */ certificateChunk, + ) + + xmlChunk.appendChunk(certificateChunk) + xmlChunk.appendChunk(XmlEndElementChunk( + /* namespaceIndex = */ -1, + /* nameIndex = */ strings.addString("certificates", /* deduplicate = */ true), + /* parent = */ xmlChunk, + )) + } + + // Add custom certificate references + for (certificateId in certificateIds) { + val certificateChunk = XmlStartElementChunk( + /* namespaceIndex = */ -1, + /* nameIndex = */ strings.addString("certificates", /* deduplicate = */ true), + /* idIndex = */ -1, + /* classIndex = */ -1, + /* styleIndex = */ -1, + /* attributes = */ listOf(), + /* parent = */ xmlChunk, + ) + certificateChunk.attributes += XmlAttribute( + /* namespaceIndex = */ -1, + /* nameIndex = */ xmlChunk.stringPool.addString("src", /* deduplicate = */ true), + /* rawValueIndex = */ -1, + /* typedValue = */ BinaryResourceValue( + /* type = */ BinaryResourceValue.Type.REFERENCE, + /* data = */ certificateId.resourceId(), + ), + /* parent = */ certificateChunk, + ) + + xmlChunk.appendChunk(certificateChunk) + xmlChunk.appendChunk(XmlEndElementChunk( + /* namespaceIndex = */ -1, + /* nameIndex = */ strings.addString("certificates", /* deduplicate = */ true), + /* parent = */ xmlChunk, + )) + } + + // Reverse nested chunks + for (chunkName in chunkNames.reversed()) { + xmlChunk.appendChunk(XmlEndElementChunk( + /* namespaceIndex = */ -1, + /* nameIndex = */ strings.addString(chunkName, /* deduplicate = */ true), + /* parent = */ xmlChunk, + )) + } + // @formatter:on + + return axml.toByteArray() + } + + private companion object { + val CERTIFICATES = mapOf( + "globalsign_root_r4" to R.raw.globalsign_root_r4, + "gts_root_r1" to R.raw.gts_root_r1, + "gts_root_r2" to R.raw.gts_root_r2, + "gts_root_r3" to R.raw.gts_root_r3, + "gts_root_r4" to R.raw.gts_root_r4, + "isrg_root_x1" to R.raw.isrg_root_x1, + "isrg_root_x2" to R.raw.isrg_root_x2, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/PatchManifestStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/PatchManifestStep.kt new file mode 100644 index 0000000..3fcd862 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/PatchManifestStep.kt @@ -0,0 +1,56 @@ +package com.meowarex.rlmobile.patcher.steps.patch + +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep +import com.meowarex.rlmobile.patcher.util.ManifestPatcher +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import com.github.diamondminer88.zip.ZipReader +import com.github.diamondminer88.zip.ZipWriter + +/** + * Patch the APK's AndroidManifest.xml + */ +class PatchManifestStep(private val options: PatchOptions) : Step() { + override val group = StepGroup.Patch + override val localizedName = R.string.patch_step_patch_manifests + + override suspend fun execute(container: StepRunner) { + val apk = container.getStep().apk + + container.log("Reading manifest from apk") + val manifest = ZipReader(apk) + .use { zip -> zip.openEntry("AndroidManifest.xml")?.read() } + ?: throw IllegalArgumentException("No manifest found in APK") + + container.log("Patching manifest") + val patchedManifest = ManifestPatcher.patchManifest( + manifestBytes = manifest, + packageName = options.packageName, + appName = options.appName, + debuggable = options.debuggable, + ) + + container.log("Repacking apk with patched manifest") + val repacked = apk.resolveSibling(apk.name + ".repack") + repacked.delete() + + ZipReader(apk).use { reader -> + ZipWriter(repacked, /* append = */ false).use { writer -> + for (name in reader.entryNames) { + val bytes = if (name == "AndroidManifest.xml") { + patchedManifest + } else { + reader.openEntry(name)!!.read() + } + writer.writeEntry(name, bytes) + } + } + } + + if (!apk.delete() || !repacked.renameTo(apk)) + throw Error("Failed to replace apk with repacked manifest variant") + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/ReorganizeDexStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/ReorganizeDexStep.kt new file mode 100644 index 0000000..8ff8f15 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/ReorganizeDexStep.kt @@ -0,0 +1,117 @@ +package com.meowarex.rlmobile.patcher.steps.patch + +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.IDexProvider +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep +import com.github.diamondminer88.zip.* +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * Insert the dex files produced by [IDexProvider] steps into the APK. + * Higher priority dex files are placed first so that their class definitions + * shadow the originals when loaded by ART. + */ +class ReorganizeDexStep : Step(), KoinComponent { + private val paths: PathManager by inject() + + override val group = StepGroup.Patch + override val localizedName = R.string.patch_step_reorganize_dex + + override suspend fun execute(container: StepRunner) { + val apk = container.getStep().apk + val dexProviders = container.steps + .filterIsInstance() + .sortedByDescending { it.dexPriority } + val priorityDexCount = dexProviders + .filter { it.dexPriority > 0 } + .sumOf { it.dexCount } + + container.log("dexProviders: " + dexProviders.joinToString { it.javaClass.simpleName }) + container.log("priorityDexCount: $priorityDexCount") + + var dexCount = 0 + + ZipReader(apk).use { zip -> + // Count the amount of dex files currently in the apk + dexCount = zip.entryNames.count { it.endsWith(".dex") } + container.log("Existing dex files in apk: $dexCount") + + // Copy all the dex files that need to be moved out of the apk, + // to ensure there's space for our higher priority dex files + container.log("Copying dex files out of apk to be moved to a lesser priority") + for (idx in 0.. dexCount) break + + container.log("Extracting ${getDexName(idx)} from apk") + val bytes = zip.openEntry(getDexName(idx))!!.read() + val file = paths.patchingWorkingDir.resolve(getDexName(idx)) + file.writeBytes(bytes) + } + } + + ZipWriter(apk, /* append = */ true).use { zip -> + container.log("Deleting dex files to be replaced with a higher priorty dex file") + // Delete all the old dex files from the apk + for (idx in 0.. dexCount) break + + container.log("Deleting ${getDexName(idx)} from apk") + zip.deleteEntry(getDexName(idx)) + } + + // Copy all of the high priority dex files to the apk + var idx = 0 + for (dexProvider in dexProviders) { + if (dexProvider.dexPriority <= 0) continue + + container.log( + "Writing custom high priority dex files from step: " + + "${dexProvider.javaClass.simpleName} with priority of ${dexProvider.dexPriority}" + ) + + for (dexBytes in dexProvider.getDexFiles(container)) { + container.log("Writing dex file ${getDexName(idx)} unaligned uncompressed") + zip.writeEntry(getDexName(idx++), dexBytes, ZipCompression.NONE) + } + } + + // Copy back the dex files that were moved out of the apk + for (idx in 0.. dexCount) break + + container.log("Moving old low priority dex file back into apk unaligned uncompressed: " + getDexName(dexCount + idx)) + + val file = paths.patchingWorkingDir.resolve(getDexName(idx)) + val bytes = file.readBytes() + zip.writeEntry(getDexName(dexCount + idx), bytes, ZipCompression.NONE) + } + + dexCount += idx + + // Copy the rest of the injected dex files + for (dexProvider in dexProviders) { + if (dexProvider.dexPriority > 0) continue + + container.log( + "Writing remaining low priority dex files into apk from step: " + + "${dexProvider.javaClass.simpleName} with priority of ${dexProvider.dexPriority}" + ) + + for (dexBytes in dexProvider.getDexFiles(container)) { + container.log("Writing dex file ${getDexName(idx)} unaligned uncompressed") + zip.writeEntry(getDexName(dexCount++), dexBytes, ZipCompression.NONE) + } + } + } + } + + private fun getDexName(idx: Int) = "classes${if (idx == 0) "" else (idx + 1)}.dex" +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SaveMetadataStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SaveMetadataStep.kt new file mode 100644 index 0000000..87d3e9d --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SaveMetadataStep.kt @@ -0,0 +1,40 @@ +package com.meowarex.rlmobile.patcher.steps.patch + +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.network.utils.SemVer +import com.meowarex.rlmobile.patcher.InstallMetadata +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep +import com.meowarex.rlmobile.patcher.steps.download.DownloadPatchesStep +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import com.github.diamondminer88.zip.ZipWriter +import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class SaveMetadataStep(private val options: PatchOptions) : Step(), KoinComponent { + private val json: Json by inject() + + override val group = StepGroup.Patch + override val localizedName = R.string.patch_step_save_metadata + + override suspend fun execute(container: StepRunner) { + val apk = container.getStep().apk + val patches = container.getStep() + + val metadata = InstallMetadata( + customManager = !BuildConfig.RELEASE, + managerVersion = SemVer.parse(BuildConfig.VERSION_NAME), + patchesVersion = patches.getVersion(container), + options = options, + ) + + container.log("Writing serialized install metadata to APK") + ZipWriter(apk, /* append = */ true).use { + it.writeEntry("rlmobile.json", json.encodeToString(metadata)) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt new file mode 100644 index 0000000..67b353c --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt @@ -0,0 +1,161 @@ +package com.meowarex.rlmobile.patcher.steps.patch + +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.IDexProvider +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep +import com.meowarex.rlmobile.patcher.steps.download.DownloadPatchesStep +import com.android.tools.smali.baksmali.Baksmali +import com.android.tools.smali.baksmali.BaksmaliOptions +import com.android.tools.smali.dexlib2.Opcodes +import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile +import com.android.tools.smali.smali.Smali +import com.android.tools.smali.smali.SmaliOptions +import com.github.diamondminer88.zip.ZipReader +import com.github.difflib.DiffUtils +import com.github.difflib.UnifiedDiffUtils +import com.github.difflib.patch.Patch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.* + +class SmaliPatchStep : Step(), IDexProvider, KoinComponent { + private val paths: PathManager by inject() + + override val group = StepGroup.Patch + override val localizedName = R.string.patch_step_patch_smali + + private val coreCount = Runtime.getRuntime().availableProcessors() + private val smaliDir = paths.patchingWorkingDir.resolve("smali") + private val outDex = smaliDir.resolve("patched.dex") + + override suspend fun execute(container: StepRunner) { + val apk = container.getStep().apk + val patchesZip = container.getStep().getStoredFile(container) + + val patches = mutableListOf() + + // Load and parse all the patches from the smali patch archive + container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}") + ZipReader(patchesZip).use { zip -> + for (patchFile in zip.entryNames) { + container.log("Parsing patch file $patchFile") + if (!patchFile.endsWith(".patch")) continue + + val lines = zip.openEntry(patchFile)!!.read() + .decodeToString() + .replace("\r\n", "\n") // Replace CRLF endings with LF endings to be sure here + .trimEnd { it == '\n' } // Remove trailing new lines to work with diff output properly + .split('\n') + + try { + val targetLine = lines.firstOrNull { it.startsWith("--- a/") } + ?: throw Error("Patch $patchFile is missing a '--- a/...' header") + val fullClassName = targetLine + .removePrefix("--- a/") + .removeSuffix(".smali") + .trim() + val patch = LoadedPatch( + fullClassName = fullClassName, + patch = UnifiedDiffUtils.parseUnifiedDiff(lines), + ) + patches.add(patch) + container.log("Loaded patch file $patchFile for class ${patch.fullClassName}") + } catch (t: Throwable) { + throw Error("Failed to parse patch file $patchFile", t) + } + } + } + + // Disassemble all the classes we have patches for from all the dex files + container.log("Disassembling target classes in APK") + ZipReader(apk).use { zip -> + for (file in zip.entryNames) { + if (!file.endsWith(".dex")) continue + container.log("Disassembling dex $file") + + val dexFile = try { + DexBackedDexFile( + /* opcodes = */ Opcodes.getDefault(), + /* buf = */ zip.openEntry(file)!!.read(), + ) + } catch (t: Throwable) { + throw Error("Failed to parse dex $file", t) + } + + val result = try { + Baksmali.disassembleDexFile( + /* dexFile = */ dexFile, + /* outputDir = */ smaliDir, + /* jobs = */ coreCount - 1, + /* options = */ BaksmaliOptions().apply { localsDirective = true }, + /* classes = */ patches.map { "L${it.fullClassName};" }, + ) + } catch (t: Throwable) { + throw Error("Failed to disassemble dex $file", t) + } + + assert(result) { "Failed to disassemble dex $file (unknown reason)" } + container.log("Disassembled dex file for potential target classes") + } + } + + // Apply all the patches to the smali files + container.log("Applying smali patches to disassembled files") + for ((fullClassName, patch) in patches) { + container.log("Applying patch to class $fullClassName") + + val smaliFile = smaliDir.resolve("$fullClassName.smali") + if (!smaliFile.exists()) { + throw FileNotFoundException("Target smali file $fullClassName.smali not found for patching!") + } + + val patched = try { + DiffUtils.patch(smaliFile.readLines(), patch) + } catch (t: Throwable) { + throw Error("Failed to smali patch $fullClassName", t) + } + + smaliFile.bufferedWriter().use { writer -> + patched.forEach(writer::appendLine) + } + } + + // Assemble the patched classes back into a single dex + container.log("Reassembling patches smali classes into new dex") + smaliDir.mkdir() + + // Capture stdout/stderr while assembling smali + val originalStdout = System.out + val originalStderr = System.err + val captured = ByteArrayOutputStream() + System.setOut(PrintStream(captured)) + System.setErr(PrintStream(captured)) + val success = Smali.assemble( + SmaliOptions().apply { + this.jobs = coreCount - 1 + this.outputDexFile = outDex.absolutePath + }, + listOf(smaliDir.absolutePath), + ) + System.setOut(originalStdout) + System.setErr(originalStderr) + + if (!success) { + container.log(captured.toString("UTF-8").trim()) + throw Exception("Failed to assemble patched smali!") + } + } + + override val dexPriority = 2 + override val dexCount = 1 + override fun getDexFiles(container: StepRunner) = listOf(outDex.readBytes()) +} + +private data class LoadedPatch( + val fullClassName: String, + val patch: Patch, +) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/DowngradeCheckStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/DowngradeCheckStep.kt new file mode 100644 index 0000000..577d8f2 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/DowngradeCheckStep.kt @@ -0,0 +1,62 @@ +package com.meowarex.rlmobile.patcher.steps.prepare + +import android.app.Application +import android.content.pm.PackageManager.NameNotFoundException +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.installers.InstallerResult +import com.meowarex.rlmobile.manager.InstallerManager +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.steps.base.StepState +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import com.meowarex.rlmobile.util.* +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * Prompt the user to uninstall a previous version of Radiant Lyrics if it has a larger version code. + * (Prevent conflicts from downgrading) + */ +class DowngradeCheckStep(private val options: PatchOptions) : Step(), KoinComponent { + private val context: Application by inject() + private val installers: InstallerManager by inject() + + override val group = StepGroup.Prepare + override val localizedName = R.string.patch_step_downgrade_check + + override suspend fun execute(container: StepRunner) { + container.log("Fetching version of package ${options.packageName}") + val (_, currentVersion) = try { + context.getPackageVersion(options.packageName) + } + // Package is not installed + catch (_: NameNotFoundException) { + state = StepState.Skipped + container.log("Package not uninstalled, skipping check") + return + } + container.log("Version of installed TIDAL app: $currentVersion") + + val targetVersion = container + .getStep() + .data.tidalVersionCode + + container.log("Target TIDAL version: $targetVersion") + + if (currentVersion > targetVersion) { + container.log("Current installed version is greater than target, forcing uninstallation") + mainThread { context.showToast(R.string.installer_uninstall_new) } + + when (val result = installers.getActiveInstaller().waitUninstall(options.packageName)) { + is InstallerResult.Error -> throw Error("Failed to uninstall app: ${result.getDebugReason()}") + is InstallerResult.Cancelled -> { + mainThread { context.showToast(R.string.installer_uninstall_new) } + throw Error("Newer versions of TIDAL must be uninstalled prior to installing an older version") + } + + else -> {} + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/FetchInfoStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/FetchInfoStep.kt new file mode 100644 index 0000000..e938eee --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/FetchInfoStep.kt @@ -0,0 +1,46 @@ +package com.meowarex.rlmobile.patcher.steps.prepare + +import androidx.compose.runtime.Stable +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.network.models.RLBuildInfo +import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService +import com.meowarex.rlmobile.network.utils.getOrThrow +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.Step +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +@Stable +class FetchInfoStep : Step(), KoinComponent { + private val github: RadiantLyricsGithubService by inject() + + override val group = StepGroup.Prepare + override val localizedName = R.string.patch_step_fetch_info + + lateinit var data: RLBuildInfo + private set + + lateinit var patchesAssetUrl: String + private set + + override suspend fun execute(container: StepRunner) { + container.log("Fetching latest release from ${RadiantLyricsGithubService.REPO_OWNER}/${RadiantLyricsGithubService.REPO_NAME}") + val release = github.getLatestRelease(force = true).getOrThrow() + + val dataJsonUrl = release.assets + .find { it.name == RadiantLyricsGithubService.DATA_JSON_ASSET_NAME } + ?.browserDownloadUrl + ?: throw IllegalStateException("No ${RadiantLyricsGithubService.DATA_JSON_ASSET_NAME} asset found in latest release ${release.tagName}") + + patchesAssetUrl = release.assets + .find { it.name == RadiantLyricsGithubService.PATCHES_ASSET_NAME } + ?.browserDownloadUrl + ?: throw IllegalStateException("No ${RadiantLyricsGithubService.PATCHES_ASSET_NAME} asset found in latest release ${release.tagName}") + + container.log("Fetching build info from $dataJsonUrl") + data = github.getBuildInfo(dataJsonUrl, force = true).getOrThrow() + container.log("Fetched build info: $data") + container.log("Patches asset URL: $patchesAssetUrl") + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/RestoreDownloadsStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/RestoreDownloadsStep.kt new file mode 100644 index 0000000..3a28ddf --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/prepare/RestoreDownloadsStep.kt @@ -0,0 +1,35 @@ +package com.meowarex.rlmobile.patcher.steps.prepare + +import androidx.compose.runtime.Stable +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.steps.base.StepState +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * Restores downloaded files necessary for patching from the cache dir. + * Refer to [PathManager.patchingDownloadDir] and [PathManager.cacheDownloadDir] + * for more information. + */ +@Stable +class RestoreDownloadsStep : Step(), KoinComponent { + private val paths: PathManager by inject() + + override val group = StepGroup.Prepare + override val localizedName = R.string.patch_step_restore_cache + + override suspend fun execute(container: StepRunner) { + if (paths.cacheDownloadDir.exists()) { + container.log("Moving downloads from cache to permanent storage") + paths.patchingDownloadDir.deleteRecursively() + paths.cacheDownloadDir.renameTo(paths.patchingDownloadDir) + } else { + container.log("No download cache present") + state = StepState.Skipped + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ArscUtil.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ArscUtil.kt new file mode 100644 index 0000000..1da523f --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ArscUtil.kt @@ -0,0 +1,172 @@ +package com.meowarex.rlmobile.patcher.util + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.github.diamondminer88.zip.ZipReader +import com.google.devrel.gmscore.tools.apk.arsc.* +import java.io.File + +object ArscUtil { + /** + * Read and parse `resources.arsc` from an APK. + */ + fun readArsc(apk: File): BinaryResourceFile { + val bytes = ZipReader(apk).use { it.openEntry("resources.arsc")?.read() } + ?: error("APK missing resources.arsc") + + return try { + BinaryResourceFile(bytes) + } catch (t: Throwable) { + throw Error("Failed to parse resources.arsc", t) + } + } + + /** + * Get the only top-level chunk in an arsc file. + */ + fun BinaryResourceFile.getMainArscChunk(): ResourceTableChunk { + if (this.chunks.size > 1) + error("More than 1 top level chunk in resources.arsc") + + return this.chunks.first() as? ResourceTableChunk + ?: error("Invalid top-level resources.arsc chunk") + } + + /** + * Get a singular package chunk in an arsc file. + */ + fun BinaryResourceFile.getPackageChunk(): PackageChunk { + return this.getMainArscChunk().packages.singleOrNull() + ?: error("resources.arsc must contain exactly 1 package chunk") + } + + /** + * Adds a new color resource to all configuration variants in an arsc package. + * + * @param name The new resource name. + * @param color The value of the new color resource. + * @return The resource ID of the newly added resource. + */ + fun PackageChunk.addColorResource( + name: String, + color: Color, + ): BinaryResourceIdentifier { + return this.addResource( + typeName = "color", + resourceName = name, + configurations = { it.isDefault }, + valueType = BinaryResourceValue.Type.INT_COLOR_ARGB8, + valueData = color.toArgb(), + ) + } + + /** + * Adds a new color resource to the matching configuration variants in an arsc package. + * + * @param typeName The type of the resource (ex: `mipmap`, `drawable`, etc.) + * @param resourceName The new resource name. + * @param configurations A predicate whether to add the value into a matching type chunk. + * @param valueType The type of the resource value. + * @param valueData The raw data of the resource value. + * @return The resource ID of the newly added resource. + */ + fun PackageChunk.addResource( + typeName: String, + resourceName: String, + configurations: (BinaryResourceConfiguration) -> Boolean, + valueType: BinaryResourceValue.Type, + valueData: Int, + ): BinaryResourceIdentifier { + // Add a new resource entry to the "type spec chunk" and, + // a new resource entry to all matching "type chunks" + + val specChunk = this.getTypeSpecChunk(typeName) + val typeChunks = this.getTypeChunks(typeName) + + // Add a new string to the pool to be used as a key + val resourceNameIdx = this.keyStringPool.addString(resourceName, /* deduplicate = */ true) + + // Add a new resource entry to the type spec chunk + val resourceIdx = specChunk.addResource(/* flags = */ 0) + + for (typeChunk in typeChunks) { + // If no matching config, add a null entry and try next chunk + if (!configurations(typeChunk.configuration)) { + typeChunk.addEntry(null) + continue + } + + val entry = TypeChunk.Entry( + /* headerSize = */ 8, + /* flags = */ 0, + /* keyIndex = */ resourceNameIdx, + /* value = */ + BinaryResourceValue( + /* type = */ valueType, + /* data = */ valueData, + ), + /* values = */ null, // not a complex resource + /* parentEntry = */ 0, // not a complex resource + /* parent = */ typeChunk, + ) + + typeChunk.addEntry(entry) + } + + return BinaryResourceIdentifier.create( + /* packageId = */ this.id, + /* typeId = */ specChunk.id, + /* entryId = */ resourceIdx, + ) + } + + /** + * In an arsc file, for a specific resource in a configuration, get it's value. + * + * @param resourceId The target resource id. + * @param configurationName The target configuration variant of the resource. (ex: `anydpi-v26`, `xxhdpi`, `ldtrl-mpi`, etc.) + * @return The string value of the resource for the specified configuration. + */ + fun ResourceTableChunk.getResourceFileName( + resourceId: BinaryResourceIdentifier, + configurationName: String, + ): String { + return getResourceFileNames( + resourceId = resourceId, + configurations = { it.toString() == configurationName }, + ).single() + } + + /** + * In an arsc file, for a specific resource in all matching configurations, get the values. + * + * @param resourceId The target resource id. + * @param configurations A predicate whether to add the value into a matching type chunk. + * @return The string values of the resources, for each configuration. + */ + fun ResourceTableChunk.getResourceFileNames( + resourceId: BinaryResourceIdentifier, + configurations: (BinaryResourceConfiguration) -> Boolean, + ): List { + val packageChunk = this.packages.find { it.id == resourceId.packageId() } + ?: error("Unable to find target resource") + + val typeChunks = packageChunk.getTypeChunks(resourceId.typeId()) + .filter { configurations(it.configuration) } + + val entries = typeChunks.map { typeChunk -> + val entry = typeChunk.getEntry(resourceId.entryId()) + ?: error("Unable to find target resource in type chunk " + typeChunk.configuration) + + if (entry.isComplex || entry.value().type() != BinaryResourceValue.Type.STRING) + error("Target resource value type in type chunk ${typeChunk.configuration} is not STRING") + + entry + } + + return entries.map { entry -> + val valueIdx = entry.value().data() + this.stringPool.getString(valueIdx) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/AxmlUtil.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/AxmlUtil.kt new file mode 100644 index 0000000..7c5866d --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/AxmlUtil.kt @@ -0,0 +1,203 @@ +package com.meowarex.rlmobile.patcher.util + +import com.meowarex.rlmobile.patcher.util.AxmlUtil.getMainAxmlChunk +import com.meowarex.rlmobile.util.find +import com.github.diamondminer88.zip.ZipReader +import com.github.diamondminer88.zip.ZipWriter +import com.google.devrel.gmscore.tools.apk.arsc.* +import java.io.File + +object AxmlUtil { + /** + * Read and parse a specific axml resource inside an APK + * @param apk The source apk + * @param resourcePath The full path to the axml file inside the apk, which may be flattened. + */ + private fun readAxml(apk: File, resourcePath: String): BinaryResourceFile { + val bytes = ZipReader(apk).use { it.openEntry(resourcePath)?.read() } + ?: error("APK missing resource file at $resourcePath") + + return try { + BinaryResourceFile(bytes) + } catch (t: Throwable) { + throw Error("Failed to parse axml at $resourcePath", t) + } + } + + /** + * Get the only top-level chunk in an axml file. + */ + fun BinaryResourceFile.getMainAxmlChunk(): XmlChunk { + if (this.chunks.size > 1) + error("More than 1 top level chunk in axml") + + return this.chunks.first() as? XmlChunk + ?: error("Invalid top-level axml chunk") + } + + /** + * Finds the first chunk with a matching [name] in a flattened chunk list. + * @receiver The top level XmlChunk ([getMainAxmlChunk]) + */ + private fun XmlChunk.getStartElementChunk(name: String): XmlStartElementChunk? { + val nameIdx = this.stringPool.indexOf(name) + + return this.chunks + .find { it is XmlStartElementChunk && it.nameIndex == nameIdx } + as? XmlStartElementChunk + } + + /** + * Finds the first attribute with a matching name (ignoring namespace) + * in a starting element chunk. + */ + private fun XmlStartElementChunk.getAttribute(name: String): XmlAttribute { + val nameIdx = (this.parent as XmlChunk).stringPool.indexOf(name) + + return this.attributes + .find { it.nameIndex() == nameIdx } + ?: error("Failed to find $name attribute in an axml chunk") + } + + /** + * Patches an axml file to change the `background`, `foreground`, and `monochrome` resource references. + * If any of the following are not null, then they will be patched. + * @param backgroundColor A color resource id to replace with. + * + * @param foregroundIcon A drawable resource id to replace with. + * @param monochromeIcon A drawable resource id to add or replace with. + */ + fun patchAdaptiveIcon( + apk: File, + resourcePath: String, + backgroundColor: BinaryResourceIdentifier? = null, + foregroundIcon: BinaryResourceIdentifier? = null, + monochromeIcon: BinaryResourceIdentifier? = null, + ) { + val xml = readAxml(apk, resourcePath) + val xmlChunk = xml.getMainAxmlChunk() + + // Patch the background color resource reference + if (backgroundColor != null) { + val chunk = xmlChunk.getStartElementChunk("background")!! + val attribute = chunk.getAttribute("drawable") + attribute.typedValue().setValue( + /* type = */ BinaryResourceValue.Type.REFERENCE, + /* data = */ backgroundColor.resourceId(), + ) + } + + // Patch the foreground drawable reference + if (foregroundIcon != null) { + val chunk = xmlChunk.getStartElementChunk("foreground")!! + val attribute = chunk.getAttribute("drawable") + attribute.typedValue().setValue( + /* type = */ BinaryResourceValue.Type.REFERENCE, + /* data = */ foregroundIcon.resourceId(), + ) + } + + // Add or replace the monochrome drawable reference + if (monochromeIcon != null) { + // already exists, patch existing chunk + val existingChunk = xmlChunk.getStartElementChunk("monochrome") + if (existingChunk != null) { + val attribute = existingChunk.getAttribute("drawable") + attribute.typedValue().setValue( + /* type = */ BinaryResourceValue.Type.REFERENCE, + /* data = */ monochromeIcon.resourceId(), + ) + } + // Add a new start & end chunk since they don't exist + // ` + else { + val iconEndChunkIdx = xmlChunk.chunks + .indexOfLast { it is XmlEndElementChunk && it.name == "adaptive-icon" } + + val namespaceIdx = xmlChunk.stringPool.indexOf("http://schemas.android.com/apk/res/android") + val drawableIdx = xmlChunk.stringPool.indexOf("drawable") + val monochromeIdx = xmlChunk.stringPool.addString("monochrome") + + val startChunk = XmlStartElementChunk( + /* namespaceIndex = */ -1, + /* nameIndex = */ monochromeIdx, + /* idIndex = */ -1, + /* classIndex = */ -1, + /* styleIndex = */ -1, + /* attributes = */ + listOf( + XmlAttribute( + /* namespaceIndex = */ namespaceIdx, + /* nameIndex = */ drawableIdx, + /* rawValueIndex = */ -1, + /* typedValue = */ + BinaryResourceValue( + /* type = */ BinaryResourceValue.Type.REFERENCE, + /* data = */ monochromeIcon.resourceId(), + ), + // This is wrong but it doesn't matter here as long as this attribute isn't stringified + /* parent = */ null, + ) + ), + /* parent = */ xmlChunk, + ) + val endChunk = XmlEndElementChunk( + /* namespaceIndex = */ -1, + /* nameIndex = */ monochromeIdx, + /* parent = */ xmlChunk, + ) + + xmlChunk.addChunk(iconEndChunkIdx, startChunk) + xmlChunk.addChunk(iconEndChunkIdx + 1, endChunk) + } + } + + ZipWriter(apk, /* append = */ true).use { zip -> + zip.deleteEntry(resourcePath) + zip.writeEntry(resourcePath, xml.toByteArray()) + } + } + + /** + * From an APK, read the manifest's `icon` and `roundIcon` references to a resource. + * This is then used to get the filename of the resource from `resources.arsc`. + */ + fun readManifestIconInfo(apk: File): ManifestIconInfo { + val manifestBytes = ZipReader(apk).use { + it.openEntry("AndroidManifest.xml")?.read() + } ?: error("APK missing manifest") + val manifest = BinaryResourceFile(manifestBytes) + val mainChunk = manifest.getMainAxmlChunk() + + // Prefetch string indexes to avoid parsing the entire string pool + val iconStringIdx = mainChunk.stringPool.indexOf("icon") + val roundIconStringIdx = mainChunk.stringPool.indexOf("roundIcon") + val applicationStringIdx = mainChunk.stringPool.indexOf("application") + + val applicationChunk = mainChunk.chunks + .find { it is XmlStartElementChunk && it.nameIndex == applicationStringIdx } as? XmlStartElementChunk + ?: error("Unable to find in manifest") + + val squareIcon = applicationChunk.attributes + .find { it.nameIndex() == iconStringIdx } + ?: error("Unable to find android:icon in manifest") + + val roundIcon = applicationChunk.attributes + .find { it.nameIndex() == roundIconStringIdx } + ?: squareIcon // TIDAL has no android:roundIcon; fall back to icon + + assert(squareIcon.typedValue().type() == BinaryResourceValue.Type.REFERENCE) + assert(roundIcon.typedValue().type() == BinaryResourceValue.Type.REFERENCE) + + return ManifestIconInfo( + // Resource IDs into resources.arsc + squareIcon = BinaryResourceIdentifier.create(squareIcon.typedValue().data()), + roundIcon = BinaryResourceIdentifier.create(roundIcon.typedValue().data()), + ) + } + + data class ManifestIconInfo( + val squareIcon: BinaryResourceIdentifier, + val roundIcon: BinaryResourceIdentifier, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/InsufficientStorageException.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/InsufficientStorageException.kt new file mode 100644 index 0000000..fb4b524 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/InsufficientStorageException.kt @@ -0,0 +1,11 @@ +package com.meowarex.rlmobile.patcher.util + +/** + * Used to indicate that pre-allocating storage space via [android.os.storage.StorageManager.allocateBytes] + * failed due to insufficient storage space, or cache space able to be cleared. + */ +class InsufficientStorageException( + message: String?, +) : Exception() { + override val message = "Failed to preallocate sufficient storage space: $message" +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt new file mode 100644 index 0000000..bdb199a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/util/ManifestPatcher.kt @@ -0,0 +1,197 @@ +package com.meowarex.rlmobile.patcher.util + +import android.Manifest +import android.os.Build +import pxb.android.axml.* + +object ManifestPatcher { + private const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android" + private const val DEBUGGABLE = "debuggable" + private const val VM_SAFE_MODE = "vmSafeMode" + private const val USE_EMBEDDED_DEX = "useEmbeddedDex" + private const val EXTRACT_NATIVE_LIBS = "extractNativeLibs" + private const val REQUEST_LEGACY_EXTERNAL_STORAGE = "requestLegacyExternalStorage" + private const val LABEL = "label" + private const val PACKAGE = "package" + private const val COMPILE_SDK_VERSION = "compileSdkVersion" + private const val COMPILE_SDK_VERSION_CODENAME = "compileSdkVersionCodename" + + fun patchManifest( + manifestBytes: ByteArray, + packageName: String, + appName: String, + debuggable: Boolean, + ): ByteArray { + val reader = AxmlReader(manifestBytes) + val writer = AxmlWriter() + + reader.accept(object : AxmlVisitor(writer) { + // Without this, decompiling the finished manifest has the android namespace + // under an autogenerated name like axml_00 or something. + override fun ns(prefix: String?, uri: String?, ln: Int) { + val realUri = uri ?: ANDROID_NAMESPACE + super.ns(prefix, realUri, ln) + } + + override fun child(ns: String?, name: String?) = + object : ReplaceAttrsVisitor( + super.child(ns, name), + mapOf( + PACKAGE to packageName, + COMPILE_SDK_VERSION to 23, + COMPILE_SDK_VERSION_CODENAME to "6.0-2438415" + ) + ) { + private var addExternalStoragePerm = false + + override fun child(ns: String?, name: String): NodeVisitor { + val nv = super.child(ns, name) + + // Add MANAGE_EXTERNAL_STORAGE when necessary + if (addExternalStoragePerm) { + super + .child(null, "uses-permission") + .attr(ANDROID_NAMESPACE, "name", android.R.attr.name, TYPE_STRING, Manifest.permission.MANAGE_EXTERNAL_STORAGE) + addExternalStoragePerm = false + } + + return when (name) { + "uses-permission" -> object : NodeVisitor(nv) { + override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) { + if (name != "maxSdkVersion") { + super.attr(ns, name, resourceId, type, value) + } + + if (name == "name" && value == Manifest.permission.READ_EXTERNAL_STORAGE) { + addExternalStoragePerm = true + } + } + } + + "uses-sdk" -> object : NodeVisitor(nv) { + override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) { + if (name == "targetSdkVersion") { + val version = if (Build.VERSION.SDK_INT >= 31) 30 else 28 + super.attr(ns, name, resourceId, type, version) + } else { + super.attr(ns, name, resourceId, type, value) + } + } + } + + "permission" -> object : NodeVisitor(nv) { + override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) { + super.attr( + ns, name, resourceId, type, + when (name) { + "name" -> (value as String).replace("com.tidal.android", packageName) + else -> value + } + ) + } + } + + "application" -> object : ReplaceAttrsVisitor( + nv, + mapOf( + LABEL to appName, + DEBUGGABLE to debuggable, + REQUEST_LEGACY_EXTERNAL_STORAGE to true, + VM_SAFE_MODE to true, + USE_EMBEDDED_DEX to true, + EXTRACT_NATIVE_LIBS to false, + ) + ) { + private var addDebuggable = debuggable + private var addLegacyStorage = true + private var addUseEmbeddedDex = true + private var addExtractNativeLibs = true + private var addMetadata = true + + override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) { + if (name == REQUEST_LEGACY_EXTERNAL_STORAGE) addLegacyStorage = false + if (name == USE_EMBEDDED_DEX) addUseEmbeddedDex = false + if (name == EXTRACT_NATIVE_LIBS) addExtractNativeLibs = false + if (name == DEBUGGABLE) addDebuggable = false + super.attr(ns, name, resourceId, type, value) + } + + override fun child(ns: String?, name: String): NodeVisitor { + val visitor = super.child(ns, name) + + // Adds a tag to make multi-install detection by HomeScreen work + if (addMetadata) { + addMetadata = false + super.child(ANDROID_NAMESPACE, "meta-data").apply { + attr(ANDROID_NAMESPACE, "name", android.R.attr.name, TYPE_STRING, "isRadiantLyrics") + attr(ANDROID_NAMESPACE, "value", android.R.attr.value, TYPE_INT_BOOLEAN, 1) + } + } + + return when (name) { + "activity" -> ReplaceAttrsVisitor(visitor, mapOf("label" to appName)) + "provider" -> object : NodeVisitor(visitor) { + override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) { + super.attr( + ns, name, resourceId, type, + if (name == "authorities") { + (value as String).replace("com.tidal.android", packageName) + } else { + value + } + ) + } + } + + else -> visitor + } + } + + override fun end() { + if (addLegacyStorage && Build.VERSION.SDK_INT >= 29) super.attr( + ANDROID_NAMESPACE, + REQUEST_LEGACY_EXTERNAL_STORAGE, + android.R.attr.requestLegacyExternalStorage, + TYPE_INT_BOOLEAN, + 1 + ) + if (addDebuggable) super.attr(ANDROID_NAMESPACE, DEBUGGABLE, android.R.attr.debuggable, TYPE_INT_BOOLEAN, 1) + + // Disable AOT (Necessary for AOSP Android 15) + if (Build.VERSION.SDK_INT >= 29 && addUseEmbeddedDex) { + super.attr(ANDROID_NAMESPACE, USE_EMBEDDED_DEX, android.R.attr.useEmbeddedDex, TYPE_INT_BOOLEAN, 1) + } + + if (addExtractNativeLibs) super.attr( + ANDROID_NAMESPACE, + EXTRACT_NATIVE_LIBS, + android.R.attr.extractNativeLibs, + TYPE_INT_BOOLEAN, + 0 + ) + + super.end() + } + } + + else -> nv + } + } + } + }) + + return writer.toByteArray() + } + + private open class ReplaceAttrsVisitor( + nv: NodeVisitor, + private val attrs: Map, + ) : NodeVisitor(nv) { + override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) { + val replace = attrs.containsKey(name) + val newValue = attrs[name] + + super.attr(ns, name, resourceId, if (newValue is String) TYPE_STRING else type, if (replace) newValue else value) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/BackButton.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/BackButton.kt new file mode 100644 index 0000000..99a25b6 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/BackButton.kt @@ -0,0 +1,31 @@ +package com.meowarex.rlmobile.ui.components + +import androidx.activity.compose.LocalActivity +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.navigator.LocalNavigator +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.util.back + +/** + * Standalone back button for interacting with the current navigator. + */ +@Composable +fun BackButton() { + val navigator = LocalNavigator.current + val activity = LocalActivity.current + + IconButton( + onClick = { + navigator?.back(activity) + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_back), + contentDescription = stringResource(R.string.navigation_back), + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/Contributors.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/Contributors.kt new file mode 100644 index 0000000..f35c8bf --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/Contributors.kt @@ -0,0 +1,72 @@ +package com.meowarex.rlmobile.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import coil3.compose.SubcomposeAsyncImage +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.network.models.Contributor +import com.valentinilk.shimmer.shimmer + +@Composable +fun ContributorCommitsItem( + user: Contributor, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .clickable { uriHandler.openUri("https://github.com/${user.username}") } + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), + ) { + SubcomposeAsyncImage( + model = user.avatarUrl, + contentDescription = user.username, + error = { + Surface( + content = {}, + tonalElevation = 2.dp, + modifier = Modifier + .fillMaxSize() + .shimmer(), + ) + }, + modifier = Modifier + .padding(top = 6.dp) + .size(45.dp) + .clip(CircleShape) + ) + + Column { + Text( + text = user.username, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(R.string.contributors_contributions, user.commits), + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + ) + + Text( + text = user.repositories.joinToString { it.name }, + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.bodySmall + .copy(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)), + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/InteractiveSlider.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/InteractiveSlider.kt new file mode 100644 index 0000000..da246b9 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/InteractiveSlider.kt @@ -0,0 +1,113 @@ +/** + * MIT License + * + * Copyright (c) 2025 zt64 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Adapted from compose-pipette: +// https://github.com/zt64/compose-pipette/blob/3e9fd958a315dceb142bf30250b4614ecde4e723/sample/src/commonMain/kotlin/dev/zt64/compose/pipette/sample/SampleSlider.kt + +package com.meowarex.rlmobile.ui.components + +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.* +import androidx.compose.ui.unit.dp + +@Composable +fun InteractiveSlider( + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + brush: Brush, + thumbColor: Color = MaterialTheme.colorScheme.primary, + modifier: Modifier = Modifier, +) { + val interactionSource = remember(::MutableInteractionSource) + + Slider( + value = value, + onValueChange = onValueChange, + thumb = { + val interactions = remember { mutableStateListOf() } + + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> interactions.add(interaction) + is PressInteraction.Release -> interactions.remove(interaction.press) + is PressInteraction.Cancel -> interactions.remove(interaction.press) + is DragInteraction.Start -> interactions.add(interaction) + is DragInteraction.Stop -> interactions.remove(interaction.start) + is DragInteraction.Cancel -> interactions.remove(interaction.start) + } + } + } + + Box( + modifier = Modifier + .size(28.dp) + .hoverable(interactionSource = interactionSource), + ) { + val visualSize by remember { + derivedStateOf { + if (interactions.isNotEmpty()) 28.dp else 24.dp + } + } + + Spacer( + modifier = Modifier + .size(visualSize) + .align(Alignment.Center) + .background(thumbColor, CircleShape), + ) + } + }, + track = { + Canvas( + modifier = Modifier + .widthIn(max = 700.dp) + .height(12.dp) + .fillMaxWidth(), + ) { + drawLine( + brush = brush, + start = Offset(0f, size.center.y), + end = Offset(size.width, size.center.y), + strokeWidth = size.height, + cap = StrokeCap.Round, + ) + } + }, + valueRange = valueRange, + interactionSource = interactionSource, + modifier = modifier, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/Label.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/Label.kt new file mode 100644 index 0000000..44ce907 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/Label.kt @@ -0,0 +1,42 @@ +package com.meowarex.rlmobile.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.ui.util.thenIf + +@Composable +fun Label( + name: String, + description: String?, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .thenIf(description == null) { padding(bottom = 4.dp) }, + ) + + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .alpha(.7f) + .padding(bottom = 4.dp), + ) + } + + content() + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/LoadFailure.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/LoadFailure.kt new file mode 100644 index 0000000..57b6ab6 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/LoadFailure.kt @@ -0,0 +1,33 @@ +package com.meowarex.rlmobile.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R + +@Composable +fun LoadFailure(modifier: Modifier = Modifier) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + Icon( + painter = painterResource(R.drawable.ic_warning), + tint = MaterialTheme.colorScheme.error, + contentDescription = null, + modifier = Modifier.size(34.dp), + ) + Text( + text = stringResource(R.string.network_load_fail), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/MainActionButton.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/MainActionButton.kt new file mode 100644 index 0000000..a6dc0c0 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/MainActionButton.kt @@ -0,0 +1,49 @@ +package com.meowarex.rlmobile.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun MainActionButton( + text: String, + icon: Painter, + onClick: () -> Unit, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + modifier: Modifier = Modifier, +) { + FilledTonalIconButton( + shape = MaterialTheme.shapes.medium, + colors = colors, + onClick = onClick, + enabled = enabled, + modifier = modifier + .fillMaxWidth() + .height(46.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/ProjectHeader.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/ProjectHeader.kt new file mode 100644 index 0000000..4d2564a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/ProjectHeader.kt @@ -0,0 +1,51 @@ +package com.meowarex.rlmobile.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.meowarex.rlmobile.R + +@Composable +fun ProjectHeader(modifier: Modifier = Modifier) { + val uriHandler = LocalUriHandler.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier, + ) { + Text( + text = stringResource(R.string.rlmobile), + style = MaterialTheme.typography.titleMedium.copy(fontSize = 26.sp) + ) + + Text( + text = stringResource(R.string.app_description), + style = MaterialTheme.typography.titleSmall.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .6f) + ), + textAlign = TextAlign.Center, + ) + + Row( + horizontalArrangement = Arrangement.Center, + ) { + TextButton(onClick = { uriHandler.openUri("https://github.com/meowarex/rl-mobile") }) { + Icon( + painter = painterResource(R.drawable.ic_account_github_white_24dp), + contentDescription = null, + modifier = Modifier.padding(end = ButtonDefaults.IconSpacing), + ) + Text(text = stringResource(R.string.github)) + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/ResetToDefaultButton.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/ResetToDefaultButton.kt new file mode 100644 index 0000000..70f60e8 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/ResetToDefaultButton.kt @@ -0,0 +1,37 @@ +package com.meowarex.rlmobile.ui.components + +import androidx.compose.animation.* +import androidx.compose.foundation.layout.size +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.util.mirrorVertically + +@Composable +fun ResetToDefaultButton( + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = enabled, + enter = fadeIn() + slideInHorizontally(), + exit = fadeOut() + slideOutHorizontally(), + modifier = modifier, + ) { + IconButton(onClick = onClick) { + Icon( + painter = painterResource(R.drawable.ic_refresh), + tint = MaterialTheme.colorScheme.secondary, + contentDescription = stringResource(R.string.action_reset_default), + modifier = Modifier + .mirrorVertically() + .size(26.dp), + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/SegmentedButton.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/SegmentedButton.kt new file mode 100644 index 0000000..9dc8879 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/SegmentedButton.kt @@ -0,0 +1,45 @@ +package com.meowarex.rlmobile.ui.components + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.dp + +@Composable +fun RowScope.SegmentedButton( + icon: Painter, + iconColor: Color = MaterialTheme.colorScheme.primary, + iconDescription: String? = null, + text: String, + textColor: Color = MaterialTheme.colorScheme.primary, + onClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + modifier = Modifier + .clickable(onClick = onClick) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .weight(1f) + .padding(12.dp) + ) { + Icon( + painter = icon, + contentDescription = iconDescription, + tint = iconColor, + ) + + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = textColor, + maxLines = 1, + modifier = Modifier.basicMarquee(), + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/TextDivider.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/TextDivider.kt new file mode 100644 index 0000000..dfb183f --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/TextDivider.kt @@ -0,0 +1,35 @@ +package com.meowarex.rlmobile.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun TextDivider( + text: String, + style: TextStyle = MaterialTheme.typography.bodyMedium, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth(), + ) { + HorizontalDivider(Modifier.weight(1f)) + + Text( + text = text, + style = style, + color = MaterialTheme.colorScheme.outline, + fontWeight = FontWeight.SemiBold, + ) + + HorizontalDivider(Modifier.weight(1f)) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/VersionDisplay.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/VersionDisplay.kt new file mode 100644 index 0000000..58712ab --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/VersionDisplay.kt @@ -0,0 +1,30 @@ +package com.meowarex.rlmobile.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.* +import com.meowarex.rlmobile.ui.util.TidalVersion + +@Composable +fun VersionDisplay( + version: TidalVersion, + prefix: (@Composable AnnotatedString.Builder.() -> Unit)? = null, + style: TextStyle = MaterialTheme.typography.labelLarge, + modifier: Modifier = Modifier, +) { + Text( + text = buildAnnotatedString { + prefix?.invoke(this) + + if (version is TidalVersion.Existing) { + append(version.name) + append(" - ") + } + append(version.toDisplayName()) + }, + style = style, + modifier = modifier, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/Wakelock.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/Wakelock.kt new file mode 100644 index 0000000..4e03689 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/Wakelock.kt @@ -0,0 +1,26 @@ +package com.meowarex.rlmobile.ui.components + +import android.view.WindowManager +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import cafe.adriel.voyager.navigator.currentOrThrow + +/** + * Maintain an active screen wakelock as long as [active] is true and this component is in scope. + */ +@Composable +fun Wakelock(active: Boolean = false) { + val window = LocalActivity.currentOrThrow.window + DisposableEffect(active) { + if (active) { + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + onDispose { + window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/dialogs/InstallerAbortDialog.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/dialogs/InstallerAbortDialog.kt new file mode 100644 index 0000000..2a9e7da --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/dialogs/InstallerAbortDialog.kt @@ -0,0 +1,62 @@ +package com.meowarex.rlmobile.ui.components.dialogs + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R + +@Composable +fun InstallerAbortDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + FilledTonalButton( + onClick = onConfirm, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.action_exit_anyways)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + title = { + Text(stringResource(R.string.installer_abort_title)) + }, + text = { + Text( + text = stringResource(R.string.installer_abort_body), + textAlign = TextAlign.Center, + ) + }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_warning), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + }, + containerColor = MaterialTheme.colorScheme.errorContainer, + iconContentColor = MaterialTheme.colorScheme.onErrorContainer, + titleContentColor = MaterialTheme.colorScheme.onErrorContainer, + textContentColor = MaterialTheme.colorScheme.onErrorContainer + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/dialogs/NetworkWarningDialog.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/dialogs/NetworkWarningDialog.kt new file mode 100644 index 0000000..6c9c3ad --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/dialogs/NetworkWarningDialog.kt @@ -0,0 +1,96 @@ +package com.meowarex.rlmobile.ui.components.dialogs + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.meowarex.rlmobile.R + +@Composable +fun NetworkWarningDialog( + onConfirm: (neverShow: Boolean) -> Unit, + onDismiss: (neverShow: Boolean) -> Unit, +) { + val interactionSource = remember(::MutableInteractionSource) + var neverShow by rememberSaveable { mutableStateOf(false) } + val rememberedNeverShow by rememberUpdatedState(neverShow) + + AlertDialog( + onDismissRequest = { onDismiss(rememberedNeverShow) }, + properties = DialogProperties( + dismissOnClickOutside = false, + ), + confirmButton = { + FilledTonalButton( + onClick = { onConfirm(rememberedNeverShow) }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.action_continue)) + } + }, + dismissButton = { + TextButton( + onClick = { onDismiss(rememberedNeverShow) }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + ) { + Text(stringResource(R.string.navigation_back)) + } + }, + title = { Text(stringResource(R.string.network_warning_title)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.network_warning_body), + textAlign = TextAlign.Center, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { neverShow = !rememberedNeverShow }, + ) + .padding(end = 16.dp) + ) { + Checkbox( + checked = neverShow, + onCheckedChange = { neverShow = it }, + interactionSource = interactionSource, + ) + + Text(stringResource(R.string.network_warning_disable)) + } + } + }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_warning), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + }, + containerColor = MaterialTheme.colorScheme.errorContainer, + iconContentColor = MaterialTheme.colorScheme.onErrorContainer, + titleContentColor = MaterialTheme.colorScheme.onErrorContainer, + textContentColor = MaterialTheme.colorScheme.onErrorContainer, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/dialogs/PlayProtectDialog.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/dialogs/PlayProtectDialog.kt new file mode 100644 index 0000000..19bb1a0 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/dialogs/PlayProtectDialog.kt @@ -0,0 +1,103 @@ +package com.meowarex.rlmobile.ui.components.dialogs + +import android.app.Activity +import android.content.Intent +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import cafe.adriel.voyager.navigator.currentOrThrow +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.theme.customColors + +@Composable +fun PlayProtectDialog( + onDismiss: (neverShow: Boolean) -> Unit, +) { + val activity = LocalActivity.currentOrThrow + val interactionSource = remember(::MutableInteractionSource) + var neverShow by rememberSaveable { mutableStateOf(false) } + val rememberedNeverShow by rememberUpdatedState(neverShow) + + AlertDialog( + onDismissRequest = { onDismiss(rememberedNeverShow) }, + dismissButton = { + FilledTonalButton(onClick = activity::launchPlayProtect) { + Text(stringResource(R.string.play_protect_warning_open_gpp)) + } + }, + confirmButton = { + FilledTonalButton( + onClick = { onDismiss(rememberedNeverShow) }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Text(stringResource(R.string.play_protect_warning_ok)) + } + }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_protect_warning), + tint = MaterialTheme.customColors.warning, + contentDescription = null, + modifier = Modifier.size(36.dp), + ) + }, + title = { Text(stringResource(R.string.play_protect_warning_title)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.play_protect_warning_desc), + textAlign = TextAlign.Center, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { neverShow = !rememberedNeverShow }, + ) + .padding(end = 16.dp) + ) { + Checkbox( + checked = neverShow, + onCheckedChange = { neverShow = it }, + interactionSource = interactionSource, + ) + + Text(stringResource(R.string.play_protect_warning_disable)) + } + } + }, + properties = DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + ), + modifier = Modifier + .padding(25.dp), + ) +} + +private fun Activity.launchPlayProtect() { + Intent("com.google.android.gms.settings.VERIFY_APPS_SETTINGS") + .setPackage("com.google.android.gms") + .addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + .also(::startActivity) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsHeader.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsHeader.kt new file mode 100644 index 0000000..2725d3b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsHeader.kt @@ -0,0 +1,17 @@ +package com.meowarex.rlmobile.ui.components.settings + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.ui.components.TextDivider + +@Composable +fun SettingsHeader( + text: String, +) { + TextDivider( + text = text, + modifier = Modifier.padding(18.dp, 20.dp, 18.dp, 10.dp) + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsItem.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsItem.kt new file mode 100644 index 0000000..1c5c1e6 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsItem.kt @@ -0,0 +1,57 @@ +package com.meowarex.rlmobile.ui.components.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun SettingsItem( + text: @Composable () -> Unit, + secondaryText: @Composable (() -> Unit) = { }, + icon: @Composable (() -> Unit) = { }, + modifier: Modifier = Modifier, + trailing: @Composable (() -> Unit) = { }, +) { + Row( + modifier = modifier + .heightIn(min = 64.dp) + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.weight(2f, true) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.size(20.dp)) { + icon() + } + + ProvideTextStyle(MaterialTheme.typography.titleSmall) { + text() + } + } + + ProvideTextStyle( + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface.copy(0.6f) + ) + ) { + secondaryText() + } + } + + Spacer(Modifier.weight(0.05f, true)) + + trailing() + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsSwitch.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsSwitch.kt new file mode 100644 index 0000000..62bbe41 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsSwitch.kt @@ -0,0 +1,35 @@ +package com.meowarex.rlmobile.ui.components.settings + +import androidx.compose.foundation.clickable +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun SettingsSwitch( + label: String, + secondaryLabel: String? = null, + disabled: Boolean = false, + icon: @Composable () -> Unit = {}, + pref: Boolean, + onPrefChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + SettingsItem( + modifier = modifier.clickable(enabled = !disabled) { onPrefChange(!pref) }, + text = { Text(text = label, softWrap = true) }, + icon = icon, + secondaryText = { + secondaryLabel?.let { + Text(text = it) + } + } + ) { + Switch( + checked = pref, + enabled = !disabled, + onCheckedChange = { onPrefChange(!pref) } + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsTextField.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsTextField.kt new file mode 100644 index 0000000..3adb17b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/components/settings/SettingsTextField.kt @@ -0,0 +1,29 @@ +package com.meowarex.rlmobile.ui.components.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun SettingsTextField( + label: String, + disabled: Boolean = false, + pref: String, + error: Boolean = false, + onPrefChange: (String) -> Unit, +) { + Box(modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp)) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = pref, + onValueChange = onPrefChange, + enabled = !disabled, + label = { Text(label) }, + isError = error, + singleLine = true + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/TextBannerPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/TextBannerPreview.kt new file mode 100644 index 0000000..d43f16c --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/TextBannerPreview.kt @@ -0,0 +1,66 @@ +package com.meowarex.rlmobile.ui.previews + +import android.content.res.Configuration +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.* +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.screens.patching.components.TextBanner +import com.meowarex.rlmobile.ui.theme.ManagerTheme +import com.meowarex.rlmobile.ui.theme.customColors + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun ButtonVotePreview( + @PreviewParameter(TextBannerParametersProvider::class) + parameters: TextBannerParameters, +) { + ManagerTheme { + TextBanner( + text = parameters.text(), + icon = parameters.icon(), + iconColor = parameters.iconColor(), + outlineColor = parameters.outlineColor(), + containerColor = parameters.containerColor(), + ) + } +} + +private data class TextBannerParameters( + val text: @Composable () -> String, + val icon: @Composable () -> Painter, + val iconColor: @Composable () -> Color, + val outlineColor: @Composable () -> Color?, + val containerColor: @Composable () -> Color, +) + +private class TextBannerParametersProvider : PreviewParameterProvider { + override val values = sequenceOf( + TextBannerParameters( + text = { stringResource(R.string.installer_banner_minimization) }, + icon = { painterResource(R.drawable.ic_warning) }, + iconColor = { MaterialTheme.customColors.onWarningContainer }, + outlineColor = { MaterialTheme.customColors.warning }, + containerColor = { MaterialTheme.customColors.warningContainer }, + ), + TextBannerParameters( + text = { stringResource(R.string.installer_banner_failure) }, + icon = { painterResource(R.drawable.ic_warning) }, + iconColor = { MaterialTheme.colorScheme.error }, + outlineColor = { null }, + containerColor = { MaterialTheme.colorScheme.errorContainer }, + ), + TextBannerParameters( + text = { stringResource(R.string.installer_banner_success) }, + icon = { painterResource(R.drawable.ic_check_circle) }, + iconColor = { Color(0xFF59B463) }, + outlineColor = { MaterialTheme.colorScheme.surfaceVariant }, + containerColor = { MaterialTheme.colorScheme.surfaceContainerHigh }, + ) + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/DeleteLogsDialogPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/DeleteLogsDialogPreview.kt new file mode 100644 index 0000000..658f574 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/DeleteLogsDialogPreview.kt @@ -0,0 +1,19 @@ +package com.meowarex.rlmobile.ui.previews.dialogs + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.logs.components.dialogs.DeleteLogsDialog +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun DeleteLogsDialogPreview() { + ManagerTheme { + DeleteLogsDialog( + onConfirm = {}, + onDismiss = {}, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/InstallerAbortDialogPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/InstallerAbortDialogPreview.kt new file mode 100644 index 0000000..a1ce5dc --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/InstallerAbortDialogPreview.kt @@ -0,0 +1,19 @@ +package com.meowarex.rlmobile.ui.previews.dialogs + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.components.dialogs.InstallerAbortDialog +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun InstallerAbortDialogPreview() { + ManagerTheme { + InstallerAbortDialog( + onConfirm = {}, + onDismiss = {}, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/NetworkWarningDialogPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/NetworkWarningDialogPreview.kt new file mode 100644 index 0000000..c0621a2 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/NetworkWarningDialogPreview.kt @@ -0,0 +1,19 @@ +package com.meowarex.rlmobile.ui.previews.dialogs + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.components.dialogs.NetworkWarningDialog +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun NetworkWarningDialogPreview() { + ManagerTheme { + NetworkWarningDialog( + onConfirm = {}, + onDismiss = {}, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/PlayProtectDialogPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/PlayProtectDialogPreview.kt new file mode 100644 index 0000000..65422fe --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/PlayProtectDialogPreview.kt @@ -0,0 +1,16 @@ +package com.meowarex.rlmobile.ui.previews.dialogs + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.components.dialogs.PlayProtectDialog +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun PlayProtectDialogPreview() { + ManagerTheme { + PlayProtectDialog(onDismiss = {}) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/ThemeDialogPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/ThemeDialogPreview.kt new file mode 100644 index 0000000..cc10d3a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/ThemeDialogPreview.kt @@ -0,0 +1,23 @@ +package com.meowarex.rlmobile.ui.previews.dialogs + +import android.content.res.Configuration +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.settings.components.ThemeDialog +import com.meowarex.rlmobile.ui.theme.ManagerTheme +import com.meowarex.rlmobile.ui.theme.Theme + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun ThemeDialogPreview() { + val (theme, setTheme) = remember { mutableStateOf(Theme.System) } + + ManagerTheme { + ThemeDialog( + currentTheme = theme, + onDismiss = {}, + onConfirm = setTheme, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/UninstallPluginDialogPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/UninstallPluginDialogPreview.kt new file mode 100644 index 0000000..733015d --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/dialogs/UninstallPluginDialogPreview.kt @@ -0,0 +1,20 @@ +package com.meowarex.rlmobile.ui.previews.dialogs + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.plugins.components.dialogs.UninstallPluginDialog +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun UninstallPluginDialogPreview() { + ManagerTheme { + UninstallPluginDialog( + pluginName = "FakeNitro", + onConfirm = {}, + onDismiss = {}, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/ComponentOptionsScreenPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/ComponentOptionsScreenPreview.kt new file mode 100644 index 0000000..7c3f56a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/ComponentOptionsScreenPreview.kt @@ -0,0 +1,80 @@ +package com.meowarex.rlmobile.ui.previews.screens + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.* +import com.meowarex.rlmobile.network.utils.SemVer +import com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsScreenContent +import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent +import com.meowarex.rlmobile.ui.theme.ManagerTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +// This preview has scrollable/interactable content that cannot be tested from an IDE preview + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun ComponentOptionsScreenPreview( + @PreviewParameter(ComponentOptionsParametersProvider::class) + parameters: ComponentOptionsParameters, +) { + ManagerTheme { + ComponentOptionsScreenContent( + componentType = parameters.componentType, + components = parameters.components, + selected = parameters.selected, + onSelectComponent = {}, + onDeleteComponent = {}, + onBackPressed = {}, + ) + } +} + +private data class ComponentOptionsParameters( + val componentType: PatchComponent.Type, + val components: ImmutableList, + val selected: PatchComponent?, +) + +private class ComponentOptionsParametersProvider : PreviewParameterProvider { + private val components = persistentListOf( + PatchComponent( + type = PatchComponent.Type.Injector, + version = SemVer(1, 2, 3), + timestamp = Clock.System.now(), + ), + PatchComponent( + type = PatchComponent.Type.Injector, + version = SemVer(2, 3, 1), + timestamp = Clock.System.now() - 10.minutes, + ), + PatchComponent( + type = PatchComponent.Type.Injector, + version = SemVer(2, 3, 1), + timestamp = Clock.System.now() - 1.days, + ), + PatchComponent( + type = PatchComponent.Type.Injector, + version = SemVer(0, 0, 1), + timestamp = Clock.System.now() - 10.hours, + ), + PatchComponent( + type = PatchComponent.Type.Injector, + version = SemVer(3, 0, 2), + timestamp = Clock.System.now() - 7.days, + ), + ) + + override val values = sequenceOf( + ComponentOptionsParameters( + componentType = PatchComponent.Type.Injector, + components = components, + selected = null, + ), + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PatchOptionsScreenPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PatchOptionsScreenPreview.kt new file mode 100644 index 0000000..86f70f4 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PatchOptionsScreenPreview.kt @@ -0,0 +1,99 @@ +package com.meowarex.rlmobile.ui.previews.screens + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.* +import com.meowarex.rlmobile.network.utils.SemVer +import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent +import com.meowarex.rlmobile.ui.screens.patchopts.* +import com.meowarex.rlmobile.ui.theme.ManagerTheme +import kotlin.time.Clock + +// This preview has scrollable/interactable content that cannot be tested from an IDE preview + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun PatchOptionsScreenPreview( + @PreviewParameter(PatchOptionsParametersProvider::class) + parameters: PatchOptionsParameters, +) { + ManagerTheme { + PatchOptionsScreenContent( + isUpdate = parameters.isUpdate, + isDevMode = parameters.isDevMode, + debuggable = parameters.debuggable, + setDebuggable = {}, + appName = parameters.appName, + appNameIsError = parameters.appNameIsError, + setAppName = {}, + packageName = parameters.packageName, + packageNameState = parameters.packageNameState, + setPackageName = {}, + customInjector = parameters.customInjector, + onSelectCustomInjector = {}, + customPatches = parameters.customPatches, + onSelectCustomPatches = {}, + isConfigValid = parameters.isConfigValid, + onInstall = {}, + ) + } +} + +private data class PatchOptionsParameters( + val isUpdate: Boolean, + val isDevMode: Boolean, + val debuggable: Boolean, + val appName: String, + val appNameIsError: Boolean, + val packageName: String, + val packageNameState: PackageNameState, + val customInjector: PatchComponent?, + val customPatches: PatchComponent?, + val isConfigValid: Boolean, +) + +private class PatchOptionsParametersProvider : PreviewParameterProvider { + override val values = sequenceOf( + PatchOptionsParameters( + isUpdate = false, + isDevMode = false, + debuggable = false, + appName = PatchOptions.Default.appName, + appNameIsError = false, + packageName = PatchOptions.Default.packageName, + packageNameState = PackageNameState.Ok, + customInjector = null, + customPatches = null, + isConfigValid = true, + ), + PatchOptionsParameters( + isUpdate = true, + isDevMode = false, + debuggable = false, + appName = "an invalid app name.", + appNameIsError = true, + packageName = "a b", + packageNameState = PackageNameState.Invalid, + customInjector = null, + customPatches = null, + isConfigValid = false, + ), + PatchOptionsParameters( + isUpdate = false, + isDevMode = true, + debuggable = true, + appName = PatchOptions.Default.appName, + appNameIsError = false, + packageName = PatchOptions.Default.packageName, + packageNameState = PackageNameState.Taken, + customInjector = PatchComponent( + type = PatchComponent.Type.Injector, + version = SemVer(1, 2, 3), + timestamp = Clock.System.now(), + ), + customPatches = null, + isConfigValid = true, + ), + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PermissionsScreenPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PermissionsScreenPreview.kt new file mode 100644 index 0000000..458fc4a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PermissionsScreenPreview.kt @@ -0,0 +1,30 @@ +package com.meowarex.rlmobile.ui.previews.screens + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.manager.InstallerSetting +import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreenContent +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +fun PermissionsScreenPreview() { + ManagerTheme { + PermissionsScreenContent( + installer = InstallerSetting.PackageInstaller, + openInstallersDialog = {}, + storagePermsGranted = true, + onGrantStoragePerms = {}, + unknownSourcesPermsGranted = true, + onGrantUnknownSourcesPerms = {}, + notificationsPermsGranted = false, + onGrantNotificationsPerms = {}, + batteryPermsGranted = false, + onGrantBatteryPerms = {}, + canContinue = true, + onContinue = {}, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/about/AboutScreenFailedPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/about/AboutScreenFailedPreview.kt new file mode 100644 index 0000000..ff942de --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/about/AboutScreenFailedPreview.kt @@ -0,0 +1,19 @@ +package com.meowarex.rlmobile.ui.previews.screens.about + +import android.content.res.Configuration +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent +import com.meowarex.rlmobile.ui.screens.about.AboutScreenState +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun AboutScreenFailedPreview() { + ManagerTheme { + AboutScreenContent( + state = remember { mutableStateOf(AboutScreenState.Failure) }, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/about/AboutScreenLoadedPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/about/AboutScreenLoadedPreview.kt new file mode 100644 index 0000000..dd60ebc --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/about/AboutScreenLoadedPreview.kt @@ -0,0 +1,49 @@ +package com.meowarex.rlmobile.ui.previews.screens.about + +import android.content.res.Configuration +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.* +import com.meowarex.rlmobile.network.models.Contributor +import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent +import com.meowarex.rlmobile.ui.screens.about.AboutScreenState +import com.meowarex.rlmobile.ui.theme.ManagerTheme +import com.meowarex.rlmobile.ui.util.emptyImmutableList +import com.meowarex.rlmobile.util.serialization.ImmutableListSerializer +import kotlinx.collections.immutable.* +import kotlinx.serialization.json.Json + +// This preview has scrollable content that cannot be properly viewed from an IDE preview + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun AboutScreenLoadedPreview( + @PreviewParameter(ContributorsProvider::class) + contributors: ImmutableList, +) { + ManagerTheme { + AboutScreenContent( + state = remember { mutableStateOf(AboutScreenState.Loaded(contributors)) }, + ) + } +} + +private class ContributorsProvider : PreviewParameterProvider> { + @Suppress("unused") + private val realDataRaw = + "[{\"username\":\"meowarex\",\"avatarUrl\":\"https://avatars.githubusercontent.com/u/0?v=4\",\"commits\":1,\"repositories\":[{\"name\":\"Radiant Lyrics\",\"commits\":1}]}]" + private val realData = Json.decodeFromString(ImmutableListSerializer(Contributor.serializer()), realDataRaw) + + override val values = sequenceOf( + emptyImmutableList(), + persistentListOf( + Contributor( + username = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", + avatarUrl = "UNUSED", + commits = Int.MAX_VALUE, + repositories = (realData[0].repositories + realData[0].repositories).toImmutableList(), + ) + ), + realData, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/about/AboutScreenLoadingPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/about/AboutScreenLoadingPreview.kt new file mode 100644 index 0000000..42552f0 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/about/AboutScreenLoadingPreview.kt @@ -0,0 +1,22 @@ +package com.meowarex.rlmobile.ui.previews.screens.about + +import android.content.res.Configuration +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent +import com.meowarex.rlmobile.ui.screens.about.AboutScreenState +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +// This preview cannot be properly viewed from an IDE + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun AboutScreenFailedPreview() { + ManagerTheme { + AboutScreenContent( + state = remember { mutableStateOf(AboutScreenState.Loading) }, + ) + } +} + diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenFailedPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenFailedPreview.kt new file mode 100644 index 0000000..4fcf8c4 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenFailedPreview.kt @@ -0,0 +1,22 @@ +package com.meowarex.rlmobile.ui.previews.screens.home + +import android.content.res.Configuration +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.home.HomeScreenFailureContent +import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun HomeScreenFailedPreview() { + ManagerTheme { + Scaffold( + topBar = { HomeAppBar() }, + ) { padding -> + HomeScreenFailureContent(padding = padding) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadedPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadedPreview.kt new file mode 100644 index 0000000..0fc1144 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadedPreview.kt @@ -0,0 +1,77 @@ +package com.meowarex.rlmobile.ui.previews.screens.home + +import android.content.res.Configuration +import android.graphics.BitmapFactory +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.tooling.preview.* +import com.meowarex.rlmobile.ui.screens.home.* +import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar +import com.meowarex.rlmobile.ui.theme.ManagerTheme +import com.meowarex.rlmobile.ui.util.TidalVersion +import kotlinx.collections.immutable.persistentListOf +import kotlin.io.encoding.Base64 + +// This preview has animations that cannot be properly viewed from an IDE preview + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun HomeScreenLoadedPreview( + @PreviewParameter(HomeScreenParametersProvider::class) + state: InstallsState.Fetched, +) { + ManagerTheme { + Scaffold( + topBar = { HomeAppBar() }, + ) { padding -> + HomeScreenLoadedContent( + state = state, + padding = padding, + onClickInstall = {}, + onUpdate = {}, + onOpenApp = {}, + onOpenAppInfo = {}, + onOpenPlugins = {}, + ) + } + } +} + +private class HomeScreenParametersProvider : PreviewParameterProvider { + private val stableVersion = TidalVersion.Existing(TidalVersion.Type.STABLE, "126.21", 126021) + private val radiantIconBytes = Base64.decode( + "/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAKBueIx4ZKCMgoy0qqC+8P//8Nzc8P//////////////////////////////////////////////////////////2wBDAaq0tPDS8P//////////////////////////////////////////////////////////////////////////////wAARCAC9AL0DASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAIDAf/EACIQAQEAAgEFAAIDAAAAAAAAAAABAhEhAxIxQVETMiJhcf/EABcBAQEBAQAAAAAAAAAAAAAAAAABAgP/xAAZEQEBAQEBAQAAAAAAAAAAAAAAARECEjH/2gAMAwEAAhEDEQA/AMsce7/Gkkngk1NOoxboAIAAAAAAAAAAAAAAAAAAm4ys7NXVbJyx2LKoAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZLfRcbJug4AABJugDtxs9OAAAAAAAAAAAAAA7jN0HccN81cxk9OityBZuaAVP459OyKBMifxwxw1dqAyBrYCpuEvhFmq1TnNwZsZgIyAAAAAAAANcJqM8ZutRrkAVoAAAAAAAAABnnNVLTObjNGKACAAAAAAL6c9rcxmsY6rcALdTYrlyk8kzlZeRGPTYThdzSlbC2TyMsruiW4vvimK+nedIkqwFaGN4rZnnP5DPSQEZAAAAAJ5BtPACug5n+tdAYi8sL6cmFqOeO9P2sk1NCtwYtk5Yb5gljNWH7Odt+NMce2IkjoCtiOp6WjqeIJfiAEYAAAACeYANgllnAroA5lbJxAdEzOe+FblDQAAAAcuUntyZW3iCaoAUT1PEUjOy8CX4gBGAAAAAACWzw0mf1mBLjYZS2eFTqfVa9O9TWv7Zu27u3EZtd7r9O/L64Brvdfrm79AHZ55asVY56mhZWjlykRc7Ui3pWWdqQGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHMbuOsZbLw0xy2LYoAQAAAAAAAAAAAAAAAAAARnlzqGWdnEQNSP/2Q==" + ) + private val radiantIcon = BitmapFactory + .decodeByteArray(radiantIconBytes, 0, radiantIconBytes.size) + .asImageBitmap() + .let(::BitmapPainter) + + override val values = sequenceOf( + InstallsState.Fetched( + persistentListOf( + InstallData( + name = "Radiant Lyrics", + packageName = "com.radiantLyrics", + version = stableVersion, + icon = radiantIcon, + isUpToDate = true, + ) + ) + ), + InstallsState.Fetched( + persistentListOf( + InstallData( + name = "Tidal", + packageName = "com.tidal", + version = stableVersion, + icon = radiantIcon, + isUpToDate = false, + ) + ) + ), + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadingPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadingPreview.kt new file mode 100644 index 0000000..5ae1a36 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenLoadingPreview.kt @@ -0,0 +1,24 @@ +package com.meowarex.rlmobile.ui.previews.screens.home + +import android.content.res.Configuration +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.home.HomeScreenLoadingContent +import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +// This preview cannot be properly viewed from an IDE preview + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun HomeScreenLoadingPreview() { + ManagerTheme { + Scaffold( + topBar = { HomeAppBar() }, + ) { padding -> + HomeScreenLoadingContent(padding = padding) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenNonePreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenNonePreview.kt new file mode 100644 index 0000000..2e4b592 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/home/HomeScreenNonePreview.kt @@ -0,0 +1,25 @@ +package com.meowarex.rlmobile.ui.previews.screens.home + +import android.content.res.Configuration +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.home.HomeScreenNoneContent +import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun HomeScreenNonePreview() { + ManagerTheme { + Scaffold( + topBar = { HomeAppBar() }, + ) { padding -> + HomeScreenNoneContent( + padding = padding, + onClickInstall = {}, + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/logs/LogsListScreenLoadedPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/logs/LogsListScreenLoadedPreview.kt new file mode 100644 index 0000000..f86751c --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/logs/LogsListScreenLoadedPreview.kt @@ -0,0 +1,78 @@ +package com.meowarex.rlmobile.ui.previews.screens.logs + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.logs.LogEntry +import com.meowarex.rlmobile.ui.screens.logs.LogsScreenContent +import com.meowarex.rlmobile.ui.theme.ManagerTheme +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun LogsListScreenNonePreview() { + ManagerTheme { + LogsScreenContent( + logs = logs, + onOpenLog = {}, + onDeleteLogs = {}, + ) + } +} + +private val logs: SnapshotStateList = mutableStateListOf( + LogEntry( + id = UUID.randomUUID().toString(), + isError = false, + installDate = "5 min. ago, 10:18 AM", + durationSecs = 18.555f, + stacktracePreview = null, + ), + LogEntry( + id = UUID.randomUUID().toString(), + isError = true, + installDate = "7 min. ago, 10:17 AM", + durationSecs = 73.095f, + stacktracePreview = persistentListOf( + "kotlinx.coroutines.JobCancellationException: Job was cancelled; job=SupervisorJobImpl{Cancelling}@833e76f]", + ), + ), + LogEntry( + id = UUID.randomUUID().toString(), + isError = false, + installDate = "Yesterday, 11:37 PM", + durationSecs = 58.439f, + stacktracePreview = null, + ), + LogEntry( + id = UUID.randomUUID().toString(), + isError = true, + installDate = "Yesterday, 11:17 PM", + durationSecs = 24.405f, + stacktracePreview = persistentListOf( + "java.lang.Error: Installation was aborted or cancelled", + ), + ), + LogEntry( + id = UUID.randomUUID().toString(), + isError = true, + installDate = "Yesterday, 11:17 PM", + durationSecs = 0.057f, + stacktracePreview = persistentListOf( + "java.lang.IllegalStateException: balls", + "\tat com.meowarex.rlmobile.patcher.steps.prepare.FetchInfoStep.execute(FetchInfoStep.kt:31)", + "\tat com.meowarex.rlmobile.patcher.steps.prepare.FetchInfoStep\$execute\\$1.invokeSuspend(Unknown Source:15)", + ), + ), + LogEntry( + id = UUID.randomUUID().toString(), + isError = false, + installDate = "Yesterday, 1:11 PM", + durationSecs = 210.539f, + stacktracePreview = null, + ), +) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/logs/LogsListScreenNonePreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/logs/LogsListScreenNonePreview.kt new file mode 100644 index 0000000..da80c69 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/logs/LogsListScreenNonePreview.kt @@ -0,0 +1,20 @@ +package com.meowarex.rlmobile.ui.previews.screens.logs + +import android.content.res.Configuration +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.logs.LogsScreenContent +import com.meowarex.rlmobile.ui.theme.ManagerTheme + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun LogsListScreenNonePreview() { + ManagerTheme { + LogsScreenContent( + logs = remember { mutableStateListOf() }, + onOpenLog = {}, + onDeleteLogs = {}, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenFailedPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenFailedPreview.kt new file mode 100644 index 0000000..96d7fde --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenFailedPreview.kt @@ -0,0 +1,31 @@ +package com.meowarex.rlmobile.ui.previews.screens.plugins + +import android.content.res.Configuration +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent +import com.meowarex.rlmobile.ui.theme.ManagerTheme +import com.meowarex.rlmobile.ui.util.emptyImmutableList + +// This preview has interactable content that cannot be tested from an IDE preview + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun PluginsScreenFailedPreview() { + val filterState = remember { mutableStateOf("") } + + ManagerTheme { + PluginsScreenContent( + searchText = filterState, + setSearchText = filterState::value::set, + isError = true, + plugins = emptyImmutableList(), + onPluginUninstall = {}, + onPluginChangelog = {}, + onPluginToggle = { name, enabled -> }, + safeMode = false, + setSafeMode = {} + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenLoadedPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenLoadedPreview.kt new file mode 100644 index 0000000..69d816a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenLoadedPreview.kt @@ -0,0 +1,94 @@ +package com.meowarex.rlmobile.ui.previews.screens.plugins + +import android.content.res.Configuration +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent +import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem +import com.meowarex.rlmobile.ui.screens.plugins.model.PluginManifest +import com.meowarex.rlmobile.ui.theme.ManagerTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +// This preview has scrollable/interactable content that cannot be tested from an IDE preview + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun PluginsScreenLoadedPreview() { + val filterState = remember { mutableStateOf("test") } + + ManagerTheme { + PluginsScreenContent( + searchText = filterState, + setSearchText = filterState::value::set, + isError = false, + plugins = plugins, + onPluginUninstall = {}, + onPluginChangelog = {}, + onPluginToggle = { name, enabled -> }, + safeMode = false, + setSafeMode = {} + ) + } +} + +private val plugins: ImmutableList = persistentListOf( + PluginItem( + path = UUID.randomUUID().toString(), + manifest = PluginManifest( + name = "CloseDMs", + authors = persistentListOf( + PluginManifest.Author(name = "Diamond", id = 0L), + ), + description = "Shortcut to close DMs in the DM context menu.", + version = "1.0.0", + updateUrl = "", + changelog = "", + changelogMedia = null, + ) + ), + PluginItem( + path = UUID.randomUUID().toString(), + manifest = PluginManifest( + name = "ConfigurableStickerSizes", + authors = persistentListOf( + PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false), + ), + description = "Makes sticker sizes configurable.", + version = "1.1.5", + updateUrl = "", + changelog = "", + changelogMedia = null, + ) + ), + PluginItem( + path = UUID.randomUUID().toString(), + manifest = PluginManifest( + name = "AudioPlayer", + authors = persistentListOf( + PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false), + ), + description = "Makes audio files playable.", + version = "0.0.1", + updateUrl = "", + changelog = "", + changelogMedia = null, + ) + ), + PluginItem( + path = UUID.randomUUID().toString(), + manifest = PluginManifest( + name = "TypingIndicators", + authors = persistentListOf( + PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false), + ), + description = "Adds typing indicators to channels that people are currently typing in.", + version = "1.1.0", + updateUrl = "", + changelog = "", + changelogMedia = null, + ) + ), +) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenNonePreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenNonePreview.kt new file mode 100644 index 0000000..ea6d5e7 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/plugins/PluginsScreenNonePreview.kt @@ -0,0 +1,31 @@ +package com.meowarex.rlmobile.ui.previews.screens.plugins + +import android.content.res.Configuration +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.Preview +import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent +import com.meowarex.rlmobile.ui.theme.ManagerTheme +import com.meowarex.rlmobile.ui.util.emptyImmutableList + +// This preview has interactable content that cannot be tested from an IDE preview + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +private fun PluginsScreenNonePreview() { + val filterState = remember { mutableStateOf("") } + + ManagerTheme { + PluginsScreenContent( + searchText = filterState, + setSearchText = filterState::value::set, + isError = false, + plugins = emptyImmutableList(), + onPluginUninstall = {}, + onPluginChangelog = {}, + onPluginToggle = { name, enabled -> }, + safeMode = false, + setSafeMode = {} + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutModel.kt new file mode 100644 index 0000000..bfb6ef5 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutModel.kt @@ -0,0 +1,14 @@ +package com.meowarex.rlmobile.ui.screens.about + +import cafe.adriel.voyager.core.model.StateScreenModel +import com.meowarex.rlmobile.network.models.Contributor +import com.meowarex.rlmobile.network.services.HttpService +import com.meowarex.rlmobile.ui.util.toUnsafeImmutable + +class AboutModel( + @Suppress("unused") private val http: HttpService, +) : StateScreenModel( + AboutScreenState.Loaded(emptyList().toUnsafeImmutable()) +) { + fun fetchContributors() = Unit +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutScreen.kt new file mode 100644 index 0000000..72c1547 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutScreen.kt @@ -0,0 +1,110 @@ +package com.meowarex.rlmobile.ui.screens.about + +import android.os.Parcelable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.components.* +import com.meowarex.rlmobile.ui.screens.about.components.LeadContributor +import com.meowarex.rlmobile.ui.util.paddings.* +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +class AboutScreen : Screen, Parcelable { + @IgnoredOnParcel + override val key = "About" + + @Composable + override fun Content() { + val model = koinScreenModel() + + AboutScreenContent(state = model.state.collectAsState()) + } +} + +@Composable +fun AboutScreenContent(state: State) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.navigation_about)) }, + navigationIcon = { BackButton() }, + ) + } + ) { paddingValues -> + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = paddingValues + .exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top) + .add(PaddingValues(vertical = 16.dp)), + modifier = Modifier + .padding(paddingValues.exclude(PaddingValuesSides.Bottom)) + .padding(horizontal = 14.dp), + ) { + item(key = "PROJECT_HEADER") { + ProjectHeader() + } + + item(key = "HEADER_DIVIDER") { + TextDivider( + text = stringResource(R.string.contributors_lead), + modifier = Modifier.padding(top = 18.dp, bottom = 20.dp), + ) + } + + item(key = "MAIN_CONTRIBUTORS") { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + LeadContributor("meowarex", "Radiant Lyrics") + } + } + + item(key = "CONTRIBUTORS_DIVIDER") { + TextDivider( + text = stringResource(R.string.contributors), + modifier = Modifier.padding(top = 16.dp, bottom = 6.dp) + ) + } + + when (val state = state.value) { + AboutScreenState.Loading -> item(key = "CONTRIBUTIONS_LOADING") { + Box( + contentAlignment = Alignment.Center, + content = { CircularProgressIndicator() }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 38.dp), + ) + } + + AboutScreenState.Failure -> item(key = "LOAD_FAILURE") { + LoadFailure( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 38.dp) + ) + } + + is AboutScreenState.Loaded -> { + items(state.contributors, key = { it.username }) { user -> + ContributorCommitsItem(user) + } + } + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutScreenState.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutScreenState.kt new file mode 100644 index 0000000..152d5c0 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/AboutScreenState.kt @@ -0,0 +1,10 @@ +package com.meowarex.rlmobile.ui.screens.about + +import com.meowarex.rlmobile.network.models.Contributor +import kotlinx.collections.immutable.ImmutableList + +sealed interface AboutScreenState { + data object Loading : AboutScreenState + data object Failure : AboutScreenState + data class Loaded(val contributors: ImmutableList) : AboutScreenState +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/components/LeadContributor.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/components/LeadContributor.kt new file mode 100644 index 0000000..f56ef5a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/about/components/LeadContributor.kt @@ -0,0 +1,74 @@ +package com.meowarex.rlmobile.ui.screens.about.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.SubcomposeAsyncImage +import com.valentinilk.shimmer.shimmer + +@Composable +fun LeadContributor( + name: String, + roles: String, + username: String = name, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .clickable( + onClick = { uriHandler.openUri("https://github.com/$username") }, + indication = ripple(bounded = false, radius = 90.dp), + interactionSource = remember(::MutableInteractionSource) + ) + .widthIn(min = 100.dp) + ) { + SubcomposeAsyncImage( + model = "https://github.com/$username.png", + contentDescription = username, + error = { + Surface( + content = {}, + tonalElevation = 2.dp, + modifier = Modifier + .fillMaxSize() + .shimmer(), + ) + }, + modifier = Modifier + .size(80.dp) + .clip(CircleShape), + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 18.sp + ) + ) + + Text( + text = roles, + style = MaterialTheme.typography.titleSmall.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsModel.kt new file mode 100644 index 0000000..e91dd62 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsModel.kt @@ -0,0 +1,70 @@ +package com.meowarex.rlmobile.ui.screens.componentopts + +import android.app.Application +import androidx.compose.runtime.* +import cafe.adriel.voyager.core.model.screenModelScope +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.network.utils.SemVer +import com.meowarex.rlmobile.ui.util.ScreenModelWithResult +import com.meowarex.rlmobile.ui.util.ScreenResultKey +import com.meowarex.rlmobile.util.* +import kotlinx.coroutines.launch +import kotlin.time.Instant + +class ComponentOptionsModel( + screenResultKey: ScreenResultKey, + private val paths: PathManager, + private val context: Application, +) : ScreenModelWithResult(screenResultKey) { + val components = mutableStateListOf() + var selected by mutableStateOf(null) + private set + + fun selectComponent(component: PatchComponent?) { + selected = component + } + + fun deleteComponent(component: PatchComponent) = screenModelScope.launchIO { + component.getFile(paths).delete() + + mainThread { + components.remove(component) + context.showToast(R.string.componentopts_deleted) + } + } + + /** + * Loads the available imported custom components for a specified type. + */ + suspend fun refreshComponents(type: PatchComponent.Type) { + val files = when (type) { + PatchComponent.Type.Injector -> paths.customInjectors() + PatchComponent.Type.Patches -> paths.customSmaliPatches() + } + + // ${timestamp}_${componentVersion}.${componentFile.extension} + val componentNameRegex = """^(\d+)_(\d+\.\d+.\d+)\.\w+$""".toRegex() + + val newComponents = files.mapNotNull { file -> + val match = componentNameRegex.find(file.name) + ?: return@mapNotNull null + val (_, timestamp, version) = match.groupValues + + PatchComponent( + type = type, + version = SemVer.parse(version), + timestamp = Instant.fromEpochMilliseconds(timestamp.toLong()), + ) + }.sortedByDescending { it.timestamp } + + mainThread { + components.clear() + components.addAll(newComponents) + } + } + + override fun onDispose() { + screenModelScope.launch { setResult(selected) } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsScreen.kt new file mode 100644 index 0000000..f67d0a3 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsScreen.kt @@ -0,0 +1,131 @@ +package com.meowarex.rlmobile.ui.screens.componentopts + +import android.os.Parcelable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.screens.componentopts.components.* +import com.meowarex.rlmobile.ui.util.ScreenWithResult +import com.meowarex.rlmobile.ui.util.paddings.* +import com.meowarex.rlmobile.util.back +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.koin.core.parameter.parametersOf + +@Parcelize +class ComponentOptionsScreen( + /** + * The type of custom component that this screen will be selecting. + */ + private val componentType: PatchComponent.Type, + /** + * A previously selected custom component that should be pre-selected on this screen. + */ + private val default: PatchComponent?, +) : ScreenWithResult(), Parcelable { + @IgnoredOnParcel + override val key = "ComponentOptions-$componentType" + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val model = koinScreenModel { parametersOf(this.resultKey) } + + LaunchedEffect(Unit) { + model.components.clear() + withContext(Dispatchers.IO) { + model.refreshComponents(componentType) + } + if (default in model.components) { + model.selectComponent(default) + } + } + + ComponentOptionsScreenContent( + componentType = componentType, + components = model.components.toImmutableList(), + selected = model.selected, + onSelectComponent = model::selectComponent, + onDeleteComponent = model::deleteComponent, + onBackPressed = { navigator.back(null) }, + ) + } +} + +@Composable +fun ComponentOptionsScreenContent( + componentType: PatchComponent.Type, + components: ImmutableList, + selected: PatchComponent?, + onSelectComponent: (PatchComponent?) -> Unit, + onDeleteComponent: (PatchComponent) -> Unit, + onBackPressed: () -> Unit, +) { + Scaffold( + topBar = { ComponentOptionsAppBar(componentType = componentType) }, + ) { paddingValues -> + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = paddingValues + .exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top) + .add(PaddingValues(16.dp)), + modifier = Modifier + .padding(paddingValues.exclude(PaddingValuesSides.Bottom)), + ) { + item(key = "NONE") { + PatchComponentCardBase( + selected = selected == null, + onSelect = { onSelectComponent(null) }, + ) { + Text( + text = stringResource(R.string.componentopts_selected_none), + style = MaterialTheme.typography.titleMedium, + ) + } + } + + items( + items = components, + contentType = { "COMPONENT" }, + key = { it }, + ) { component -> + PatchComponentCard( + version = component.version, + timestamp = component.timestamp, + selected = selected == component, + onSelect = { onSelectComponent(component) }, + onDelete = { onDeleteComponent(component) }, + ) + } + + item("EXIT_BTN") { + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + ) { + FilledTonalButton( + onClick = onBackPressed, + ) { + Text(stringResource(R.string.action_confirm)) + } + } + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/PatchComponent.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/PatchComponent.kt new file mode 100644 index 0000000..c087813 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/PatchComponent.kt @@ -0,0 +1,61 @@ +package com.meowarex.rlmobile.ui.screens.componentopts + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.network.utils.SemVer +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.io.File +import kotlin.time.Instant + +/** + * A custom component that was deployed to this device with + * the `deployWithAdb` task and imported by Manager. + */ +@Immutable +@Parcelize +@Serializable +data class PatchComponent( + /** + * The type of this custom component. + */ + val type: Type, + /** + * The build version of this custom component. + */ + val version: SemVer, + /** + * The time at which this custom component was deployed to the device and imported by manager. + */ + val timestamp: Instant, +) : Parcelable { + @Parcelize + @Serializable + enum class Type : Parcelable { + @SerialName("injector") + Injector, + + @SerialName("patches") + Patches, + } + + /** + * Returns the imported file where this custom component should be stored. + * This is not guaranteed to exist. + */ + fun getFile(paths: PathManager): File { + val dir = when (type) { + Type.Injector -> paths.customInjectorsDir + Type.Patches -> paths.customPatchesDir + } + val ext = when (type) { + Type.Injector -> "dex" + Type.Patches -> "zip" + } + + // ${timestamp}_${componentVersion}.${componentFile.extension} + return dir.resolve("${timestamp.toEpochMilliseconds()}_$version.$ext") + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/components/ComponentOptionsAppBar.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/components/ComponentOptionsAppBar.kt new file mode 100644 index 0000000..84f4a82 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/components/ComponentOptionsAppBar.kt @@ -0,0 +1,34 @@ +package com.meowarex.rlmobile.ui.screens.componentopts.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.components.BackButton +import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent + +@Composable +fun ComponentOptionsAppBar( + componentType: PatchComponent.Type, +) { + TopAppBar( + navigationIcon = { BackButton() }, + title = { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(stringResource(R.string.componentopts_screen_title, componentType.name)) + Text( + text = stringResource(R.string.componentopts_screen_desc), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(.7f), + ) + } + }, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/components/PatchComponentCard.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/components/PatchComponentCard.kt new file mode 100644 index 0000000..6167992 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/components/PatchComponentCard.kt @@ -0,0 +1,115 @@ +package com.meowarex.rlmobile.ui.screens.componentopts.components + +import android.text.format.DateUtils +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.network.utils.SemVer +import kotlin.time.Instant + +@Composable +fun PatchComponentCard( + version: SemVer, + timestamp: Instant, + selected: Boolean, + onSelect: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + PatchComponentCardBase( + selected = selected, + onSelect = onSelect, + modifier = modifier, + ) { + Icon( + painter = painterResource(R.drawable.ic_page), + contentDescription = null, + modifier = Modifier + .alpha(.8f) + .padding(end = 4.dp), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "v$version", + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = DateUtils.getRelativeDateTimeString( + /* c = */ LocalContext.current, + /* time = */ timestamp.toEpochMilliseconds(), + /* minResolution = */ DateUtils.SECOND_IN_MILLIS, + /* transitionResolution = */ DateUtils.WEEK_IN_MILLIS, + /* flags = */ DateUtils.FORMAT_ABBREV_ALL, + ).toString(), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(.6f), + ) + } + + Spacer(Modifier.weight(1f, fill = true)) + + IconButton( + onClick = onDelete, + ) { + Icon( + painter = painterResource(R.drawable.ic_delete_forever), + tint = MaterialTheme.colorScheme.error, + contentDescription = stringResource(R.string.action_delete), + ) + } + } +} + +@Composable +fun PatchComponentCardBase( + selected: Boolean, + onSelect: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + val interaction = remember(::MutableInteractionSource) + + Surface( + tonalElevation = 1.dp, + shadowElevation = 1.dp, + modifier = modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable( + interactionSource = interaction, + role = Role.RadioButton, + onClick = onSelect, + ), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(10.dp), + ) { + RadioButton( + selected = selected, + onClick = onSelect, + interactionSource = interaction, + ) + + content() + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeModel.kt new file mode 100644 index 0000000..f23c4c2 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeModel.kt @@ -0,0 +1,243 @@ +package com.meowarex.rlmobile.ui.screens.home + +import android.app.Application +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.provider.Settings +import android.util.Log +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.github.diamondminer88.zip.ZipReader +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.network.models.RLBuildInfo +import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService +import com.meowarex.rlmobile.network.utils.fold +import com.meowarex.rlmobile.patcher.InstallMetadata +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen +import com.meowarex.rlmobile.ui.util.TidalVersion +import com.meowarex.rlmobile.ui.util.toUnsafeImmutable +import com.meowarex.rlmobile.util.* +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream + +class HomeModel( + private val application: Application, + private val github: RadiantLyricsGithubService, + private val json: Json, +) : ScreenModel { + var installsState by mutableStateOf(InstallsState.Fetching) + private set + + private val refreshingLock = Mutex() + private var remoteDataJson: RLBuildInfo? = null + + init { + refresh() + } + + fun refresh(delay: Boolean = false) = screenModelScope.launchIO { + if (refreshingLock.isLocked) return@launchIO + + if (delay) { + delay(250) + + if (refreshingLock.isLocked) + return@launchIO + } + + refreshingLock.withLock { + val packages = fetchRadiantLyricsPackages() + + val jobs = listOf( + screenModelScope.launch(Dispatchers.IO) { + fetchInstallations(packages) + }, + screenModelScope.launch(Dispatchers.IO) { + if (remoteDataJson == null) + fetchRemoteData() + } + ) + + jobs.joinAll() + mainThread { refreshInstallationsUpToDate(packages) } + } + } + + fun openApp(packageName: String) { + val launchIntent = application.packageManager + .getLaunchIntentForPackage(packageName) + + if (launchIntent != null) { + application.startActivity(launchIntent) + } else { + application.showToast(R.string.launch_app_fail) + } + } + + fun openAppInfo(packageName: String) { + val launchIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setData("package:$packageName".toUri()) + + application.startActivity(launchIntent) + } + + fun createPrefilledPatchOptsScreen(packageName: String): PatchOptionsScreen { + val metadata = try { + val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0) + val metadataFile = ZipReader(applicationInfo.publicSourceDir) + .use { it.openEntry("rlmobile.json")?.read() } + + metadataFile?.let { json.decodeFromStream(it.inputStream()) } + } catch (t: Throwable) { + Log.w(BuildConfig.TAG, "Failed to parse Radiant Lyrics install metadata from package $packageName", t) + null + } + + val patchOptions = metadata?.options + ?: PatchOptions.Default.copy(packageName = packageName) + + return PatchOptionsScreen(prefilledOptions = patchOptions) + } + + private suspend fun fetchInstallations(packages: List) { + mainThread { + if (installsState !is InstallsState.Fetched) + installsState = InstallsState.Fetching + } + + try { + val packageManager = application.packageManager + val rlMobileInstallations = packages.mapNotNull { pkg -> + @Suppress("DEPRECATION") + val versionCode = pkg.versionCode + val versionName = pkg.versionName ?: return@mapNotNull null + val applicationInfo = pkg.applicationInfo ?: return@mapNotNull null + + InstallData( + name = packageManager.getApplicationLabel(applicationInfo).toString(), + packageName = pkg.packageName, + isUpToDate = isInstallationUpToDate(pkg), + icon = packageManager + .getApplicationIcon(applicationInfo) + .toBitmap() + .asImageBitmap() + .let(::BitmapPainter), + version = TidalVersion.Existing( + type = TidalVersion.parseVersionType(versionCode), + name = versionName.split("-")[0].trim(), + code = versionCode, + ), + ) + } + + mainThread { + installsState = if (rlMobileInstallations.isNotEmpty()) { + InstallsState.Fetched(data = rlMobileInstallations.toUnsafeImmutable()) + } else { + InstallsState.None + } + } + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to query Radiant Lyrics installations", t) + mainThread { installsState = InstallsState.Error } + } + } + + private suspend fun refreshInstallationsUpToDate(packages: List) { + val installations = mainThread { (installsState as? InstallsState.Fetched)?.data } + ?: return + + try { + val newInstallations = installations.map { data -> + val packageInfo = packages.find { it.packageName == data.packageName } + ?: throw IllegalStateException("Checking up-to-date status for package that has not been fetched") + + data.copy(isUpToDate = isInstallationUpToDate(packageInfo)) + } + + mainThread { installsState = InstallsState.Fetched(data = newInstallations.toUnsafeImmutable()) } + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to check installations up-to-date", t) + mainThread { installsState = InstallsState.Error } + } + } + + private suspend fun fetchRemoteData() { + val release = try { + github.getLatestRelease().let { response -> + response.fold( + success = { it }, + fail = { + Log.w(BuildConfig.TAG, "Failed to fetch latest release", it) + return + }, + ) + } + } catch (t: Throwable) { + Log.w(BuildConfig.TAG, "Failed to fetch remote data", t) + mainThread { application.showToast(R.string.home_network_fail) } + return + } + + val dataJsonUrl = release.assets + .find { it.name == RadiantLyricsGithubService.DATA_JSON_ASSET_NAME } + ?.browserDownloadUrl + ?: run { + Log.w(BuildConfig.TAG, "No data.json asset in latest release") + return + } + + github.getBuildInfo(dataJsonUrl).fold( + success = { remoteDataJson = it }, + fail = { Log.w(BuildConfig.TAG, "Failed to fetch remote build info", it) }, + ) + + if (remoteDataJson == null) { + mainThread { application.showToast(R.string.home_network_fail) } + } + } + + private fun fetchRadiantLyricsPackages(): List { + return application.packageManager + .getInstalledPackages(PackageManager.GET_META_DATA) + .filter { + it.applicationInfo?.metaData?.containsKey("isRadiantLyrics") == true + } + } + + private fun isInstallationUpToDate(pkg: PackageInfo): Boolean? { + val remoteBuildData = remoteDataJson ?: return null + + @Suppress("DEPRECATION") + val versionCode = pkg.versionCode + + if (remoteBuildData.tidalVersionCode != versionCode) return false + + val apkPath = pkg.applicationInfo?.publicSourceDir ?: return false + val installMetadata = try { + val metadataFile = ZipReader(apkPath).use { it.openEntry("rlmobile.json")?.read() } + ?: return false + + json.decodeFromStream(metadataFile.inputStream()) + } catch (t: Throwable) { + Log.d(BuildConfig.TAG, "Failed to parse Radiant Lyrics InstallMetadata from package ${pkg.packageName}", t) + return false + } + + if (installMetadata.options.customPatches != null) return true + + return remoteBuildData.patchesVersion == installMetadata.patchesVersion + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeScreen.kt new file mode 100644 index 0000000..7be285b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeScreen.kt @@ -0,0 +1,223 @@ + +package com.meowarex.rlmobile.ui.screens.home + +import android.os.Parcelable +import androidx.compose.animation.* +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.LifecycleResumeEffect +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.components.LoadFailure +import com.meowarex.rlmobile.ui.components.ProjectHeader +import com.meowarex.rlmobile.ui.screens.home.components.* +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen +import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen +import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides +import com.meowarex.rlmobile.ui.util.paddings.exclude +import com.meowarex.rlmobile.util.* +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +class HomeScreen : Screen, Parcelable { + @IgnoredOnParcel + override val key = "Home" + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val scope = rememberCoroutineScope() + val model = koinScreenModel() + + // Refresh installations list when the screen changes or activity resumes + LifecycleResumeEffect(Unit) { + model.refresh(delay = true) + + onPauseOrDispose {} + } + + Scaffold( + topBar = { HomeAppBar() }, + ) { padding -> + when (val state = model.installsState) { + is InstallsState.Fetched -> HomeScreenLoadedContent( + state = state, + padding = padding, + onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) }, + onUpdate = { + scope.launchIO { + val screen = model.createPrefilledPatchOptsScreen(it) + mainThread { navigator.push(screen) } + } + }, + onOpenApp = model::openApp, + onOpenAppInfo = model::openAppInfo, + onOpenPlugins = { navigator.push(PluginsScreen()) }, // TODO: install-specific plugins + ) + + InstallsState.Fetching -> HomeScreenLoadingContent(padding = padding) + + InstallsState.None -> HomeScreenNoneContent( + padding = padding, + onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) }, + ) + + InstallsState.Error -> HomeScreenFailureContent(padding = padding) + } + } + } +} + +@Composable +fun HomeScreenLoadingContent(padding: PaddingValues) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) { + ProjectHeader() + + AnimatedVisibility( + visibleState = remember { MutableTransitionState(false) }.apply { targetState = true }, + enter = fadeIn(animationSpec = tween(durationMillis = 800)), + exit = ExitTransition.None, + ) { + Box( + contentAlignment = Alignment.Center, + content = { CircularProgressIndicator() }, + modifier = Modifier + .fillMaxSize(), + ) + } + } +} + +@Composable +fun HomeScreenLoadedContent( + state: InstallsState.Fetched, + padding: PaddingValues, + onClickInstall: () -> Unit, + onUpdate: (packageName: String) -> Unit, + onOpenApp: (packageName: String) -> Unit, + onOpenAppInfo: (packageName: String) -> Unit, + onOpenPlugins: (packageName: String) -> Unit, +) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = padding + .exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top), + modifier = Modifier + .fillMaxSize() + .padding(padding.exclude(PaddingValuesSides.Bottom)) + .padding(top = 16.dp, start = 16.dp, end = 16.dp), + ) { + item(key = "PROJECT_HEADER") { + ProjectHeader() + } + + item(key = "ADD_INSTALL_BUTTON") { + InstallButton( + secondaryInstall = true, + onClick = onClickInstall, + modifier = Modifier + .padding(vertical = 4.dp) + .height(50.dp) + .fillMaxWidth() + ) + } + + items(state.data, key = { it.packageName }) { item -> + InstalledItemCard( + data = item, + onUpdate = { onUpdate(item.packageName) }, + onOpenApp = { onOpenApp(item.packageName) }, + onOpenInfo = { onOpenAppInfo(item.packageName) }, + onOpenPlugins = { onOpenPlugins(item.packageName) }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +fun HomeScreenNoneContent( + padding: PaddingValues, + onClickInstall: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .padding(padding) + .padding(16.dp) + .fillMaxSize(), + ) { + ProjectHeader() + + InstallButton( + secondaryInstall = false, + onClick = onClickInstall, + modifier = Modifier + .padding(12.dp) + .height(height = 50.dp) + .fillMaxWidth() + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .alpha(.7f) + .fillMaxSize() + .padding(bottom = 80.dp) + ) { + Text( + text = """ /ᐠﹷ ‸ ﹷ ᐟ\ノ""", + style = MaterialTheme.typography.labelLarge + .copy(fontSize = 38.sp), + ) + + Text( + text = stringResource(R.string.installs_no_installs), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 10.dp), + ) + } + } +} + +@Composable +fun HomeScreenFailureContent( + padding: PaddingValues, +) { + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(padding) + .padding(16.dp) + .fillMaxSize(), + ) { + ProjectHeader() + LoadFailure(modifier = Modifier.fillMaxSize()) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/InstallData.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/InstallData.kt new file mode 100644 index 0000000..89a7c62 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/InstallData.kt @@ -0,0 +1,14 @@ +package com.meowarex.rlmobile.ui.screens.home + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.painter.BitmapPainter +import com.meowarex.rlmobile.ui.util.TidalVersion + +@Immutable +data class InstallData( + val name: String, + val packageName: String, + val version: TidalVersion, + val icon: BitmapPainter, + val isUpToDate: Boolean?, +) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/InstallsState.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/InstallsState.kt new file mode 100644 index 0000000..c4293c2 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/InstallsState.kt @@ -0,0 +1,12 @@ +package com.meowarex.rlmobile.ui.screens.home + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface InstallsState { + data object None : InstallsState + data object Error : InstallsState + data object Fetching : InstallsState + data class Fetched(val data: ImmutableList) : InstallsState +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/HomeAppBar.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/HomeAppBar.kt new file mode 100644 index 0000000..1db1241 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/HomeAppBar.kt @@ -0,0 +1,42 @@ +package com.meowarex.rlmobile.ui.screens.home.components + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.navigator.LocalNavigator +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.screens.about.AboutScreen +import com.meowarex.rlmobile.ui.screens.logs.LogsListScreen +import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen + +@Composable +fun HomeAppBar() { + TopAppBar( + title = {}, + actions = { + val navigator = LocalNavigator.current + + IconButton(onClick = { navigator?.push(AboutScreen()) }) { + Icon( + painter = painterResource(R.drawable.ic_info), + contentDescription = stringResource(R.string.navigation_about), + ) + } + + IconButton(onClick = { navigator?.push(LogsListScreen()) }) { + Icon( + painter = painterResource(R.drawable.ic_receipt), + contentDescription = stringResource(R.string.navigation_logs), + ) + } + + IconButton(onClick = { navigator?.push(SettingsScreen()) }) { + Icon( + painter = painterResource(R.drawable.ic_settings), + contentDescription = stringResource(R.string.navigation_settings), + ) + } + } + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstallButton.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstallButton.kt new file mode 100644 index 0000000..403f04d --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstallButton.kt @@ -0,0 +1,88 @@ +package com.meowarex.rlmobile.ui.screens.home.components + +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.* +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.util.thenIf +import com.valentinilk.shimmer.* + +private val shimmerTheme = defaultShimmerTheme.copy( + shimmerWidth = 150.dp, + animationSpec = infiniteRepeatable( + animation = shimmerSpec( + durationMillis = 2000, + easing = LinearEasing, + delayMillis = 3500, + ), + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(1000), + ), + blendMode = BlendMode.Lighten, + shaderColors = listOf( + Color.White.copy(alpha = 0.00f), + Color.White.copy(alpha = 0.50f), + Color.White.copy(alpha = 1.00f), + Color.White.copy(alpha = 0.50f), + Color.White.copy(alpha = 0.00f), + ), + shaderColorStops = listOf( + 0.0f, + 0.25f, + 0.5f, + 0.75f, + 1.0f, + ), +) + +@Composable +fun InstallButton( + enabled: Boolean = true, + secondaryInstall: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + CompositionLocalProvider( + LocalShimmerTheme provides shimmerTheme + ) { + FilledTonalIconButton( + shape = RectangleShape, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = if (secondaryInstall) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.primary + }, + ), + enabled = enabled, + onClick = onClick, + modifier = modifier + .clip(MaterialTheme.shapes.medium) + .thenIf(!secondaryInstall) { shimmer() } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 10.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = null, + ) + Text( + text = stringResource(R.string.action_add_install), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstalledItemCard.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstalledItemCard.kt new file mode 100644 index 0000000..101559c --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/components/InstalledItemCard.kt @@ -0,0 +1,123 @@ +package com.meowarex.rlmobile.ui.screens.home.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.components.SegmentedButton +import com.meowarex.rlmobile.ui.components.VersionDisplay +import com.meowarex.rlmobile.ui.screens.home.InstallData + +@Composable +fun InstalledItemCard( + data: InstallData, + onUpdate: () -> Unit, + onOpenApp: () -> Unit, + onOpenInfo: () -> Unit, + onOpenPlugins: () -> Unit, + modifier: Modifier = Modifier, +) { + ElevatedCard( + shape = MaterialTheme.shapes.medium, + elevation = CardDefaults.elevatedCardElevation( + defaultElevation = 3.dp, + ), + modifier = modifier + .width(IntrinsicSize.Max), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.padding(20.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Image( + painter = data.icon, + contentDescription = null, + modifier = Modifier + .size(34.dp) + .clip(CircleShape), + ) + + Column { + Text( + text = data.name, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = .94f), + ) + + Text( + text = data.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(start = 1.dp) + .offset(y = (-2).dp) + .alpha(.7f) + .basicMarquee(), + ) + } + + Spacer(Modifier.weight(1f, fill = true)) + + VersionDisplay( + version = data.version, + prefix = { append("v") }, + modifier = Modifier + .alpha(.6f) + .padding(end = 4.dp), + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.clip(MaterialTheme.shapes.large), + ) { + SegmentedButton( + icon = painterResource(R.drawable.ic_extension), + text = stringResource(R.string.plugins_title), + onClick = onOpenPlugins, + ) + SegmentedButton( + icon = painterResource(R.drawable.ic_info), + text = stringResource(R.string.action_open_info), + onClick = onOpenInfo, + ) + + // If the up-to-date status cannot be determined, assume it is up-to-date + if (data.isUpToDate ?: true) { + SegmentedButton( + icon = painterResource(R.drawable.ic_launch), + text = stringResource(R.string.action_launch), + onClick = onOpenApp, + ) + } else { + val warningColor = Color(0xFFFFBB33) + + SegmentedButton( + icon = painterResource(R.drawable.ic_update), + text = stringResource(R.string.action_update), + iconColor = warningColor, + textColor = warningColor, + onClick = onUpdate, + ) + } + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/LogScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/LogScreen.kt new file mode 100644 index 0000000..b4882ae --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/LogScreen.kt @@ -0,0 +1,125 @@ +package com.meowarex.rlmobile.ui.screens.log + +import android.os.Parcelable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.InstallLogData +import com.meowarex.rlmobile.ui.components.Label +import com.meowarex.rlmobile.ui.screens.log.components.LogAppBar +import com.meowarex.rlmobile.ui.screens.log.components.LogTextArea +import com.meowarex.rlmobile.util.back +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.koin.core.parameter.parametersOf + +@Parcelize +class LogScreen(private val installId: String) : Screen, Parcelable { + @IgnoredOnParcel + override val key: ScreenKey + get() = "LogScreen-$installId" + + @Composable + override fun Content() { + val model = koinScreenModel { parametersOf(installId) } + val navigator = LocalNavigator.currentOrThrow + + if (model.shouldCloseScreen) { + navigator.back(currentActivity = null) + } + + model.data?.let { + LogScreenContent( + data = it, + onExportLog = model::saveLog, + onShareLog = model::shareLog, + ) + } + } +} + +@Composable +fun LogScreenContent( + data: InstallLogData, + onExportLog: () -> Unit, + onShareLog: () -> Unit, +) { + Scaffold( + topBar = { + LogAppBar( + onExportLog = onExportLog, + onShareLog = onShareLog, + ) + }, + ) { paddingValues -> + LazyColumn( + verticalArrangement = Arrangement.spacedBy(28.dp), + modifier = Modifier + .padding(paddingValues) + .padding(vertical = 10.dp, horizontal = 22.dp) + ) { + item("INSTALL_INFO") { + Label( + name = stringResource(R.string.log_section_install_info), + description = null, + ) { + LogTextArea( + text = """ + Installation ID: ${data.id} + Installation Date: ${data.installDate} + """.trimIndent(), + modifier = Modifier.fillMaxWidth(), + ) + } + } + + item("ENVIRONMENT_INFO") { + Label( + name = stringResource(R.string.log_section_env_info), + description = null, + ) { + LogTextArea( + text = data.environmentInfo, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + if (data.errorStacktrace != null) { + item("ERROR_STACKTRACE") { + Label( + name = stringResource(R.string.log_section_error), + description = null, + ) { + LogTextArea( + text = data.errorStacktrace, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + item("LOG") { + Label( + name = stringResource(R.string.log_section_log), + description = null, + ) { + LogTextArea( + text = data.installationLog, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/LogScreenModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/LogScreenModel.kt new file mode 100644 index 0000000..cfc7a78 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/LogScreenModel.kt @@ -0,0 +1,94 @@ +package com.meowarex.rlmobile.ui.screens.log + +import android.app.Application +import android.content.Intent +import android.util.Log +import androidx.compose.runtime.* +import androidx.core.content.FileProvider +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.InstallLogData +import com.meowarex.rlmobile.manager.InstallLogManager +import com.meowarex.rlmobile.util.* + +class LogScreenModel( + private val installId: String, + private val logs: InstallLogManager, + private val application: Application, +) : ScreenModel { + var shouldCloseScreen by mutableStateOf(false) + private set + + var data by mutableStateOf(null) + private set + + init { + loadLogData() + } + + /** + * Formats the log data into a file and writes it to the downloads folder. + */ + fun saveLog() = screenModelScope.launchIO { + val data = data ?: return@launchIO + + val formattedDate = data.getFormattedInstallDate() + val content = data.getLogFileContents() + + application.saveFile("RadiantLyrics Install $formattedDate.log", content) + } + + /** + * Writes the log to internal cache and launches a share intent of the log file. + */ + fun shareLog() { + val data = data ?: return + val formattedDate = data.getFormattedInstallDate() + val formattedName = "RadiantLyrics Install $formattedDate.log" + val content = data.getLogFileContents() + + val file = application.cacheDir.resolve(formattedName) + val fileUri = FileProvider.getUriForFile( + /* context = */ application, + /* authority = */ "${BuildConfig.APPLICATION_ID}.provider", + /* file = */ file, + /* displayName = */ formattedName, + ) + + val intent = Intent(Intent.ACTION_SEND) + .setType("text/*") + .putExtra(Intent.EXTRA_STREAM, fileUri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .let { + Intent.createChooser( + /* target = */ it, + /* title = */ application.getString(R.string.log_action_share), + ) + } + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + try { + file.writeText(content) + file.deleteOnExit() + application.startActivity(intent) + } catch (t: Throwable) { + Log.w(BuildConfig.TAG, "Failed to share log", t) + application.showToast(R.string.status_failed) + } + } + + private fun loadLogData() = screenModelScope.launchIO { + val result = logs.fetchInstallData(installId) + + mainThread { + if (result != null) { + data = result + } else { + shouldCloseScreen = true + application.showToast(R.string.network_load_fail) + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/components/LogAppBar.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/components/LogAppBar.kt new file mode 100644 index 0000000..f41ef9e --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/components/LogAppBar.kt @@ -0,0 +1,33 @@ +package com.meowarex.rlmobile.ui.screens.log.components + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.components.BackButton + +@Composable +fun LogAppBar( + onExportLog: () -> Unit, + onShareLog: () -> Unit, +) { + TopAppBar( + title = { Text(stringResource(R.string.log_title)) }, + navigationIcon = { BackButton() }, + actions = { + IconButton(onClick = onExportLog) { + Icon( + painter = painterResource(R.drawable.ic_save), + contentDescription = stringResource(R.string.log_action_export), + ) + } + IconButton(onClick = onShareLog) { + Icon( + painter = painterResource(R.drawable.ic_share), + contentDescription = stringResource(R.string.log_action_share), + ) + } + }, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/components/LogTextArea.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/components/LogTextArea.kt new file mode 100644 index 0000000..0546d65 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/log/components/LogTextArea.kt @@ -0,0 +1,42 @@ +package com.meowarex.rlmobile.ui.screens.log.components + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.meowarex.rlmobile.ui.util.horizontalScrollbar +import com.meowarex.rlmobile.ui.util.thenIf + +@Composable +fun LogTextArea( + text: String, + modifier: Modifier = Modifier.Companion, +) { + val scrollState = rememberScrollState() + val scrollable by remember { derivedStateOf { scrollState.maxValue > 0 } } + + SelectionContainer { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + lineHeight = 18.sp, + fontFamily = FontFamily.Companion.Monospace, + softWrap = false, + modifier = modifier + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(start = 18.dp, end = 18.dp, top = 14.dp, bottom = 14.dp) + .horizontalScroll(scrollState) + .horizontalScrollbar(scrollState) + .thenIf(scrollable) { padding(bottom = 10.dp) } + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/LogsListScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/LogsListScreen.kt new file mode 100644 index 0000000..c5347e0 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/LogsListScreen.kt @@ -0,0 +1,99 @@ +package com.meowarex.rlmobile.ui.screens.logs + +import android.os.Parcelable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.meowarex.rlmobile.ui.screens.log.LogScreen +import com.meowarex.rlmobile.ui.screens.logs.components.* +import com.meowarex.rlmobile.ui.screens.logs.components.dialogs.DeleteLogsDialog +import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides +import com.meowarex.rlmobile.ui.util.paddings.exclude +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +class LogsListScreen : Screen, Parcelable { + @IgnoredOnParcel + override val key: ScreenKey + get() = "LogsScreen" + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val model = koinScreenModel() + + var showWipeConfirmDialog by remember { mutableStateOf(false) } + + if (showWipeConfirmDialog) { + DeleteLogsDialog( + onConfirm = { + showWipeConfirmDialog = false + model.deleteLogs() + }, + onDismiss = { + showWipeConfirmDialog = false + }, + ) + } + + LogsScreenContent( + logs = model.logEntries, + onOpenLog = { navigator.push(LogScreen(installId = it)) }, + onDeleteLogs = { showWipeConfirmDialog = true }, + ) + } +} + +@Composable +fun LogsScreenContent( + logs: SnapshotStateList, + onOpenLog: (id: String) -> Unit, + onDeleteLogs: () -> Unit, +) { + Scaffold( + topBar = { + LogsListAppBar( + onDeleteLogs = onDeleteLogs, + ) + }, + ) { paddingValues -> + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = paddingValues.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top), + modifier = Modifier + .padding(paddingValues.exclude(PaddingValuesSides.Bottom)) + .padding(vertical = 12.dp, horizontal = 22.dp) + ) { + if (logs.isEmpty()) { + item(key = "EMPTY") { + LogsNone( + modifier = Modifier.fillParentMaxSize(), + ) + } + } + + items( + items = logs, + contentType = { "LOG" }, + key = { it.id }, + ) { data -> + LogEntryCard( + data = data, + onClick = remember(onOpenLog, data.id) { { onOpenLog(data.id) } }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/LogsListScreenModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/LogsListScreenModel.kt new file mode 100644 index 0000000..567517a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/LogsListScreenModel.kt @@ -0,0 +1,71 @@ +package com.meowarex.rlmobile.ui.screens.logs + +import android.app.Application +import android.text.format.DateUtils +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableStateListOf +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.InstallLogManager +import com.meowarex.rlmobile.util.* +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +class LogsListScreenModel( + private val logsManager: InstallLogManager, + private val application: Application, +) : ScreenModel { + /** + * All the loaded log entries sorted descending by creation date. + */ + val logEntries = mutableStateListOf() + + init { + loadLogsList() + } + + fun deleteLogs() = screenModelScope.launchIO { + logsManager.deleteAllEntries() + + mainThread { + logEntries.clear() + application.showToast(R.string.logs_status_delete_success) + } + } + + private fun loadLogsList() = screenModelScope.launchIO { + for (installId in logsManager.fetchInstallDataEntries()) { + val data = logsManager.fetchInstallData(id = installId) + ?: continue + + val entry = LogEntry( + id = data.id, + isError = data.isError, + installDate = DateUtils.getRelativeDateTimeString( + /* c = */ application, + /* time = */ data.installDate.toEpochMilliseconds(), + /* minResolution = */ DateUtils.SECOND_IN_MILLIS, + /* transitionResolution = */ DateUtils.WEEK_IN_MILLIS, + /* flags = */ DateUtils.FORMAT_ABBREV_ALL, + ).toString(), + durationSecs = data.installDuration.inWholeMilliseconds / 1000f, + stacktracePreview = data.errorStacktrace + ?.splitToSequence('\n') + ?.take(3) + ?.toImmutableList(), + ) + + mainThread { logEntries += entry } + } + } +} + +@Immutable +data class LogEntry( + val id: String, + val isError: Boolean, + val installDate: String, + val durationSecs: Float, + val stacktracePreview: ImmutableList?, +) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/LogEntryCard.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/LogEntryCard.kt new file mode 100644 index 0000000..edfb1b8 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/LogEntryCard.kt @@ -0,0 +1,122 @@ +package com.meowarex.rlmobile.ui.screens.logs.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.patcher.steps.base.StepState +import com.meowarex.rlmobile.ui.screens.logs.LogEntry +import com.meowarex.rlmobile.ui.screens.patching.components.StepStateIcon +import com.meowarex.rlmobile.ui.screens.patching.components.TimeElapsed + +@Composable +fun LogEntryCard( + data: LogEntry, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val errorColor = MaterialTheme.colorScheme.error + + ElevatedCard( + shape = RectangleShape, + modifier = modifier + .clickable(onClick = onClick) + .clip(RoundedCornerShape(topStart = 6.dp, 12.0.dp, bottomStart = 6.dp, bottomEnd = 12.0.dp)) + .drawWithCache { + val color = when (data.isError) { + true -> errorColor + false -> Color(0xFF59B463) + } + + onDrawWithContent { + drawContent() + drawRect( + color = color, + alpha = .8f, + topLeft = Offset.Zero, + size = Size(4.dp.toPx(), size.height), + ) + } + } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(vertical = 18.dp, horizontal = 22.dp), + ) { + StepStateIcon( + state = if (data.isError) StepState.Error else StepState.Success, + size = 24.dp, + ) + + Column { + Text( + text = when (data.isError) { + true -> stringResource(R.string.status_failed) + false -> stringResource(R.string.status_success) + }, + ) + + Text( + text = data.installDate, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.alpha(.6f), + ) + } + + Spacer(Modifier.weight(1f, fill = true)) + + TimeElapsed( + seconds = data.durationSecs, + modifier = Modifier.alpha(.9f), + ) + } + + if (data.stacktracePreview != null) { + Column( + modifier = Modifier + .padding(start = 26.dp, end = 20.dp, bottom = 18.dp) + // https://stackoverflow.com/a/76270310/13964629 + .graphicsLayer( + alpha = .95f, + compositingStrategy = CompositingStrategy.Offscreen, + ) + .drawWithContent { + val colors = listOf(Color.Black, Color.Black, Color.Transparent) + drawContent() + drawRect( + brush = Brush.verticalGradient(colors), + blendMode = BlendMode.DstIn, + ) + } + ) { + // The stacktrace is separated into multiple Text elements because ellipsis is not supported per-line + for (line in data.stacktracePreview) key(line) { + Text( + text = line, + softWrap = false, + overflow = TextOverflow.Ellipsis, + lineHeight = 18.sp, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Companion.Monospace, + ) + } + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/LogsListAppBar.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/LogsListAppBar.kt new file mode 100644 index 0000000..ec86ab4 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/LogsListAppBar.kt @@ -0,0 +1,37 @@ +package com.meowarex.rlmobile.ui.screens.logs.components + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.navigator.LocalNavigator +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.components.BackButton +import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen + +@Composable +fun LogsListAppBar( + onDeleteLogs: () -> Unit, +) { + TopAppBar( + navigationIcon = { BackButton() }, + title = { Text(stringResource(R.string.logs_title)) }, + actions = { + val navigator = LocalNavigator.current + + IconButton(onClick = onDeleteLogs) { + Icon( + painter = painterResource(R.drawable.ic_delete_forever), + contentDescription = stringResource(R.string.logs_action_delete_all) + ) + } + + IconButton(onClick = { navigator?.push(SettingsScreen()) }) { + Icon( + painter = painterResource(R.drawable.ic_settings), + contentDescription = stringResource(R.string.navigation_settings) + ) + } + } + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/LogsNone.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/LogsNone.kt new file mode 100644 index 0000000..0b64045 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/LogsNone.kt @@ -0,0 +1,33 @@ +package com.meowarex.rlmobile.ui.screens.logs.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R + +@Composable +fun LogsNone(modifier: Modifier = Modifier) { + Box(modifier = modifier) { + Column( + modifier = Modifier.align(Alignment.Center), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.ic_reciept_off), + contentDescription = null, + ) + Text( + text = stringResource(R.string.logs_none), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.alpha(.8f), + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/dialogs/DeleteLogsDialog.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/dialogs/DeleteLogsDialog.kt new file mode 100644 index 0000000..14b6b13 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/logs/components/dialogs/DeleteLogsDialog.kt @@ -0,0 +1,53 @@ +package com.meowarex.rlmobile.ui.screens.logs.components.dialogs + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R + +@Composable +fun DeleteLogsDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + painter = painterResource(R.drawable.ic_delete_forever), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + }, + title = { Text(stringResource(R.string.logs_wipe_title)) }, + text = { + Text( + text = stringResource(R.string.logs_wipe_desc), + textAlign = TextAlign.Center, + ) + }, + confirmButton = { + Button( + onClick = onConfirm, + ) { + Text(stringResource(R.string.action_confirm)) + } + }, + dismissButton = { + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Text(stringResource(R.string.action_cancel)) + } + } + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreen.kt new file mode 100644 index 0000000..a7d5d4e --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreen.kt @@ -0,0 +1,316 @@ +package com.meowarex.rlmobile.ui.screens.patching + +import android.os.Parcelable +import androidx.activity.compose.BackHandler +import androidx.compose.animation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.ui.components.MainActionButton +import com.meowarex.rlmobile.ui.components.Wakelock +import com.meowarex.rlmobile.ui.components.dialogs.InstallerAbortDialog +import com.meowarex.rlmobile.ui.components.dialogs.NetworkWarningDialog +import com.meowarex.rlmobile.ui.screens.log.LogScreen +import com.meowarex.rlmobile.ui.screens.patching.components.* +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen +import com.meowarex.rlmobile.ui.theme.customColors +import com.meowarex.rlmobile.ui.util.paddings.* +import com.meowarex.rlmobile.ui.util.spacedByLastAtBottom +import com.meowarex.rlmobile.ui.util.thenIf +import com.meowarex.rlmobile.util.back +import com.meowarex.rlmobile.util.isIgnoringBatteryOptimizations +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.filter +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.koin.core.parameter.parametersOf + +val VERTICAL_PADDING: Dp = 18.dp + +@Parcelize +class PatchingScreen( + /** + * User-selected patching options. This may originate from [PatchOptionsScreen] or + * from an existing installation, to update it. + */ + private val options: PatchOptions, +) : Screen, Parcelable { + @IgnoredOnParcel + override val key = "Patching" + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val context = LocalContext.current + val model = koinScreenModel { parametersOf(options) } + + val state by model.state.collectAsState() + val listState = rememberLazyListState() + val showMinimizationWarning = rememberSaveable { !context.isIgnoringBatteryOptimizations() } + + // Exit warning dialog (dismiss itself if install process state changes, esp. for Success) + var showAbortWarning by rememberSaveable(model.state.collectAsState().value) { mutableStateOf(false) } + + // The currently expanded step group on this screen + var expandedGroup by rememberSaveable { mutableStateOf(StepGroup.Prepare) } + + // Only show exit warning if currently working + val onTryExit: () -> Unit = remember { + { + // Show cancellation if currently running + if (state == PatchingScreenState.Working && !model.devMode) { + showAbortWarning = true + } + // Go home directly if install was successful + else if (state is PatchingScreenState.Success) { + navigator.popUntilRoot() + } + // Go back to the patch options screen + else { + navigator.back(currentActivity = null) + } + } + } + + // Prevent screen from turning off while working + Wakelock(active = state is PatchingScreenState.Working) + + LaunchedEffect(state) { + when (state) { + // Go home directly if screen model mandates so (usually caused by cancelled PackageInstaller dialog) + PatchingScreenState.CloseScreen -> navigator.popUntilRoot() + + // Close all groups when successfully finished everything + PatchingScreenState.Success -> { + expandedGroup = null + } + + else -> {} + } + + listState.animateScrollToItem(0) + } + + if (model.showNetworkWarningDialog) { + NetworkWarningDialog( + onConfirm = { neverShow -> + model.hideNetworkWarning(neverShow) + model.install() + }, + onDismiss = { neverShow -> + model.hideNetworkWarning(neverShow) + navigator.pop() + }, + ) + } + + if (showAbortWarning) { + InstallerAbortDialog( + onDismiss = { showAbortWarning = false }, + onConfirm = { + navigator.back(currentActivity = null) + model.cancelInstall() + }, + ) + } else { + BackHandler(onBack = onTryExit) + } + + Scaffold( + topBar = { PatchingAppBar(onTryExit) }, + ) { paddingValues -> + Column( + verticalArrangement = Arrangement.spacedBy(VERTICAL_PADDING), + modifier = Modifier + .padding(paddingValues.exclude(PaddingValuesSides.Bottom)), + ) { + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedByLastAtBottom(0.dp), + contentPadding = paddingValues + .exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top) + .add(PaddingValues(bottom = 25.dp)), + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize(), + ) { + item(key = "MINIMIZATION_WARNING") { + BannerSection(visible = showMinimizationWarning && !state.isFinished) { + TextBanner( + text = stringResource(R.string.installer_banner_minimization), + icon = painterResource(R.drawable.ic_warning), + iconColor = MaterialTheme.customColors.onWarningContainer, + outlineColor = MaterialTheme.customColors.warning, + containerColor = MaterialTheme.customColors.warningContainer, + modifier = Modifier + .padding(bottom = VERTICAL_PADDING) + .fillMaxWidth(), + ) + } + } + + item(key = "FAILED_BANNER") { + BannerSection(visible = state is PatchingScreenState.Failed) { + val handler = LocalUriHandler.current + + TextBanner( + text = stringResource(R.string.installer_banner_failure), + icon = painterResource(R.drawable.ic_warning), + iconColor = MaterialTheme.colorScheme.error, + outlineColor = null, + containerColor = MaterialTheme.colorScheme.errorContainer, + onClick = { handler.openUri("https://tidal.gg/${BuildConfig.SUPPORT_SERVER}") }, + modifier = Modifier + .padding(bottom = VERTICAL_PADDING) + .fillMaxWidth(), + ) + } + } + + item(key = "INSTALLED_BANNER") { + BannerSection(visible = state is PatchingScreenState.Success) { + TextBanner( + text = stringResource(R.string.installer_banner_success), + icon = painterResource(R.drawable.ic_check_circle), + iconColor = Color(0xFF59B463), + outlineColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier + .padding(bottom = VERTICAL_PADDING) + .fillMaxWidth(), + ) + } + } + + for ((group, steps) in model.steps?.entries ?: persistentListOf()) { + item(key = System.identityHashCode(group)) { + StepGroupCard( + name = stringResource(group.localizedName), + subSteps = steps, + isExpanded = expandedGroup == group, + onExpand = { expandedGroup = group }, + modifier = Modifier + .padding(bottom = VERTICAL_PADDING) + .fillMaxWidth() + .thenIf(state is PatchingScreenState.Success) { alpha(.5f) } + ) + } + } + + item(key = "BUTTONS") { + var cacheCleared by rememberSaveable { mutableStateOf(false) } + val filteredState by remember { model.state.filter { it.isProgressChange } } + .collectAsState(initial = PatchingScreenState.Working) + + AnimatedVisibility( + visible = filteredState != PatchingScreenState.Working, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically { it * -2 }, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(VERTICAL_PADDING / 2), + ) { + HorizontalDivider( + thickness = 1.dp, + modifier = Modifier + .padding(bottom = VERTICAL_PADDING / 2) + ) + + when (filteredState) { + PatchingScreenState.Working -> {} + PatchingScreenState.CloseScreen -> error("unreachable") + + PatchingScreenState.Success -> { + MainActionButton( + text = stringResource(R.string.action_launch), + icon = painterResource(R.drawable.ic_launch), + onClick = model::launchApp, + ) + } + + is PatchingScreenState.Failed -> { + MainActionButton( + text = stringResource(R.string.action_retry_install), + icon = painterResource(R.drawable.ic_refresh), + onClick = model::install, + ) + + MainActionButton( + text = stringResource(R.string.action_open_error_log), + icon = painterResource(R.drawable.ic_launch), + onClick = { navigator.push(LogScreen(installId = model.getCurrentInstallId()!!)) }, + ) + } + } + + MainActionButton( + text = stringResource(R.string.settings_clear_cache), + icon = painterResource(R.drawable.ic_delete_forever), + enabled = !cacheCleared, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + onClick = { + cacheCleared = true + model.clearCache() + }, + ) + } + } + } + + item(key = "FUN_FACT") { + FunFact( + text = stringResource(model.funFact), + state = state, + ) + } + } + } + } + } +} + +@Composable +private fun BannerSection( + visible: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically(), + modifier = modifier + .padding(bottom = VERTICAL_PADDING), + ) { + Column { + content() + + HorizontalDivider( + thickness = 1.dp, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreenModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreenModel.kt new file mode 100644 index 0000000..f7a4fbe --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreenModel.kt @@ -0,0 +1,220 @@ +package com.meowarex.rlmobile.ui.screens.patching + +import android.annotation.SuppressLint +import android.app.Application +import android.util.Log +import androidx.annotation.StringRes +import androidx.compose.runtime.* +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.* +import com.meowarex.rlmobile.patcher.TidalPatchRunner +import com.meowarex.rlmobile.patcher.StepRunner +import com.meowarex.rlmobile.patcher.steps.StepGroup +import com.meowarex.rlmobile.patcher.steps.base.* +import com.meowarex.rlmobile.patcher.steps.install.InstallStep +import com.meowarex.rlmobile.patcher.util.InsufficientStorageException +import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions +import com.meowarex.rlmobile.ui.util.toUnsafeImmutable +import com.meowarex.rlmobile.util.* +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.coroutines.* +import java.util.UUID +import kotlin.time.* +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class PatchingScreenModel( + private val options: PatchOptions, + private val paths: PathManager, + private val prefs: PreferencesManager, + private val application: Application, + private val installLogs: InstallLogManager, +) : StateScreenModel(PatchingScreenState.Working) { + private var installId: String? = null + private var startTime: Instant? = null + private var runnerJob: Job? = null + private var stepRunner: StepRunner? = null + + val devMode get() = prefs.devMode + + var showNetworkWarningDialog by mutableStateOf(!alreadyShownNetworkWarning && application.isNetworkDangerous()) + private set + + var steps by mutableStateOf>?>(null) + private set + + @get:StringRes + var funFact by mutableIntStateOf(0) + private set + + init { + if (!prefs.showNetworkWarning) + showNetworkWarningDialog = false + + if (!showNetworkWarningDialog) + install() + + // Rotate fun facts every so often + screenModelScope.launch { + while (true) { + funFact = FUN_FACTS.random() + delay(8.seconds) + } + } + } + + fun hideNetworkWarning(neverShow: Boolean) { + showNetworkWarningDialog = false + alreadyShownNetworkWarning = true + prefs.showNetworkWarning = !neverShow + } + + fun launchApp() { + if (state.value !is PatchingScreenState.Success) + return + + val launchIntent = application.packageManager + .getLaunchIntentForPackage(options.packageName) + + if (launchIntent != null) { + application.startActivity(launchIntent) + } else { + application.showToast(R.string.launch_app_fail) + } + } + + fun clearCache() = screenModelScope.launchIO { + paths.clearCache() + mainThread { application.showToast(R.string.action_cleared_cache) } + } + + fun getCurrentInstallId(): String? = installId + + fun cancelInstall() = screenModelScope.launchIO { + runnerJob?.cancel("Manual cancellation") + + // Delete any in-progress downloads to be safe + stepRunner?.also { container -> + val incompleteDownloadStep = container.steps + .filterIsInstance>() + .lastOrNull { it.state == StepState.Running } + + incompleteDownloadStep?.getStoredFile(container)?.delete() + } + + paths.patchingWorkingDir.deleteRecursively() + } + + fun install() = screenModelScope.launchBlock { + runnerJob?.cancel("Manual cancellation") + mainThread { steps = null } + + @SuppressLint("MemberExtensionConflict") + installId = UUID.randomUUID().toString() + startTime = Clock.System.now() + mutableState.value = PatchingScreenState.Working + + runnerJob = screenModelScope.launch(Dispatchers.Default) { + Log.i(BuildConfig.TAG, "Starting installation with environment:\n" + installLogs.getEnvironmentInfo()) + + try { + startPatchRunner() + } catch (_: CancellationException) { + Log.w(BuildConfig.TAG, "Installation was cancelled before completion") + mutableState.value = PatchingScreenState.CloseScreen + } catch (error: Throwable) { + Log.e(BuildConfig.TAG, "Failed to orchestrate patch runner", error) + mutableState.value = PatchingScreenState.Failed(installId = installId!!) + installLogs.storeInstallData( + id = installId!!, + installDate = startTime!!, + installDuration = Duration.ZERO, + options = options, + log = "- Failed to initialize patch runner", + error = error, + ) + } + } + } + + private suspend fun startPatchRunner() { + val runner = TidalPatchRunner(options) + .also { stepRunner = it } + + val newSteps = runner.steps.groupBy { it.group } + .mapValues { it.value.toUnsafeImmutable() } + .toUnsafeImmutable() + mainThread { steps = newSteps } + + // Intentionally delay to show the state change of the first step when it runs in the UI. + // Without this, on a fast internet connection the step just immediately shows as "Success". + delay(400) + + // Execute all the steps and catch any errors + val error = when (val error = runner.executeAll()) { + null -> { + // If install step is marked skipped then the installation was manually aborted + // and if so, immediately close install screen + if (runner.getStep().state == StepState.Skipped) { + mutableState.value = PatchingScreenState.CloseScreen + + Error("Installation was aborted or cancelled") + .apply { stackTrace = emptyArray() } + } + // At this point, the installation has successfully completed + else { + mutableState.value = PatchingScreenState.Success + + null + } + } + + else -> { + Log.e(BuildConfig.TAG, "Failed to perform installation process", error) + mutableState.value = PatchingScreenState.Failed(installId = installId!!) + + if (error is InsufficientStorageException) { + mainThread { application.showToast(R.string.installer_insufficient_storage) } + } + + error + } + } + + installLogs.storeInstallData( + id = installId!!, + installDate = startTime!!, + installDuration = runner.steps.sumOf { it.getDuration() }.milliseconds, + options = options, + log = runner.getLog(), + error = error, + ) + } + + companion object { + // Global state to avoid showing the warning more than once per launch + private var alreadyShownNetworkWarning = false + + /** + * Random fun facts to show on the installation screen. + */ + private val FUN_FACTS = arrayOf( + R.string.fun_fact_1, + R.string.fun_fact_2, + R.string.fun_fact_3, + R.string.fun_fact_4, + R.string.fun_fact_5, + R.string.fun_fact_6, + R.string.fun_fact_7, + R.string.fun_fact_8, + R.string.fun_fact_9, + R.string.fun_fact_10, + R.string.fun_fact_11, + R.string.fun_fact_12, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreenState.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreenState.kt new file mode 100644 index 0000000..0df2b60 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreenState.kt @@ -0,0 +1,16 @@ +package com.meowarex.rlmobile.ui.screens.patching + +import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenState.CloseScreen + +sealed interface PatchingScreenState { + data object Working : PatchingScreenState + data object Success : PatchingScreenState + data class Failed(val installId: String) : PatchingScreenState + data object CloseScreen : PatchingScreenState +} + +val PatchingScreenState.isProgressChange: Boolean + inline get() = this != CloseScreen + +val PatchingScreenState.isFinished: Boolean + inline get() = isProgressChange || this == CloseScreen diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/FunFact.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/FunFact.kt new file mode 100644 index 0000000..cfcb297 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/FunFact.kt @@ -0,0 +1,50 @@ +package com.meowarex.rlmobile.ui.screens.patching.components + +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenState +import com.meowarex.rlmobile.ui.screens.patching.VERTICAL_PADDING + +@Composable +fun FunFact( + text: String, + state: PatchingScreenState, +) { + AnimatedVisibility( + visible = state !is PatchingScreenState.Failed, + enter = fadeIn() + slideInVertically { it * 2 }, + exit = fadeOut() + slideOutVertically { it * 2 }, + label = "fun fact visibility" + ) { + AnimatedContent( + targetState = text, + label = "fun fact change", + transitionSpec = { + val inSpec = fadeIn(tween(220, delayMillis = 90)) + slideInHorizontally { it * -2 } + val outSpec = fadeOut(tween(90)) + slideOutHorizontally { it * 2 } + inSpec togetherWith outSpec + } + ) { text -> + Text( + text = stringResource(R.string.fun_fact_prefix, text), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(top = VERTICAL_PADDING, bottom = 25.dp, start = VERTICAL_PADDING, end = VERTICAL_PADDING) + .fillMaxWidth() + .alpha(.6f) + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/PatchingAppBar.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/PatchingAppBar.kt new file mode 100644 index 0000000..f3bc755 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/PatchingAppBar.kt @@ -0,0 +1,24 @@ +package com.meowarex.rlmobile.ui.screens.patching.components + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.meowarex.rlmobile.R + +@Composable +fun PatchingAppBar( + onBack: () -> Unit, +) { + TopAppBar( + title = { Text(stringResource(R.string.installer)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(R.drawable.ic_back), + contentDescription = stringResource(R.string.navigation_back), + ) + } + } + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/StepGroupCard.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/StepGroupCard.kt new file mode 100644 index 0000000..d8a0392 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/StepGroupCard.kt @@ -0,0 +1,115 @@ +package com.meowarex.rlmobile.ui.screens.patching.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.steps.base.StepState +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun StepGroupCard( + name: String, + subSteps: ImmutableList, + isExpanded: Boolean, + onExpand: () -> Unit, + modifier: Modifier = Modifier, +) { + val groupState by remember(subSteps) { + derivedStateOf { + when { + // If all steps are pending then show pending + subSteps.all { it.state == StepState.Pending } -> StepState.Pending + // If any step has finished with an error then default to error + subSteps.any { it.state == StepState.Error } -> StepState.Error + // If all steps have finished as Skipped/Success then show success + subSteps.all { it.state.isFinished } -> StepState.Success + + else -> StepState.Running + } + } + } + + val totalSeconds = remember(groupState.isFinished) { + if (!groupState.isFinished) { + 0f + } else { + subSteps + .sumOf { step -> step.getDuration() } + .div(1000f) + } + } + + LaunchedEffect(groupState) { + if (groupState == StepState.Running) + onExpand() + } + + Column( + modifier = modifier + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier + .clickable(true, onClick = onExpand) + .fillMaxWidth() + .padding(20.dp) + ) { + StepStateIcon( + state = groupState, + size = 24.dp, + ) + + Text( + text = name, + modifier = Modifier + .basicMarquee() + .weight(0.05f), + ) + + TimeElapsed( + enabled = groupState.isFinished, + seconds = totalSeconds, + ) + + if (isExpanded) { + Icon( + painter = painterResource(R.drawable.ic_arrow_up_small), + contentDescription = stringResource(R.string.action_collapse) + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_arrow_down_small), + contentDescription = stringResource(R.string.action_expand) + ) + } + } + + AnimatedVisibility(visible = isExpanded) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .background(MaterialTheme.colorScheme.background.copy(0.6f)) + .fillMaxWidth() + .padding(20.dp) + .padding(start = 4.dp) + ) { + for (step in subSteps) key(step) { + StepItem(step) + } + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/StepItem.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/StepItem.kt new file mode 100644 index 0000000..5880898 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/StepItem.kt @@ -0,0 +1,49 @@ +package com.meowarex.rlmobile.ui.screens.patching.components + +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.patcher.steps.base.Step +import com.meowarex.rlmobile.patcher.steps.base.StepState +import com.meowarex.rlmobile.ui.util.thenIf + +@Composable +fun StepItem( + step: Step, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier, + ) { + StepStateIcon( + size = 18.dp, + state = step.state, + stepProgress = step.progress, + ) + + Text( + text = stringResource(step.localizedName), + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f, true) + .thenIf(step.state == StepState.Running) { basicMarquee() }, + ) + + TimeElapsed( + enabled = step.state != StepState.Pending, + seconds = step.collectDurationAsState().value, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/StepStateIcon.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/StepStateIcon.kt new file mode 100644 index 0000000..d86809d --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/StepStateIcon.kt @@ -0,0 +1,88 @@ +package com.meowarex.rlmobile.ui.screens.patching.components + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.size +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.patcher.steps.base.StepState +import kotlin.math.floor +import kotlin.math.roundToInt + +@Composable +fun StepStateIcon( + state: StepState, + size: Dp, + stepProgress: Float = -1f, +) { + val animatedProgress by animateFloatAsState( + targetValue = stepProgress, + animationSpec = spring(stiffness = Spring.StiffnessVeryLow), + label = "Progress", + ) + + Crossfade(targetState = state, label = "State CrossFade") { animatedState -> + when (animatedState) { + StepState.Pending -> Icon( + painter = painterResource(R.drawable.ic_circle), + contentDescription = stringResource(R.string.status_queued), + tint = MaterialTheme.colorScheme.onSurface.copy(.2f), + modifier = Modifier.size(size) + ) + + StepState.Running -> { + val strokeWidth = Dp(floor(size.value / 10) + 1) + + if (stepProgress > .05f) { + CircularProgressIndicator( + progress = { animatedProgress }, + strokeWidth = strokeWidth, + modifier = Modifier + .size(size) + .semantics { contentDescription = "${(stepProgress * 100).roundToInt()}%" }, + ) + } else { + val description = stringResource(R.string.status_ongoing) + + // Infinite spinner + CircularProgressIndicator( + strokeWidth = strokeWidth, + modifier = Modifier + .size(size) + .semantics { contentDescription = description }, + ) + } + } + + StepState.Success -> Icon( + painter = painterResource(R.drawable.ic_check_circle), + contentDescription = stringResource(R.string.status_success), + tint = Color(0xFF59B463), + modifier = Modifier.size(size) + ) + + StepState.Error -> Icon( + painter = painterResource(R.drawable.ic_canceled), + contentDescription = stringResource(R.string.status_failed), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(size) + ) + + StepState.Skipped -> Icon( + painter = painterResource(R.drawable.ic_check_circle), + contentDescription = stringResource(R.string.status_skipped), + tint = Color(0xFFAEAEAE), + modifier = Modifier.size(size) + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/TextBanner.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/TextBanner.kt new file mode 100644 index 0000000..a97ace9 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/TextBanner.kt @@ -0,0 +1,58 @@ +package com.meowarex.rlmobile.ui.screens.patching.components + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.ui.util.thenIf + +@Composable +fun TextBanner( + text: String, + icon: Painter, + iconColor: Color, + outlineColor: Color?, + containerColor: Color, + onClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .thenIf(outlineColor) { color -> + border( + width = 2.dp, + color = color, + shape = MaterialTheme.shapes.medium, + ) + } + .clip(MaterialTheme.shapes.medium) + .background(containerColor) + .thenIf(onClick) { clickable(onClick = it) } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 14.dp) + ) { + Icon( + painter = icon, + tint = iconColor, + contentDescription = null, + modifier = Modifier.size(28.dp), + ) + + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/TimeElapsed.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/TimeElapsed.kt new file mode 100644 index 0000000..78b7b1b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/components/TimeElapsed.kt @@ -0,0 +1,30 @@ +package com.meowarex.rlmobile.ui.screens.patching.components + +import androidx.compose.animation.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.meowarex.rlmobile.R + +@Composable +fun TimeElapsed( + seconds: Float, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = enabled, + enter = fadeIn(), + exit = ExitTransition.None, + label = "TimeElapsed Visibility" + ) { + Text( + text = stringResource(R.string.time_elapsed_seconds, seconds), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + modifier = modifier, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt new file mode 100644 index 0000000..624f9f0 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt @@ -0,0 +1,47 @@ +package com.meowarex.rlmobile.ui.screens.patchopts + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Immutable +@Parcelize +@Serializable +data class PatchOptions( + /** + * The app name that's user-facing in launchers. + */ + val appName: String, + + /** + * Changes the installation package name. + */ + val packageName: String, + + /** + * Adding the debuggable APK flag. + */ + val debuggable: Boolean, + + /** + * A custom build of injector that was used rather than the latest. + */ + val customInjector: PatchComponent? = null, + + /** + * A custom smali patches bundle that was used rather than the latest. + */ + val customPatches: PatchComponent? = null, +) : Parcelable { + companion object { + val Default = PatchOptions( + appName = "TIDAL", + packageName = "com.tidal.music", + debuggable = false, + customInjector = null, + customPatches = null, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt new file mode 100644 index 0000000..e46f996 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt @@ -0,0 +1,136 @@ +package com.meowarex.rlmobile.ui.screens.patchopts + +import android.content.Context +import android.content.pm.PackageManager.NameNotFoundException +import androidx.compose.runtime.* +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.Navigator +import com.meowarex.rlmobile.manager.PreferencesManager +import com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsScreen +import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent +import com.meowarex.rlmobile.ui.util.pushForResult +import com.meowarex.rlmobile.util.* +import kotlinx.coroutines.launch + +class PatchOptionsModel( + prefilledOptions: PatchOptions, + private val context: Context, + private val prefs: PreferencesManager, +) : ScreenModel { + // ---------- Package name state ---------- + var packageName by mutableStateOf(prefilledOptions.packageName) + private set + + var packageNameState by mutableStateOf(PackageNameState.Ok) + private set + + fun changePackageName(newPackageName: String) { + packageName = newPackageName + fetchPkgNameStateDebounced() + } + + // ---------- App name state ---------- + var appName by mutableStateOf(prefilledOptions.appName) + private set + + var appNameIsError by mutableStateOf(false) + private set + + fun changeAppName(newAppName: String) { + appName = newAppName + appNameIsError = newAppName.length !in (1..150) + } + + // ---------- Debuggable state ---------- + var debuggable by mutableStateOf(prefilledOptions.debuggable) + private set + + fun changeDebuggable(value: Boolean) { + debuggable = value + } + + // ---------- Custom components state ---------- + var customInjector by mutableStateOf(null) + private set + var customPatches by mutableStateOf(null) + private set + + fun selectCustomInjector(navigator: Navigator) = screenModelScope.launch { + customInjector = navigator.pushForResult( + ComponentOptionsScreen( + default = customInjector, + componentType = PatchComponent.Type.Injector, + ) + ) + } + + fun selectCustomPatches(navigator: Navigator) = screenModelScope.launch { + customPatches = navigator.pushForResult( + ComponentOptionsScreen( + default = customPatches, + componentType = PatchComponent.Type.Patches, + ) + ) + } + + // ---------- Config generation ---------- + val isConfigValid by derivedStateOf { + val invalidChecks = arrayOf( + packageNameState == PackageNameState.Invalid, + appNameIsError, + ) + + invalidChecks.none { it } + } + + fun generateConfig(): PatchOptions { + if (!isConfigValid) error("invalid config state") + + return PatchOptions( + appName = appName, + packageName = packageName, + debuggable = debuggable, + customInjector = customInjector, + customPatches = customPatches, + ) + } + + // ---------- Other ---------- + val isDevMode: Boolean + get() = prefs.devMode + + // A throttled variant of fetchPkgNameState() + private val fetchPkgNameStateDebounced: () -> Unit = + screenModelScope.debounce(100L, function = ::fetchPkgNameState) + + private suspend fun fetchPkgNameState() { + val state = if (packageName.length !in (3..150) || !PACKAGE_REGEX.matches(this.packageName)) { + PackageNameState.Invalid + } else { + try { + context.packageManager.getPackageInfo(packageName, 0) + PackageNameState.Taken + } catch (_: NameNotFoundException) { + PackageNameState.Ok + } + } + + mainThread { packageNameState = state } + } + + init { + screenModelScope.launchBlock { fetchPkgNameState() } + } + + companion object { + private val PACKAGE_REGEX = """^[a-z]\w*(\.[a-z]\w*)+$""" + .toRegex(RegexOption.IGNORE_CASE) + } +} + +enum class PackageNameState { + Ok, + Invalid, + Taken, +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsScreen.kt new file mode 100644 index 0000000..43cede0 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsScreen.kt @@ -0,0 +1,210 @@ +package com.meowarex.rlmobile.ui.screens.patchopts + +import android.os.Parcelable +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.components.* +import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent +import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen +import com.meowarex.rlmobile.ui.screens.patchopts.components.PackageNameStateLabel +import com.meowarex.rlmobile.ui.screens.patchopts.components.PatchOptionsAppBar +import com.meowarex.rlmobile.ui.screens.patchopts.components.options.* +import com.meowarex.rlmobile.ui.util.spacedByLastAtBottom +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.koin.core.parameter.parametersOf + +@Parcelize +class PatchOptionsScreen( + private val prefilledOptions: PatchOptions? = null, +) : Screen, Parcelable { + @IgnoredOnParcel + override val key = "PatchOptions" + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val model = koinScreenModel { parametersOf(prefilledOptions ?: PatchOptions.Default) } + + PatchOptionsScreenContent( + isUpdate = prefilledOptions != null, + isDevMode = model.isDevMode, + + debuggable = model.debuggable, + setDebuggable = model::changeDebuggable, + + appName = model.appName, + appNameIsError = model.appNameIsError, + setAppName = model::changeAppName, + + packageName = model.packageName, + packageNameState = model.packageNameState, + setPackageName = model::changePackageName, + + customInjector = model.customInjector, + customPatches = model.customPatches, + onSelectCustomInjector = { model.selectCustomInjector(navigator) }, + onSelectCustomPatches = { model.selectCustomPatches(navigator) }, + + isConfigValid = model.isConfigValid, + onInstall = { + navigator.push(PatchingScreen(model.generateConfig())) + }, + ) + } +} + +@Composable +fun PatchOptionsScreenContent( + isUpdate: Boolean, + isDevMode: Boolean, + + debuggable: Boolean, + setDebuggable: (Boolean) -> Unit, + + appName: String, + appNameIsError: Boolean, + setAppName: (String) -> Unit, + + packageName: String, + packageNameState: PackageNameState, + setPackageName: (String) -> Unit, + + customInjector: PatchComponent?, + onSelectCustomInjector: () -> Unit, + customPatches: PatchComponent?, + onSelectCustomPatches: () -> Unit, + + isConfigValid: Boolean, + onInstall: () -> Unit, +) { + Scaffold( + topBar = { PatchOptionsAppBar(isUpdate = isUpdate) }, + ) { paddingValues -> + Column( + verticalArrangement = Arrangement.spacedByLastAtBottom(20.dp), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(paddingValues) + .padding(horizontal = 20.dp, vertical = 10.dp) + ) { + Text( + text = stringResource(R.string.patchopts_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + TextDivider(text = stringResource(R.string.patchopts_divider_basic)) + + val appNameIsDefault by remember { + derivedStateOf { + appName == PatchOptions.Default.appName + } + } + TextPatchOption( + name = stringResource(R.string.patchopts_appname_title), + description = stringResource(R.string.patchopts_appname_desc), + value = appName, + valueIsError = appNameIsError, + valueIsDefault = appNameIsDefault, + onValueChange = setAppName, + onValueReset = { setAppName(PatchOptions.Default.appName) }, + ) + + if (!isUpdate) { + val packageNameIsDefault by remember { + derivedStateOf { + packageName == PatchOptions.Default.packageName + } + } + TextPatchOption( + name = stringResource(R.string.patchopts_pkgname_title), + description = stringResource(R.string.patchopts_pkgname_desc), + value = packageName, + valueIsError = packageNameState == PackageNameState.Invalid, + valueIsDefault = packageNameIsDefault, + onValueChange = setPackageName, + onValueReset = { setPackageName(PatchOptions.Default.packageName) }, + ) { + PackageNameStateLabel( + state = packageNameState, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + + if (isDevMode) { + TextDivider( + text = stringResource(R.string.patchopts_divider_advanced), + modifier = Modifier.padding(top = 12.dp), + ) + + SwitchPatchOption( + icon = painterResource(R.drawable.ic_bug), + name = stringResource(R.string.patchopts_debuggable_title), + description = stringResource(R.string.patchopts_debuggable_desc), + value = debuggable, + onValueChange = setDebuggable, + ) + + IconPatchOption( + icon = painterResource(R.drawable.ic_extension), + name = stringResource(R.string.patchopts_custom_injector_title), + description = stringResource(R.string.patchopts_custom_injector_desc), + modifier = Modifier.clickable(onClick = onSelectCustomInjector), + ) { + FilledTonalButton(onClick = onSelectCustomInjector) { + Text( + text = customInjector?.version?.toString() + ?: stringResource(R.string.componentopts_selected_none) + ) + } + } + + IconPatchOption( + icon = painterResource(R.drawable.ic_extension), + name = stringResource(R.string.patchopts_custom_patches_title), + description = stringResource(R.string.patchopts_custom_patches_desc), + modifier = Modifier.clickable(onClick = onSelectCustomPatches), + ) { + FilledTonalButton(onClick = onSelectCustomPatches) { + Text( + text = customPatches?.version?.toString() + ?: stringResource(R.string.componentopts_selected_none) + ) + } + } + } + + Spacer(Modifier.weight(1f)) + + FilledTonalButton( + enabled = isConfigValid, + onClick = onInstall, + colors = ButtonDefaults.filledTonalButtonColors( + contentColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier + .padding(bottom = 10.dp) + .align(Alignment.End), + ) { + Text(stringResource(R.string.action_install)) + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PackageNameStateLabel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PackageNameStateLabel.kt new file mode 100644 index 0000000..51a1491 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PackageNameStateLabel.kt @@ -0,0 +1,65 @@ +package com.meowarex.rlmobile.ui.screens.patchopts.components + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.screens.patchopts.PackageNameState + +@Composable +fun PackageNameStateLabel( + state: PackageNameState, + modifier: Modifier = Modifier, +) { + Crossfade( + targetState = state, + label = "PackageNameStateLabel CrossFade" + ) { animatedState -> + val (label, icon, tint) = when (animatedState) { + PackageNameState.Invalid -> Triple( + R.string.patchopts_pkgname_invalid, + R.drawable.ic_canceled, + MaterialTheme.colorScheme.error, + ) + + PackageNameState.Taken -> Triple( + R.string.patchopts_pkgname_taken, + R.drawable.ic_warning, + Color(0xFFFFCC00), + ) + + PackageNameState.Ok -> Triple( + R.string.patchopts_pkgname_ok, + R.drawable.ic_check_circle, + Color(0xFF59B463), + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = tint, + modifier = Modifier.size(20.dp), + ) + + Text( + text = stringResource(label), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(.7f), + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchOptionsAppBar.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchOptionsAppBar.kt new file mode 100644 index 0000000..229d117 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchOptionsAppBar.kt @@ -0,0 +1,30 @@ +package com.meowarex.rlmobile.ui.screens.patchopts.components + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.navigator.LocalNavigator +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.components.BackButton +import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen + +@Composable +fun PatchOptionsAppBar( + isUpdate: Boolean = false, +) { + TopAppBar( + navigationIcon = { BackButton() }, + title = { Text(stringResource(if (!isUpdate) R.string.action_add_install else R.string.action_update_install)) }, + actions = { + val navigator = LocalNavigator.current + + IconButton(onClick = { navigator?.push(SettingsScreen()) }) { + Icon( + painter = painterResource(R.drawable.ic_settings), + contentDescription = stringResource(R.string.navigation_settings) + ) + } + } + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/options/IconPatchOption.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/options/IconPatchOption.kt new file mode 100644 index 0000000..80ee3e2 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/options/IconPatchOption.kt @@ -0,0 +1,50 @@ +package com.meowarex.rlmobile.ui.screens.patchopts.components.options + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.dp + +@Composable +fun IconPatchOption( + icon: Painter, + name: String, + description: String, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.size(26.dp), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.weight(1f) + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .alpha(.7f) + ) + } + + content() + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/options/SwitchPatchOption.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/options/SwitchPatchOption.kt new file mode 100644 index 0000000..f6a8e8b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/options/SwitchPatchOption.kt @@ -0,0 +1,50 @@ +package com.meowarex.rlmobile.ui.screens.patchopts.components.options + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp + +@Composable +fun SwitchPatchOption( + icon: Painter, + name: String, + description: String, + value: Boolean, + onValueChange: (Boolean) -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + val interactionSource = remember(::MutableInteractionSource) + val onClick = remember(value) { { onValueChange(!value) } } + + IconPatchOption( + icon = icon, + name = name, + description = description, + modifier = modifier + .fillMaxWidth() + .clickable( + onClick = onClick, + enabled = enabled, + interactionSource = interactionSource, + indication = null, + role = Role.Switch, + ), + ) { + Switch( + checked = value, + enabled = enabled, + onCheckedChange = onValueChange, + interactionSource = interactionSource, + modifier = Modifier.padding(start = 6.dp), + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/options/TextPatchOption.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/options/TextPatchOption.kt new file mode 100644 index 0000000..46414d5 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/options/TextPatchOption.kt @@ -0,0 +1,51 @@ +package com.meowarex.rlmobile.ui.screens.patchopts.components.options + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.ui.components.Label +import com.meowarex.rlmobile.ui.components.ResetToDefaultButton + +@Composable +fun TextPatchOption( + name: String, + description: String, + value: String, + valueIsError: Boolean, + valueIsDefault: Boolean, + onValueChange: (String) -> Unit, + onValueReset: () -> Unit, + modifier: Modifier = Modifier, + extra: (@Composable ColumnScope.() -> Unit)? = null, +) { + Label( + name = name, + description = description, + modifier = modifier, + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + isError = valueIsError, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + errorContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + trailingIcon = { + ResetToDefaultButton( + enabled = !valueIsDefault, + onClick = onValueReset, + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + + extra?.invoke(this) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/PermissionsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/PermissionsModel.kt new file mode 100644 index 0000000..e6d84ac --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/PermissionsModel.kt @@ -0,0 +1,139 @@ +package com.meowarex.rlmobile.ui.screens.permissions + +import android.Manifest +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.runtime.* +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.di.ActivityProvider +import com.meowarex.rlmobile.manager.InstallerSetting +import com.meowarex.rlmobile.manager.PreferencesManager +import com.meowarex.rlmobile.util.* +import java.util.UUID + +class PermissionsModel( + private val application: Application, + private val activities: ActivityProvider, + private val preferences: PreferencesManager, +) : ViewModel() { + private var timesRequestedNotificationsPerms = 0 + + var showInstallersDialog by mutableStateOf(false) + private set + + val installer: InstallerSetting + get() = preferences.installer + + var storagePermsGranted by mutableStateOf(false) + private set + var unknownSourcesPermsGranted by mutableStateOf(Build.VERSION.SDK_INT < 26) + private set + var notificationsPermsGranted by mutableStateOf(Build.VERSION.SDK_INT < 33) + private set + var batteryPermsGranted by mutableStateOf(Build.VERSION.SDK_INT < 24) + private set + + val requiredPermsGranted by derivedStateOf { + // Unknown Sources permission is only required when the installer is PM + if (preferences.installer == InstallerSetting.PackageInstaller && !unknownSourcesPermsGranted) + return@derivedStateOf false + + storagePermsGranted + } + val allPermsGranted by derivedStateOf { + requiredPermsGranted && notificationsPermsGranted && batteryPermsGranted + } + + fun showInstallersDialog() { + showInstallersDialog = true + } + + fun hideInstallersDialog() { + showInstallersDialog = false + } + + fun setInstaller(installer: InstallerSetting) { + preferences.installer = installer + } + + fun requestUnknownSourcesPerms() { + if (Build.VERSION.SDK_INT < 26) return + + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) + .setData("package:${BuildConfig.APPLICATION_ID}".toUri()) + .let(activities.get()::startActivity) + } + + fun requestStoragePerms() = permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + + @RequiresApi(Build.VERSION_CODES.R) + fun requestManageStoragePerms() { + Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + .setData("package:${BuildConfig.APPLICATION_ID}".toUri()) + .let(activities.get()::startActivity) + } + + fun requestNotificationsPerms() { + if (Build.VERSION.SDK_INT < 33) return + + // If the user denies the permission twice (not dismiss), then the dialog will no longer show, + // and the user will have to manually enable it from system settings. + if (++timesRequestedNotificationsPerms <= 2) { + permissionRequestLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID) + .let(activities.get()::startActivity) + } + } + + fun grantBatteryPerms() { + if (Build.VERSION.SDK_INT < 23) return + + activities.get().requestNoBatteryOptimizations() + } + + fun refresh() = viewModelScope.launchBlock { + storagePermsGranted = if (Build.VERSION.SDK_INT >= 30) { + Environment.isExternalStorageManager() + } else { + application.selfHasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + + unknownSourcesPermsGranted = Build.VERSION.SDK_INT < 26 || application.packageManager.canRequestPackageInstalls() + notificationsPermsGranted = Build.VERSION.SDK_INT < 33 || application.selfHasPermission(Manifest.permission.POST_NOTIFICATIONS) + batteryPermsGranted = Build.VERSION.SDK_INT < 24 || application.isIgnoringBatteryOptimizations() + } + + init { + refresh() + } + + override fun onCleared() { + permissionRequestLauncher.unregister() + } + + /** + * Used for requesting permissions that launch a popup to the user. + * Refreshes the permissions state once returned to the app. + */ + private val permissionRequestLauncher = run { + val activity = activities.get() + + activity.activityResultRegistry.register( + key = UUID.randomUUID().toString(), + contract = ActivityResultContracts.RequestPermission(), + callback = { refresh() }, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/PermissionsScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/PermissionsScreen.kt new file mode 100644 index 0000000..deeb8c0 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/PermissionsScreen.kt @@ -0,0 +1,222 @@ +package com.meowarex.rlmobile.ui.screens.permissions + +import android.os.Build +import android.os.Parcelable +import androidx.compose.animation.EnterTransition +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.stack.StackEvent +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cafe.adriel.voyager.transitions.ScreenTransition +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.InstallerSetting +import com.meowarex.rlmobile.ui.components.TextDivider +import com.meowarex.rlmobile.ui.components.settings.SettingsItem +import com.meowarex.rlmobile.ui.screens.home.HomeScreen +import com.meowarex.rlmobile.ui.screens.permissions.components.PermissionButton +import com.meowarex.rlmobile.ui.screens.permissions.components.PermissionsAppBar +import com.meowarex.rlmobile.ui.screens.settings.components.InstallersDialog +import com.meowarex.rlmobile.ui.util.paddings.* +import com.meowarex.rlmobile.ui.util.spacedByLastAtBottom +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.koin.compose.viewmodel.koinActivityViewModel + +@Parcelize +@OptIn(ExperimentalVoyagerApi::class) +class PermissionsScreen : Screen, ScreenTransition, Parcelable { + @IgnoredOnParcel + override val key = "Permissions" + + override fun enter(lastEvent: StackEvent): EnterTransition? = EnterTransition.None + + @Composable + override fun Content() { + val navigator = LocalNavigator.currentOrThrow + val model = koinActivityViewModel() + + // Go back (ex: HomeScreen) when all permissions have been granted + LaunchedEffect(model.allPermsGranted) { + if (model.allPermsGranted) + navigator.pop() + } + + if (model.showInstallersDialog) { + InstallersDialog( + currentInstaller = model.installer, + onDismiss = model::hideInstallersDialog, + onConfirm = model::setInstaller, + ) + } + + PermissionsScreenContent( + installer = model.installer, + openInstallersDialog = model::showInstallersDialog, + storagePermsGranted = model.storagePermsGranted, + onGrantStoragePerms = if (Build.VERSION.SDK_INT >= 30) { + model::requestManageStoragePerms + } else { + model::requestStoragePerms + }, + unknownSourcesPermsGranted = model.unknownSourcesPermsGranted, + onGrantUnknownSourcesPerms = model::requestUnknownSourcesPerms, + notificationsPermsGranted = model.notificationsPermsGranted, + onGrantNotificationsPerms = model::requestNotificationsPerms, + batteryPermsGranted = model.batteryPermsGranted, + onGrantBatteryPerms = model::grantBatteryPerms, + canContinue = model.requiredPermsGranted, + onContinue = { navigator.replace(HomeScreen()) }, + ) + } +} + +@Composable +fun PermissionsScreenContent( + installer: InstallerSetting, + openInstallersDialog: () -> Unit, + storagePermsGranted: Boolean, + onGrantStoragePerms: () -> Unit, + unknownSourcesPermsGranted: Boolean, + onGrantUnknownSourcesPerms: () -> Unit, + notificationsPermsGranted: Boolean, + onGrantNotificationsPerms: () -> Unit, + batteryPermsGranted: Boolean, + onGrantBatteryPerms: () -> Unit, + canContinue: Boolean, + onContinue: () -> Unit, +) { + Scaffold( + topBar = { PermissionsAppBar() }, + ) { padding -> + LazyColumn( + verticalArrangement = Arrangement.spacedByLastAtBottom(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = padding + .exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top) + .add(PaddingValues(bottom = 12.dp, top = 24.dp)), + modifier = Modifier + .fillMaxSize() + .padding(padding.exclude(PaddingValuesSides.Bottom)) + ) { + item(key = "DIVIDER_OPTIONS", contentType = "DIVIDER") { + TextDivider( + text = stringResource(R.string.permissions_header_options), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + item(key = "INSTALLER") { + SettingsItem( + text = { Text(stringResource(R.string.setting_installer)) }, + secondaryText = { Text(stringResource(R.string.setting_installer_desc)) }, + icon = { Icon(painterResource(R.drawable.ic_apk_install), null) }, + modifier = Modifier.clickable(onClick = openInstallersDialog), + ) { + FilledTonalButton(onClick = openInstallersDialog) { + Icon( + painter = installer.icon(), + contentDescription = null, + modifier = Modifier + .size(20.dp) + .padding(end = 6.dp), + ) + Text(installer.title()) + } + } + } + + item(key = "DIVIDER_PERMS", contentType = "DIVIDER") { + TextDivider( + text = stringResource(R.string.permissions_header_permissions), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + if (installer == InstallerSetting.PackageInstaller) { + item(key = "PERMS_UNKNOWN_SOURCES", contentType = "PERMISSION_BUTTON") { + PermissionButton( + name = stringResource(R.string.permissions_install_title), + description = stringResource(R.string.permissions_install_desc), + granted = unknownSourcesPermsGranted, + required = true, + icon = painterResource(R.drawable.ic_alt_route), + onClick = onGrantUnknownSourcesPerms, + modifier = Modifier.animateItem(), + ) + } + } + + item(key = "PERMS_STORAGE", contentType = "PERMISSION_BUTTON") { + PermissionButton( + name = stringResource(R.string.permissions_storage_title), + description = stringResource(R.string.permissions_storage_desc), + granted = storagePermsGranted, + required = true, + icon = painterResource(R.drawable.ic_save), + onClick = onGrantStoragePerms, + ) + } + + item(key = "PERMS_NOTIFICATIONS", contentType = "PERMISSION_BUTTON") { + PermissionButton( + name = stringResource(R.string.permissions_notifs_title), + description = stringResource(R.string.permissions_notifs_desc), + granted = notificationsPermsGranted, + required = false, + icon = painterResource(R.drawable.ic_bell), + onClick = onGrantNotificationsPerms, + ) + } + + item(key = "PERMS_BATTERY", contentType = "PERMISSION_BUTTON") { + PermissionButton( + name = stringResource(R.string.permissions_battery_title), + description = stringResource(R.string.permissions_battery_desc), + granted = batteryPermsGranted, + required = false, + icon = painterResource(R.drawable.ic_battery_settings), + onClick = onGrantBatteryPerms, + ) + } + + item(key = "LEGEND") { + Text( + text = stringResource(R.string.permissions_legend, "*"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .padding(horizontal = 32.dp, vertical = 12.dp) + .fillMaxWidth(), + ) + } + + item(key = "CONTINUE") { + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, end = 32.dp), + ) { + FilledTonalButton( + onClick = onContinue, + enabled = canContinue, + ) { + Text(stringResource(R.string.action_continue)) + } + } + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/components/PermissionButton.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/components/PermissionButton.kt new file mode 100644 index 0000000..b4c5d41 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/components/PermissionButton.kt @@ -0,0 +1,77 @@ +package com.meowarex.rlmobile.ui.screens.permissions.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.meowarex.rlmobile.R + +@Composable +fun PermissionButton( + name: String, + description: String, + granted: Boolean, + required: Boolean, + icon: Painter, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = modifier + .heightIn(min = 64.dp) + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 8.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Companion.CenterVertically, + ) { + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + + ProvideTextStyle(MaterialTheme.typography.titleSmall) { + Text(name) + + if (required) { + Text( + text = "*", + color = MaterialTheme.colorScheme.error, + fontSize = 10.sp, + ) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Companion.CenterVertically, + ) { + ProvideTextStyle( + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface.copy(0.6f), + ), + ) { + Text( + text = description, + modifier = Modifier.weight(1f), + ) + } + + OutlinedButton( + onClick = onClick, + enabled = !granted, + ) { + Text(stringResource(if (granted) R.string.permissions_granted else R.string.permissions_grant)) + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/components/PermissionsAppBar.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/components/PermissionsAppBar.kt new file mode 100644 index 0000000..de21f6b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/permissions/components/PermissionsAppBar.kt @@ -0,0 +1,45 @@ +package com.meowarex.rlmobile.ui.screens.permissions.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.navigator.LocalNavigator +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen + +@Composable +fun PermissionsAppBar() { + LargeTopAppBar( + title = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(start = 12.dp), + ) { + Text( + text = stringResource(R.string.permissions_title), + style = MaterialTheme.typography.displaySmall, + ) + Text( + text = stringResource(R.string.permissions_subtitle), + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface.copy(.6f), + ), + ) + } + }, + actions = { + val navigator = LocalNavigator.current + + IconButton(onClick = { navigator?.push(SettingsScreen()) }) { + Icon( + painter = painterResource(R.drawable.ic_settings), + contentDescription = stringResource(R.string.navigation_settings) + ) + } + } + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsModel.kt new file mode 100644 index 0000000..3e44bd3 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsModel.kt @@ -0,0 +1,279 @@ +package com.meowarex.rlmobile.ui.screens.plugins + +import android.app.Application +import android.util.Log +import androidx.compose.runtime.* +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem +import com.meowarex.rlmobile.ui.screens.plugins.model.PluginManifest +import com.meowarex.rlmobile.ui.util.emptyImmutableList +import com.meowarex.rlmobile.ui.util.toUnsafeImmutable +import com.meowarex.rlmobile.util.* +import com.github.diamondminer88.zip.ZipReader +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.plus +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* +import java.io.File +import kotlin.time.Duration + +class PluginsModel( + private val context: Application, + private val paths: PathManager, + private val json: Json, +) : ScreenModel { + private val plugins = MutableStateFlow>(emptyImmutableList()) + + var error by mutableStateOf(false) + private set + + var showChangelogDialog by mutableStateOf(null) + private set + + var showUninstallDialog by mutableStateOf(null) + private set + + val searchText: StateFlow + field = MutableStateFlow("") + + var pluginsSafeMode = MutableStateFlow(false) + private set + + val filteredPlugins: StateFlow> = searchText + .combine(plugins) { searchText, plugins -> + if (searchText.isBlank()) { + plugins + } else { + plugins.filter { plugin -> + plugin.manifest.name.contains(searchText, ignoreCase = true) + || plugin.manifest.description.contains(searchText, ignoreCase = true) + || plugin.manifest.authors.any { (name) -> name.contains(searchText, ignoreCase = true) } + }.toUnsafeImmutable() + } + }.stateIn( + scope = screenModelScope + Dispatchers.Default, + started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO), + initialValue = plugins.value, + ) + + // ---- State setters ---- // + + fun setSearchText(search: String) { + searchText.value = search + } + + fun showChangelogDialog(plugin: PluginItem) { + showChangelogDialog = plugin + } + + fun hideChangelogDialog() { + showChangelogDialog = null + } + + fun showUninstallDialog(plugin: PluginItem) { + showUninstallDialog = plugin + } + + fun hideUninstallDialog() { + showUninstallDialog = null + } + + // ---- IO state setters ---- // + + fun uninstallPlugin(plugin: PluginItem) = screenModelScope.launchIO { + if (!plugins.value.any { it.path == plugin.path }) { + mainThread { hideUninstallDialog() } + return@launchIO + } + + val deleteSuccess = try { + File(plugin.path).delete() + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to delete plugin", t) + false + } + + if (!deleteSuccess) { + mainThread { + hideUninstallDialog() + context.showToast(R.string.plugins_error) + } + return@launchIO + } + + plugins.update { (it - plugin).toUnsafeImmutable() } + mainThread { hideUninstallDialog() } + } + + fun setPluginEnabled(pluginName: String, enabled: Boolean) = screenModelScope.launchIO { + try { + editTidalSettings { + put(JsonPrimitive("AC_PM_$pluginName"), JsonPrimitive(enabled)) + } + mainThread { + plugins.value.forEach { + if (it.manifest.name == pluginName) + it.enabled = enabled + } + } + } catch (e: Exception) { + Log.e(BuildConfig.TAG, "Failed to toggle plugin", e) + mainThread { context.showToast(R.string.status_failed) } + } + } + + fun setSafeMode(safeMode: Boolean) = screenModelScope.launchIO { + try { + editTidalSettings { + put(JsonPrimitive("RL_safe_mode_enabled"), JsonPrimitive(safeMode)) + } + pluginsSafeMode.value = safeMode + } catch (e: Exception) { + Log.e(BuildConfig.TAG, "Failed to toggle plugin", e) + mainThread { context.showToast(R.string.status_failed) } + } + } + + // ---- State loading ---- // + + // Called by screen to load initial data + fun refreshData() = screenModelScope.launchIO { + try { + loadSafeMode() + loadPlugins() + loadPluginsEnabled() + } catch (e: Exception) { + Log.e(BuildConfig.TAG, "Failed to load plugins state", e) + mainThread { + context.showToast(R.string.plugins_error) + error = true + } + } + } + + private fun loadSafeMode() = screenModelScope.launchIO { + @Serializable + data class SafeModeSettings( + @SerialName("RL_safe_mode_enabled") + val safeMode: Boolean = false, + ) + + pluginsSafeMode.value = readTidalSettings()?.safeMode ?: false + } + + private suspend fun loadPluginsEnabled() { + val pluginToggles = readTidalSettings>() + ?.filterKeys { it.isString && it.content.startsWith("AC_PM_") } + ?.filterValues { (it as? JsonPrimitive)?.booleanOrNull == true } + ?.mapKeys { (key, _) -> key.content.substring("AC_PM_".length) } + ?.mapValues { (_, value) -> value.jsonPrimitive.boolean } + + if (pluginToggles != null) mainThread { + plugins.value.forEach { + if (!pluginToggles.getOrDefault(it.manifest.name, true)) + it.enabled = false + } + } + } + + private fun loadPlugins() { + if (!paths.pluginsDir.exists() && !paths.pluginsDir.mkdirs()) + throw IllegalStateException("Failed to create plugins directory") + + val pluginFiles = paths.pluginsDir.listFiles { file -> file.extension == "zip" } + ?: throw IllegalStateException("Failed to read plugins directory") + + val pluginItems = pluginFiles + .mapNotNull { + try { + PluginItem( + manifest = loadPluginManifest(it), + path = it.absolutePath, + ) + } catch (e: Exception) { + Log.e(BuildConfig.TAG, "Failed to load plugin at ${it.absolutePath}", e) + null + } + } + .sortedBy { it.manifest.name } + + plugins.value = pluginItems.toUnsafeImmutable() + } + + private fun loadPluginManifest(pluginFile: File): PluginManifest { + return ZipReader(pluginFile).use { + val manifest = it.openEntry("manifest.json") + ?: throw Exception("Plugin ${pluginFile.name} has no manifest") + + try { + json.decodeFromStream(manifest.read().inputStream()) + } catch (t: Throwable) { + throw Exception("Failed to parse plugin manifest for ${pluginFile.name}", t) + } + } + } + + // ---- Radiant Lyrics settings ---- // + + /** + * Reads Radiant Lyrics core's settings, applies [block] to it, and writes it back. + */ + private suspend fun editTidalSettings(block: (MutableMap).() -> Unit) { + SETTINGS_MUTEX.withLock { + val settings = try { + if (paths.coreSettingsFile.exists()) { + json.decodeFromStream>(paths.coreSettingsFile.inputStream()) + } else { + mutableMapOf() + } + } catch (e: Exception) { + Log.e(BuildConfig.TAG, "Radiant Lyrics settings are corrupted!", e) + mutableMapOf() + } + + // Apply modifier block + block(settings) + + paths.coreSettingsFile.parentFile!!.mkdirs() + paths.coreSettingsFile.outputStream() + .use { out -> json.encodeToStream(settings, out) } + } + } + + /** + * Reads Radiant Lyrics core's settings and parses it into a specific model. + * This should not be used for future writes. + * + * @return The parsed settings model, or null if settings are missing or corrupt. + */ + private suspend inline fun readTidalSettings(): T? { + return SETTINGS_MUTEX.withLock { + try { + if (paths.coreSettingsFile.exists()) { + json.decodeFromStream(paths.coreSettingsFile.inputStream()) + } else { + null + } + } catch (e: Exception) { + Log.e(BuildConfig.TAG, "Radiant Lyrics settings are corrupted!", e) + null + } + } + } + + private companion object { + /** + * Global lock on the main Radiant Lyrics settings. + */ + private val SETTINGS_MUTEX = Mutex() + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsScreen.kt new file mode 100644 index 0000000..caff00b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/PluginsScreen.kt @@ -0,0 +1,152 @@ +package com.meowarex.rlmobile.ui.screens.plugins + +import android.os.Parcelable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.components.BackButton +import com.meowarex.rlmobile.ui.components.settings.SettingsSwitch +import com.meowarex.rlmobile.ui.screens.plugins.components.* +import com.meowarex.rlmobile.ui.screens.plugins.components.dialogs.UninstallPluginDialog +import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem +import com.meowarex.rlmobile.ui.util.paddings.* +import kotlinx.collections.immutable.ImmutableList +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +class PluginsScreen : Screen, Parcelable { + @IgnoredOnParcel + override val key = "Plugins" + + @Composable + override fun Content() { + val model = koinScreenModel() + + // Refresh plugins list on activity resume or when this initially opens + LifecycleResumeEffect(Unit) { + model.refreshData() + + onPauseOrDispose {} + } + + model.showUninstallDialog?.let { plugin -> + UninstallPluginDialog( + pluginName = plugin.manifest.name, + onConfirm = { model.uninstallPlugin(plugin) }, + onDismiss = model::hideUninstallDialog + ) + } + + model.showChangelogDialog?.let { plugin -> + Changelog( + plugin = plugin, + onDismiss = model::hideChangelogDialog + ) + } + + PluginsScreenContent( + searchText = model.searchText.collectAsState(), + setSearchText = model::setSearchText, + isError = model.error, + plugins = model.filteredPlugins.collectAsState().value, + onPluginUninstall = model::showUninstallDialog, + onPluginChangelog = model::showChangelogDialog, + onPluginToggle = model::setPluginEnabled, + safeMode = model.pluginsSafeMode.collectAsState().value, + setSafeMode = model::setSafeMode, + ) + } +} + +@Composable +fun PluginsScreenContent( + searchText: State, + setSearchText: (String) -> Unit, + isError: Boolean, + plugins: ImmutableList, + onPluginUninstall: (PluginItem) -> Unit, + onPluginChangelog: (PluginItem) -> Unit, + onPluginToggle: (name: String, enabled: Boolean) -> Unit, + safeMode: Boolean, + setSafeMode: (Boolean) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.plugins_title)) }, + navigationIcon = { BackButton() }, + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues.exclude(PaddingValuesSides.Bottom)), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SettingsSwitch( + label = stringResource(R.string.plugins_safe_mode_title), + secondaryLabel = stringResource(R.string.plugins_safe_mode_desc), + icon = { Icon(painterResource(R.drawable.ic_security), null) }, + pref = safeMode, + onPrefChange = setSafeMode + ) + + Column( + modifier = Modifier.padding(horizontal = 20.dp) + ) { + PluginSearch( + currentFilter = searchText, + onFilterChange = setSearchText, + modifier = Modifier + .fillMaxWidth() + ) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = paddingValues + .exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top) + .add(PaddingValues(vertical = 12.dp)), + modifier = Modifier.fillMaxSize(), + ) { + when { + isError -> item(key = "ERROR") { + PluginsError(modifier = Modifier.fillParentMaxSize()) + } + + plugins.isNotEmpty() -> { + items( + items = plugins, + contentType = { "PLUGIN" }, + key = { it.path }, + ) { plugin -> + PluginCard( + plugin = plugin, + onClickDelete = { onPluginUninstall(plugin) }, + onClickShowChangelog = { onPluginChangelog(plugin) }, + onSetEnabled = { onPluginToggle(plugin.manifest.name, it) }, + ) + } + + } + + else -> item("PLUGINS_NONE") { + PluginsNone(Modifier.fillParentMaxSize()) + } + } + } + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/Changelog.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/Changelog.kt new file mode 100644 index 0000000..16729d4 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/Changelog.kt @@ -0,0 +1,154 @@ +package com.meowarex.rlmobile.ui.screens.plugins.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem + +private val hyperLinkPattern = Regex("\\[(.+?)]\\((.+?\\))") + +@Suppress("RegExpRedundantEscape") // It is very much not redundant and causes a crash lol +private val headerStylePattern = Regex("\\{(improved|added|fixed)( marginTop)?\\}") + +@Composable +private fun AnnotatedString.Builder.MarkdownHyperlink(content: String) { + var idx = 0 + + with(hyperLinkPattern.toPattern().matcher(content)) { + while (find()) { + val start = start() + val end = end() + val title = group(1)!! + val url = group(2)!! + + append(content.substring(idx, start)) + + // @formatter:off + pushLink(LinkAnnotation.Url( + url, + TextLinkStyles(SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + )) + )) + append(title) + pop() + // @formatter:on + + idx = end + } + } + + if (idx < content.length) append(content.substring(idx)) +} + +@Composable +fun Changelog( + plugin: PluginItem, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + painter = painterResource(R.drawable.ic_history), + contentDescription = stringResource(R.string.plugins_view_changelog, plugin.manifest.name) + ) + }, + title = { Text(plugin.manifest.name) }, + text = { + Column { + plugin.manifest.changelogMedia?.let { mediaUrl -> + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 90.dp) + .clip(RoundedCornerShape(14.dp)), + model = mediaUrl, + contentDescription = stringResource(R.string.plugins_changelog_media) + ) + } + + LazyColumn { + items(plugin.manifest.changelog!!.lines()) { + var line = it.trim() + + if (line.isNotEmpty()) { + when (line[0]) { + '#' -> { + do { + line = line.substring(1) + } while (line.startsWith("#")) + + Text( + text = line.trimStart(), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp, bottom = 6.dp) + ) + } + + '*' -> { + Text( + modifier = Modifier.padding(bottom = 2.dp), + text = buildAnnotatedString { + withStyle( + SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + ) { + append("● ") + } + + MarkdownHyperlink(line.substring(1)) + } + ) + } + + else -> { + when { + line.endsWith("marginTop}") -> { + val color = MaterialTheme.colorScheme.onSurface + + Text( + text = line, + fontWeight = FontWeight.Bold, + color = color, + modifier = Modifier.padding(top = 16.dp, bottom = 6.dp) + ) + } + + line.all { c -> c == '=' } -> {} // Tidal ignores ======= + else -> { + Text(buildAnnotatedString { + MarkdownHyperlink(line) + }) + } + } + } + } + } + } + } + } + }, + confirmButton = { + Button(onClick = onDismiss) { + Text(stringResource(R.string.action_close)) + } + } + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginCard.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginCard.kt new file mode 100644 index 0000000..50e1671 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginCard.kt @@ -0,0 +1,150 @@ +package com.meowarex.rlmobile.ui.screens.plugins.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem + +@Composable +fun PluginCard( + plugin: PluginItem, + onClickDelete: () -> Unit, + onClickShowChangelog: () -> Unit, + onSetEnabled: (Boolean) -> Unit, +) { + val uriHandler = LocalUriHandler.current + + ElevatedCard { + // Header + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onSetEnabled(!plugin.enabled) } + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 14.dp), + ) { + Column { + // Name + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(plugin.manifest.name) + } + append(" v") + append(plugin.manifest.version) + } + ) + + // Authors + val authors = buildAnnotatedString { + withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { + for ((idx, author) in plugin.manifest.authors.withIndex()) { + if (idx > 0) append(", ") + + if (author.hyperlink) pushLink( + LinkAnnotation.Url( + url = author.socialUrl, + styles = TextLinkStyles( + SpanStyle( + textDecoration = TextDecoration.Underline, + ) + ), + ) + ) + append(author.name) + if (author.hyperlink) pop() + } + } + } + Text( + text = authors, + style = MaterialTheme.typography.labelLarge, + ) + } + + Spacer(Modifier.weight(1f, true)) + + // Toggle Switch + Switch( + checked = plugin.enabled, + onCheckedChange = { onSetEnabled(!plugin.enabled) } + ) + } + + HorizontalDivider( + modifier = Modifier + .alpha(0.3f) + .padding(horizontal = 16.dp) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Description + Text( + text = plugin.manifest.description, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .heightIn(max = 150.dp, min = 40.dp) + .padding(bottom = 20.dp), + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + plugin.manifest.repositoryUrl?.let { repositoryUrl -> + IconButton( + onClick = { uriHandler.openUri(repositoryUrl) }, + modifier = Modifier.size(25.dp), + ) { + Icon( + modifier = Modifier.fillMaxSize(), + painter = painterResource(R.drawable.ic_account_github_white_24dp), + contentDescription = stringResource(R.string.github) + ) + } + } + + if (plugin.manifest.changelog != null) { + IconButton( + onClick = onClickShowChangelog, + modifier = Modifier.size(25.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_history), + contentDescription = stringResource(R.string.plugins_view_changelog), + modifier = Modifier.fillMaxSize(), + ) + } + } + + Spacer(Modifier.weight(1f, true)) + + IconButton( + onClick = onClickDelete, + modifier = Modifier.size(25.dp), + ) { + Icon( + modifier = Modifier.fillMaxSize(), + painter = painterResource(R.drawable.ic_delete_forever), + contentDescription = stringResource(R.string.action_uninstall), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginSearch.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginSearch.kt new file mode 100644 index 0000000..07e2a29 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginSearch.kt @@ -0,0 +1,46 @@ +package com.meowarex.rlmobile.ui.screens.plugins.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.components.ResetToDefaultButton + +@Composable +fun PluginSearch( + currentFilter: State, + onFilterChange: (String) -> Unit, + modifier: Modifier = Modifier.Companion, +) { + val focusManager = LocalFocusManager.current + + OutlinedTextField( + value = currentFilter.value, + onValueChange = onFilterChange, + singleLine = true, + shape = MaterialTheme.shapes.medium, + label = { Text(stringResource(R.string.action_search)) }, + trailingIcon = { + val isFilterBlank by remember { derivedStateOf { currentFilter.value.isEmpty() } } + + ResetToDefaultButton( + enabled = !isFilterBlank, + onClick = { onFilterChange("") }, + modifier = Modifier.padding(end = 4.dp), + ) + }, + keyboardOptions = KeyboardOptions( + autoCorrectEnabled = false, + imeAction = ImeAction.Companion.Search + ), + keyboardActions = KeyboardActions { focusManager.clearFocus() }, + modifier = modifier, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsError.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsError.kt new file mode 100644 index 0000000..4fd02c9 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsError.kt @@ -0,0 +1,35 @@ +package com.meowarex.rlmobile.ui.screens.plugins.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R + +@Composable +fun PluginsError(modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_warning), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = stringResource(R.string.plugins_error), + color = MaterialTheme.colorScheme.error, + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsNone.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsNone.kt new file mode 100644 index 0000000..3350f64 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/PluginsNone.kt @@ -0,0 +1,31 @@ +package com.meowarex.rlmobile.ui.screens.plugins.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Icon +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.painterResource +import androidx.compose.ui.res.stringResource +import com.meowarex.rlmobile.R + +@Composable +fun PluginsNone(modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(R.drawable.ic_extension_off), + contentDescription = null + ) + Text(stringResource(R.string.plugins_none_installed)) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/dialogs/UninstallPluginDialog.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/dialogs/UninstallPluginDialog.kt new file mode 100644 index 0000000..e391686 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/components/dialogs/UninstallPluginDialog.kt @@ -0,0 +1,56 @@ +package com.meowarex.rlmobile.ui.screens.plugins.components.dialogs + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R + +@Composable +fun UninstallPluginDialog( + pluginName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + painter = painterResource(R.drawable.ic_delete_forever), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + }, + title = { + Text(stringResource(R.string.plugins_delete_plugin, pluginName)) + }, + text = { + Text( + text = stringResource(R.string.plugins_delete_plugin_body, pluginName), + textAlign = TextAlign.Center, + ) + }, + confirmButton = { + Button( + onClick = onConfirm, + ) { + Text(stringResource(R.string.action_confirm)) + } + }, + dismissButton = { + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Text(stringResource(R.string.action_cancel)) + } + } + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginItem.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginItem.kt new file mode 100644 index 0000000..f92b078 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginItem.kt @@ -0,0 +1,12 @@ +package com.meowarex.rlmobile.ui.screens.plugins.model + +import androidx.compose.runtime.* + +@Stable +data class PluginItem( + val manifest: PluginManifest, + val path: String, +) { + // Plugins are enabled by default unless disabled in Radiant Lyrics settings + var enabled by mutableStateOf(true) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginManifest.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginManifest.kt new file mode 100644 index 0000000..e9f84a5 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/plugins/model/PluginManifest.kt @@ -0,0 +1,36 @@ +package com.meowarex.rlmobile.ui.screens.plugins.model + +import androidx.compose.runtime.Immutable +import com.meowarex.rlmobile.util.serialization.ImmutableListSerializer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class PluginManifest( + val name: String, + @Serializable(with = ImmutableListSerializer::class) + val authors: ImmutableList, + val description: String, + val version: String, + val updateUrl: String?, + val changelog: String?, + val changelogMedia: String?, +) { + val repositoryUrl: String? + get() = updateUrl?.replaceFirst( + "https://(raw\\.githubusercontent\\.com|cdn\\.jsdelivr\\.net/gh)/([^/]+)/([^/@]+).*".toRegex(), + "https://github.com/$2/$3" + ) + + @Immutable + @Serializable + data class Author( + val name: String, + val id: Long, + val hyperlink: Boolean = true, + ) { + val socialUrl: String + get() = "https://tidal.com/users/$id" + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsModel.kt new file mode 100644 index 0000000..6cedacc --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsModel.kt @@ -0,0 +1,115 @@ +package com.meowarex.rlmobile.ui.screens.settings + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.util.Log +import androidx.compose.runtime.* +import androidx.core.content.FileProvider +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.di.ActivityProvider +import com.meowarex.rlmobile.manager.* +import com.meowarex.rlmobile.ui.theme.Theme +import com.meowarex.rlmobile.util.* + +class SettingsModel( + private val application: Application, + private val activities: ActivityProvider, + private val paths: PathManager, + val preferences: PreferencesManager, +) : ScreenModel { + val installInfo = InstallInfo + + var patchedApkExists by mutableStateOf(paths.patchedApk.exists()) + private set + var showThemeDialog by mutableStateOf(false) + private set + var showInstallersDialog by mutableStateOf(false) + private set + + fun showThemeDialog() { + showThemeDialog = true + } + + fun hideThemeDialog() { + showThemeDialog = false + } + + fun showInstallersDialog() { + showInstallersDialog = true + } + + fun hideInstallersDialog() { + showInstallersDialog = false + } + + fun setTheme(theme: Theme) { + preferences.theme = theme + } + + fun setInstaller(installer: InstallerSetting) { + preferences.installer = installer + } + + fun setKeepPatchedApks(value: Boolean) { + preferences.keepPatchedApks = value + } + + fun clearCache() = screenModelScope.launchIO { + paths.clearCache() + + mainThread { + patchedApkExists = false + application.showToast(R.string.action_cleared_cache) + } + } + + fun copyInstallInfo() { + application.copyToClipboard(installInfo) + application.showToast(R.string.action_copied) + } + + fun shareApk() { + val file = paths.patchingWorkingDir.resolve("patched.apk") + val fileUri = FileProvider.getUriForFile( + /* context = */ application, + /* authority = */ "${BuildConfig.APPLICATION_ID}.provider", + /* file = */ file, + /* displayName = */ "RadiantLyrics.apk", + ) + + val intent = Intent(Intent.ACTION_SEND) + .setType("application/vnd.android.package-archive") + .putExtra(Intent.EXTRA_STREAM, fileUri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .let { + Intent.createChooser( + /* target = */ it, + /* title = */ application.getString(R.string.log_action_export_apk), + ) + } + + try { + activities.get().startActivity(intent) + } catch (t: Throwable) { + Log.w(BuildConfig.TAG, "Failed to share APK", t) + application.showToast(R.string.status_failed) + } + } + + companion object { + @Suppress("KotlinConstantConditions") + private val InstallInfo: String = """ + Radiant Lyrics Manager + Version: ${BuildConfig.VERSION_NAME} + Version Code: ${BuildConfig.VERSION_CODE} + Release: ${if (BuildConfig.RELEASE) "Yes" else "No"} + Git Branch: ${BuildConfig.GIT_BRANCH} + Git Commit: ${BuildConfig.GIT_COMMIT} + Git Changes: ${if (BuildConfig.GIT_LOCAL_CHANGES) "Yes" else "No"} + """.trimIndent() + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..80104cd --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,217 @@ +package com.meowarex.rlmobile.ui.screens.settings + +import android.os.Build +import android.os.Parcelable +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.components.BackButton +import com.meowarex.rlmobile.ui.components.MainActionButton +import com.meowarex.rlmobile.ui.components.settings.* +import com.meowarex.rlmobile.ui.screens.settings.components.InstallersDialog +import com.meowarex.rlmobile.ui.screens.settings.components.ThemeDialog +import com.meowarex.rlmobile.ui.util.paddings.* +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +class SettingsScreen : Screen, Parcelable { + @IgnoredOnParcel + override val key = "Settings" + + @Composable + override fun Content() { + val model = koinScreenModel() + var clearedCache by rememberSaveable { mutableStateOf(false) } + val preferences = model.preferences + + if (model.showThemeDialog) { + ThemeDialog( + currentTheme = preferences.theme, + onDismiss = model::hideThemeDialog, + onConfirm = model::setTheme + ) + } + + if (model.showInstallersDialog) { + InstallersDialog( + currentInstaller = preferences.installer, + onDismiss = model::hideInstallersDialog, + onConfirm = model::setInstaller, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.navigation_settings)) }, + navigationIcon = { BackButton() }, + ) + }, + ) { padding -> + LazyColumn( + contentPadding = padding + .exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top) + .add(PaddingValues(bottom = 12.dp)), + modifier = Modifier + .fillMaxSize() + .padding(padding.exclude(PaddingValuesSides.Bottom)) + ) { + item(key = "HEADER_APPEARANCE", contentType = "DIVIDER") { + SettingsHeader(stringResource(R.string.settings_header_appearance)) + } + + item(key = "SETTING_THEME") { + SettingsItem( + modifier = Modifier.clickable(onClick = model::showThemeDialog), + icon = { Icon(painterResource(R.drawable.ic_brush), null) }, + text = { Text(stringResource(R.string.setting_theme)) }, + secondaryText = { Text(stringResource(R.string.setting_theme_desc)) } + ) { + FilledTonalButton(onClick = model::showThemeDialog) { + Text(preferences.theme.toDisplayName()) + } + } + } + + // Material You theming on Android 12+ + if (Build.VERSION.SDK_INT >= 31) { + item(key = "SETTING_DYNAMIC_COLOR", contentType = "SETTING_SWITCH") { + SettingsSwitch( + label = stringResource(R.string.setting_dynamic_color), + secondaryLabel = stringResource(R.string.setting_dynamic_color_desc), + pref = preferences.dynamicColor, + icon = { Icon(painterResource(R.drawable.ic_palette), null) }, + onPrefChange = { preferences.dynamicColor = it }, + ) + } + } + + item(key = "HEADER_INSTALL", contentType = "DIVIDER") { + SettingsHeader(stringResource(R.string.settings_header_installation)) + } + + item(key = "SETTING_INSTALLER") { + SettingsItem( + text = { Text(stringResource(R.string.setting_installer)) }, + secondaryText = { Text(stringResource(R.string.setting_installer_desc)) }, + icon = { Icon(painterResource(R.drawable.ic_apk_install), null) }, + modifier = Modifier.clickable(onClick = model::showInstallersDialog), + ) { + FilledTonalButton(onClick = model::showInstallersDialog) { + Icon( + painter = preferences.installer.icon(), + contentDescription = null, + modifier = Modifier + .size(20.dp) + .padding(end = 6.dp), + ) + Text(preferences.installer.title()) + } + } + } + + item(key = "SETTING_KEEP_APK", contentType = "SETTING_SWITCH") { + SettingsSwitch( + label = stringResource(R.string.setting_keep_patched_apks), + secondaryLabel = stringResource(R.string.setting_keep_patched_apks_desc), + icon = { Icon(painterResource(R.drawable.ic_delete_forever), null) }, + pref = preferences.keepPatchedApks, + onPrefChange = { model.setKeepPatchedApks(it) }, + ) + } + + if (preferences.keepPatchedApks) { + item(key = "BUTTON_EXPORT_APK", contentType = "BUTTON") { + MainActionButton( + text = stringResource(R.string.settings_export_apk), + icon = painterResource(R.drawable.ic_save), + enabled = model.patchedApkExists, + onClick = model::shareApk, + modifier = Modifier + .padding(start = 32.dp, end = 32.dp, top = 16.dp) + .fillMaxWidth() + .animateItem(), + ) + } + } + + item(key = "BUTTON_CACHE_CLEAR", contentType = "BUTTON") { + MainActionButton( + text = stringResource(R.string.settings_clear_cache), + icon = painterResource(R.drawable.ic_delete_forever), + enabled = !clearedCache, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + onClick = { + clearedCache = true + model.clearCache() + }, + modifier = Modifier + .padding(start = 32.dp, end = 32.dp, top = 18.dp) + .fillMaxWidth() + ) + } + + item(key = "HEADER_ADVANCED", contentType = "DIVIDER") { + SettingsHeader(stringResource(R.string.settings_header_advanced)) + } + + item(key = "SETTING_DEVMODE", contentType = "SETTING_SWITCH") { + SettingsSwitch( + label = stringResource(R.string.setting_developer_options), + secondaryLabel = stringResource(R.string.setting_developer_options_desc), + pref = preferences.devMode, + icon = { Icon(painterResource(R.drawable.ic_code), null) }, + onPrefChange = { preferences.devMode = it }, + ) + } + + item(key = "HEADER_INFO", contentType = "DIVIDER") { + SettingsHeader(stringResource(R.string.settings_header_info)) + } + + item(key = "INFO") { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 36.dp, vertical = 12.dp), + ) { + Text( + text = model.installInfo, + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurface.copy(0.6f), + ), + ) + + Spacer(Modifier.weight(1f, fill = true)) + + IconButton(onClick = model::copyInstallInfo) { + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = stringResource(R.string.action_copy), + modifier = Modifier + .size(28.dp) + .alpha(.8f), + ) + } + } + } + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/components/InstallersDialog.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/components/InstallersDialog.kt new file mode 100644 index 0000000..2e0bd7f --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/components/InstallersDialog.kt @@ -0,0 +1,180 @@ +package com.meowarex.rlmobile.ui.screens.settings.components + +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.manager.* +import com.meowarex.rlmobile.util.showToast +import com.topjohnwu.superuser.Shell +import org.koin.compose.koinInject + +@Composable +fun InstallersDialog( + currentInstaller: InstallerSetting, + onDismiss: () -> Unit, + onConfirm: (InstallerSetting) -> Unit, +) { + val context = LocalContext.current + val shizuku = koinInject() + val dhizuku = koinInject() + + var shizukuAvailable by remember { mutableStateOf(false) } + var dhizukuAvailable by remember { mutableStateOf(false) } + var selectedInstaller by rememberSaveable { mutableStateOf(currentInstaller) } + + LaunchedEffect(Unit) { + shizukuAvailable = shizuku.shizukuAvailable() + dhizukuAvailable = dhizuku.dhizukuAvailable() + } + + // Check if selected installer is usable and ask for permissions when necessary + LaunchedEffect(selectedInstaller) { + when (selectedInstaller) { + InstallerSetting.PackageInstaller -> { + // Once the Google sideloading block is in place, + // check whether it is applicable to the device, and if so then it needs + // to be inaccessible. (Disable button) + } + + InstallerSetting.Root -> { + val shell = Shell.getShell() + if (!shell.isRoot) { + shell.waitAndClose() + Shell.getShell() + } + + if (Shell.isAppGrantedRoot() != true) { + context.showToast(R.string.permissions_root_denied) + selectedInstaller = InstallerSetting.PackageInstaller + } + } + + InstallerSetting.Intent -> { + // We don't know whether this device supports this method until we try. + } + + InstallerSetting.Shizuku -> { + if (!shizuku.requestPermissions()) { + selectedInstaller = InstallerSetting.PackageInstaller + } + } + + InstallerSetting.Dhizuku -> { + if (!dhizuku.requestPermissions()) { + selectedInstaller = InstallerSetting.PackageInstaller + } + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + painter = painterResource(R.drawable.ic_apk_install), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + }, + title = { Text(stringResource(R.string.setting_installer)) }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + for (installer in InstallerSetting.entries) key(installer) { + InstallerItem( + installer = installer, + selected = installer == selectedInstaller, + enabled = when (installer) { + InstallerSetting.PackageInstaller -> true + InstallerSetting.Root -> true + InstallerSetting.Intent -> true + InstallerSetting.Shizuku -> shizukuAvailable + InstallerSetting.Dhizuku -> dhizukuAvailable + }, + onClick = { selectedInstaller = installer }, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + onConfirm(selectedInstaller) + onDismiss() + }, + ) { + Text(stringResource(R.string.action_apply)) + } + }, + ) +} + +@Composable +private fun InstallerItem( + installer: InstallerSetting, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + val interactionSource = remember(::MutableInteractionSource) + + Row( + verticalAlignment = Alignment.Companion.CenterVertically, + modifier = Modifier.Companion + .clickable( + indication = null, + interactionSource = interactionSource, + onClick = onClick, + ) + .clip(MaterialTheme.shapes.medium) + .padding(horizontal = 6.dp, vertical = 8.dp), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Companion.CenterVertically, + ) { + Icon( + painter = installer.icon(), + contentDescription = null, + modifier = Modifier + .size(20.dp) + ) + Text( + text = installer.title(), + style = MaterialTheme.typography.labelLarge + .copy(fontSize = 14.sp) + ) + } + Text( + text = installer.description(), + style = MaterialTheme.typography.labelMedium.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .6f), + ), + ) + } + + Spacer(Modifier.Companion.weight(0.05f, true)) + + RadioButton( + selected = selected, + enabled = enabled, + onClick = onClick, + interactionSource = interactionSource, + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/components/ThemeDialog.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/components/ThemeDialog.kt new file mode 100644 index 0000000..b5fcf7a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/components/ThemeDialog.kt @@ -0,0 +1,89 @@ +package com.meowarex.rlmobile.ui.screens.settings.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.theme.Theme + +@Composable +fun ThemeDialog( + currentTheme: Theme, + onDismiss: () -> Unit, + onConfirm: (Theme) -> Unit, +) { + var selectedTheme by rememberSaveable { mutableStateOf(currentTheme) } + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + painter = painterResource(R.drawable.ic_brush), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + }, + title = { Text(stringResource(R.string.setting_theme)) }, + text = { + Column { + for (theme in Theme.entries) key(theme) { + val interactionSource = remember(::MutableInteractionSource) + + Row( + verticalAlignment = Alignment.Companion.CenterVertically, + modifier = Modifier.Companion + .clickable( + indication = null, + interactionSource = interactionSource, + onClick = { selectedTheme = theme }, + ) + .clip(MaterialTheme.shapes.medium) + .padding(horizontal = 6.dp, vertical = 8.dp), + ) { + Icon( + painter = theme.toPainter(), + contentDescription = null, + modifier = Modifier + .padding(end = 14.dp) + .size(26.dp), + ) + + Text( + text = theme.toDisplayName(), + style = MaterialTheme.typography.labelLarge + .copy(fontSize = 14.sp) + ) + + Spacer(Modifier.Companion.weight(1f, true)) + + RadioButton( + selected = theme == selectedTheme, + onClick = { selectedTheme = theme }, + interactionSource = interactionSource, + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + onConfirm(selectedTheme) + onDismiss() + }, + ) { + Text(stringResource(R.string.action_apply)) + } + }, + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/theme/Theme.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/theme/Theme.kt new file mode 100644 index 0000000..c246eb6 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/theme/Theme.kt @@ -0,0 +1,104 @@ +package com.meowarex.rlmobile.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.* +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 com.meowarex.rlmobile.R +import com.google.accompanist.systemuicontroller.rememberSystemUiController + +@Composable +fun ManagerTheme( + theme: Theme = Theme.System, + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val dynamicColor = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + val isBlack = theme == Theme.Black + val isDark = when (theme) { + Theme.System -> isSystemInDarkTheme() + Theme.Light -> false + Theme.Dark, Theme.Black -> true + } + + val baseScheme = when { + dynamicColor && isDark -> dynamicDarkColorScheme(context) + dynamicColor -> dynamicLightColorScheme(context) + isDark -> darkColorScheme() + else -> lightColorScheme() + } + val colorScheme = when (isBlack) { + true -> baseScheme.toPitchBlack() + false -> baseScheme + } + val customColors = when (isDark) { + true -> DarkCustomColors + false -> LightCustomColors + } + + // As usual, Google deprecates accompanist libraries and replaces them with an incomplete and shitty replacement in androidx + // enableEdgeToEdge() does not work for our use case. + @Suppress("DEPRECATION") + val systemUiController = rememberSystemUiController() + + SideEffect { + systemUiController.setSystemBarsColor( + color = colorScheme.background, + darkIcons = !isDark, + ) + systemUiController.setNavigationBarColor( + color = Color.Transparent, + ) + } + + CompositionLocalProvider(LocalCustomColors provides customColors) { + MaterialTheme( + colorScheme = colorScheme, + typography = ThemeTypography, + content = content, + ) + } +} + +enum class Theme { + System, + Light, + Dark, + Black; + + @Composable + fun toDisplayName() = stringResource( + when (this) { + System -> R.string.theme_system + Light -> R.string.theme_light + Dark -> R.string.theme_dark + Black -> R.string.theme_black + } + ) + + @Composable + fun toPainter() = painterResource( + when (this) { + System -> R.drawable.ic_sync + Light -> R.drawable.ic_light + Dark -> R.drawable.ic_night + Black -> R.drawable.ic_brightness_empty + } + ) +} + +private fun ColorScheme.toPitchBlack(): ColorScheme { + return this.copy( + background = Color.Black, + surface = Color.Black, + surfaceVariant = Color.Black, + onBackground = Color.White, + onSurface = Color.White + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/theme/ThemeCustomColors.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/theme/ThemeCustomColors.kt new file mode 100644 index 0000000..7eaaf43 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/theme/ThemeCustomColors.kt @@ -0,0 +1,42 @@ +package com.meowarex.rlmobile.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color + +@Suppress("UnusedReceiverParameter") +val MaterialTheme.customColors: CustomColors + @Composable + inline get() = LocalCustomColors.current + +val LocalCustomColors = staticCompositionLocalOf { + error("No LocalCustomColors provided!") +} + +@Immutable +data class CustomColors( + val warning: Color, + val onWarning: Color, + val warningContainer: Color, + val onWarningContainer: Color, +) + +private val YellowAlt1 = Color(0xFFE9C414) +private val Shandy = Color(0xFFFFE172) +private val DarkBrown = Color(0xFF3B2F00) +private val DarkerBrown = Color(0xFF221B00) +private val DarkBronze = Color(0xFF554600) + +val DarkCustomColors = CustomColors( + warning = YellowAlt1, + onWarning = DarkBrown, + warningContainer = DarkBronze, + onWarningContainer = Shandy, +) + +val LightCustomColors = CustomColors( + warning = YellowAlt1, + onWarning = Color.White, + warningContainer = Shandy, + onWarningContainer = DarkerBrown, +) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/theme/ThemeTypography.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/theme/ThemeTypography.kt new file mode 100644 index 0000000..aa4d4cc --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/theme/ThemeTypography.kt @@ -0,0 +1,54 @@ +package com.meowarex.rlmobile.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.font.* +import com.meowarex.rlmobile.R + +// This uses a variable font variant of Roboto. +// ref: https://medium.com/androiddevelopers/just-your-type-variable-fonts-in-compose-5bf63b357994 + +@OptIn(ExperimentalTextApi::class) +val ThemeTypography = run { + val baseline = Typography() + + val weights = arrayOf( + FontWeight.Thin, + FontWeight.ExtraLight, + FontWeight.Light, + FontWeight.Normal, + FontWeight.Medium, + FontWeight.SemiBold, + FontWeight.ExtraBold, + FontWeight.Bold, + FontWeight.Black, + ) + val fonts = weights.map { weight -> + Font( + resId = R.font.roboto_variable, + weight = weight, + variationSettings = FontVariation.Settings( + FontVariation.weight(weight.weight), + ) + ) + } + val fontFamily = FontFamily(fonts) + + Typography( + displayLarge = baseline.displayLarge.copy(fontFamily = fontFamily), + displayMedium = baseline.displayMedium.copy(fontFamily = fontFamily), + displaySmall = baseline.displaySmall.copy(fontFamily = fontFamily), + headlineLarge = baseline.headlineLarge.copy(fontFamily = fontFamily), + headlineMedium = baseline.headlineMedium.copy(fontFamily = fontFamily), + headlineSmall = baseline.headlineSmall.copy(fontFamily = fontFamily), + titleLarge = baseline.titleLarge.copy(fontFamily = fontFamily), + titleMedium = baseline.titleMedium.copy(fontFamily = fontFamily), + titleSmall = baseline.titleSmall.copy(fontFamily = fontFamily), + bodyLarge = baseline.bodyLarge.copy(fontFamily = fontFamily), + bodyMedium = baseline.bodyMedium.copy(fontFamily = fontFamily), + bodySmall = baseline.bodySmall.copy(fontFamily = fontFamily), + labelLarge = baseline.labelLarge.copy(fontFamily = fontFamily), + labelMedium = baseline.labelMedium.copy(fontFamily = fontFamily), + labelSmall = baseline.labelSmall.copy(fontFamily = fontFamily), + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/ArrangementLastAtBottom.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/ArrangementLastAtBottom.kt new file mode 100644 index 0000000..fff1b22 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/ArrangementLastAtBottom.kt @@ -0,0 +1,50 @@ +package com.meowarex.rlmobile.ui.util + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.unit.* +import kotlin.math.min + +/** + * Arranges all the elements with [spacing] except for the last element which is + * spaced to the very bottom of the viewable component. + * + * ref: https://stackoverflow.com/a/69196765/13964629 + */ +@Immutable +class ArrangementLastAtBottom( + override val spacing: Dp = 0.dp, +) : Arrangement.Vertical { + override fun Density.arrange( + totalSize: Int, + sizes: IntArray, + outPositions: IntArray, + ) { + if (sizes.isEmpty()) return + + val spacingPx = spacing.roundToPx() + var occupied = 0 + var lastSpace = 0 + + sizes.forEachIndexed { index, size -> + if (index == sizes.lastIndex) { + outPositions[index] = totalSize - size + } else { + outPositions[index] = min(occupied, totalSize - size) + } + + lastSpace = min(spacingPx, totalSize - outPositions[index] - size) + occupied = outPositions[index] + size + lastSpace + } + } +} + +/** + * Arranges all the elements vertically with [space] except for the last element + * which is drawn at the very bottom of the viewable bounds of the component. + */ +@Stable +@Suppress("UnusedReceiverParameter") +fun Arrangement.spacedByLastAtBottom(space: Dp) = + ArrangementLastAtBottom(space) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/IfModifier.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/IfModifier.kt new file mode 100644 index 0000000..fe9e41e --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/IfModifier.kt @@ -0,0 +1,22 @@ +@file:Suppress("unused") + +package com.meowarex.rlmobile.ui.util + +import androidx.compose.ui.Modifier + +/** + * Apply additional modifiers if [value] is not null. + */ +inline fun Modifier.thenIf(value: T?, block: Modifier.(T) -> Modifier): Modifier = + value?.let { block(it) } ?: this + +/** + * Apply additional modifiers if [predicate] is true. + */ +inline fun Modifier.thenIf(predicate: Boolean, block: Modifier.() -> Modifier): Modifier { + return if (predicate) { + block() + } else { + this + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/InstallNotifications.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/InstallNotifications.kt new file mode 100644 index 0000000..996350b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/InstallNotifications.kt @@ -0,0 +1,65 @@ +package com.meowarex.rlmobile.ui.util + +import android.app.* +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.annotation.StringRes +import androidx.core.app.* +import com.meowarex.rlmobile.* + +object InstallNotifications { + private const val CHANNEL_ID = "installation" + + /** + * Creates or replaces a notification with id [id] that brings + * up the existing [MainActivity] when clicked upon. + * + * @param id A unique notification ID for different notifications + * @param title Main notification title + * @param description Notification description + */ + fun createNotification( + context: Context, + id: Int, + @StringRes title: Int, + @StringRes description: Int, + ) { + val manager = NotificationManagerCompat.from(context) + + // Create the target notification channel + if (Build.VERSION.SDK_INT >= 26 && manager.getNotificationChannel(CHANNEL_ID) == null) { + val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManager.IMPORTANCE_HIGH) + .setName(context.getString(R.string.notif_group_install_title)) + .setDescription(context.getString(R.string.notif_group_install_desc)) + .build() + + manager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setDefaults(Notification.DEFAULT_LIGHTS or Notification.DEFAULT_SOUND) + .setAutoCancel(true) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(context.getString(title)) + .setContentText(context.getString(description)) + .setContentIntent( + PendingIntent.getActivity( + /* context = */ context, + /* requestCode = */ 0, + /* intent = */ + Intent(context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT), + /* flags = */ PendingIntent.FLAG_IMMUTABLE, + ) + ) + .build() + + try { + manager.notify(id, notification) + } catch (e: SecurityException) { + Log.w(BuildConfig.TAG, "Failed to send install notification", e) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/ScreenResultRegistry.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/ScreenResultRegistry.kt new file mode 100644 index 0000000..5284d2d --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/ScreenResultRegistry.kt @@ -0,0 +1,113 @@ +package com.meowarex.rlmobile.ui.util + +import android.os.Parcelable +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.Navigator +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import java.util.UUID + +// TODO: migrate to androidx-nav v3 since it has built-in screen result support +/** + * Global result registry for managing screen results across the app. + * This is a hacky workaround for the lack of a proper screen result system in Voyager navigation. + * https://github.com/adrielcafe/voyager/issues/465#issuecomment-2696523168 + */ +private object ScreenResultRegistry { + private val mutex = Mutex() + private val results = HashMap>() + + suspend fun registerResult(key: String): CompletableDeferred { + val deferred = CompletableDeferred() + mutex.withLock { + results[key] = deferred + } + @Suppress("UNCHECKED_CAST") + return deferred as CompletableDeferred + } + + suspend fun setResult(key: String, result: Any?) { + mutex.withLock { + results[key]?.complete(result) + results.remove(key) + } + } + + suspend fun clear(key: String) { + mutex.withLock { + results[key]?.cancel() + results.remove(key) + } + } +} + +/** + * Base class for screens that can return results + */ +abstract class ScreenWithResult : Screen { + val resultKey: ScreenResultKey = ScreenResultKey() + + /** + * This sets the result for this screen and completes any listeners. + */ + protected suspend fun setResult(value: R) { + ScreenResultRegistry.setResult(resultKey.key, value) + } +} + +/** + * Base class for screen models that can set a result for their parent screen. + */ +abstract class ScreenModelWithResult( + private val resultKey: ScreenResultKey, +) : ScreenModel { + /** + * This sets the result for this screen and completes any listeners. + * Preferably, this should be called in [ScreenModel.onDispose] + */ + protected suspend fun setResult(value: R) { + ScreenResultRegistry.setResult(resultKey.key, value) + } +} + +@Parcelize +@Serializable +data class ScreenResultKey(val key: String = UUID.randomUUID().toString()) : Parcelable + +/** + * Extension function to show a screen and get its result from anywhere + */ +suspend fun Navigator.pushForResult( + screen: ScreenWithResult, +): R { + val deferred = ScreenResultRegistry.registerResult(screen.resultKey.key) + this.push(screen) + return try { + deferred.await() + } finally { + ScreenResultRegistry.clear(screen.resultKey.key) + } +} + +/** + * Extension function to show a screen without waiting for the result + */ +suspend fun Navigator.showWithoutWaiting( + screen: ScreenWithResult, +): ScreenResultKey { + ScreenResultRegistry.registerResult(screen.resultKey.key) + this.push(screen) + return screen.resultKey +} + +/** + * Function to get a result using the key from a previous [showWithoutWaiting] call + */ +suspend fun getScreenResult(key: ScreenResultKey): R { + val deferred = ScreenResultRegistry.registerResult(key.key) + return deferred.await() +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/Scrollbars.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/Scrollbars.kt new file mode 100644 index 0000000..1eac212 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/Scrollbars.kt @@ -0,0 +1,39 @@ +package com.meowarex.rlmobile.ui.util + +import androidx.compose.foundation.ScrollState +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.max + +// Based on https://gist.github.com/XFY9326/2067efcc3c5899557cc6a334d76a92c8 + +fun Modifier.horizontalScrollbar( + scrollState: ScrollState, + scrollBarHeight: Dp = 4.dp, + minScrollBarWidth: Dp = 5.dp, + scrollBarColor: Color = Color.White.copy(alpha = .4f), + cornerRadius: Dp = 2.dp, +): Modifier = composed { + drawWithContent { + drawContent() + + if (scrollState.maxValue <= 0) return@drawWithContent + + val visibleWidth: Float = this.size.width - scrollState.maxValue + val scrollBarWidth: Float = max(visibleWidth * (visibleWidth / this.size.width), minScrollBarWidth.toPx()) + val scrollPercent: Float = scrollState.value.toFloat() / scrollState.maxValue + val scrollBarOffsetX: Float = scrollState.value + (visibleWidth - scrollBarWidth) * scrollPercent + + drawRoundRect( + color = scrollBarColor, + topLeft = Offset(scrollBarOffsetX, this.size.height - scrollBarHeight.toPx()), + size = Size(scrollBarWidth, scrollBarHeight.toPx()), + cornerRadius = CornerRadius(cornerRadius.toPx()) + ) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/ThrottledStateEffect.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/ThrottledStateEffect.kt new file mode 100644 index 0000000..14ac6dd --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/ThrottledStateEffect.kt @@ -0,0 +1,34 @@ +package com.meowarex.rlmobile.ui.util + +import androidx.compose.runtime.* +import kotlinx.coroutines.* + +@Composable +fun throttledState( + value: T, + throttleMs: Long, +): State { + val scope = rememberCoroutineScope() + val state = remember { mutableStateOf(value) } + var lastEmitTime by remember { mutableLongStateOf(0L) } + var trailingJob by remember { mutableStateOf(null) } + + LaunchedEffect(value) { + val now = System.currentTimeMillis() + val sinceLastEmit = now - lastEmitTime + + if (sinceLastEmit >= throttleMs) { + lastEmitTime = now + state.value = value + } else { + trailingJob?.cancel() + trailingJob = scope.launch { + delay(throttleMs - sinceLastEmit) + lastEmitTime = System.currentTimeMillis() + state.value = value + } + } + } + + return state +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/TidalVersion.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/TidalVersion.kt new file mode 100644 index 0000000..b2e4a97 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/TidalVersion.kt @@ -0,0 +1,73 @@ +package com.meowarex.rlmobile.ui.util + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.stringResource +import com.meowarex.rlmobile.R +import kotlinx.parcelize.Parcelize + +@Parcelize +@Immutable +sealed interface TidalVersion : Comparable, Parcelable { + @Parcelize + data object Error : TidalVersion + + @Parcelize + data object None : TidalVersion + + @Parcelize + data class Existing( + val type: Type, + val name: String, + val code: Int, + ) : TidalVersion { + /** The code without the release type. (ie. 126021 -> 12621) */ + val typelessCode: Int + get() = (code / 1000 * 100) + code % 100 + } + + override fun compareTo(other: TidalVersion): Int { + return when (this) { + is Error -> 0 + is None -> 0 + is Existing -> { + if (other is Existing) { + other.typelessCode.compareTo(typelessCode) + } else { + 0 + } + } + } + } + + @Composable + fun toDisplayName() = when (this) { + is Error -> stringResource(R.string.version_load_fail) + is None -> stringResource(R.string.version_none) + is Existing -> when (type) { + Type.STABLE -> stringResource(R.string.version_stable) + Type.BETA -> stringResource(R.string.version_beta) + Type.ALPHA -> stringResource(R.string.version_alpha) + Type.UNKNOWN -> stringResource(R.string.version_unknown) + } + } + + enum class Type { + STABLE, + BETA, + ALPHA, + UNKNOWN, + } + + companion object { + fun parseVersionType(versionCode: Int?): Type { + return when (versionCode?.div(100)?.mod(10)) { + 0 -> Type.STABLE + 1 -> Type.BETA + 2 -> Type.ALPHA + else -> Type.UNKNOWN + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/UnsafeImmutables.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/UnsafeImmutables.kt new file mode 100644 index 0000000..8db3fa9 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/UnsafeImmutables.kt @@ -0,0 +1,29 @@ +@file:Suppress("unused", "NOTHING_TO_INLINE") + +package com.meowarex.rlmobile.ui.util + +import kotlinx.collections.immutable.* +import kotlinx.collections.immutable.adapters.* + +/* + * Compose-stable wrappers over a list for performance. + * + * This does NOT guarantee stability. It is merely a stable wrapper over another collection, + * and assumes the user knows that it shouldn't change through crucial parts of rendering. + */ + +inline fun Collection.toUnsafeImmutable(): ImmutableCollection = + ImmutableCollectionAdapter(this) + +inline fun List.toUnsafeImmutable(): ImmutableList = + ImmutableListAdapter(this) + +inline fun Set.toUnsafeImmutable(): ImmutableSet = + ImmutableSetAdapter(this) + +inline fun Map.toUnsafeImmutable(): ImmutableMap = + ImmutableMapAdapter(this) + +inline fun emptyImmutableList(): ImmutableList = persistentListOf() +inline fun emptyImmutableSet(): ImmutableSet = persistentSetOf() +inline fun emptyImmutableMap(): ImmutableMap = persistentMapOf() diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/Utils.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/Utils.kt new file mode 100644 index 0000000..c968879 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/Utils.kt @@ -0,0 +1,21 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.meowarex.rlmobile.ui.util + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color + +@Stable +inline fun Modifier.mirrorVertically(): Modifier = + scale(scaleX = -1f, scaleY = 1f) + +/** + * Allow using compose [Color] in [androidx.compose.runtime.saveable.rememberSaveable] + */ +val ColorSaver = Saver( + save = { it.value.toLong() }, + restore = { Color(it.toULong()) }, +) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/paddings/CompositePaddingValues.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/paddings/CompositePaddingValues.kt new file mode 100644 index 0000000..d213c6b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/paddings/CompositePaddingValues.kt @@ -0,0 +1,29 @@ +package com.meowarex.rlmobile.ui.util.paddings + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Stable +import androidx.compose.ui.unit.LayoutDirection + +/** + * Add the values of two [PaddingValues] together. + */ +fun PaddingValues.add(values: PaddingValues): PaddingValues = + CompositePaddingValues(this, values) + +@Stable +private class CompositePaddingValues( + private val valuesA: PaddingValues, + private val valuesB: PaddingValues, +) : PaddingValues { + override fun calculateBottomPadding() = + valuesA.calculateBottomPadding() + valuesB.calculateBottomPadding() + + override fun calculateLeftPadding(layoutDirection: LayoutDirection) = + valuesA.calculateLeftPadding(layoutDirection) + valuesB.calculateLeftPadding(layoutDirection) + + override fun calculateRightPadding(layoutDirection: LayoutDirection) = + valuesA.calculateRightPadding(layoutDirection) + valuesB.calculateRightPadding(layoutDirection) + + override fun calculateTopPadding() = + valuesA.calculateTopPadding() + valuesB.calculateTopPadding() +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/paddings/ExcludePaddingValues.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/paddings/ExcludePaddingValues.kt new file mode 100644 index 0000000..8a428e6 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/util/paddings/ExcludePaddingValues.kt @@ -0,0 +1,148 @@ +package com.meowarex.rlmobile.ui.util.paddings + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.runtime.Stable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides.Companion.Bottom +import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides.Companion.End +import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides.Companion.Left +import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides.Companion.Right +import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides.Companion.Start +import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides.Companion.Top + +/** + * Remove particular side values from [PaddingValues]. + */ +fun PaddingValues.exclude(sides: PaddingValuesSides): PaddingValues = + ExcludePaddingValues(this, sides) + +@Stable +private class ExcludePaddingValues( + private val values: PaddingValues, + private val excludeSides: PaddingValuesSides, +) : PaddingValues { + override fun calculateBottomPadding(): Dp { + return if (!excludeSides.hasAny(PaddingValuesSides.Bottom)) { + values.calculateBottomPadding() + } else { + Dp.Hairline + } + } + + override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp { + return if (!excludeSides.hasAny(PaddingValuesSides.Left)) { + values.calculateLeftPadding(layoutDirection) + } else { + Dp.Hairline + } + } + + override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp { + return if (!excludeSides.hasAny(PaddingValuesSides.Right)) { + values.calculateRightPadding(layoutDirection) + } else { + Dp.Hairline + } + } + + override fun calculateTopPadding(): Dp { + return if (!excludeSides.hasAny(PaddingValuesSides.Top)) { + values.calculateTopPadding() + } else { + Dp.Hairline + } + } +} + +/** + * Copy of [WindowInsetsSides] with the necessary methods exposed publicly. + */ +@Suppress("unused") +@JvmInline +value class PaddingValuesSides private constructor(private val value: Int) { + /** + * Returns a [PaddingValuesSides] containing sides defied in [sides] and the + * sides in `this`. + */ + operator fun plus(sides: PaddingValuesSides): PaddingValuesSides = + PaddingValuesSides(value or sides.value) + + internal fun hasAny(sides: PaddingValuesSides): Boolean = + (value and sides.value) != 0 + + override fun toString(): String = "PaddingValuesSides(${valueToString()})" + + private fun valueToString(): String = buildString { + fun appendPlus(text: String) { + if (isNotEmpty()) append('+') + append(text) + } + + if (value and Start.value == Start.value) appendPlus("Start") + if (value and Left.value == Left.value) appendPlus("Left") + if (value and Top.value == Top.value) appendPlus("Top") + if (value and End.value == End.value) appendPlus("End") + if (value and Right.value == Right.value) appendPlus("Right") + if (value and Bottom.value == Bottom.value) appendPlus("Bottom") + } + + companion object { + internal val AllowLeftInLtr = PaddingValuesSides(1 shl 3) + internal val AllowRightInLtr = PaddingValuesSides(1 shl 2) + internal val AllowLeftInRtl = PaddingValuesSides(1 shl 1) + internal val AllowRightInRtl = PaddingValuesSides(1 shl 0) + + /** + * Indicates a start side, which is left or right + * depending on [LayoutDirection]. If [LayoutDirection.Ltr], [Start] + * is the left side. If [LayoutDirection.Rtl], [Start] is the right side. + * + * Use [Left] or [Right] if the physical direction is required. + */ + val Start = AllowLeftInLtr + AllowRightInRtl + + /** + * Indicates an end side, which is left or right + * depending on [LayoutDirection]. If [LayoutDirection.Ltr], [End] + * is the right side. If [LayoutDirection.Rtl], [End] is the left side. + * + * Use [Left] or [Right] if the physical direction is required. + */ + val End = AllowRightInLtr + AllowLeftInRtl + + /** + * Indicates the top side. + */ + val Top = PaddingValuesSides(1 shl 4) + + /** + * Indicates the bottom side. + */ + val Bottom = PaddingValuesSides(1 shl 5) + + /** + * Indicates a left side. Most layouts will prefer using + * [Start] or [End] to account for [LayoutDirection]. + */ + val Left = AllowLeftInLtr + AllowLeftInRtl + + /** + * Indicates a right side. Most layouts will prefer using + * [Start] or [End] to account for [LayoutDirection]. + */ + val Right = AllowRightInLtr + AllowRightInRtl + + /** + * Indicates a horizontal sides. This is a combination of + * [Left] and [Right] sides, or [Start] and [End] sides. + */ + val Horizontal = Left + Right + + /** + * Indicates a [Top] and [Bottom] sides. + */ + val Vertical = Top + Bottom + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/widgets/updater/UpdaterDialog.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/widgets/updater/UpdaterDialog.kt new file mode 100644 index 0000000..c321083 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/widgets/updater/UpdaterDialog.kt @@ -0,0 +1,103 @@ +package com.meowarex.rlmobile.ui.widgets.updater + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.meowarex.rlmobile.R +import org.koin.androidx.compose.koinViewModel + +@Composable +fun UpdaterDialog( + viewModel: UpdaterViewModel = koinViewModel(), +) { + if (!viewModel.showDialog) return + + val isWorking by viewModel.isWorking.collectAsState() + val downloadProgress by viewModel.progress.collectAsState() + val downloadInProgress by remember { derivedStateOf { downloadProgress != null } } + + AlertDialog( + confirmButton = { + FilledTonalButton( + onClick = viewModel::triggerUpdate, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + if (!isWorking) { + Text(stringResource(R.string.action_update)) + } else if (!downloadInProgress) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onSecondary, + strokeWidth = 2.5.dp, + modifier = Modifier.size(20.dp) + ) + } else { + CircularProgressIndicator( + progress = { downloadProgress ?: 1f }, + color = MaterialTheme.colorScheme.onSecondary, + trackColor = MaterialTheme.colorScheme.onSecondary.copy(alpha = 0.6f), + strokeWidth = 2.5.dp, + modifier = Modifier.size(20.dp) + ) + } + } + }, + dismissButton = { + TextButton( + onClick = viewModel::dismissDialog, + enabled = !isWorking, + ) { + Text(stringResource(R.string.action_dismiss)) + } + }, + onDismissRequest = {}, + title = { + Text(stringResource(R.string.updater_title, viewModel.targetVersion ?: "")) + }, + text = { + val uriHandler = LocalUriHandler.current + + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.updater_body), + textAlign = TextAlign.Center, + ) + + TextButton( + onClick = { uriHandler.openUri(viewModel.targetReleaseUrl!!) } + ) { + Text( + text = stringResource(R.string.updater_open_github), + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline, + ) + } + } + }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_warning), + contentDescription = null, + modifier = Modifier.size(36.dp), + ) + }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + ), + ) +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/widgets/updater/UpdaterViewModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/widgets/updater/UpdaterViewModel.kt new file mode 100644 index 0000000..aec036f --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/widgets/updater/UpdaterViewModel.kt @@ -0,0 +1,188 @@ +package com.meowarex.rlmobile.ui.widgets.updater + +import android.app.Application +import android.content.Intent +import android.util.Log +import androidx.compose.runtime.* +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.meowarex.rlmobile.BuildConfig +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.installers.InstallerResult +import com.meowarex.rlmobile.manager.InstallerManager +import com.meowarex.rlmobile.manager.download.IDownloadManager +import com.meowarex.rlmobile.manager.download.KtorDownloadManager +import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService +import com.meowarex.rlmobile.network.utils.SemVer +import com.meowarex.rlmobile.network.utils.getOrThrow +import com.meowarex.rlmobile.util.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.system.exitProcess + +class UpdaterViewModel( + private val github: RadiantLyricsGithubService, + private val downloader: KtorDownloadManager, + private val installers: InstallerManager, + private val application: Application, +) : ViewModel() { + var showDialog by mutableStateOf(false) + private set + var targetReleaseUrl by mutableStateOf(null) + private set + var targetVersion by mutableStateOf(null) + private set + val progress: StateFlow + field = MutableStateFlow(null) + val isWorking: StateFlow + field = MutableStateFlow(false) + + private var targetApkUrl: String? = null + + init { + viewModelScope.launchIO { + try { + fetchInfo() + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to check for updates!", t) + mainThread { application.showToast(R.string.updater_check_fail) } + } + } + } + + fun dismissDialog() { + showDialog = false + } + + fun triggerUpdate() = viewModelScope.launchIO { + if (!isWorking.compareAndSet(expect = false, update = true)) + return@launchIO + + progress.value = null + + val url = targetApkUrl ?: return@launchIO + val apkFile = application.cacheDir.resolve("manager.apk") + + try { + apkFile.apply { + parentFile!!.mkdirs() + exists() && delete() + } + + val downloadResult = downloader.download( + url = url, + out = apkFile, + onProgressUpdate = { progress.value = it }, + ) + + when (downloadResult) { + is IDownloadManager.Result.Success -> + Log.d(BuildConfig.TAG, "Downloaded update") + + is IDownloadManager.Result.Cancelled -> { + Log.i(BuildConfig.TAG, "Update cancelled") + return@launchIO + } + + is IDownloadManager.Result.Error -> + throw IllegalStateException("Failed to download update: ${downloadResult.getDebugReason()}", downloadResult.getError()) + } + + progress.value = null + + val installer = installers.getActiveInstaller() + val installResult = installer.waitInstall( + apks = listOf(apkFile), + silent = true, + onProgressUpdate = { progress.value = it }, + ) + + progress.value = null + + when (installResult) { + InstallerResult.Success -> { + Log.w(BuildConfig.TAG, "Update completed without restarting app!") + exitProcess(1) + } + + is InstallerResult.Cancelled -> + Log.i(BuildConfig.TAG, "Update cancelled") + + is InstallerResult.Error -> + throw Exception("Failed to install update: ${installResult.getDebugReason()}") + } + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to perform update!") + t.printStackTrace() + + mainThread { + application.showToast(R.string.updater_update_fail) + launchReleasesPage() + } + } finally { + isWorking.value = false + + try { + apkFile.apply { exists() && delete() } + } catch (t: Throwable) { + Log.w(BuildConfig.TAG, "Failed to clean up installed update!", t) + } + } + } + + /** + * This fetched the releases data from GitHub and populates the state if there is an update. + * + * It obtains all the releases that have an asset named `radiantLyrics-manager-[tag].apk`, + * then finds the latest release based on the largest semantic version extracted from the tag name (`v1.0.0`), + * and populates the state to show to the user. + */ + private suspend fun fetchInfo() { + Log.d(BuildConfig.TAG, "Checking for updates...") + + val currentVersion = SemVer.parseOrNull(BuildConfig.VERSION_NAME) + ?: throw Error("Failed to parse current app version") + + // Fetch releases from GitHub (60s local cache) + val releases = github.getManagerReleases().getOrThrow() + + // Find the latest release + APK release asset + val (version, release, apkUrl) = releases + .mapNotNull { release -> + val version = SemVer.parseOrNull(release.tagName) + ?: return@mapNotNull null + + val asset = release.assets.find { it.name == "rl-mobile-manager-${release.tagName}.apk" } + ?: return@mapNotNull null + + Triple(version, release, asset.browserDownloadUrl) + } + .maxByOrNull { (version) -> version } + ?: return + + // Check if currently installed version is greater + if (currentVersion >= version) { + Log.d(BuildConfig.TAG, "Already updated to latest version!") + return + } + + Log.d(BuildConfig.TAG, "Found an update! $targetVersion $targetApkUrl") + mainThread { + targetReleaseUrl = release.htmlUrl + targetVersion = version.toString() + targetApkUrl = apkUrl + showDialog = true + } + } + + private fun launchReleasesPage() { + try { + Intent(Intent.ACTION_VIEW, targetReleaseUrl!!.toUri()) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .also(application::startActivity) + } catch (t: Throwable) { + Log.w(BuildConfig.TAG, "Failed to open latest Github release in browser!", t) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Context.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Context.kt new file mode 100644 index 0000000..6368d32 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Context.kt @@ -0,0 +1,202 @@ +package com.meowarex.rlmobile.util + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.* +import android.content.pm.PackageManager +import android.content.res.Resources +import android.net.ConnectivityManager +import android.net.Uri +import android.os.* +import android.provider.Settings +import android.telephony.TelephonyManager +import android.util.Log +import android.util.TypedValue +import android.widget.Toast +import androidx.annotation.AnyRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import com.meowarex.rlmobile.* +import com.google.android.gms.safetynet.SafetyNet +import java.io.File +import java.io.InputStream +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +fun Context.copyToClipboard(text: String) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + clipboard.setPrimaryClip( + ClipData.newPlainText(BuildConfig.APPLICATION_ID, text) + ) +} + +suspend fun Context.saveFile(name: String, text: String): Boolean { + val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val file = File(downloads, name) + val base = downloads.parentFile!! + + return try { + file.writeText(text) + mainThread { showToast(R.string.installer_file_save_success, file.toRelativeString(base)) } + true + } catch (e: Throwable) { + Log.e(BuildConfig.TAG, Log.getStackTraceString(e)) + mainThread { showToast(R.string.installer_file_save_failed, file.toRelativeString(base)) } + false + } +} + +fun Context.showToast(@StringRes text: Int, vararg args: Any, length: Int = Toast.LENGTH_LONG) { + Toast.makeText(this, this.getString(text, *args), length).show() +} + +fun Context.selfHasPermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED +} + +/** + * @return (versionName, versionCode) + */ +fun Context.getPackageVersion(pkg: String): Pair { + @Suppress("DEPRECATION") + return packageManager.getPackageInfo(pkg, 0) + .let { it.versionName to it.versionCode } +} + +fun Context.isPackageInstalled(packageName: String): Boolean { + return try { + packageManager.getPackageInfo(packageName, 0) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } +} + +fun Context.findActivity(): Activity? { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + return null +} + +fun Context.isIgnoringBatteryOptimizations(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false + + val power = applicationContext.getSystemService(PowerManager::class.java) + val name = applicationContext.packageName + return power.isIgnoringBatteryOptimizations(name) +} + +/** + * Launch a system dialog to enable unrestricted battery usage. + */ +@SuppressLint("BatteryLife") +fun Activity.requestNoBatteryOptimizations() { + val intent = Intent( + /* action = */ Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + /* uri = */ Uri.fromParts("package", this.packageName, null) + ) + + with(intent) { + addCategory(Intent.CATEGORY_DEFAULT) + addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) + } + + startActivity(intent) +} + +/** + * Get the raw bytes for any resource stored as a file within the APK. + * @param id The resource identifier + * @return The resource's raw bytes as stored inside the APK (no parsing is done). + */ +fun Resources.getRawBytes(@AnyRes id: Int): ByteArray { + val tValue = TypedValue() + this.getValue( + /* id = */ id, + /* outValue = */ tValue, + /* resolveRefs = */ true, + ) + + val resPath = tValue.string.toString() + + return ManagerApplication::class.java.classLoader + ?.getResourceAsStream(resPath) + ?.use(InputStream::readBytes) + ?: error("Failed to get resource file $resPath from APK") +} + +/** + * Checks if the Play Protect/Verify Apps feature is enabled on this device. + * @return `null` if failed to obtain, otherwise whether it's enabled. + */ +suspend fun Context.isPlayProtectEnabled(): Boolean? { + return suspendCoroutine { continuation -> + SafetyNet.getClient(this) + .isVerifyAppsEnabled + .addOnCompleteListener { task -> + if (task.isSuccessful) { + val enabled = task.result.isVerifyAppsEnabled + Log.d(BuildConfig.TAG, "Play Protect enabled: $enabled") + continuation.resume(enabled) + } else { + Log.d(BuildConfig.TAG, "Failed to check Play Protect status", task.exception) + continuation.resume(null) + } + } + } +} + +/** + * Check whether the device is connected on a metered WIFI connection or through any type of mobile data, + * to avoid unknowingly downloading a lot of stuff through a potentially metered network. + */ +@Suppress("DEPRECATION") +fun Context.isNetworkDangerous(): Boolean { + val connectivity = this.getSystemService() + ?: error("Unable to get system connectivity service") + + if (connectivity.isActiveNetworkMetered) return true + + when (val info = connectivity.activeNetworkInfo) { + null -> return false + else -> { + if (info.isRoaming) return true + if (info.type == ConnectivityManager.TYPE_WIFI) return false + } + } + + val telephony = this.getSystemService() + ?: error("Unable to get system telephony service") + + val dangerousMobileDataStates = arrayOf( + /* TelephonyManager.DATA_DISCONNECTING */ 4, + TelephonyManager.DATA_CONNECTED, + TelephonyManager.DATA_CONNECTING, + ) + + return dangerousMobileDataStates.contains(telephony.dataState) +} + +/** + * Gets the user associated with this context. + */ +fun Context.getUserId(): Int? { + HiddenAPI.disable() + + return try { + @SuppressLint("DiscouragedPrivateApi") + val method = Context::class.java.getDeclaredMethod("getUserId") + .apply { isAccessible = true } + + method.invoke(this) as Int + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to get current Android user ID", t) + null + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Coroutines.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Coroutines.kt new file mode 100644 index 0000000..f99e804 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Coroutines.kt @@ -0,0 +1,30 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.meowarex.rlmobile.util + +import kotlinx.coroutines.* +import kotlin.coroutines.CoroutineContext + +/** + * Launches a Job in a fixed-size CPU-bound thread pool. + * Used for heavy or intensive CPU-bound tasks. + */ +inline fun CoroutineScope.launchBlock( + context: CoroutineContext = Dispatchers.Default, + noinline block: suspend CoroutineScope.() -> Unit, +) { + launch(context, block = block) +} + +/** + * Launches a Job on a background thread in a dynamically sized thread pool. + * Used for IO or other lightweight tasks that spend most of their time waiting. + */ +inline fun CoroutineScope.launchIO(noinline block: suspend CoroutineScope.() -> Unit) = + launchBlock(Dispatchers.IO, block) + +/** + * Utility wrapper around [withContext] to switch to the main thread for the [block]. + */ +suspend inline fun mainThread(noinline block: suspend CoroutineScope.() -> T): T = + withContext(Dispatchers.Main, block) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Debounce.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Debounce.kt new file mode 100644 index 0000000..b592616 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Debounce.kt @@ -0,0 +1,55 @@ +package com.meowarex.rlmobile.util + +import kotlinx.coroutines.* +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Returns a function that when called any number of times, only executes [function] after + * [waitMs] has passed after the last call, using the last call's passed in param(s). + * This is for things like "throttling" search text input to reduce the amount of actual + * calls to only when the user stops typing for example. + * + * @param waitMs Milliseconds to wait for additional calls before executing [function] + * @param waitCompletion If true, then ignore any calls while [function] is executing instead of + * cancelling it and restarting the debouncing job. + * @param function The target function to debounce. + * @return Refer to the general description + */ +fun CoroutineScope.debounce( + waitMs: Long, + waitCompletion: Boolean = false, + function: suspend (P1, P2) -> Unit, +): (P1, P2) -> Unit { + var job: Job? = null + val executing = AtomicBoolean(false) + + return block@{ p1: P1, p2: P2 -> + if (waitCompletion && !executing.get()) { + return@block + } else if (job?.isActive == true) { + job?.cancel() + } + + job = launch { + delay(waitMs) + + try { + executing.set(true) + function(p1, p2) + } finally { + executing.set(false) + } + } + } +} + +// Re-binding the same function but with different amount of params +// Yes this is ugly but there isn't really a better way to do this efficiently afaik + +inline fun CoroutineScope.debounce(waitMs: Long, waitCompletion: Boolean = false, crossinline function: suspend () -> Unit) = + debounce(waitMs, waitCompletion) { _, _ -> function() } + .let { { it(null, null) } } + +inline fun CoroutineScope.debounce(waitMs: Long, waitCompletion: Boolean = false, crossinline function: suspend (T) -> Unit) = + debounce(waitMs, waitCompletion) { p1, _ -> function(p1) } + .let { { p1: T -> it(p1, null) } } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/HiddenAPI.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/HiddenAPI.kt new file mode 100644 index 0000000..c83419b --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/HiddenAPI.kt @@ -0,0 +1,18 @@ +package com.meowarex.rlmobile.util + +import android.os.Build +import org.lsposed.hiddenapibypass.HiddenApiBypass +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Globally disables the Android Hidden API restrictions. + */ +object HiddenAPI { + private var disabled = AtomicBoolean(false) + + fun disable() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !disabled.getAndSet(true)) + HiddenApiBypass.setHiddenApiExemptions("") + } +} + diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Navigation.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Navigation.kt new file mode 100644 index 0000000..1f5a83e --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Navigation.kt @@ -0,0 +1,33 @@ +package com.meowarex.rlmobile.util + +import android.app.Activity +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.stack.Stack +import com.meowarex.rlmobile.ui.screens.home.HomeScreen + +/** + * Custom back logic for handling special screens differently. + * If on Home, Landing, or Debug then exit immediately otherwise go back one. + */ +fun Stack.back(currentActivity: Activity?) { + val top = this.lastItemOrNull ?: return + val stackSize = this.items.size + + if (stackSize > 1) { + pop() + } else if (top is HomeScreen) { + currentActivity?.finish() + } else { + replaceAll(HomeScreen()) + } +} + +/** + * Workaround for an issue where a button can push a screen with the same key twice onto the stack. + * https://github.com/adrielcafe/voyager/issues/474#issuecomment-2907701009 + */ +infix fun Stack.pushOnce(item: Screen) { + if (lastItemOrNull?.key != item.key) { + push(item) + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Throttling.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Throttling.kt new file mode 100644 index 0000000..ec6eef2 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Throttling.kt @@ -0,0 +1,22 @@ +package com.meowarex.rlmobile.util + +import kotlinx.coroutines.* + +/** + * Constructs a function that executes [destinationFunction] only once per [skipMs]. + */ +inline fun throttle( + skipMs: Long, + coroutineScope: CoroutineScope, + crossinline destinationFunction: suspend () -> Unit, +): () -> Unit { + var throttleJob: Job? = null + return { + if (throttleJob?.isCompleted != false) { + throttleJob = coroutineScope.launch { + destinationFunction() + delay(skipMs) + } + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Utils.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Utils.kt new file mode 100644 index 0000000..0834f3a --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/Utils.kt @@ -0,0 +1,115 @@ +package com.meowarex.rlmobile.util + +import android.os.Build +import androidx.collection.ObjectList +import java.io.BufferedReader +import java.io.IOException +import java.lang.Long.signum +import java.text.StringCharacterIterator +import java.util.Locale +import kotlin.math.* + +/** + * Formats this number as bytes to a human readable short file size in terms of 1024b = 1KiB + */ +// https://stackoverflow.com/a/3758880/13964629 +fun Long.formatShortFileSize(): String { + val bytes = this + + val absB = if (bytes == Long.MIN_VALUE) Long.MAX_VALUE else abs(bytes) + if (absB < 1024) { + return "$bytes B" + } + var value = absB + val ci = StringCharacterIterator("KMGTPE") + var i = 40 + while (i >= 0 && absB > 0xfffccccccccccccL shr i) { + value = value shr 10 + ci.next() + i -= 10 + } + value *= signum(bytes).toLong() + return String.format(Locale.ROOT, "%.1f %ciB", value / 1024.0, ci.current()) +} + + +/** + * Truncates this value to a specific number of [decimals] digits. + */ +fun Double.toPrecision(decimals: Int): Double { + val multiplier = 10.0.pow(decimals) + return truncate(this * multiplier) / multiplier +} + +inline fun ObjectList.find(block: (E) -> Boolean): E? { + forEach { value -> + if (block(value)) + return value + } + + return null +} + +/** + * Check whether this device is most likely an emulator. + * src: https://stackoverflow.com/a/21505193/13964629 + */ +val IS_PROBABLY_EMULATOR by lazy { + // Android SDK emulator + return@lazy ((Build.MANUFACTURER == "Google" && Build.BRAND == "google" && + ((Build.FINGERPRINT.startsWith("google/sdk_gphone_") + && Build.FINGERPRINT.endsWith(":user/release-keys") + && Build.PRODUCT.startsWith("sdk_gphone_") + && Build.MODEL.startsWith("sdk_gphone_")) + // Alternative + || (Build.FINGERPRINT.startsWith("google/sdk_gphone64_") + && (Build.FINGERPRINT.endsWith(":userdebug/dev-keys") || Build.FINGERPRINT.endsWith(":user/release-keys")) + && Build.PRODUCT.startsWith("sdk_gphone64_") + && Build.MODEL.startsWith("sdk_gphone64_")))) + || Build.FINGERPRINT.startsWith("generic") + || Build.FINGERPRINT.startsWith("unknown") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + // Bluestacks + || "QC_Reference_Phone" == Build.BOARD && !"Xiaomi".equals(Build.MANUFACTURER, ignoreCase = true) + // Bluestacks + || Build.MANUFACTURER.contains("Genymotion") + || Build.HOST.startsWith("Build") + // MSI App Player + || Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") + || Build.PRODUCT == "google_sdk" + // Another Android SDK emulator check + || getSystemProp("ro.kernel.qemu") == "1") +} + +/** + * Checks whether this device is running MIUI + */ +fun isMiui(): Boolean { + return getSystemProp("ro.miui.ui.version.name") + ?.isNotEmpty() ?: false +} + +/** + * Gets a system property from build.prop + */ +private fun getSystemProp(name: String): String? { + var reader: BufferedReader? = null + + return try { + val process = Runtime.getRuntime().exec("getprop $name") + reader = process.inputStream.bufferedReader() + reader.readLine() + } catch (_: Exception) { + null + } finally { + try { + reader?.close() + } catch (_: IOException) { + // ignore + } + } +} + + diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/serialization/ImmutableListSerializer.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/serialization/ImmutableListSerializer.kt new file mode 100644 index 0000000..6b76e23 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/serialization/ImmutableListSerializer.kt @@ -0,0 +1,28 @@ +package com.meowarex.rlmobile.util.serialization + +import com.meowarex.rlmobile.ui.util.toUnsafeImmutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SealedSerializationApi +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.serialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +class ImmutableListSerializer(private val dataSerializer: KSerializer) : KSerializer> { + @OptIn(SealedSerializationApi::class) + private class PersistentListDescriptor : SerialDescriptor by serialDescriptor>() { + override val serialName: String = "kotlinx.serialization.immutable.ImmutableList" + } + + override val descriptor: SerialDescriptor = PersistentListDescriptor() + + override fun serialize(encoder: Encoder, value: ImmutableList) { + return ListSerializer(dataSerializer).serialize(encoder, value.toList()) + } + + override fun deserialize(decoder: Decoder): ImmutableList { + return ListSerializer(dataSerializer).deserialize(decoder).toUnsafeImmutable() + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/serialization/IntAsStringSerializer.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/serialization/IntAsStringSerializer.kt new file mode 100644 index 0000000..30eca29 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/util/serialization/IntAsStringSerializer.kt @@ -0,0 +1,28 @@ +package com.meowarex.rlmobile.util.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * (De)serializes an integer as a string. + */ +object IntAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Int", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Int) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Int { + val stringValue = decoder.decodeString() + + return try { + stringValue.toInt() + } catch (e: NumberFormatException) { + throw SerializationException(e) + } + } +} diff --git a/Manager/app/src/main/res/drawable/ic_account_github_white_24dp.xml b/Manager/app/src/main/res/drawable/ic_account_github_white_24dp.xml new file mode 100644 index 0000000..2d4f0b4 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_account_github_white_24dp.xml @@ -0,0 +1,11 @@ + + + + diff --git a/Manager/app/src/main/res/drawable/ic_add.xml b/Manager/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..028ffed --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_alt_route.xml b/Manager/app/src/main/res/drawable/ic_alt_route.xml new file mode 100644 index 0000000..92899ae --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_alt_route.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_android.xml b/Manager/app/src/main/res/drawable/ic_android.xml new file mode 100644 index 0000000..91a5a51 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_android.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_apk_install.xml b/Manager/app/src/main/res/drawable/ic_apk_install.xml new file mode 100644 index 0000000..d34b45f --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_apk_install.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_app_shortcut.xml b/Manager/app/src/main/res/drawable/ic_app_shortcut.xml new file mode 100644 index 0000000..a37f996 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_app_shortcut.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_arrow_down_small.xml b/Manager/app/src/main/res/drawable/ic_arrow_down_small.xml new file mode 100644 index 0000000..e4b4da0 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_arrow_down_small.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_arrow_up_small.xml b/Manager/app/src/main/res/drawable/ic_arrow_up_small.xml new file mode 100644 index 0000000..2467c78 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_arrow_up_small.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_back.xml b/Manager/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..8faa004 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_battery_settings.xml b/Manager/app/src/main/res/drawable/ic_battery_settings.xml new file mode 100644 index 0000000..e029b9e --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_battery_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_bell.xml b/Manager/app/src/main/res/drawable/ic_bell.xml new file mode 100644 index 0000000..23df5ad --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_bell.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_brightness_empty.xml b/Manager/app/src/main/res/drawable/ic_brightness_empty.xml new file mode 100644 index 0000000..7424ef5 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_brightness_empty.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_brush.xml b/Manager/app/src/main/res/drawable/ic_brush.xml new file mode 100644 index 0000000..0a54e17 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_brush.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_bug.xml b/Manager/app/src/main/res/drawable/ic_bug.xml new file mode 100644 index 0000000..f0ecc4b --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_bug.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_canceled.xml b/Manager/app/src/main/res/drawable/ic_canceled.xml new file mode 100644 index 0000000..5614958 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_canceled.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_check_circle.xml b/Manager/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..3f30f6d --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_circle.xml b/Manager/app/src/main/res/drawable/ic_circle.xml new file mode 100644 index 0000000..bbf2e79 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_code.xml b/Manager/app/src/main/res/drawable/ic_code.xml new file mode 100644 index 0000000..bb345bd --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_code.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_copy.xml b/Manager/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000..763ad53 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_delete_forever.xml b/Manager/app/src/main/res/drawable/ic_delete_forever.xml new file mode 100644 index 0000000..fa5ef42 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_delete_forever.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_dhizuku.xml b/Manager/app/src/main/res/drawable/ic_dhizuku.xml new file mode 100644 index 0000000..7f432b5 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_dhizuku.xml @@ -0,0 +1,12 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_download.xml b/Manager/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..c51fac3 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_extension.xml b/Manager/app/src/main/res/drawable/ic_extension.xml new file mode 100644 index 0000000..c7651e9 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_extension.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_extension_off.xml b/Manager/app/src/main/res/drawable/ic_extension_off.xml new file mode 100644 index 0000000..3decc79 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_extension_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_hashtag.xml b/Manager/app/src/main/res/drawable/ic_hashtag.xml new file mode 100644 index 0000000..444b436 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_hashtag.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_history.xml b/Manager/app/src/main/res/drawable/ic_history.xml new file mode 100644 index 0000000..6f9d1f6 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_history.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_info.xml b/Manager/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..4633761 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_launch.xml b/Manager/app/src/main/res/drawable/ic_launch.xml new file mode 100644 index 0000000..a7683aa --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_launch.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_launcher_foreground.xml b/Manager/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..528ce9e --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/Manager/app/src/main/res/drawable/ic_launcher_monochrome.xml b/Manager/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..5fb7a77 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/Manager/app/src/main/res/drawable/ic_light.xml b/Manager/app/src/main/res/drawable/ic_light.xml new file mode 100644 index 0000000..ddef03b --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_light.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_night.xml b/Manager/app/src/main/res/drawable/ic_night.xml new file mode 100644 index 0000000..0cb32d0 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_night.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_page.xml b/Manager/app/src/main/res/drawable/ic_page.xml new file mode 100644 index 0000000..6aabae7 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_page.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_palette.xml b/Manager/app/src/main/res/drawable/ic_palette.xml new file mode 100644 index 0000000..3b6a542 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_palette.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_protect_warning.xml b/Manager/app/src/main/res/drawable/ic_protect_warning.xml new file mode 100644 index 0000000..d783a7d --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_protect_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_radiant_logo.xml b/Manager/app/src/main/res/drawable/ic_radiant_logo.xml new file mode 100644 index 0000000..ce5b8ad --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_radiant_logo.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/Manager/app/src/main/res/drawable/ic_receipt.xml b/Manager/app/src/main/res/drawable/ic_receipt.xml new file mode 100644 index 0000000..0b9203d --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_receipt.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_reciept_off.xml b/Manager/app/src/main/res/drawable/ic_reciept_off.xml new file mode 100644 index 0000000..e7c6f35 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_reciept_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_refresh.xml b/Manager/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..c09c444 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_save.xml b/Manager/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 0000000..ed5766a --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_security.xml b/Manager/app/src/main/res/drawable/ic_security.xml new file mode 100644 index 0000000..d5ee109 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_security.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_settings.xml b/Manager/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..db5d5fa --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_share.xml b/Manager/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..c3c2187 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_shizuku.xml b/Manager/app/src/main/res/drawable/ic_shizuku.xml new file mode 100644 index 0000000..21c6330 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_shizuku.xml @@ -0,0 +1,13 @@ + + + + diff --git a/Manager/app/src/main/res/drawable/ic_sync.xml b/Manager/app/src/main/res/drawable/ic_sync.xml new file mode 100644 index 0000000..81641ea --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_tidal.xml b/Manager/app/src/main/res/drawable/ic_tidal.xml new file mode 100644 index 0000000..3e6249b --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_tidal.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_tidal_classic.xml b/Manager/app/src/main/res/drawable/ic_tidal_classic.xml new file mode 100644 index 0000000..9910927 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_tidal_classic.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_tidal_classic_monochrome.xml b/Manager/app/src/main/res/drawable/ic_tidal_classic_monochrome.xml new file mode 100644 index 0000000..739f813 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_tidal_classic_monochrome.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_tidal_monochrome.xml b/Manager/app/src/main/res/drawable/ic_tidal_monochrome.xml new file mode 100644 index 0000000..917e8c8 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_tidal_monochrome.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/Manager/app/src/main/res/drawable/ic_update.xml b/Manager/app/src/main/res/drawable/ic_update.xml new file mode 100644 index 0000000..297f620 --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_update.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/drawable/ic_warning.xml b/Manager/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..754b6bc --- /dev/null +++ b/Manager/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/Manager/app/src/main/res/font/roboto_variable.ttf b/Manager/app/src/main/res/font/roboto_variable.ttf new file mode 100644 index 0000000..bba55f6 Binary files /dev/null and b/Manager/app/src/main/res/font/roboto_variable.ttf differ diff --git a/Manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..fbf44be --- /dev/null +++ b/Manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..fbf44be --- /dev/null +++ b/Manager/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Manager/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Manager/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..426f6f0 Binary files /dev/null and b/Manager/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Manager/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Manager/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..512cb0a Binary files /dev/null and b/Manager/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/Manager/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Manager/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..f5f0dcf Binary files /dev/null and b/Manager/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Manager/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Manager/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..9c578ca Binary files /dev/null and b/Manager/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/Manager/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Manager/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..a5b7f6a Binary files /dev/null and b/Manager/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Manager/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Manager/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b6c752c Binary files /dev/null and b/Manager/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/Manager/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Manager/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d6f0c21 Binary files /dev/null and b/Manager/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Manager/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Manager/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..41d74fe Binary files /dev/null and b/Manager/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/Manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..c5c22b1 Binary files /dev/null and b/Manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7fae848 Binary files /dev/null and b/Manager/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/Manager/app/src/main/res/raw/globalsign_root_r4.der b/Manager/app/src/main/res/raw/globalsign_root_r4.der new file mode 100644 index 0000000..f63cc7f Binary files /dev/null and b/Manager/app/src/main/res/raw/globalsign_root_r4.der differ diff --git a/Manager/app/src/main/res/raw/gts_root_r1.der b/Manager/app/src/main/res/raw/gts_root_r1.der new file mode 100644 index 0000000..e193a05 Binary files /dev/null and b/Manager/app/src/main/res/raw/gts_root_r1.der differ diff --git a/Manager/app/src/main/res/raw/gts_root_r2.der b/Manager/app/src/main/res/raw/gts_root_r2.der new file mode 100644 index 0000000..726e544 Binary files /dev/null and b/Manager/app/src/main/res/raw/gts_root_r2.der differ diff --git a/Manager/app/src/main/res/raw/gts_root_r3.der b/Manager/app/src/main/res/raw/gts_root_r3.der new file mode 100644 index 0000000..8c59853 Binary files /dev/null and b/Manager/app/src/main/res/raw/gts_root_r3.der differ diff --git a/Manager/app/src/main/res/raw/gts_root_r4.der b/Manager/app/src/main/res/raw/gts_root_r4.der new file mode 100644 index 0000000..58d018a Binary files /dev/null and b/Manager/app/src/main/res/raw/gts_root_r4.der differ diff --git a/Manager/app/src/main/res/raw/isrg_root_x1.der b/Manager/app/src/main/res/raw/isrg_root_x1.der new file mode 100644 index 0000000..9d2132e Binary files /dev/null and b/Manager/app/src/main/res/raw/isrg_root_x1.der differ diff --git a/Manager/app/src/main/res/raw/isrg_root_x2.der b/Manager/app/src/main/res/raw/isrg_root_x2.der new file mode 100644 index 0000000..0f5f95f Binary files /dev/null and b/Manager/app/src/main/res/raw/isrg_root_x2.der differ diff --git a/Manager/app/src/main/res/resources.properties b/Manager/app/src/main/res/resources.properties new file mode 100644 index 0000000..467b3ef --- /dev/null +++ b/Manager/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US diff --git a/Manager/app/src/main/res/values/colors.xml b/Manager/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..51f40b8 --- /dev/null +++ b/Manager/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0A1929 + diff --git a/Manager/app/src/main/res/values/strings.xml b/Manager/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..25800f2 --- /dev/null +++ b/Manager/app/src/main/res/values/strings.xml @@ -0,0 +1,263 @@ + + Radiant Lyrics Manager + A patcher for the TIDAL Android App + + Radiant Lyrics + TIDAL + GitHub + Support Server + Installer + + Cancel + Retry + Continue + Retry installation + Open error log + Apply + Confirm + Dismiss + Install + Uninstall + New Install + Update Install + Update + Clear + Delete + Launch + Close + Search + Copy + Collapse + Expand + Copied! + Cleared cache! + Exit Anyways + Open Info + Reset to default + + Failed to automatically reinstall! Please try doing it manually. + Successfully imported %s + Failed to import custom component! + + Grant Permissions + Radiant Lyrics Manager requires permissions: + %s indicates required permissions! + Grant + Granted + Options + Permissions + Install from Unknown Sources + Permissions are required to initiate an installation from this app. + External Storage + Radiant Lyrics Manager stores shared data in ~/RadiantLyrics, which requires full storage permissions. Scoped storage is not currently supported. + Notifications + Background Battery + Ensures the installation process does not get automatically cancelled if the app is minimized. + Used only to show the download progress if Radiant Lyrics Manager is minimized during installation. + + Failed to obtain root permissions + Failed to obtain Shizuku permissions + Failed to obtain Dhizuku permissions + + No installations found! + Failed to launch app + + Advanced + Installation + Appearance + Info + Theme + The theme to apply to this app + Dynamic color + Enables Material You theming on Android 12+ + Developer options + Don\'t enable this unless you know what it does! (yes, really) + Installation Method + Various methods are supported to interface with the system\'s package installer. + Keep patched APKs + Keep all patched files and APKs after installation for debugging purposes. Note that exporting the APK is only useful after a successful patching session. + Clear cache + Export APK + + Direct + Invokes the PackageInstaller API directly from the app\'s context. (Default) + Root + Intent + Launches an intent to handle installation through the system\'s configured default installer. + Invokes PackageManager directly using device root such as Magisk or KernelSU. + Shizuku + Invokes the PackageInstaller API remotely through Shizuku. ADB, Wireless ADB, and Root backends are all supported. + Dhizuku + Invokes the PackageInstaller API remotely through Dhizuku using DeviceOwner permissions. + + System + Dark + Light + Black + + Plugins + View changelog + Uninstall %s + Are you sure you want to uninstall the %s plugin? + No plugins installed! + Changelog Media + An error occurred while processing plugins! + Safe mode + Temporarily disables all plugins when starting Radiant Lyrics + + Back + About + Logs + Settings + + Lead + Contributors + %s contributions + + Failed to load + Metered network + It appears you are connected to mobile data or a potentially metered network. Continuing with the installation may result in several hundred megabytes being downloaded. Are you sure you want to continue? + Don\'t show this again + + None + Stable + Beta + Alpha + Unknown + Failed to retrieve version + + Saved to %s + Failed to save %s + Successfully installed Radiant Lyrics + Cancelled Radiant Lyrics installation + Please uninstall your current version in order to continue! + Successfully uninstalled app + Cancelled uninstallation + Failed to verify download + Insufficient storage space! + Since battery optimizations have not been disabled, minimizing this screen may abort the installation process! + Installation failed! You can either retry or click this banner to open the GitHub repository for help. + Successfully installed Radiant Lyrics! Do *NOT* uninstall the manager (this app) as it is required to perform certain types of updates. + + Installer failure (Unknown reason) + No handlers available for %s! + Installation was blocked + One or more APKs were invalid or corrupt + Conflicts with an existing app, usually due to mismatched signatures + Not enough storage available to install + Application is incompatible with this device + Installer timed out + + Prepare + Download dependencies + Patch APK + Install APK + + Fetching target TIDAL version + Checking for newer installations + Restoring from cache + Downloading TIDAL APK + Downloading smali patches + Copying dependencies + Patching APK manifest + Adding modern root certificates + Patching app icon + Applying smali patches + Reorganizing dex files + Store metadata + Aligning APK + Signing APK + Installing APK + Cleaning up + + Queued + Ongoing + Skipped + Success + Failed + + Really exit? + Are you sure you really want to abort an in-progress installation? + + Update to v%s + A new update has been released for Radiant Lyrics Manager! It may be required in order to function properly. Would you like to update? + View release on GitHub + Failed to check for updates! + Failed to update! Please download and install the update manually! + + Download failed (Unknown) + Download failed (%s) + Download failed (Timeout) + Download failed (Invalid response) + Download failed (File exists) + Download failed (Insufficient space) + + Failed to fetch data! + + Radiant Lyrics provides several customizations at installation time that are not able to be changed once installed. + Package Name + The package name is a unique identifier for all apps. Using different ones can allow for multiple installations of Radiant Lyrics. + Invalid package name! + Target app will be overwritten! + Valid package name! + App Name + The app name is what\'s displayed in your home launcher. This should be changed on secondary installations for ease of use. + Debuggable + Enable the debuggable manifest flag. Only use this if you know what you are doing! + Custom Injector + A custom injector build that was imported by Manager. + Custom Patches + A custom smali patch bundle that was imported by Manager. + Basic + Advanced + + Custom Component (%s) + Select a custom build that was imported by Manager. + Latest + Successfully deleted component! + + Log + Installation Info + Environment Info + Error Stacktrace + Installation Log + Export log + Export APK + Share log + + Logs + No logs present! + Delete all logs + Successfully deleted all logs + Clear Logs + Are you sure you want to permanently delete all the logs? + + Active installation + Progress notifications sent during a minimized installation + Radiant Lyrics installation ready! + Click to install… + Radiant Lyrics failed to install + Click to view more info… + + Play Protect + Google Play Protect appears to be enabled on your device. It may attempt to interfere with a new installation due to the usage of a unique signing key. You can disable it in Play Protect\'s settings.\n\nIf it does show a warning dialog, press\n\"More Details\" -> \"Install anyway\" + Continue + Open Play Protect + Don\'t show this again + + Fun Fact: %s + Did you know that Google Play Protect is useless? + Radiant Lyrics restores the blurred album art lyrics dialog from TIDAL\'s legacy player. + The legacy LyricsDialog uses a BlurTransformation with radius 5 via RenderScript. + i am in your walls + The TIDAL app has two coexisting player implementations — old View-based and new Compose. + Having issues? Open an issue on GitHub! + The lyricsButton was hidden because TIDAL rerouted lyrics data to the new Compose player. + \"just patch it lol\" + Radiant Lyrics targets TIDAL v2.192.0 + Smali patches are distributed as unified diffs inside patches.zip + The blurred background uses Coil + BlurTransformation(radius=5) crossfaded onto an ImageView. + meowarex made this + + %.2fs + diff --git a/Manager/app/src/main/res/values/themes.xml b/Manager/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..f3879b7 --- /dev/null +++ b/Manager/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/Manager/app/src/main/res/xml/network_security_config.xml b/Manager/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..c860b0d --- /dev/null +++ b/Manager/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Manager/app/src/main/res/xml/new_backup_rules.xml b/Manager/app/src/main/res/xml/new_backup_rules.xml new file mode 100644 index 0000000..0c8e993 --- /dev/null +++ b/Manager/app/src/main/res/xml/new_backup_rules.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/Manager/app/src/main/res/xml/old_backup_rules.xml b/Manager/app/src/main/res/xml/old_backup_rules.xml new file mode 100644 index 0000000..b28a0db --- /dev/null +++ b/Manager/app/src/main/res/xml/old_backup_rules.xml @@ -0,0 +1,6 @@ + + + + diff --git a/Manager/app/src/main/res/xml/provider_paths.xml b/Manager/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000..9354caf --- /dev/null +++ b/Manager/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/Manager/build.gradle.kts b/Manager/build.gradle.kts new file mode 100644 index 0000000..a78d911 --- /dev/null +++ b/Manager/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.kotlin.serialization) apply false +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/Manager/crowdin.yml b/Manager/crowdin.yml new file mode 100644 index 0000000..60b6445 --- /dev/null +++ b/Manager/crowdin.yml @@ -0,0 +1,7 @@ +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_PERSONAL_TOKEN + +preserve_hierarchy: true +files: + - source: app/src/main/res/values/strings.xml + translation: app/src/main/res/values-%android_code%/strings.xml diff --git a/Manager/gradle.properties b/Manager/gradle.properties new file mode 100644 index 0000000..8f4d1e0 --- /dev/null +++ b/Manager/gradle.properties @@ -0,0 +1,14 @@ +# Gradle +org.gradle.configuration-cache=true +org.gradle.configureondemand=true +org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 +org.gradle.parallel=true + +# Kotlin +kotlin.code.style=official + +# Android +android.useAndroidX=true +android.nonTransitiveRClass=true +android.enableR8.fullMode=true +android.javaCompile.suppressSourceTargetDeprecationWarning=true diff --git a/Manager/gradle/gradle-daemon-jvm.properties b/Manager/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/Manager/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/Manager/gradle/libs.versions.toml b/Manager/gradle/libs.versions.toml new file mode 100644 index 0000000..6411730 --- /dev/null +++ b/Manager/gradle/libs.versions.toml @@ -0,0 +1,159 @@ +[versions] +accompanist = "0.37.3" +agp = "9.0.0" +androidx-activity = "1.12.2" +androidx-core = "1.17.0" +androidx-lifecycle = "2.10.0" +androidx-splashscreen = "1.2.0" +apksig = "9.0.0" +axml = "1.0.1" +binary-resources = "2.1.0" +bouncycastle = "1.83" +coil = "3.3.0" +compose = "1.10.1" +compose-material3 = "1.4.0" +compose-pipette = "2.0.0-beta03" +desugaring = "2.1.5" +dhizuku = "2.5.4" +diff = "4.16" +hiddenApi-bypass = "6.1" +hiddenApi-refine = "4.4.0" +hiddenApi-stub = "4.4.0" +koin = "4.1.1" +kotlin = "2.3.0" +kotlinx-immutable = "0.4.0" +kotlinx-serialization = "1.10.0" +ktor = "3.4.0" +libsu = "6.0.0" +microg = "0.3.6.244735" +shimmer = "1.3.3" +shizuku = "13.1.5" +smali = "3.0.9" +voyager = "1.1.0-beta03" +zip = "2.3.1" + +[libraries] +# Accompanist +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +accompanist-systemUiController = { module = "com.google.accompanist:accompanist-systemuicontroller", version = "0.36.0" } + +# AndroidX +androidx-activity = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } +androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } + +# Coil (image library) +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } + +# Compose +compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } +compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } +compose-animations = { module = "androidx.compose.animation:animation-graphics", version.ref = "compose" } +compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } + +# Compose debug tooling +compose-runtime-tracing = { module = "androidx.compose.runtime:runtime-tracing", version.ref = "compose" } +compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } + +# Extra compose libs +compose-pipette = { module = "dev.zt64.compose.pipette:compose-pipette", version.ref = "compose-pipette" } +compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "shimmer" } + +# Koin +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } +koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } + +# KotlinX +kotlinx-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +# Ktor +ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } + +# Voyager +voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } +voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } +voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" } + +# Patching +apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig" } +axml = { module = "com.aliucord:axml", version.ref = "axml" } +binaryResources = { module = "com.aliucord:binary-resources", version.ref = "binary-resources" } +bouncycastle = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" } +diff = { module = "io.github.java-diff-utils:java-diff-utils", version.ref = "diff" } +smali = { module = "com.android.tools.smali:smali", version.ref = "smali" } +baksmali = { module = "com.android.tools.smali:smali-baksmali", version.ref = "smali" } +zip = { module = "io.github.diamondminer88:zip-android", version.ref = "zip" } + +# Shizuku + Dhizuku + Utils +dhizuku-api = { module = "io.github.iamr0s:Dhizuku-API", version.ref = "dhizuku" } +shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" } +shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" } +hiddenApi-bypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenApi-bypass" } +hiddenApi-refine = { module = "dev.rikka.tools.refine:runtime", version.ref = "hiddenApi-refine" } +hiddenApi-stub = { module = "dev.rikka.hidden:stub", version.ref = "hiddenApi-stub" } + +# Other +microg = { module = "org.microg.gms:play-services-safetynet", version.ref = "microg" } +desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugaring" } +libsu = { module = "com.aliucord.libsu:core", version.ref = "libsu" } + +[bundles] +accompanist = [ + "accompanist-permissions", + "accompanist-systemUiController", +] +androidx = [ + "androidx-core", + "androidx-activity", + "androidx-lifecycle", + "androidx-lifecycle-process", + "androidx-splashscreen", +] +coil = [ + "coil-compose", + "coil-okhttp", +] +compose = [ + "compose-runtime", + "compose-ui", + "compose-foundation", + "compose-material3", + "compose-animations", +] +koin = [ + "koin-android", + "koin-compose", +] +ktor = [ + "ktor-core", + "ktor-okhttp", + "ktor-logging", + "ktor-content-negotiation", + "ktor-serialization-json", +] +shizuku = [ + "shizuku-api", + "shizuku-provider", +] +voyager = [ + "voyager-koin", + "voyager-navigator", + "voyager-transitions", +] + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +hiddenApi-refine = { id = "dev.rikka.tools.refine", version.ref = "hiddenApi-refine" } diff --git a/Manager/gradle/wrapper/gradle-wrapper.jar b/Manager/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/Manager/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Manager/gradle/wrapper/gradle-wrapper.properties b/Manager/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..19a6bde --- /dev/null +++ b/Manager/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Manager/gradlew b/Manager/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/Manager/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/Manager/gradlew.bat b/Manager/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/Manager/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/Manager/settings.gradle.kts b/Manager/settings.gradle.kts new file mode 100644 index 0000000..70a4aa2 --- /dev/null +++ b/Manager/settings.gradle.kts @@ -0,0 +1,24 @@ +@file:Suppress("UnstableApiUsage") + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven { + name = "aliucord" + url = uri("https://maven.aliucord.com/releases") + } + } +} + +rootProject.name = "RadiantLyricsManager" +include(":app") diff --git a/patches/background-blur.patch b/patches/background-blur.patch new file mode 100644 index 0000000..0152f2a --- /dev/null +++ b/patches/background-blur.patch @@ -0,0 +1,115 @@ +--- a/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali ++++ b/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali +@@ -2966,7 +2966,7 @@ + .end method + + .method public static final e(ILandroidx/compose/runtime/Composer;Lcom/tidal/android/feature/playerscreen/ui/k;Lcom/tidal/android/feature/playerscreen/ui/r$a;Ltl0/a;Ltl0/l;Z)V +- .locals 61 ++ .locals 89 # extra regs for blur + sparkle + .annotation build Landroidx/compose/runtime/Composable; + .end annotation + +@@ -4164,6 +4164,103 @@ + + invoke-static {v5, v3, v4}, Landroidx/compose/runtime/Updater;->set-impl(Landroidx/compose/runtime/Composer;Ljava/lang/Object;Ltl0/p;)V + ++ const v3, 0x52414449 # group key for slot table ++ ++ invoke-interface {v10, v3}, Landroidx/compose/runtime/Composer;->startReplaceGroup(I)V # open group ++ ++ move-object/from16 v3, p3 # player state ++ ++ iget-object v3, v3, Lcom/tidal/android/feature/playerscreen/ui/r$a;->c:Lcom/tidal/android/feature/playerscreen/ui/d; # cover pager ++ ++ iget-object v4, v3, Lcom/tidal/android/feature/playerscreen/ui/d;->a:Lon0/b; # item list ++ ++ iget v5, v3, Lcom/tidal/android/feature/playerscreen/ui/d;->b:I # current index ++ ++ invoke-interface {v4}, Ljava/util/List;->size()I ++ ++ move-result v6 ++ ++ if-le v6, v5, :radiant_skip # index out of bounds -> skip ++ ++ if-ltz v5, :radiant_skip ++ ++ invoke-interface {v4, v5}, Ljava/util/List;->get(I)Ljava/lang/Object; ++ ++ move-result-object v4 ++ ++ instance-of v6, v4, Lcom/tidal/android/feature/playerscreen/ui/c$a; # only album covers ++ ++ if-eqz v6, :radiant_skip ++ ++ check-cast v4, Lcom/tidal/android/feature/playerscreen/ui/c$a; ++ ++ iget v5, v4, Lcom/tidal/android/feature/playerscreen/ui/c$a;->b:I # album id ++ ++ iget-object v4, v4, Lcom/tidal/android/feature/playerscreen/ui/c$a;->c:Ljava/lang/String; # cover uuid ++ ++ new-instance v6, Lcom/tidal/android/feature/playerscreen/ui/composables/p0; # tidal's cover request ++ ++ invoke-direct {v6, v5, v4}, Lcom/tidal/android/feature/playerscreen/ui/composables/p0;->(ILjava/lang/String;)V ++ ++ sget-object v5, Landroidx/compose/ui/Modifier;->Companion:Landroidx/compose/ui/Modifier$Companion; ++ ++ const/4 v7, 0x0 ++ ++ const/4 v8, 0x1 ++ ++ const/4 v3, 0x0 ++ ++ invoke-static {v5, v7, v8, v3}, Landroidx/compose/foundation/layout/SizeKt;->fillMaxSize$default(Landroidx/compose/ui/Modifier;FILjava/lang/Object;)Landroidx/compose/ui/Modifier; # fill the player root ++ ++ move-result-object v5 ++ ++ const/high16 v7, 0x42c00000 # 96f (blur radius dp) ++ ++ invoke-static {v7}, Landroidx/compose/ui/unit/Dp;->constructor-impl(F)F ++ ++ move-result v7 ++ ++ sget-object v8, Landroidx/compose/ui/draw/BlurredEdgeTreatment;->Companion:Landroidx/compose/ui/draw/BlurredEdgeTreatment$Companion; ++ ++ invoke-virtual {v8}, Landroidx/compose/ui/draw/BlurredEdgeTreatment$Companion;->getRectangle---Goahg()Landroidx/compose/ui/graphics/Shape; ++ ++ move-result-object v8 ++ ++ invoke-static {v5, v7, v8}, Landroidx/compose/ui/draw/BlurKt;->blur-F8QBwvs(Landroidx/compose/ui/Modifier;FLandroidx/compose/ui/graphics/Shape;)Landroidx/compose/ui/Modifier; # apply blur ++ ++ move-result-object v5 ++ ++ sget-object v7, Landroidx/compose/ui/layout/ContentScale;->Companion:Landroidx/compose/ui/layout/ContentScale$Companion; ++ ++ invoke-virtual {v7}, Landroidx/compose/ui/layout/ContentScale$Companion;->getCrop()Landroidx/compose/ui/layout/ContentScale; # cover-crop scaling ++ ++ move-result-object v7 ++ ++ move-object/from16 v61, v6 # request ++ ++ const/16 v62, 0x0 # contentDescription ++ ++ move-object/from16 v63, v5 # modifier (blurred + fillMaxSize) ++ ++ const/16 v64, 0x0 # colorFilter ++ ++ move-object/from16 v65, v7 # contentScale ++ ++ move-object/from16 v66, v4 # cover uuid ++ ++ const/16 v67, 0x0 ++ ++ move-object/from16 v68, v10 # composer ++ ++ const/16 v69, 0x0 ++ ++ const/16 v70, 0x48 ++ ++ invoke-static/range {v61 .. v70}, Lsd0/f;->a(Ltl0/l;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/ColorFilter;Landroidx/compose/ui/layout/ContentScale;Ljava/lang/Object;Ltl0/a;Landroidx/compose/runtime/Composer;II)V # render blurred cover ++ ++ :radiant_skip ++ invoke-interface {v10}, Landroidx/compose/runtime/Composer;->endReplaceGroup()V # close group ++ + .line 138 + sget-object v3, Landroidx/compose/foundation/layout/BoxScopeInstance;->INSTANCE:Landroidx/compose/foundation/layout/BoxScopeInstance; + diff --git a/patches/data.json b/patches/data.json new file mode 100644 index 0000000..4ad4d7f --- /dev/null +++ b/patches/data.json @@ -0,0 +1,5 @@ +{ + "tidalVersionCode": 9089, + "tidalApkUrl": "", + "patchesVersion": "0.5.0" +} diff --git a/patches/extension/radiant/NoOp.smali b/patches/extension/radiant/NoOp.smali new file mode 100644 index 0000000..3228036 --- /dev/null +++ b/patches/extension/radiant/NoOp.smali @@ -0,0 +1,39 @@ +.class public final Lradiant/NoOp; +.super Ljava/lang/Object; +.implements Ltl0/a; + + +# static fields +.field public static final a:Lradiant/NoOp; + + +# direct methods +.method static constructor ()V + .locals 1 + + new-instance v0, Lradiant/NoOp; + + invoke-direct {v0}, Lradiant/NoOp;->()V + + sput-object v0, Lradiant/NoOp;->a:Lradiant/NoOp; + + return-void +.end method + +.method private constructor ()V + .locals 0 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + return-void +.end method + + +# virtual methods +.method public final invoke()Ljava/lang/Object; + .locals 1 + + sget-object v0, Lkotlin/u;->a:Lkotlin/u; + + return-object v0 +.end method diff --git a/patches/extension/radiant/SparkleButton.smali b/patches/extension/radiant/SparkleButton.smali new file mode 100644 index 0000000..74e6eaa --- /dev/null +++ b/patches/extension/radiant/SparkleButton.smali @@ -0,0 +1,200 @@ +.class public final Lradiant/SparkleButton; +.super Ljava/lang/Object; +.source "SourceFile" + + +# direct methods +.method public static final a(ILandroidx/compose/runtime/Composer;Landroidx/compose/ui/Modifier;Ltl0/a;)V + .locals 21 + .annotation build Landroidx/compose/runtime/Composable; + .end annotation + + .annotation build Landroidx/compose/runtime/ComposableTarget; + applier = "androidx.compose.ui.UiComposable" + .end annotation + + move/from16 v0, p0 + + move-object/from16 v1, p3 + + invoke-virtual {v1}, Ljava/lang/Object;->getClass()Ljava/lang/Class; + + const v2, 0x5c08c0ab + + move-object/from16 v3, p1 + + invoke-interface {v3, v2}, Landroidx/compose/runtime/Composer;->startRestartGroup(I)Landroidx/compose/runtime/Composer; + + move-result-object v11 + + and-int/lit8 v3, v0, 0x6 + + if-nez v3, :cond_1 + + invoke-interface {v11, v1}, Landroidx/compose/runtime/Composer;->changedInstance(Ljava/lang/Object;)Z + + move-result v3 + + if-eqz v3, :cond_0 + + const/4 v3, 0x4 + + goto :goto_0 + + :cond_0 + const/4 v3, 0x2 + + :goto_0 + or-int/2addr v3, v0 + + goto :goto_1 + + :cond_1 + move v3, v0 + + :goto_1 + or-int/lit8 v3, v3, 0x30 + + and-int/lit8 v4, v3, 0x13 + + const/16 v5, 0x12 + + const/4 v6, 0x0 + + if-eq v4, v5, :cond_2 + + const/4 v4, 0x1 + + goto :goto_2 + + :cond_2 + move v4, v6 + + :goto_2 + and-int/lit8 v5, v3, 0x1 + + invoke-interface {v11, v4, v5}, Landroidx/compose/runtime/Composer;->shouldExecute(ZI)Z + + move-result v4 + + if-eqz v4, :cond_6 + + sget-object v14, Landroidx/compose/ui/Modifier;->Companion:Landroidx/compose/ui/Modifier$Companion; + + invoke-static {}, Landroidx/compose/runtime/ComposerKt;->isTraceInProgress()Z + + move-result v4 + + if-eqz v4, :cond_3 + + const/4 v4, -0x1 + + const-string v5, "radiant.SparkleButton" + + invoke-static {v2, v3, v4, v5}, Landroidx/compose/runtime/ComposerKt;->traceEventStart(IIILjava/lang/String;)V + + :cond_3 + sget-object v2, Lcom/squareup/ui/market/core/theme/k;->e:Lcom/squareup/ui/market/core/theme/k$a; + + const/4 v4, 0x6 + + invoke-static {v2, v11, v4}, Lcom/squareup/ui/market/core/theme/w;->t(Lcom/squareup/ui/market/core/theme/k$a;Landroidx/compose/runtime/Composer;I)Lcom/squareup/ui/market/core/theme/MarketStylesheet; + + move-result-object v15 + + invoke-interface {v11, v15}, Landroidx/compose/runtime/Composer;->changed(Ljava/lang/Object;)Z + + move-result v2 + + invoke-interface {v11}, Landroidx/compose/runtime/Composer;->rememberedValue()Ljava/lang/Object; + + move-result-object v4 + + if-nez v2, :cond_4 + + sget-object v2, Landroidx/compose/runtime/Composer;->Companion:Landroidx/compose/runtime/Composer$Companion; + + invoke-virtual {v2}, Landroidx/compose/runtime/Composer$Companion;->getEmpty()Ljava/lang/Object; + + move-result-object v2 + + if-ne v4, v2, :cond_5 + + :cond_4 + sget-object v16, Lcom/squareup/ui/market/core/components/properties/IconButton$Size;->MEDIUM:Lcom/squareup/ui/market/core/components/properties/IconButton$Size; + + sget-object v17, Lcom/squareup/ui/market/core/components/properties/IconButton$Rank;->SECONDARY:Lcom/squareup/ui/market/core/components/properties/IconButton$Rank; + + const/16 v19, 0x4 + + const/16 v20, 0x0 + + const/16 v18, 0x0 + + invoke-static/range {v15 .. v20}, Lcom/squareup/ui/market/components/MarketIconButtonKt;->P(Lcom/squareup/ui/market/core/theme/MarketStylesheet;Lcom/squareup/ui/market/core/components/properties/IconButton$Size;Lcom/squareup/ui/market/core/components/properties/IconButton$Rank;Lcom/squareup/ui/market/core/components/properties/IconButton$Variant;ILjava/lang/Object;)Ll20/v1; + + move-result-object v4 + + invoke-interface {v11, v4}, Landroidx/compose/runtime/Composer;->updateRememberedValue(Ljava/lang/Object;)V + + :cond_5 + move-object v9, v4 + + check-cast v9, Ll20/v1; + + sget v2, Lcom/tidal/android/feature/playerscreen/ui/R$string;->lyrics:I + + invoke-static {v2, v11, v6}, Landroidx/compose/ui/res/StringResources_androidKt;->stringResource(ILandroidx/compose/runtime/Composer;I)Ljava/lang/String; + + move-result-object v2 + + move v4, v3 + + invoke-static {v14}, Lcom/tidal/android/feature/playerscreen/ui/composables/anim/BouncePressKt;->a(Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; + + move-result-object v3 + + new-instance v10, Lradiant/SparkleContent; + + invoke-direct {v10}, Ljava/lang/Object;->()V + + and-int/lit8 v12, v4, 0xe + + const/16 v13, 0xf8 + + const/4 v4, 0x0 + + const/4 v5, 0x0 + + const/4 v6, 0x0 + + const/4 v7, 0x0 + + const/4 v8, 0x0 + + invoke-static/range {v1 .. v13}, Lcom/squareup/ui/market/components/MarketIconButtonKt;->c(Ltl0/a;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/interaction/MutableInteractionSource;ZLcom/squareup/ui/market/components/n;Ltl0/a;Ljava/lang/String;Ll20/v1;Ltl0/p;Landroidx/compose/runtime/Composer;II)V + + invoke-static {}, Landroidx/compose/runtime/ComposerKt;->isTraceInProgress()Z + + move-result v2 + + if-eqz v2, :cond_7 + + invoke-static {}, Landroidx/compose/runtime/ComposerKt;->traceEventEnd()V + + goto :goto_3 + + :cond_6 + invoke-interface {v11}, Landroidx/compose/runtime/Composer;->skipToGroupEnd()V + + :cond_7 + :goto_3 + invoke-interface {v11}, Landroidx/compose/runtime/Composer;->endRestartGroup()Landroidx/compose/runtime/ScopeUpdateScope; + + move-result-object v2 + + goto :cond_8 + + :cond_8 + return-void +.end method diff --git a/patches/extension/radiant/SparkleContent.smali b/patches/extension/radiant/SparkleContent.smali new file mode 100644 index 0000000..23c6330 --- /dev/null +++ b/patches/extension/radiant/SparkleContent.smali @@ -0,0 +1,58 @@ +.class public final Lradiant/SparkleContent; +.super Ljava/lang/Object; +.source "SourceFile" + +# interfaces +.implements Ltl0/p; + + +# virtual methods +.method public final invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + .locals 3 + + check-cast p1, Landroidx/compose/runtime/Composer; + + check-cast p2, Ljava/lang/Integer; + + invoke-virtual {p2}, Ljava/lang/Integer;->intValue()I + + move-result p2 + + const v0, 0x7fdc5692 + + invoke-interface {p1, v0}, Landroidx/compose/runtime/Composer;->startReplaceGroup(I)V + + invoke-static {}, Landroidx/compose/runtime/ComposerKt;->isTraceInProgress()Z + + move-result v1 + + if-eqz v1, :cond_0 + + const/4 v1, -0x1 + + const-string v2, "radiant.SparkleContent." + + invoke-static {v0, p2, v1, v2}, Landroidx/compose/runtime/ComposerKt;->traceEventStart(IIILjava/lang/String;)V + + :cond_0 + const p2, 0x7f08051d + + const/4 v0, 0x0 + + invoke-static {p2, p1, v0}, Landroidx/compose/ui/res/PainterResources_androidKt;->painterResource(ILandroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/painter/Painter; + + move-result-object p2 + + invoke-static {}, Landroidx/compose/runtime/ComposerKt;->isTraceInProgress()Z + + move-result v0 + + if-eqz v0, :cond_1 + + invoke-static {}, Landroidx/compose/runtime/ComposerKt;->traceEventEnd()V + + :cond_1 + invoke-interface {p1}, Landroidx/compose/runtime/Composer;->endReplaceGroup()V + + return-object p2 +.end method diff --git a/patches/extension/radiant/SpvFactory.smali b/patches/extension/radiant/SpvFactory.smali new file mode 100644 index 0000000..9339c6d --- /dev/null +++ b/patches/extension/radiant/SpvFactory.smali @@ -0,0 +1,49 @@ +.class public final Lradiant/SpvFactory; +.super Ljava/lang/Object; +.implements Ltl0/l; + + +# static fields +.field public static final a:Lradiant/SpvFactory; + + +# direct methods +.method static constructor ()V + .locals 1 + + new-instance v0, Lradiant/SpvFactory; + + invoke-direct {v0}, Lradiant/SpvFactory;->()V + + sput-object v0, Lradiant/SpvFactory;->a:Lradiant/SpvFactory; + + return-void +.end method + +.method private constructor ()V + .locals 0 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + return-void +.end method + + +# virtual methods +.method public final invoke(Ljava/lang/Object;)Ljava/lang/Object; + .locals 2 + + check-cast p1, Landroid/content/Context; + + new-instance v0, Lcom/aspiro/wamp/nowplaying/widgets/secondaryProgressView/SecondaryProgressView; + + const/4 v1, 0x0 + + invoke-direct {v0, p1, v1}, Lcom/aspiro/wamp/nowplaying/widgets/secondaryProgressView/SecondaryProgressView;->(Landroid/content/Context;Landroid/util/AttributeSet;)V + + const/4 v1, 0x1 + + invoke-virtual {v0, v1}, Landroid/view/View;->setClickable(Z)V + + return-object v0 +.end method diff --git a/patches/lyrics-active-line-only.patch b/patches/lyrics-active-line-only.patch new file mode 100644 index 0000000..de22c9d --- /dev/null +++ b/patches/lyrics-active-line-only.patch @@ -0,0 +1,30 @@ +--- a/com/tidal/android/feature/playerscreen/ui/composables/LyricsKt.smali ++++ b/com/tidal/android/feature/playerscreen/ui/composables/LyricsKt.smali +@@ -110,16 +110,18 @@ + .line 36 + move-result-wide v6 + +- .line 37 +- sget-object v5, Lcom/tidal/android/feature/playerscreen/ui/composables/LyricsKt;->b:[F ++ add-int/lit8 v8, v3, -0xa # i - 10 (10 = active line index) + +- .line 38 +- .line 39 +- rsub-int/lit8 v8, v3, 0xa +- +- .line 40 +- .line 41 +- aget v8, v5, v8 ++ if-nez v8, :radiant_alpha_zero # not active line -> hide ++ ++ const/high16 v8, 0x3f800000 # alpha 1.0 (visible) ++ ++ goto :radiant_alpha_done ++ ++ :radiant_alpha_zero ++ const/4 v8, 0x0 # alpha 0.0 (hidden) ++ ++ :radiant_alpha_done + + .line 42 + .line 43 diff --git a/patches/lyrics-disable-cover.patch b/patches/lyrics-disable-cover.patch new file mode 100644 index 0000000..81b64fe --- /dev/null +++ b/patches/lyrics-disable-cover.patch @@ -0,0 +1,26 @@ +--- a/com/tidal/android/feature/playerscreen/ui/g0.smali ++++ b/com/tidal/android/feature/playerscreen/ui/g0.smali +@@ -666,8 +666,23 @@ + move-object v5, v1 + + .line 288 ++ const v1, 0x52414443 # group key ++ ++ invoke-interface {v7, v1}, Landroidx/compose/runtime/Composer;->startReplaceGroup(I)V # open group around the cover ++ ++ iget-object v1, v0, Lcom/tidal/android/feature/playerscreen/ui/g0;->b:Lcom/tidal/android/feature/playerscreen/ui/r$a; # player state ++ ++ iget-object v1, v1, Lcom/tidal/android/feature/playerscreen/ui/r$a;->j:Lcom/tidal/android/feature/playerscreen/ui/g; # current view mode ++ ++ instance-of v1, v1, Lcom/tidal/android/feature/playerscreen/ui/g$a; # only render when on cover mode ++ ++ if-eqz v1, :radiant_after_cover # lyrics/credits mode -> skip cover ++ + invoke-static/range {v2 .. v9}, Lcom/tidal/android/feature/playerscreen/ui/composables/CoverPagerKt;->c(Lcom/tidal/android/feature/playerscreen/ui/d;Ltl0/l;FLandroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;II)V + ++ :radiant_after_cover ++ invoke-interface {v7}, Landroidx/compose/runtime/Composer;->endReplaceGroup()V # close group ++ + .line 289 + .line 290 + .line 291 diff --git a/patches/lyrics-fade-region.patch b/patches/lyrics-fade-region.patch new file mode 100644 index 0000000..fb20411 --- /dev/null +++ b/patches/lyrics-fade-region.patch @@ -0,0 +1,11 @@ +--- a/com/tidal/android/feature/playerscreen/ui/composables/k1.smali ++++ b/com/tidal/android/feature/playerscreen/ui/composables/k1.smali +@@ -64,7 +64,7 @@ + + .line 16 + .line 17 +- iget v2, p0, Lcom/tidal/android/feature/playerscreen/ui/composables/k1;->a:F ++ const/high16 v2, 0x43480000 # hardcode top fade region to 200dp (decouple from contentPadding) + + .line 18 + .line 19 diff --git a/patches/lyrics-progress-pill.patch b/patches/lyrics-progress-pill.patch new file mode 100644 index 0000000..e99db13 --- /dev/null +++ b/patches/lyrics-progress-pill.patch @@ -0,0 +1,64 @@ +--- a/com/tidal/android/feature/playerscreen/ui/b0.smali ++++ b/com/tidal/android/feature/playerscreen/ui/b0.smali +@@ -45,7 +45,7 @@ + + # virtual methods + .method public final invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +- .locals 17 ++ .locals 26 # extra regs for the pill AndroidView call + + .line 1 + move-object/from16 v0, p0 +@@ -460,6 +460,52 @@ + .line 200 + invoke-static/range {v2 .. v9}, Lcom/tidal/android/feature/playerscreen/ui/composables/LyricsKt;->a(Lcom/tidal/android/feature/playerscreen/ui/g;Ltl0/l;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Ltl0/l;Landroidx/compose/runtime/Composer;II)V + ++ sget-object v17, Lradiant/SpvFactory;->a:Lradiant/SpvFactory; # factory that builds the legacy progress view ++ ++ sget-object v18, Landroidx/compose/ui/Modifier;->Companion:Landroidx/compose/ui/Modifier$Companion; ++ ++ const/16 v19, 0x0 ++ ++ const/16 v20, 0x0 ++ ++ const/16 v21, 0x0 ++ ++ sget-object v22, Lradiant/NoOp;->a:Lradiant/NoOp; # swallow taps so lyrics behind don't trigger ++ ++ const/16 v23, 0xe ++ ++ const/16 v24, 0x0 ++ ++ invoke-static/range {v18 .. v24}, Landroidx/compose/foundation/ClickableKt;->clickable-XHw0xAI$default(Landroidx/compose/ui/Modifier;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Ltl0/a;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; ++ ++ move-result-object v23 ++ ++ const/high16 v24, 0x41800000 # 16f (horizontal padding dp) ++ ++ invoke-static/range {v24 .. v24}, Landroidx/compose/ui/unit/Dp;->constructor-impl(F)F ++ ++ move-result v24 ++ ++ const/high16 v25, 0x42a00000 # 80f (vertical padding dp) ++ ++ invoke-static/range {v25 .. v25}, Landroidx/compose/ui/unit/Dp;->constructor-impl(F)F ++ ++ move-result v25 ++ ++ invoke-static/range {v23 .. v25}, Landroidx/compose/foundation/layout/PaddingKt;->padding-VpY3zN4(Landroidx/compose/ui/Modifier;FF)Landroidx/compose/ui/Modifier; ++ ++ move-result-object v18 ++ ++ const/16 v19, 0x0 ++ ++ move-object/from16 v20, v7 # composer ++ ++ const/16 v21, 0x0 ++ ++ const/16 v22, 0x4 ++ ++ invoke-static/range {v17 .. v22}, Landroidx/compose/ui/viewinterop/AndroidView_androidKt;->AndroidView(Ltl0/l;Landroidx/compose/ui/Modifier;Ltl0/l;Landroidx/compose/runtime/Composer;II)V # mount the progress pill on top of the lyrics ++ + .line 201 + .line 202 + .line 203 diff --git a/patches/sparkle-button.patch b/patches/sparkle-button.patch new file mode 100644 index 0000000..36a6dff --- /dev/null +++ b/patches/sparkle-button.patch @@ -0,0 +1,38 @@ +--- a/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali ++++ b/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali +@@ -4931,7 +4931,11 @@ + const/4 v10, 0x0 + + .line 226 +- invoke-static {v10, v9, v4, v2, v7}, Lcom/tidal/android/feature/playerscreen/ui/composables/h1;->a(Landroidx/compose/ui/Modifier;Ltl0/a;ZLandroidx/compose/runtime/Composer;I)V ++ const v10, 0x52414448 # empty group replaces removed top-right lyrics pill ++ ++ invoke-interface {v2, v10}, Landroidx/compose/runtime/Composer;->startReplaceGroup(I)V ++ ++ invoke-interface {v2}, Landroidx/compose/runtime/Composer;->endReplaceGroup()V + + .line 227 + invoke-interface {v2}, Landroidx/compose/runtime/Composer;->endReplaceGroup()V +@@ -5707,6 +5707,22 @@ + :cond_51 + check-cast v4, Ltl0/a; + ++ new-instance v74, Lc8/j; # build lyrics-toggle lambda (same one h1 used) ++ ++ move-object/from16 v75, p5 ++ ++ const/16 v76, 0x1 # discriminator 1 = lyrics action ++ ++ invoke-direct/range {v74 .. v76}, Lc8/j;->(Ljava/lang/Object;I)V ++ ++ const/16 v71, 0x0 # $$changed flags ++ ++ move-object/from16 v72, v7 # composer ++ ++ const/16 v73, 0x0 # modifier (null -> Companion) ++ ++ invoke-static/range {v71 .. v74}, Lradiant/SparkleButton;->a(ILandroidx/compose/runtime/Composer;Landroidx/compose/ui/Modifier;Ltl0/a;)V # render bottom-left sparkle ++ + const/4 v2, 0x0 + + invoke-static {v13, v7, v2, v4}, Lcom/tidal/android/feature/playerscreen/ui/composables/h3;->a(ILandroidx/compose/runtime/Composer;Landroidx/compose/ui/Modifier;Ltl0/a;)V