This commit is contained in:
2026-05-20 19:47:33 +10:00
commit dbb6302bd1
313 changed files with 17869 additions and 0 deletions
+40
View File
@@ -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
+65
View File
@@ -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
View File
@@ -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/
+46
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+1
View File
@@ -0,0 +1 @@
github: [ "rushiiMachine" ]
+20
View File
@@ -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
View File
@@ -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: Whats 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: Ive checked that this issue hasnt 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
View File
@@ -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.
+28
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+20
View 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
+10
View File
@@ -0,0 +1,10 @@
.idea
.gradle
.kotlin
/**/build/
/local.properties
/captures
.DS_Store
*.log
+172
View File
@@ -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.
+227
View File
@@ -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) {
""
}
+60
View File
@@ -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
+81
View File
@@ -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)
}
@@ -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,
)
}
}
}
@@ -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)
}
}
}
@@ -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)
@@ -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,
)
}
}
}
@@ -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)
}
}
}
@@ -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
)
}
@@ -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()
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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()
)
}
@@ -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"
}
@@ -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")
}
}
}
@@ -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")
}
}
}
@@ -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()
}
}
@@ -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,
)
}
}
@@ -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")
}
}
@@ -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"
}
@@ -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))
}
}
}
@@ -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>,
)
@@ -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 -> {}
}
}
}
}
@@ -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")
}
}
@@ -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,
)
}
@@ -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))
}
}
}
}
@@ -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)
}
}
}
@@ -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
)
}
@@ -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,
)
}
@@ -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