mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-06-17 21:13:11 +10:00
Alpha
This commit is contained in:
@@ -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
|
||||||
@@ -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"
|
||||||
+23
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
github: [ "rushiiMachine" ]
|
||||||
+20
@@ -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
|
||||||
+81
@@ -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.
|
||||||
+5
@@ -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.
|
||||||
@@ -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
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
BIN
Binary file not shown.
Vendored
+39
@@ -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
|
||||||
+32
@@ -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 }}
|
||||||
+64
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
.kotlin
|
||||||
|
|
||||||
|
/**/build/
|
||||||
|
/local.properties
|
||||||
|
/captures
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
+172
@@ -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 <insert your license name here>" 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.
|
||||||
@@ -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<JavaCompile> {
|
||||||
|
// 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) {
|
||||||
|
""
|
||||||
|
}
|
||||||
Vendored
+60
@@ -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
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-sdk tools:overrideLibrary="com.rosan.dhizuku.api" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||||
|
tools:ignore="RequestInstallPackagesPolicy" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="AllFilesAccessPolicy,ScopedStorage" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
|
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".ManagerApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/new_backup_rules"
|
||||||
|
android:fullBackupContent="@xml/old_backup_rules"
|
||||||
|
android:hasFragileUserData="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:largeHeap="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.AppCompat.NoActionBar"
|
||||||
|
tools:targetApi="29">
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".installers.pm.PMIntentReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/provider_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="rikka.shizuku.ShizukuProvider"
|
||||||
|
android:authorities="${applicationId}.shizuku"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:multiprocess="false"
|
||||||
|
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:theme="@style/Theme.RadiantLyricsManager.SplashScreen"
|
||||||
|
tools:ignore="DiscouragedApi,LockedOrientationActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<action android:name="com.meowarex.rlmobile.REINSTALL" />
|
||||||
|
<action android:name="com.meowarex.rlmobile.OPEN_PLUGINS" />
|
||||||
|
<action android:name="com.meowarex.rlmobile.IMPORT_COMPONENT" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -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<InstallMetadata>(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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <T : Activity> 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<InetAddress> {
|
||||||
|
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<Inet4Address>()
|
||||||
|
} 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<CacheControl> = AttributeKey("CustomCacheControl")
|
||||||
|
|
||||||
|
fun HttpRequestBuilder.cacheControl(cacheControl: CacheControl) {
|
||||||
|
attributes.put(CustomCacheControl, cacheControl)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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<File>, 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<File>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+158
@@ -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<File>, 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<File>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
@@ -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<File>, silent: Boolean) {
|
||||||
|
coroutineScope.launch { waitInstall(apks, silent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
@SuppressLint("RequestInstallPackagesPolicy")
|
||||||
|
override suspend fun waitInstall(
|
||||||
|
apks: List<File>,
|
||||||
|
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<ComponentActivity>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<File>, 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<File>,
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>(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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<InstallerResult>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SessionParamsHidden>(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<SessionParams>(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<File>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> = 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<File>, silent: Boolean) {
|
||||||
|
coroutineScope.launch { waitInstall(apks, silent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun waitInstall(
|
||||||
|
apks: List<File>,
|
||||||
|
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)
|
||||||
+171
@@ -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<File>, 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<File>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
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<StorageManager>()!!
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<out Installer>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<R> = @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<ResultComposable<Any?>>()
|
||||||
|
private var overlayResults = MutableSharedFlow<Pair<ResultComposable<Any?>, 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 <R> startComposableForResult(composable: ResultComposable<R>): 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<InstallerSetting>("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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -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 <reified E : Enum<E>> getEnum(key: String, defaultValue: E): E {
|
||||||
|
return try {
|
||||||
|
enumValueOf<E>(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 <reified E : Enum<E>> putEnum(key: String, value: E) = putString(key, value.name)
|
||||||
|
|
||||||
|
protected class Preference<T>(
|
||||||
|
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 <reified E : Enum<E>> enumPreference(
|
||||||
|
key: String,
|
||||||
|
defaultValue: E,
|
||||||
|
) = Preference(
|
||||||
|
key = key,
|
||||||
|
defaultValue = defaultValue,
|
||||||
|
getter = ::getEnum,
|
||||||
|
setter = ::putEnum
|
||||||
|
)
|
||||||
|
}
|
||||||
+153
@@ -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<DownloadManager>()
|
||||||
|
?: 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
@@ -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)
|
||||||
|
"<failed to read>"
|
||||||
|
}
|
||||||
|
|
||||||
|
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<StorageManager>()!!
|
||||||
|
|
||||||
|
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<Any> = 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")
|
||||||
|
}
|
||||||
@@ -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<Repository>,
|
||||||
|
) {
|
||||||
|
@Immutable
|
||||||
|
@Serializable
|
||||||
|
data class Repository(
|
||||||
|
val name: String,
|
||||||
|
val commits: Int,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<GithubReleaseAssets>,
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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 <reified T> request(
|
||||||
|
crossinline builder: HttpRequestBuilder.() -> Unit = {},
|
||||||
|
): ApiResponse<T> = 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<T>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -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<GithubRelease> =
|
||||||
|
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<RLBuildInfo> =
|
||||||
|
http.request {
|
||||||
|
url(dataJsonUrl)
|
||||||
|
if (force) {
|
||||||
|
header(HttpHeaders.CacheControl, "no-cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches manager self-update releases.
|
||||||
|
*/
|
||||||
|
suspend fun getManagerReleases(): ApiResponse<List<GithubRelease>> =
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
@file:Suppress("NOTHING_TO_INLINE")
|
||||||
|
|
||||||
|
package com.meowarex.rlmobile.network.utils
|
||||||
|
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
|
||||||
|
sealed interface ApiResponse<T> {
|
||||||
|
data class Success<T>(val data: T) : ApiResponse<T>
|
||||||
|
data class Error<T>(val error: ApiError) : ApiResponse<T>
|
||||||
|
data class Failure<T>(val error: ApiFailure) : ApiResponse<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiError(code: HttpStatusCode, body: String?) : Error("HTTP Code $code, Body: $body")
|
||||||
|
|
||||||
|
class ApiFailure(error: Throwable, body: String?) : Error(body, error)
|
||||||
|
|
||||||
|
inline fun <T, R> ApiResponse<T>.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 <T, R> ApiResponse<T>.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 <T, R> ApiResponse<T>.transform(block: (T) -> R): ApiResponse<R> {
|
||||||
|
return if (this !is ApiResponse.Success) {
|
||||||
|
// Error and Failure do not use the generic value
|
||||||
|
this as ApiResponse<R>
|
||||||
|
} else {
|
||||||
|
ApiResponse.Success(block(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> ApiResponse<T>.getOrThrow(): T {
|
||||||
|
return fold(
|
||||||
|
success = { it },
|
||||||
|
fail = { throw it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> ApiResponse<T>.getOrNull(): T? {
|
||||||
|
return fold(
|
||||||
|
success = { it },
|
||||||
|
fail = { null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
inline fun <T, R> ApiResponse<T>.chain(block: (T) -> ApiResponse<R>): ApiResponse<R> {
|
||||||
|
return if (this !is ApiResponse.Success) {
|
||||||
|
// Error and Failure do not use the generic value
|
||||||
|
this as ApiResponse<R>
|
||||||
|
} else {
|
||||||
|
block(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
inline fun <T, R> ApiResponse<T>.chain(secondary: ApiResponse<R>): ApiResponse<R> {
|
||||||
|
return if (secondary is ApiResponse.Success) {
|
||||||
|
secondary
|
||||||
|
} else {
|
||||||
|
// Error and Failure do not use the generic value
|
||||||
|
this as ApiResponse<R>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SemVer>, 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<SemVer> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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<String> = mutableListOf()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The steps to be run, defined by specific step runners.
|
||||||
|
*/
|
||||||
|
abstract val steps: ImmutableList<Step>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 <reified T : Step> getStep(completed: Boolean = true): T {
|
||||||
|
val step = steps.asSequence()
|
||||||
|
.filterIsInstance<T>()
|
||||||
|
.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<IVersion> : 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ByteArray>
|
||||||
|
}
|
||||||
@@ -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<Float> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+63
@@ -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<DownloadTidalStep>().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<StorageManager>()!!
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -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<SemVer>(), KoinComponent {
|
||||||
|
private val paths: PathManager by inject()
|
||||||
|
|
||||||
|
override val localizedName = R.string.patch_step_dl_smali
|
||||||
|
|
||||||
|
override fun getRemoteUrl(container: StepRunner) =
|
||||||
|
container.getStep<FetchInfoStep>().patchesAssetUrl
|
||||||
|
|
||||||
|
override fun getVersion(container: StepRunner) =
|
||||||
|
custom?.version ?: container.getStep<FetchInfoStep>().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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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<Int>(), KoinComponent {
|
||||||
|
private val paths: PathManager by inject()
|
||||||
|
|
||||||
|
override val localizedName = R.string.patch_step_dl_tidal_apk
|
||||||
|
|
||||||
|
override fun getVersion(container: StepRunner) =
|
||||||
|
container.getStep<FetchInfoStep>().data.tidalVersionCode
|
||||||
|
|
||||||
|
override fun getRemoteUrl(container: StepRunner) =
|
||||||
|
container.getStep<FetchInfoStep>().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<ApkVerifier.IssueWithParams>) : Exception(
|
||||||
|
"Failed to verify APK signatures! " +
|
||||||
|
"This is an unoriginal APK that has been tampered with. " +
|
||||||
|
"Verification errors: " + errors.joinToString()
|
||||||
|
)
|
||||||
|
}
|
||||||
+122
@@ -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<CopyDependenciesStep>().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..<dexCount) {
|
||||||
|
val bytes = zip.openEntry(getDexName(idx))!!.read()
|
||||||
|
val file = paths.patchingWorkingDir.resolve(getDexName(idx))
|
||||||
|
file.writeBytes(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.log("Extracted $dexCount dex files")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Align native libs due to using extractNativeLibs
|
||||||
|
container.log("Extracting native libraries to be aligned later")
|
||||||
|
val nativeLibPaths = ZipReader(apk).use { zip ->
|
||||||
|
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..<dexCount)
|
||||||
|
zip.deleteEntry(getDexName(i))
|
||||||
|
|
||||||
|
container.log("Deleting native libraries: $nativeLibPaths")
|
||||||
|
for (path in nativeLibPaths)
|
||||||
|
zip.deleteEntry(path)
|
||||||
|
|
||||||
|
// Write all the files back aligned this time
|
||||||
|
if (resourcesArscBytes != null) {
|
||||||
|
container.log("Writing resources.arsc uncompressed aligned to 4 bytes")
|
||||||
|
zip.writeEntry("resources.arsc", resourcesArscBytes, ZipCompression.NONE, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
container.log("Writing dex files uncompressed aligned to 4 bytes")
|
||||||
|
for (idx in 0..<dexCount) {
|
||||||
|
val file = paths.patchingWorkingDir.resolve(getDexName(idx))
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
zip.writeEntry(getDexName(idx), bytes, ZipCompression.NONE, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back native libraries aligned to 16KiB page boundary
|
||||||
|
for ((idx, path) in nativeLibPaths.withIndex()) {
|
||||||
|
// Ignore lib architectures that don't match this device
|
||||||
|
if (!path.startsWith("lib/$currentDeviceArch"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
val file = paths.patchingWorkingDir.resolve("$idx.so")
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
|
||||||
|
container.log("Writing $path uncompressed aligned to 16KiB")
|
||||||
|
zip.writeEntry(path, bytes, ZipCompression.NONE, 16384)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDexName(idx: Int) = "classes${if (idx == 0) "" else (idx + 1)}.dex"
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package com.meowarex.rlmobile.patcher.steps.install
|
||||||
|
|
||||||
|
import com.meowarex.rlmobile.R
|
||||||
|
import com.meowarex.rlmobile.manager.PathManager
|
||||||
|
import com.meowarex.rlmobile.manager.PreferencesManager
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup patching working directory once the installation has completed.
|
||||||
|
*/
|
||||||
|
class CleanupStep : Step(), KoinComponent {
|
||||||
|
private val paths: PathManager by inject()
|
||||||
|
private val prefs: PreferencesManager by inject()
|
||||||
|
|
||||||
|
override val group = StepGroup.Install
|
||||||
|
override val localizedName = R.string.patch_step_cleanup
|
||||||
|
|
||||||
|
override suspend fun execute(container: StepRunner) {
|
||||||
|
container.log("Moving downloads back to cache")
|
||||||
|
paths.patchingDownloadDir.renameTo(paths.cacheDownloadDir)
|
||||||
|
|
||||||
|
if (prefs.keepPatchedApks) {
|
||||||
|
container.log("keepPatchedApks enabled, keeping working dir")
|
||||||
|
} else {
|
||||||
|
container.log("Deleting patching working dir")
|
||||||
|
if (!paths.patchingWorkingDir.deleteRecursively())
|
||||||
|
throw IllegalStateException("Failed to delete patching working dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package com.meowarex.rlmobile.patcher.steps.install
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.*
|
||||||
|
import com.meowarex.rlmobile.R
|
||||||
|
import com.meowarex.rlmobile.installers.InstallerResult
|
||||||
|
import com.meowarex.rlmobile.installers.root.RootInstaller
|
||||||
|
import com.meowarex.rlmobile.installers.shizuku.ShizukuInstaller
|
||||||
|
import com.meowarex.rlmobile.manager.*
|
||||||
|
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.ui.components.dialogs.PlayProtectDialog
|
||||||
|
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||||
|
import com.meowarex.rlmobile.ui.util.InstallNotifications
|
||||||
|
import com.meowarex.rlmobile.util.*
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID used for showing ready notifications if the activity is currently minimized when having reached this step.
|
||||||
|
*/
|
||||||
|
private const val READY_NOTIF_ID = 200001
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the final APK with the system's PackageManager.
|
||||||
|
*/
|
||||||
|
class InstallStep(private val options: PatchOptions) : Step(), KoinComponent {
|
||||||
|
private val context: Context by inject()
|
||||||
|
private val installers: InstallerManager by inject()
|
||||||
|
private val prefs: PreferencesManager by inject()
|
||||||
|
private val overlays: OverlayManager by inject()
|
||||||
|
|
||||||
|
override val group = StepGroup.Install
|
||||||
|
override val localizedName = R.string.patch_step_install
|
||||||
|
|
||||||
|
override suspend fun execute(container: StepRunner) {
|
||||||
|
val apk = container.getStep<CopyDependenciesStep>().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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -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<CopyDependenciesStep>().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<KeyStore, ByteArray> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+230
@@ -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<CopyDependenciesStep>().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 <application> 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<BinaryResourceIdentifier>): 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -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<CopyDependenciesStep>().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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -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<CopyDependenciesStep>().apk
|
||||||
|
val dexProviders = container.steps
|
||||||
|
.filterIsInstance<IDexProvider>()
|
||||||
|
.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..<priorityDexCount) {
|
||||||
|
// Not enough dex files to move
|
||||||
|
if (idx + 1 > 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..<priorityDexCount) {
|
||||||
|
// Not enough dex files to move
|
||||||
|
if (idx + 1 > 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..<priorityDexCount) {
|
||||||
|
// Not enough dex files to move
|
||||||
|
if (idx + 1 > 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"
|
||||||
|
}
|
||||||
+40
@@ -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<CopyDependenciesStep>().apk
|
||||||
|
val patches = container.getStep<DownloadPatchesStep>()
|
||||||
|
|
||||||
|
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<InstallMetadata>(metadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -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<CopyDependenciesStep>().apk
|
||||||
|
val patchesZip = container.getStep<DownloadPatchesStep>().getStoredFile(container)
|
||||||
|
|
||||||
|
val patches = mutableListOf<LoadedPatch>()
|
||||||
|
|
||||||
|
// 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<String>,
|
||||||
|
)
|
||||||
+62
@@ -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<FetchInfoStep>()
|
||||||
|
.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 -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <adaptive-icon> 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 <background> with.
|
||||||
|
*
|
||||||
|
* @param foregroundIcon A drawable resource id to replace <foreground> with.
|
||||||
|
* @param monochromeIcon A drawable resource id to add or replace <monochrome> 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) {
|
||||||
|
// <monochrome> 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
|
||||||
|
// `<monochrome android:drawable="@drawable/xyz"></monochrome>
|
||||||
|
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 <application> 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+11
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 <meta-data> 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<String, Any>,
|
||||||
|
) : 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Float>,
|
||||||
|
brush: Brush,
|
||||||
|
thumbColor: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val interactionSource = remember(::MutableInteractionSource)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
thumb = {
|
||||||
|
val interactions = remember { mutableStateListOf<Interaction>() }
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
+96
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+103
@@ -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)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user