mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-06-17 21:13:11 +10:00
Alpha
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.hiddenApi.refine)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
val isRelease = System.getenv("RELEASE")?.toBoolean() ?: false
|
||||
val gitCurrentBranch = providers.execIgnoreCode("git", "symbolic-ref", "--quiet", "--short", "HEAD").takeIf { it.isNotEmpty() }
|
||||
val gitLatestCommit = providers.execIgnoreCode("git", "rev-parse", "--short", "HEAD")
|
||||
val gitHasLocalCommits = gitCurrentBranch?.let { branch ->
|
||||
val remoteBranchExists = providers.execIgnoreCode("git", "ls-remote", "--heads", "origin", branch)
|
||||
.isNotEmpty()
|
||||
|
||||
remoteBranchExists && providers.execIgnoreCode("git", "log", "origin/$branch..HEAD").isNotEmpty()
|
||||
} ?: false
|
||||
val gitHasHasLocalChanges = providers.execIgnoreCode("git", "status", "-s").isNotEmpty()
|
||||
|
||||
android {
|
||||
namespace = "com.meowarex.rlmobile"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 10_03_00
|
||||
versionName = "1.3.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
buildConfigField("String", "TAG", "\"RLMobileManager\"")
|
||||
buildConfigField("String", "SUPPORT_SERVER", "\"\"") // no support server yet
|
||||
|
||||
buildConfigField("String", "PATCHES_REPO_OWNER", "\"meowarex\"")
|
||||
buildConfigField("String", "PATCHES_REPO_NAME", "\"rl-mobile\"")
|
||||
|
||||
buildConfigField("Boolean", "RELEASE", isRelease.toString())
|
||||
buildConfigField("String", "GIT_BRANCH", "\"$gitCurrentBranch\"")
|
||||
buildConfigField("String", "GIT_COMMIT", "\"$gitLatestCommit\"")
|
||||
buildConfigField("boolean", "GIT_LOCAL_COMMITS", "$gitHasLocalCommits")
|
||||
buildConfigField("boolean", "GIT_LOCAL_CHANGES", "$gitHasHasLocalChanges")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
enableV3Signing = true
|
||||
enableV4Signing = true
|
||||
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
||||
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
|
||||
storeFile = System.getenv("SIGNING_STORE_FILE")?.let(::File)
|
||||
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
val isRelease = System.getenv("RELEASE")?.toBoolean() ?: false
|
||||
val hasReleaseSigning = System.getenv("SIGNING_STORE_PASSWORD")?.isNotEmpty() == true
|
||||
|
||||
if (isRelease && !hasReleaseSigning)
|
||||
error("Missing keystore in a release workflow!")
|
||||
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
isCrunchPngs = true
|
||||
signingConfig = signingConfigs.getByName(if (hasReleaseSigning) "release" else "debug")
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
|
||||
create("staging") {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
isCrunchPngs = true
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) {
|
||||
it.packaging.resources.excludes.apply {
|
||||
// Debug metadata
|
||||
add("/**/*.version")
|
||||
add("/kotlin-tooling-metadata.json")
|
||||
// Kotlin debugging (https://github.com/Kotlin/kotlinx.coroutines/issues/2274)
|
||||
add("/DebugProbesKt.bin")
|
||||
// Reflection symbol list (https://stackoverflow.com/a/41073782/13964629)
|
||||
add("/**/*.kotlin_builtins")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// okhttp3 is used by some lib (no cookies so publicsuffixes.gz can be dropped)
|
||||
excludes += "/okhttp3/**"
|
||||
|
||||
// Remnants of smali/baksmali lib
|
||||
excludes += "/*.properties"
|
||||
excludes += "/org/antlr/**"
|
||||
excludes += "/com/android/tools/smali/**"
|
||||
excludes += "/org/eclipse/jgit/**"
|
||||
|
||||
// bouncycastle
|
||||
excludes += "/META-INF/versions/9/OSGI-INF/MANIFEST.MF"
|
||||
excludes += "/org/bouncycastle/**"
|
||||
}
|
||||
jniLibs {
|
||||
// x86 is dead
|
||||
excludes += "/lib/x86/*.so"
|
||||
|
||||
// Equivalent of AndroidManifest's extractNativeLibs=false
|
||||
useLegacyPackaging = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += "ModifierParameter"
|
||||
disable += "ExtraTranslation"
|
||||
}
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
// Temporary workaround for https://youtrack.jetbrains.com/projects/KT/issues/KT-83266/
|
||||
// Remove once updated to Kotlin 2.3.10
|
||||
includeComposeMappingFile.set(false)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
val reportsDir = layout.buildDirectory.asFile.get()
|
||||
.resolve("reports").absolutePath
|
||||
|
||||
jvmTarget = JvmTarget.JVM_21
|
||||
optIn.addAll(
|
||||
"androidx.compose.animation.ExperimentalAnimationApi",
|
||||
"androidx.compose.foundation.ExperimentalFoundationApi",
|
||||
"androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
"androidx.compose.material3.ExperimentalMaterial3Api",
|
||||
"kotlin.time.ExperimentalTime",
|
||||
"kotlinx.serialization.ExperimentalSerializationApi",
|
||||
)
|
||||
freeCompilerArgs.addAll(
|
||||
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${reportsDir}",
|
||||
"-XXLanguage:+ExplicitBackingFields",
|
||||
"-XXLanguage:+PropertyParamAnnotationDefaultTargetMode", // @StringRes in field parameters of a class warning
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
// Disable warnings about obsolete target version
|
||||
options.compilerArgs.add("-Xlint:-options")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.bundles.accompanist)
|
||||
implementation(libs.bundles.androidx)
|
||||
implementation(libs.bundles.coil)
|
||||
implementation(libs.bundles.compose)
|
||||
implementation(libs.bundles.koin)
|
||||
implementation(libs.bundles.ktor)
|
||||
implementation(libs.bundles.shizuku)
|
||||
implementation(libs.bundles.voyager)
|
||||
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
debugImplementation(libs.compose.runtime.tracing)
|
||||
|
||||
implementation(libs.kotlinx.immutable)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
compileOnly(libs.hiddenApi.stub)
|
||||
implementation(libs.hiddenApi.refine)
|
||||
implementation(libs.hiddenApi.bypass)
|
||||
|
||||
implementation(libs.apksig)
|
||||
implementation(libs.axml)
|
||||
implementation(libs.bouncycastle)
|
||||
implementation(libs.binaryResources)
|
||||
implementation(libs.dhizuku.api)
|
||||
implementation(libs.diff)
|
||||
implementation(libs.microg)
|
||||
implementation(libs.smali)
|
||||
implementation(libs.baksmali)
|
||||
implementation(libs.compose.pipette)
|
||||
implementation(libs.compose.shimmer)
|
||||
implementation(libs.libsu)
|
||||
implementation(libs.zip)
|
||||
|
||||
coreLibraryDesugaring(libs.desugaring)
|
||||
}
|
||||
|
||||
fun ProviderFactory.execIgnoreCode(vararg command: String): String = try {
|
||||
val result = exec {
|
||||
commandLine = command.toList()
|
||||
isIgnoreExitValue = true
|
||||
}
|
||||
|
||||
result.standardOutput.asText.get().trim()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
# Keep `Companion` object fields of serializable classes.
|
||||
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
|
||||
-if @kotlinx.serialization.Serializable class **
|
||||
-keepclassmembers class <1> {
|
||||
static <1>$Companion Companion;
|
||||
}
|
||||
|
||||
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
static **$* *;
|
||||
}
|
||||
-keepclassmembers class <2>$<3> {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Keep `INSTANCE.serializer()` of serializable objects.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
public static ** INSTANCE;
|
||||
}
|
||||
-keepclassmembers class <1> {
|
||||
public static <1> INSTANCE;
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
|
||||
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
||||
|
||||
# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`.
|
||||
# If you have any, uncomment and replace classes with those containing named companion objects.
|
||||
-keepattributes InnerClasses # Needed for `getDeclaredClasses`.
|
||||
-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept.
|
||||
static <1>$$serializer INSTANCE;
|
||||
}
|
||||
|
||||
# Keep fields for APK verification, used through reflection
|
||||
-keepclassmembers public class com.android.apksig.** {
|
||||
public *;
|
||||
}
|
||||
|
||||
# Keep class names of patching steps since they're used via reflection
|
||||
-keepnames class com.meowarex.rlmobile.patcher.steps.**
|
||||
|
||||
# Repackage classes into the top-level.
|
||||
-repackageclasses
|
||||
|
||||
# Amount of optimization iterations, taken from an SO post
|
||||
-optimizationpasses 5
|
||||
-mergeinterfacesaggressively
|
||||
|
||||
# Broaden access modifiers to increase results during optimization
|
||||
-allowaccessmodification
|
||||
|
||||
# Suppress missing class warnings
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
# Keeps all class and field names for debugging
|
||||
-keepnames class ** { *; }
|
||||
|
||||
# Preserve the line number information for debugging stack traces.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-sdk tools:overrideLibrary="com.rosan.dhizuku.api" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||
<uses-permission
|
||||
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||
tools:ignore="RequestInstallPackagesPolicy" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="AllFilesAccessPolicy,ScopedStorage" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
|
||||
|
||||
<application
|
||||
android:name=".ManagerApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/new_backup_rules"
|
||||
android:fullBackupContent="@xml/old_backup_rules"
|
||||
android:hasFragileUserData="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.AppCompat.NoActionBar"
|
||||
tools:targetApi="29">
|
||||
|
||||
<receiver
|
||||
android:name=".installers.pm.PMIntentReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name="rikka.shizuku.ShizukuProvider"
|
||||
android:authorities="${applicationId}.shizuku"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:multiprocess="false"
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.RadiantLyricsManager.SplashScreen"
|
||||
tools:ignore="DiscouragedApi,LockedOrientationActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<action android:name="com.meowarex.rlmobile.REINSTALL" />
|
||||
<action android:name="com.meowarex.rlmobile.OPEN_PLUGINS" />
|
||||
<action android:name="com.meowarex.rlmobile.IMPORT_COMPONENT" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,210 @@
|
||||
package com.meowarex.rlmobile
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.navigator.*
|
||||
import cafe.adriel.voyager.transitions.SlideTransition
|
||||
import com.meowarex.rlmobile.MainActivity.Companion.EXTRA_COMPONENT_TYPE
|
||||
import com.meowarex.rlmobile.MainActivity.Companion.EXTRA_FILE_PATH
|
||||
import com.meowarex.rlmobile.MainActivity.Companion.EXTRA_PACKAGE_NAME
|
||||
import com.meowarex.rlmobile.manager.*
|
||||
import com.meowarex.rlmobile.patcher.InstallMetadata
|
||||
import com.meowarex.rlmobile.ui.screens.home.HomeScreen
|
||||
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel
|
||||
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreen
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import com.meowarex.rlmobile.ui.widgets.updater.UpdaterDialog
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val permissions: PermissionsModel by viewModel()
|
||||
private val preferences: PreferencesManager by inject()
|
||||
private val overlays: OverlayManager by inject()
|
||||
private val paths: PathManager by inject()
|
||||
private val json: Json by inject()
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
installSplashScreen()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
// Refresh permissions when the activity resumes
|
||||
LifecycleResumeEffect(Unit) {
|
||||
permissions.refresh()
|
||||
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
ManagerTheme(
|
||||
theme = preferences.theme,
|
||||
dynamicColor = preferences.dynamicColor,
|
||||
) {
|
||||
if (BuildConfig.RELEASE) {
|
||||
UpdaterDialog()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalVoyagerApi::class)
|
||||
CompositionLocalProvider(
|
||||
LocalNavigatorSaver provides parcelableNavigatorSaver(),
|
||||
) {
|
||||
Navigator(
|
||||
screen = HomeScreen(),
|
||||
onBackPressed = null,
|
||||
) { navigator ->
|
||||
// Open the permissions screen whenever permissions are insufficient
|
||||
LaunchedEffect(permissions.requiredPermsGranted) {
|
||||
if (!permissions.requiredPermsGranted)
|
||||
navigator.pushOnce(PermissionsScreen())
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
this@MainActivity.intent?.let { handleNewIntent(it, navigator) }
|
||||
|
||||
fun handle(intent: Intent) = handleNewIntent(intent, navigator)
|
||||
addOnNewIntentListener(::handle)
|
||||
onDispose { removeOnNewIntentListener(::handle) }
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
navigator.back(this@MainActivity)
|
||||
}
|
||||
|
||||
SlideTransition(
|
||||
navigator = navigator,
|
||||
disposeScreenAfterTransitionEnd = true,
|
||||
animationSpec = spring(
|
||||
stiffness = Spring.StiffnessMedium,
|
||||
visibilityThreshold = IntOffset.VisibilityThreshold,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
overlays.Overlays()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNewIntent(intent: Intent, navigator: Navigator) = scope.launchBlock {
|
||||
when (intent.action) {
|
||||
INTENT_REINSTALL -> {
|
||||
val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: run {
|
||||
Log.w(BuildConfig.TAG, "Missing $EXTRA_PACKAGE_NAME extra for intent $INTENT_REINSTALL")
|
||||
return@launchBlock
|
||||
}
|
||||
|
||||
navigator.push(handleReinstall(packageName))
|
||||
}
|
||||
|
||||
INTENT_OPEN_PLUGINS -> {
|
||||
// TODO: per-install plugins screen
|
||||
// val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: run {
|
||||
// Log.w(BuildConfig.TAG, "Missing $EXTRA_PACKAGE_NAME extra for intent $INTENT_REINSTALL")
|
||||
// return@launchBlock
|
||||
// }
|
||||
|
||||
navigator.push(PluginsScreen())
|
||||
}
|
||||
|
||||
INTENT_IMPORT_COMPONENT -> {
|
||||
val path = intent.getStringExtra(EXTRA_FILE_PATH) ?: run {
|
||||
Log.w(BuildConfig.TAG, "Missing $EXTRA_FILE_PATH extra for intent $INTENT_IMPORT_COMPONENT")
|
||||
mainThread { showToast(R.string.intent_import_component_failure) }
|
||||
return@launchBlock
|
||||
}
|
||||
val componentType = intent.getStringExtra(EXTRA_COMPONENT_TYPE) ?: run {
|
||||
Log.w(BuildConfig.TAG, "Missing $EXTRA_COMPONENT_TYPE extra for intent $INTENT_IMPORT_COMPONENT")
|
||||
mainThread { showToast(R.string.intent_import_component_failure) }
|
||||
return@launchBlock
|
||||
}
|
||||
|
||||
val file = File("/data/local/tmp", path)
|
||||
if (!file.exists()) {
|
||||
Log.w(BuildConfig.TAG, "Intent $INTENT_IMPORT_COMPONENT specified an invalid file!")
|
||||
mainThread { showToast(R.string.intent_import_component_failure) }
|
||||
return@launchBlock
|
||||
}
|
||||
|
||||
val targetDir = when (componentType) {
|
||||
"injector" -> paths.customInjectorsDir
|
||||
"patches" -> paths.customPatchesDir
|
||||
else -> {
|
||||
Log.w(BuildConfig.TAG, "Extra $EXTRA_COMPONENT_TYPE is not a valid value!")
|
||||
mainThread { showToast(R.string.intent_import_component_failure) }
|
||||
return@launchBlock
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
file.copyTo(targetDir.resolve(file.name), overwrite = true)
|
||||
file.delete() // This most likely silently fails
|
||||
} catch (e: Exception) {
|
||||
Log.e(BuildConfig.TAG, "Failed to import custom component", e)
|
||||
mainThread { showToast(R.string.intent_import_component_failure) }
|
||||
}
|
||||
|
||||
mainThread { showToast(R.string.intent_import_component_success, file.name) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(BuildConfig.TAG, "Unhandled intent ${intent.action}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleReinstall(packageName: String): Screen {
|
||||
val metadata = try {
|
||||
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||
val metadataFile = ZipReader(applicationInfo.publicSourceDir)
|
||||
.use { it.openEntry("rlmobile.json")?.read() }
|
||||
|
||||
metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) }
|
||||
} catch (t: Throwable) {
|
||||
Log.w(BuildConfig.TAG, "Failed to parse Radiant Lyrics install metadata from package $packageName", t)
|
||||
mainThread { showToast(R.string.intent_reinstall_fail) }
|
||||
null
|
||||
}
|
||||
|
||||
val patchOptions = metadata?.options
|
||||
?: PatchOptions.Default.copy(packageName = packageName)
|
||||
|
||||
return PatchingScreen(patchOptions)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val INTENT_REINSTALL = "com.meowarex.rlmobile.REINSTALL"
|
||||
const val INTENT_OPEN_PLUGINS = "com.meowarex.rlmobile.OPEN_PLUGINS"
|
||||
const val INTENT_IMPORT_COMPONENT = "com.meowarex.rlmobile.IMPORT_COMPONENT"
|
||||
|
||||
const val EXTRA_PACKAGE_NAME = "rlmobile.packageName"
|
||||
const val EXTRA_FILE_PATH = "rlmobile.file"
|
||||
const val EXTRA_COMPONENT_TYPE = "rlmobile.componentType"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.meowarex.rlmobile
|
||||
|
||||
import android.app.Application
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.annotation.DelicateCoilApi
|
||||
import com.meowarex.rlmobile.di.*
|
||||
import com.meowarex.rlmobile.installers.dhizuku.DhizukuInstaller
|
||||
import com.meowarex.rlmobile.installers.intent.IntentInstaller
|
||||
import com.meowarex.rlmobile.installers.pm.PMInstaller
|
||||
import com.meowarex.rlmobile.installers.root.RootInstaller
|
||||
import com.meowarex.rlmobile.installers.shizuku.ShizukuInstaller
|
||||
import com.meowarex.rlmobile.manager.*
|
||||
import com.meowarex.rlmobile.manager.download.AndroidDownloadManager
|
||||
import com.meowarex.rlmobile.manager.download.KtorDownloadManager
|
||||
import com.meowarex.rlmobile.network.services.*
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutModel
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsModel
|
||||
import com.meowarex.rlmobile.ui.screens.home.HomeModel
|
||||
import com.meowarex.rlmobile.ui.screens.log.LogScreenModel
|
||||
import com.meowarex.rlmobile.ui.screens.logs.LogsListScreenModel
|
||||
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenModel
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsModel
|
||||
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.PluginsModel
|
||||
import com.meowarex.rlmobile.ui.screens.settings.SettingsModel
|
||||
import com.meowarex.rlmobile.ui.widgets.updater.UpdaterViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.module.dsl.*
|
||||
import org.koin.dsl.module
|
||||
|
||||
class ManagerApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
startKoin {
|
||||
// Android activities & context
|
||||
androidContext(this@ManagerApplication)
|
||||
modules(module(createdAtStart = true) {
|
||||
singleOf(::ActivityProvider)
|
||||
})
|
||||
|
||||
// HTTP
|
||||
modules(module {
|
||||
single { provideJson() }
|
||||
single { provideHttpClient() }
|
||||
})
|
||||
|
||||
// Services
|
||||
modules(module {
|
||||
singleOf(::HttpService)
|
||||
singleOf(::RadiantLyricsGithubService)
|
||||
})
|
||||
|
||||
// UI Models
|
||||
modules(module {
|
||||
factoryOf(::HomeModel)
|
||||
factoryOf(::PluginsModel)
|
||||
factoryOf(::AboutModel)
|
||||
factoryOf(::PatchingScreenModel)
|
||||
factoryOf(::SettingsModel)
|
||||
factoryOf(::PatchOptionsModel)
|
||||
factoryOf(::ComponentOptionsModel)
|
||||
factoryOf(::LogScreenModel)
|
||||
factoryOf(::LogsListScreenModel)
|
||||
factoryOf(::PermissionsModel)
|
||||
viewModelOf(::UpdaterViewModel)
|
||||
})
|
||||
|
||||
// Managers
|
||||
modules(module {
|
||||
single { providePreferences() }
|
||||
singleOf(::PathManager)
|
||||
singleOf(::InstallerManager)
|
||||
singleOf(::OverlayManager)
|
||||
singleOf(::InstallLogManager)
|
||||
|
||||
singleOf(::ShizukuManager)
|
||||
singleOf(::DhizukuManager)
|
||||
|
||||
singleOf(::AndroidDownloadManager)
|
||||
singleOf(::KtorDownloadManager)
|
||||
})
|
||||
|
||||
// Installers
|
||||
modules(module {
|
||||
singleOf(::PMInstaller)
|
||||
singleOf(::RootInstaller)
|
||||
singleOf(::IntentInstaller)
|
||||
singleOf(::ShizukuInstaller)
|
||||
singleOf(::DhizukuInstaller)
|
||||
})
|
||||
}
|
||||
|
||||
// Limit parallel fetching of images using Coil
|
||||
@OptIn(DelicateCoilApi::class)
|
||||
SingletonImageLoader.setUnsafe { context ->
|
||||
ImageLoader.Builder(context)
|
||||
.fetcherCoroutineContext(Dispatchers.IO.limitedParallelism(5))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.meowarex.rlmobile.di
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import java.util.Objects
|
||||
|
||||
class ActivityProvider(application: Application) {
|
||||
private var activeActivity: Activity? = null
|
||||
|
||||
/**
|
||||
* Gets the current active activity as a specific activity or errors otherwise.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T : Activity> get(): T = Objects.requireNonNull(activeActivity, "No active activity cached!") as T
|
||||
|
||||
init {
|
||||
application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
override fun onActivityStopped(activity: Activity) {}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
activeActivity = activity
|
||||
}
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
activeActivity = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.meowarex.rlmobile.di
|
||||
|
||||
import android.app.Application
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.HttpClientCall
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.cache.HttpCache
|
||||
import io.ktor.client.plugins.cache.storage.FileStorage
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.statement.HttpReceivePipeline
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.util.AttributeKey
|
||||
import io.ktor.util.date.GMTDate
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.InternalAPI
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Dns
|
||||
import org.koin.core.scope.Scope
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
@Suppress("UnusedReceiverParameter")
|
||||
fun Scope.provideJson() = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun Scope.provideHttpClient() = HttpClient(OkHttp) {
|
||||
val json: Json = get()
|
||||
val application: Application = get()
|
||||
|
||||
defaultRequest {
|
||||
header(HttpHeaders.UserAgent, "Radiant Lyrics Manager/${BuildConfig.VERSION_NAME}")
|
||||
}
|
||||
|
||||
engine {
|
||||
config {
|
||||
dns(object : Dns {
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
val addresses = Dns.SYSTEM.lookup(hostname)
|
||||
|
||||
// Github's nameservers do not respond to IPv6 requests for raw.githubusercontent.com,
|
||||
// which causes CIO, Android and OkHTTP to all hang
|
||||
return if (hostname == "raw.githubusercontent.com") {
|
||||
addresses.filterIsInstance<Inet4Address>()
|
||||
} else {
|
||||
addresses
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
install(ContentNegotiation) {
|
||||
json(json)
|
||||
}
|
||||
|
||||
install(HttpTimeout) {
|
||||
socketTimeoutMillis = 30000
|
||||
connectTimeoutMillis = 30000
|
||||
}
|
||||
|
||||
install(HttpCache) {
|
||||
val dir = application.cacheDir.resolve("ktor")
|
||||
publicStorage(FileStorage(dir))
|
||||
}
|
||||
|
||||
install(HttpCookies) {
|
||||
// Default storage is in-memory
|
||||
}
|
||||
|
||||
// Custom plugin to allow overriding response cache headers, and force caching
|
||||
install("OverrideCacheControl") {
|
||||
receivePipeline.intercept(HttpReceivePipeline.Before) { response ->
|
||||
val customCacheControl = response.call.attributes.getOrNull(CustomCacheControl)
|
||||
?: return@intercept
|
||||
|
||||
proceedWith(object : HttpResponse() {
|
||||
@InternalAPI
|
||||
override val rawContent: ByteReadChannel = response.rawContent
|
||||
override val call: HttpClientCall = response.call
|
||||
override val coroutineContext: CoroutineContext = response.coroutineContext
|
||||
override val requestTime: GMTDate = response.requestTime
|
||||
override val responseTime: GMTDate = response.responseTime
|
||||
override val status: HttpStatusCode = response.status
|
||||
override val version: HttpProtocolVersion = response.version
|
||||
|
||||
override val headers: Headers = headers {
|
||||
appendAll(response.headers)
|
||||
set(HttpHeaders.CacheControl, customCacheControl.toString())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val CustomCacheControl: AttributeKey<CacheControl> = AttributeKey("CustomCacheControl")
|
||||
|
||||
fun HttpRequestBuilder.cacheControl(cacheControl: CacheControl) {
|
||||
attributes.put(CustomCacheControl, cacheControl)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.meowarex.rlmobile.di
|
||||
|
||||
import android.content.Context
|
||||
import com.meowarex.rlmobile.manager.PreferencesManager
|
||||
import org.koin.core.scope.Scope
|
||||
|
||||
fun Scope.providePreferences(): PreferencesManager {
|
||||
val ctx: Context = get()
|
||||
return PreferencesManager(ctx.getSharedPreferences("preferences", Context.MODE_PRIVATE))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.meowarex.rlmobile.installers
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A generic installer interface that manages installing APKs
|
||||
*/
|
||||
interface Installer {
|
||||
/**
|
||||
* Starts an installation and forgets about it. A toast will be shown if the installation completes successfully.
|
||||
* @param apks All APKs (including any splits) willed be merged into a single install.
|
||||
* @param silent If this is an update, then the update will occur without user interaction.
|
||||
*/
|
||||
suspend fun install(apks: List<File>, silent: Boolean = true)
|
||||
|
||||
/**
|
||||
* Starts an installation and waits for it to finish with a result. A toast will be shown for all result states.
|
||||
* @param apks All APKs (including any splits) willed be merged into a single install.
|
||||
* @param silent If this is an update, then the update will occur without user interaction.
|
||||
* @param onProgressUpdate A progress callback invoked by the Android system representing the total progress of the installation.
|
||||
* This may not be available for all underlying installers.
|
||||
*/
|
||||
suspend fun waitInstall(
|
||||
apks: List<File>,
|
||||
silent: Boolean = true,
|
||||
onProgressUpdate: ProgressListener? = null,
|
||||
): InstallerResult
|
||||
|
||||
/**
|
||||
* Triggers an uninstallation and waits for it to complete with a result. A toast will be shown for all result states.
|
||||
* @param packageName The package name of the target package.
|
||||
*/
|
||||
suspend fun waitUninstall(packageName: String): InstallerResult
|
||||
|
||||
/**
|
||||
* A callback executed from a coroutine called when the Android system returns progress updates
|
||||
* about a currently running installation session. This callback should finish as soon as possible,
|
||||
* otherwise it will slow down installation.
|
||||
*/
|
||||
fun interface ProgressListener {
|
||||
/**
|
||||
* @param progress The current installation progress in a `[0,1]` range.
|
||||
*/
|
||||
fun onUpdate(progress: Float)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.meowarex.rlmobile.installers
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* The state of an APK installation after it has completed and cleaned up.
|
||||
*/
|
||||
sealed interface InstallerResult : Parcelable {
|
||||
/**
|
||||
* The installation was successfully completed.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Success : InstallerResult
|
||||
|
||||
/**
|
||||
* This installation was interrupted and the install session has been canceled.
|
||||
* @param systemTriggered Whether the cancellation happened from the system (ie. clicked cancel on the install prompt)
|
||||
* Otherwise, this was caused by a coroutine cancellation.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Cancelled(val systemTriggered: Boolean) : InstallerResult
|
||||
|
||||
/**
|
||||
* This installation encountered an error and has been aborted.
|
||||
* All implementors should implement [Parcelable].
|
||||
*/
|
||||
abstract class Error : InstallerResult, Parcelable {
|
||||
/**
|
||||
* The full internal error representation.
|
||||
*/
|
||||
abstract fun getDebugReason(): String
|
||||
|
||||
/**
|
||||
* Simplified + translatable user facing reason for the failure.
|
||||
* If null is returned, then the [getDebugReason] will be used instead.
|
||||
*/
|
||||
open fun getLocalizedReason(context: Context): String? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.meowarex.rlmobile.installers
|
||||
|
||||
import android.content.Context
|
||||
import com.meowarex.rlmobile.R
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class UnknownInstallerError(val error: Throwable) : InstallerResult.Error() {
|
||||
override fun getDebugReason() = error.stackTraceToString()
|
||||
|
||||
// No localizations for exceptions, use short message anyway
|
||||
override fun getLocalizedReason(context: Context) =
|
||||
error.message ?: context.getString(R.string.install_error_unknown)
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package com.meowarex.rlmobile.installers.dhizuku
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.*
|
||||
import com.meowarex.rlmobile.installers.Installer
|
||||
import com.meowarex.rlmobile.installers.InstallerResult
|
||||
import com.meowarex.rlmobile.installers.pm.PMUtils
|
||||
import com.meowarex.rlmobile.manager.DhizukuManager
|
||||
import com.meowarex.rlmobile.util.HiddenAPI
|
||||
import com.rosan.dhizuku.api.Dhizuku
|
||||
import dev.rikka.tools.refine.Refine
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import rikka.shizuku.SystemServiceHelper
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Uses Dhizuku to remotely invoke the [PackageInstaller] API using device owner.
|
||||
*/
|
||||
class DhizukuInstaller(
|
||||
private val context: Context,
|
||||
private val dhizuku: DhizukuManager,
|
||||
) : Installer {
|
||||
/**
|
||||
* Gets the Dhizuku API binder for [IPackageInstaller].
|
||||
*/
|
||||
private fun getPackageInstallerBinder(): IPackageInstaller {
|
||||
HiddenAPI.disable()
|
||||
|
||||
val iPackageManager = IPackageManager.Stub.asInterface(
|
||||
Dhizuku.binderWrapper(SystemServiceHelper.getSystemService("package"))
|
||||
)
|
||||
val iPackageInstaller = IPackageInstaller.Stub.asInterface(
|
||||
Dhizuku.binderWrapper(iPackageManager.packageInstaller.asBinder())
|
||||
)
|
||||
|
||||
return iPackageInstaller
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens and binds a [PackageInstaller.Session] wrapper through Dhizuku.
|
||||
*/
|
||||
fun openSession(sessionId: Int): PackageInstaller.Session {
|
||||
HiddenAPI.disable()
|
||||
|
||||
val iPackageInstaller = getPackageInstallerBinder()
|
||||
val iSession = IPackageInstallerSession.Stub.asInterface(
|
||||
Dhizuku.binderWrapper(iPackageInstaller.openSession(sessionId).asBinder())
|
||||
)
|
||||
return Refine.unsafeCast(PackageInstallerHidden.SessionHidden(iSession))
|
||||
}
|
||||
|
||||
override suspend fun install(apks: List<File>, silent: Boolean) {
|
||||
if (!dhizuku.requestPermissions())
|
||||
throw IllegalStateException("Dhizuku is not available!")
|
||||
|
||||
// Construct install session and create it
|
||||
val params = PMUtils.createInstallSessionParams(silent = true)
|
||||
val packageInstaller = PMUtils.getPackageInstaller(
|
||||
context = context,
|
||||
iPackageInstaller = getPackageInstallerBinder(),
|
||||
installerPackageName = Dhizuku.getOwnerPackageName(),
|
||||
)
|
||||
val sessionId = packageInstaller.createSession(params)
|
||||
|
||||
PMUtils.startInstall(
|
||||
context = context,
|
||||
session = openSession(sessionId),
|
||||
sessionId = sessionId,
|
||||
apks = apks,
|
||||
relay = false,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun waitInstall(
|
||||
apks: List<File>,
|
||||
silent: Boolean,
|
||||
onProgressUpdate: Installer.ProgressListener?,
|
||||
): InstallerResult {
|
||||
if (!dhizuku.requestPermissions())
|
||||
throw IllegalStateException("Dhizuku is not available!")
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
// Construct install session and create it
|
||||
val params = PMUtils.createInstallSessionParams(silent = true)
|
||||
val packageInstaller = PMUtils.getPackageInstaller(
|
||||
context = context,
|
||||
iPackageInstaller = getPackageInstallerBinder(),
|
||||
installerPackageName = Dhizuku.getOwnerPackageName(),
|
||||
)
|
||||
val sessionId = packageInstaller.createSession(params)
|
||||
|
||||
// Create and register a result receiver
|
||||
val relayReceiver = PMUtils.registerRelayReceiver(
|
||||
context = context,
|
||||
sessionId = sessionId,
|
||||
isUninstall = false,
|
||||
onResult = continuation::resume,
|
||||
)
|
||||
|
||||
// Create and register a progress callback
|
||||
val sessionCallback = onProgressUpdate?.let { onProgressUpdate ->
|
||||
PMUtils.registerSessionCallback(
|
||||
sessionId = sessionId,
|
||||
packageInstaller = packageInstaller,
|
||||
onProgressUpdate = onProgressUpdate,
|
||||
)
|
||||
}
|
||||
|
||||
// Unregister PMResultReceiver when this coroutine finishes or errors
|
||||
// Explicitly cancel the install session if it did not finish.
|
||||
continuation.invokeOnCancellation {
|
||||
context.unregisterReceiver(relayReceiver)
|
||||
sessionCallback?.let { packageInstaller.unregisterSessionCallback(it) }
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
}
|
||||
|
||||
PMUtils.startInstall(
|
||||
context = context,
|
||||
session = openSession(sessionId),
|
||||
sessionId = sessionId,
|
||||
apks = apks,
|
||||
relay = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun waitUninstall(packageName: String): InstallerResult {
|
||||
if (!dhizuku.requestPermissions())
|
||||
throw IllegalStateException("Dhizuku is not available!")
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val packageInstaller = PMUtils.getPackageInstaller(
|
||||
context = context,
|
||||
iPackageInstaller = getPackageInstallerBinder(),
|
||||
installerPackageName = Dhizuku.getOwnerPackageName(),
|
||||
)
|
||||
|
||||
// Create and register a result receiver
|
||||
val relayReceiver = PMUtils.registerRelayReceiver(
|
||||
context = context,
|
||||
sessionId = -1,
|
||||
isUninstall = true,
|
||||
onResult = continuation::resume,
|
||||
)
|
||||
|
||||
// Unregister PMResultReceiver when this coroutine finishes or errors
|
||||
continuation.invokeOnCancellation {
|
||||
context.unregisterReceiver(relayReceiver)
|
||||
}
|
||||
|
||||
packageInstaller.uninstall(
|
||||
/* packageName = */ packageName,
|
||||
/* statusReceiver = */ PMUtils.createUninstallRelayingIntent(context).intentSender,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package com.meowarex.rlmobile.installers.intent
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.FileProvider
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.di.ActivityProvider
|
||||
import com.meowarex.rlmobile.installers.*
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Launches an (un)installation intent to invoke the system's default app installer.
|
||||
* This defaults to the package installer in AOSP, however custom installers such as InstallerX
|
||||
* can intercept install intents if configured to do so.
|
||||
*/
|
||||
class IntentInstaller(
|
||||
private val context: Context,
|
||||
private val activities: ActivityProvider,
|
||||
) : Installer {
|
||||
val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
override suspend fun install(apks: List<File>, silent: Boolean) {
|
||||
coroutineScope.launch { waitInstall(apks, silent) }
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("RequestInstallPackagesPolicy")
|
||||
override suspend fun waitInstall(
|
||||
apks: List<File>,
|
||||
silent: Boolean,
|
||||
onProgressUpdate: Installer.ProgressListener?,
|
||||
): InstallerResult {
|
||||
val file = apks.singleOrNull()
|
||||
?: throw IllegalArgumentException("IntentInstaller only supports installing a single APK")
|
||||
val fileUri = if (Build.VERSION.SDK_INT >= 24) {
|
||||
FileProvider.getUriForFile(
|
||||
/* context = */ context,
|
||||
/* authority = */ "${BuildConfig.APPLICATION_ID}.provider",
|
||||
/* file = */ file,
|
||||
)
|
||||
} else {
|
||||
Uri.fromFile(file)
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE, fileUri)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.putExtra(Intent.EXTRA_RETURN_RESULT, true)
|
||||
.putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, BuildConfig.APPLICATION_ID)
|
||||
|
||||
val resultCode = try {
|
||||
launchForResultCode(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
return UnsupportedIntentInstallerError(Intent.ACTION_INSTALL_PACKAGE)
|
||||
}
|
||||
|
||||
return parseResultCode(resultCode)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override suspend fun waitUninstall(packageName: String): InstallerResult {
|
||||
// Ignore if the package does not exist
|
||||
try {
|
||||
context.packageManager.getPackageInfo(packageName, 0)
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
return InstallerResult.Success
|
||||
}
|
||||
|
||||
val uri = Uri.fromParts("package", packageName, null)
|
||||
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, uri)
|
||||
.putExtra(Intent.EXTRA_RETURN_RESULT, true)
|
||||
|
||||
val resultCode = try {
|
||||
launchForResultCode(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
return UnsupportedIntentInstallerError(Intent.ACTION_UNINSTALL_PACKAGE)
|
||||
}
|
||||
|
||||
return parseResultCode(resultCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the result code of an intent that was launched with [Intent.EXTRA_RETURN_RESULT]
|
||||
* for package installer operations.
|
||||
*/
|
||||
private fun parseResultCode(resultCode: Int): InstallerResult {
|
||||
return when (resultCode) {
|
||||
AppCompatActivity.RESULT_OK -> InstallerResult.Success
|
||||
AppCompatActivity.RESULT_CANCELED -> InstallerResult.Cancelled(systemTriggered = true)
|
||||
AppCompatActivity.RESULT_FIRST_USER -> // This is returned on errors
|
||||
UnknownInstallerError(IllegalStateException("External installer failed!"))
|
||||
|
||||
else -> UnknownInstallerError(IllegalStateException("External installer returned unknown result code $resultCode"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches an intent activity and captures it's result code.
|
||||
*/
|
||||
private suspend fun launchForResultCode(intent: Intent): Int {
|
||||
val activity = activities.get<ComponentActivity>()
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val launcher = activity.activityResultRegistry.register(
|
||||
key = UUID.randomUUID().toString(),
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
callback = { continuation.resume(it.resultCode) },
|
||||
)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
launcher.unregister()
|
||||
}
|
||||
|
||||
launcher.launch(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.meowarex.rlmobile.installers.intent
|
||||
|
||||
import android.content.Context
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.installers.InstallerResult
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class UnsupportedIntentInstallerError(private val action: String) : InstallerResult.Error() {
|
||||
override fun getDebugReason() = "This Android rom does not support $action!"
|
||||
|
||||
override fun getLocalizedReason(context: Context) =
|
||||
context.getString(R.string.install_error_unhandled_intent, action)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.meowarex.rlmobile.installers.pm
|
||||
|
||||
import android.app.Application
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.installers.Installer
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Uses the [PackageInstaller] API from the system's [PackageManager] service.
|
||||
* This installer invokes the API directly from this app's context.
|
||||
*/
|
||||
class PMInstaller(
|
||||
private val context: Application,
|
||||
) : Installer {
|
||||
private val _packageInstaller = context.packageManager.packageInstaller
|
||||
|
||||
init {
|
||||
// Destroy all open sessions that may have not been previously cleaned up due to fatal errors
|
||||
for (session in _packageInstaller.mySessions) {
|
||||
Log.d(BuildConfig.TAG, "Deleting old PackageInstaller session ${session.sessionId}")
|
||||
_packageInstaller.abandonSession(session.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun install(apks: List<File>, silent: Boolean) {
|
||||
val sessionId = createInstallSession(silent)
|
||||
|
||||
PMUtils.startInstall(
|
||||
context = context,
|
||||
session = _packageInstaller.openSession(sessionId),
|
||||
sessionId = sessionId,
|
||||
apks = apks,
|
||||
relay = false,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun waitInstall(
|
||||
apks: List<File>,
|
||||
silent: Boolean,
|
||||
onProgressUpdate: Installer.ProgressListener?,
|
||||
) = suspendCancellableCoroutine { continuation ->
|
||||
// Create a new install session
|
||||
val sessionId = createInstallSession(silent)
|
||||
|
||||
// Create and register a result receiver
|
||||
val relayReceiver = PMUtils.registerRelayReceiver(
|
||||
context = context,
|
||||
sessionId = sessionId,
|
||||
isUninstall = false,
|
||||
onResult = continuation::resume,
|
||||
)
|
||||
|
||||
// Create and register a progress callback
|
||||
val sessionCallback = onProgressUpdate?.let { onProgressUpdate ->
|
||||
PMUtils.registerSessionCallback(
|
||||
sessionId = sessionId,
|
||||
packageInstaller = _packageInstaller,
|
||||
onProgressUpdate = onProgressUpdate,
|
||||
)
|
||||
}
|
||||
|
||||
// Unregister PMResultReceiver when this coroutine finishes or errors
|
||||
// Explicitly cancel the install session if it did not finish.
|
||||
continuation.invokeOnCancellation {
|
||||
context.unregisterReceiver(relayReceiver)
|
||||
sessionCallback?.let { _packageInstaller.unregisterSessionCallback(it) }
|
||||
_packageInstaller.abandonSession(sessionId)
|
||||
}
|
||||
|
||||
PMUtils.startInstall(
|
||||
context = context,
|
||||
session = _packageInstaller.openSession(sessionId),
|
||||
sessionId = sessionId,
|
||||
apks = apks,
|
||||
relay = true,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun waitUninstall(packageName: String) = suspendCancellableCoroutine { continuation ->
|
||||
// Create and register a result receiver
|
||||
val relayReceiver = PMUtils.registerRelayReceiver(
|
||||
context = context,
|
||||
sessionId = -1,
|
||||
isUninstall = true,
|
||||
onResult = continuation::resume,
|
||||
)
|
||||
|
||||
// Unregister PMResultReceiver when this coroutine finishes or errors
|
||||
continuation.invokeOnCancellation {
|
||||
context.unregisterReceiver(relayReceiver)
|
||||
}
|
||||
|
||||
_packageInstaller.uninstall(
|
||||
/* packageName = */ packageName,
|
||||
/* statusReceiver = */ PMUtils.createUninstallRelayingIntent(context).intentSender,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a [PackageInstaller] session with the necessary params.
|
||||
* @param silent If this is an update, then the update will occur without user interaction.
|
||||
* @return The open install session id.
|
||||
*/
|
||||
private fun createInstallSession(silent: Boolean): Int {
|
||||
return _packageInstaller.createSession(PMUtils.createInstallSessionParams(silent = silent))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.meowarex.rlmobile.installers.pm
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Parcelable
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.installers.InstallerResult
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Translates the errors returned by PackageInstaller's [PackageInstaller.EXTRA_STATUS]
|
||||
* that is captured by a receiver into something human readable.
|
||||
*/
|
||||
@Parcelize
|
||||
data class PMInstallerError(val status: Int) : InstallerResult.Error(), Parcelable {
|
||||
override fun getDebugReason() = when (status) {
|
||||
PackageInstaller.STATUS_FAILURE -> "Unknown failure"
|
||||
PackageInstaller.STATUS_FAILURE_BLOCKED -> "Blocked"
|
||||
PackageInstaller.STATUS_FAILURE_INVALID -> "Invalid package"
|
||||
PackageInstaller.STATUS_FAILURE_CONFLICT -> "Package conflict"
|
||||
PackageInstaller.STATUS_FAILURE_STORAGE -> "Storage error"
|
||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Device incompatibility"
|
||||
/* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> "Operation timeout"
|
||||
else -> "Unknown code ($status)"
|
||||
}
|
||||
|
||||
override fun getLocalizedReason(context: Context): String {
|
||||
val string = when (status) {
|
||||
PackageInstaller.STATUS_FAILURE -> R.string.install_error_unknown
|
||||
PackageInstaller.STATUS_FAILURE_BLOCKED -> R.string.install_error_blocked
|
||||
PackageInstaller.STATUS_FAILURE_INVALID -> R.string.install_error_invalid
|
||||
PackageInstaller.STATUS_FAILURE_CONFLICT -> R.string.install_error_conflict
|
||||
PackageInstaller.STATUS_FAILURE_STORAGE -> R.string.install_error_storage
|
||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> R.string.install_error_incompatible
|
||||
/* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> R.string.install_error_timeout
|
||||
else -> R.string.install_error_unknown
|
||||
}
|
||||
|
||||
return context.getString(string)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.meowarex.rlmobile.installers.pm
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.*
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.installers.InstallerResult
|
||||
import com.meowarex.rlmobile.installers.UnknownInstallerError
|
||||
|
||||
/**
|
||||
* This class is used as a callback receiver for [PackageInstaller] events,
|
||||
* registered as a [PendingIntent]. If [PMIntentReceiver.EXTRA_RELAY_ENABLED] is set to true on
|
||||
* incoming intents, then the incoming intent will be parsed and relayed as an intent intended for [PMResultReceiver]
|
||||
* that was registered dynamically with the correct session id, which will handle the relayed result and return
|
||||
* it as a callback back to the application.
|
||||
*/
|
||||
class PMIntentReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val realSessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
|
||||
val expectedSessionId = intent.getIntExtra(EXTRA_SESSION_ID, -1)
|
||||
|
||||
if (realSessionId != expectedSessionId) return
|
||||
|
||||
try {
|
||||
handleSessionIntent(context, intent, expectedSessionId)
|
||||
} catch (error: Throwable) {
|
||||
if (intent.getBooleanExtra(EXTRA_RELAY_ENABLED, false)) {
|
||||
val relayIntent = Intent(PMResultReceiver.ACTION_RECEIVE_RESULT)
|
||||
.setPackage(BuildConfig.APPLICATION_ID)
|
||||
.putExtra(PMResultReceiver.EXTRA_SESSION_ID, expectedSessionId)
|
||||
.putExtra(PMResultReceiver.EXTRA_RESULT, UnknownInstallerError(error))
|
||||
|
||||
context.sendBroadcast(relayIntent)
|
||||
} else {
|
||||
Log.e(BuildConfig.TAG, "[PMIntentReceiver] Failed to handle intent", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionIntent(context: Context, intent: Intent, sessionId: Int) {
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)
|
||||
|
||||
// Launch the user action intent
|
||||
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
|
||||
@Suppress("DEPRECATION")
|
||||
val confirmationIntent = intent
|
||||
.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)!!
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
context.startActivity(confirmationIntent)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Handle install result
|
||||
val installerResult = when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS -> InstallerResult.Success
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallerResult.Cancelled(systemTriggered = true)
|
||||
|
||||
else -> {
|
||||
Log.w(BuildConfig.TAG, "PM failed with error code $status")
|
||||
|
||||
if (status <= PackageInstaller.STATUS_SUCCESS) {
|
||||
// Unknown status code (not an error)
|
||||
return
|
||||
} else {
|
||||
PMInstallerError(status).also {
|
||||
Toast.makeText(
|
||||
/* context = */ context,
|
||||
/* text = */ it.getLocalizedReason(context),
|
||||
/* duration = */ Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forward result to PMResultReceiver if relaying is enabled and have a real result
|
||||
if (intent.getBooleanExtra(EXTRA_RELAY_ENABLED, false)) {
|
||||
val relayIntent = Intent(PMResultReceiver.ACTION_RECEIVE_RESULT)
|
||||
.setPackage(BuildConfig.APPLICATION_ID)
|
||||
.putExtra(PMResultReceiver.EXTRA_SESSION_ID, sessionId)
|
||||
.putExtra(PMResultReceiver.EXTRA_RESULT, installerResult)
|
||||
|
||||
context.sendBroadcast(relayIntent)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_RELAY_ENABLED = "relayEnabled"
|
||||
const val EXTRA_SESSION_ID = "expectedSessionId"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.meowarex.rlmobile.installers.pm
|
||||
|
||||
import android.content.*
|
||||
import android.content.pm.PackageInstaller
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.installers.InstallerResult
|
||||
import com.meowarex.rlmobile.installers.UnknownInstallerError
|
||||
import com.meowarex.rlmobile.util.showToast
|
||||
|
||||
/**
|
||||
* This receiver is meant to be registered dynamically in combination with [PMIntentReceiver] in order to
|
||||
* relay parsed [PackageInstaller] results back to the running application.
|
||||
*/
|
||||
class PMResultReceiver(
|
||||
private val sessionId: Int,
|
||||
private val isUninstall: Boolean,
|
||||
private val onResult: (InstallerResult) -> Unit,
|
||||
) : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.getIntExtra(EXTRA_SESSION_ID, -1) != sessionId) return
|
||||
|
||||
try {
|
||||
when (intent.action) {
|
||||
ACTION_RECEIVE_RESULT -> handleResult(context, intent)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
context.unregisterReceiver(this)
|
||||
onResult(UnknownInstallerError(t))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleResult(context: Context, intent: Intent) {
|
||||
@Suppress("DEPRECATION")
|
||||
val result = intent.getParcelableExtra<InstallerResult>(EXTRA_RESULT) ?: return
|
||||
|
||||
// Show toast for successful and aborted sessions
|
||||
when (result) {
|
||||
InstallerResult.Success -> {
|
||||
context.showToast(if (!isUninstall) R.string.installer_install_success else R.string.installer_uninstall_success)
|
||||
}
|
||||
|
||||
// The reason we don't do this in PMIntentReceiver is we can't tell whether it was
|
||||
// an old session that for which `abandonSession(...)` was called
|
||||
is InstallerResult.Cancelled -> {
|
||||
context.showToast(if (!isUninstall) R.string.installer_install_aborted else R.string.installer_uninstall_aborted)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
context.unregisterReceiver(this)
|
||||
onResult(result)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_RECEIVE_INTENT = "com.meowarex.rlmobile.RELAY_PM_INTENT"
|
||||
const val ACTION_RECEIVE_RESULT = "com.meowarex.rlmobile.RELAY_PM_RESULT"
|
||||
const val EXTRA_RESULT = "installerResult"
|
||||
const val EXTRA_SESSION_ID = "sessionId"
|
||||
|
||||
/**
|
||||
* The intent filter this receiver should be registered with to work properly.
|
||||
*/
|
||||
val intentFilter = IntentFilter().apply {
|
||||
addAction(ACTION_RECEIVE_INTENT)
|
||||
addAction(ACTION_RECEIVE_RESULT)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.meowarex.rlmobile.installers.pm
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.*
|
||||
import android.content.pm.PackageInstaller.SessionCallback
|
||||
import android.content.pm.PackageInstaller.SessionParams
|
||||
import android.content.pm.PackageInstallerHidden.SessionParamsHidden
|
||||
import android.os.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.meowarex.rlmobile.installers.Installer
|
||||
import com.meowarex.rlmobile.installers.InstallerResult
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import dev.rikka.tools.refine.Refine
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Shared functionality between all types of installers that directly interface with the [PackageInstaller] API.
|
||||
*/
|
||||
object PMUtils {
|
||||
/**
|
||||
* Gets a binded [PackageInstaller] service wrapper.
|
||||
* This is used by remote installers such as Shizuku and Dhizuku.
|
||||
*/
|
||||
fun getPackageInstaller(
|
||||
context: Context,
|
||||
iPackageInstaller: IPackageInstaller,
|
||||
installerPackageName: String,
|
||||
): PackageInstaller {
|
||||
HiddenAPI.disable()
|
||||
|
||||
val userId = context.getUserId() ?: 0
|
||||
|
||||
val hiddenPackageInstaller = if (Build.VERSION.SDK_INT >= 31) {
|
||||
PackageInstallerHidden(
|
||||
/* installer = */ iPackageInstaller,
|
||||
/* installerPackageName = */ installerPackageName,
|
||||
/* installerAttributionTag = */ null,
|
||||
/* userId = */ userId,
|
||||
)
|
||||
} else if (Build.VERSION.SDK_INT >= 26) {
|
||||
PackageInstallerHidden(
|
||||
/* installer = */ iPackageInstaller,
|
||||
/* installerPackageName = */ installerPackageName,
|
||||
/* userId = */ userId,
|
||||
)
|
||||
} else {
|
||||
PackageInstallerHidden(
|
||||
/* context = */ context,
|
||||
/* pm = */ context.packageManager,
|
||||
/* installer = */ iPackageInstaller,
|
||||
/* installerPackageName = */ installerPackageName,
|
||||
/* userId = */ userId,
|
||||
)
|
||||
}
|
||||
return Refine.unsafeCast(hiddenPackageInstaller)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates install sessions params for [PackageInstaller].
|
||||
* @param silent If this is an update, then the update will occur without user interaction.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
fun createInstallSessionParams(silent: Boolean): SessionParams {
|
||||
val params = SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
|
||||
if (Build.VERSION.SDK_INT >= 24) setOriginatingUid(Process.myUid())
|
||||
if (Build.VERSION.SDK_INT >= 26) setInstallReason(PackageManager.INSTALL_REASON_USER)
|
||||
if (Build.VERSION.SDK_INT >= 30) setAutoRevokePermissionsMode(false)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
setInstallScenario(PackageManager.INSTALL_SCENARIO_FAST)
|
||||
|
||||
// Allegedly MIUI is not happy with silent installs
|
||||
if (silent && !isMiui()) {
|
||||
setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 34) setPackageSource(PackageInstaller.PACKAGE_SOURCE_OTHER)
|
||||
}
|
||||
|
||||
val hiddenParams = Refine.unsafeCast<SessionParamsHidden>(params)
|
||||
HiddenAPI.disable()
|
||||
hiddenParams.installFlags = hiddenParams.installFlags or
|
||||
PackageManagerHidden.INSTALL_REPLACE_EXISTING or
|
||||
PackageManagerHidden.INSTALL_ALLOW_TEST or
|
||||
(if (Build.VERSION.SDK_INT >= 34) PackageManagerHidden.INSTALL_BYPASS_LOW_TARGET_SDK_BLOCK else 0)
|
||||
|
||||
return Refine.unsafeCast<SessionParams>(hiddenParams)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a [PMResultReceiver] to receive results relayed by [PMIntentReceiver], to return the state
|
||||
* back to the application.
|
||||
*
|
||||
* @param context Android context.
|
||||
* @param sessionId The [PackageInstaller] install session ID to filter for.
|
||||
* @param isUninstall Whether this operation is an uninstallation.
|
||||
* @param onResult A callback lambda providing the parsed installation result.
|
||||
* @return The receiver that was registered. This should be unregistered manually, such as
|
||||
* upon the cancellation of the registering coroutine.
|
||||
*/
|
||||
fun registerRelayReceiver(
|
||||
context: Context,
|
||||
sessionId: Int,
|
||||
isUninstall: Boolean,
|
||||
onResult: (InstallerResult) -> Unit,
|
||||
): PMResultReceiver {
|
||||
// This will receive parsed data forwarded by PMIntentReceiver
|
||||
val relayReceiver = PMResultReceiver(
|
||||
sessionId = sessionId,
|
||||
isUninstall = isUninstall,
|
||||
onResult = onResult,
|
||||
)
|
||||
|
||||
ContextCompat.registerReceiver(
|
||||
/* context = */ context,
|
||||
/* receiver = */ relayReceiver,
|
||||
/* filter = */ PMResultReceiver.intentFilter,
|
||||
/* flags = */ ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
|
||||
return relayReceiver
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a session callback listener that is then registered to receive
|
||||
* installation progress updates from the system.
|
||||
*/
|
||||
fun registerSessionCallback(
|
||||
sessionId: Int,
|
||||
packageInstaller: PackageInstaller,
|
||||
onProgressUpdate: Installer.ProgressListener,
|
||||
): SessionCallback {
|
||||
val callback = object : SessionCallback() {
|
||||
override fun onActiveChanged(callbackSessionId: Int, active: Boolean) {}
|
||||
override fun onBadgingChanged(callbackSessionId: Int) {}
|
||||
override fun onCreated(callbackSessionId: Int) {}
|
||||
override fun onFinished(callbackSessionId: Int, success: Boolean) {}
|
||||
|
||||
override fun onProgressChanged(callbackSessionId: Int, progress: Float) {
|
||||
if (sessionId != callbackSessionId) return
|
||||
|
||||
onProgressUpdate.onUpdate(progress)
|
||||
}
|
||||
}
|
||||
|
||||
// Register callback to receive invocations on main thread
|
||||
packageInstaller.registerSessionCallback(callback, Handler(Looper.getMainLooper()))
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a [PackageInstaller] session for installation.
|
||||
* @param context Android context
|
||||
* @param session A newly opened install session to be written to. Ones binded through Shizuku also work.
|
||||
* @param sessionId The install session ID of [session].
|
||||
* @param apks The apks to install
|
||||
* @param relay Whether to use the [PMResultReceiver] flow.
|
||||
*/
|
||||
fun startInstall(
|
||||
context: Context,
|
||||
session: PackageInstaller.Session,
|
||||
sessionId: Int,
|
||||
apks: List<File>,
|
||||
relay: Boolean,
|
||||
) {
|
||||
val callbackIntent = Intent(context, PMIntentReceiver::class.java)
|
||||
.putExtra(PMIntentReceiver.EXTRA_SESSION_ID, sessionId)
|
||||
.putExtra(PMIntentReceiver.EXTRA_RELAY_ENABLED, relay)
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
/* context = */ context,
|
||||
/* requestCode = */ 0,
|
||||
/* intent = */ callbackIntent,
|
||||
/* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
session.use { session ->
|
||||
val bufferSize = 1 * 1024 * 1024 // 1MiB
|
||||
|
||||
for (apkIdx in 0..apks.lastIndex) {
|
||||
val apk = apks[apkIdx]
|
||||
val apkSize = apk.length()
|
||||
val filesProgress = (apkIdx + 1f) / apks.size
|
||||
|
||||
session.openWrite(apk.name, 0, apkSize).use { out ->
|
||||
apk.inputStream().use { input ->
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var bytesCopied: Long = 0
|
||||
var bytes = input.read(buffer)
|
||||
while (bytes >= 0) {
|
||||
out.write(buffer, 0, bytes)
|
||||
bytesCopied += bytes
|
||||
|
||||
val apkProgress = bytes.toFloat() / apkSize
|
||||
session.setStagingProgress(apkProgress * filesProgress)
|
||||
|
||||
bytes = input.read(buffer)
|
||||
}
|
||||
}
|
||||
session.fsync(out)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RequestInstallPackagesPolicy")
|
||||
session.commit(pendingIntent.intentSender)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an uninstallation callback [PendingIntent] that will forward events
|
||||
* to the relaying [PMIntentReceiver]. These events can be captured by registering [PMResultReceiver]
|
||||
* through [PMUtils.registerRelayReceiver].
|
||||
*/
|
||||
fun createUninstallRelayingIntent(context: Context): PendingIntent {
|
||||
// FIXME: Conflicting pending intents when multiple simultaneous uninstalls are happening.
|
||||
// The extras will end up being merged into one pending intent, with only the newest one working.
|
||||
val callbackIntent = Intent(context, PMIntentReceiver::class.java)
|
||||
.putExtra(PMIntentReceiver.EXTRA_SESSION_ID, -1)
|
||||
.putExtra(PMIntentReceiver.EXTRA_RELAY_ENABLED, true)
|
||||
|
||||
return PendingIntent.getBroadcast(
|
||||
/* context = */ context,
|
||||
/* requestCode = */ 0,
|
||||
/* intent = */ callbackIntent,
|
||||
/* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.meowarex.rlmobile.installers.root
|
||||
|
||||
import android.content.Context
|
||||
import com.meowarex.rlmobile.installers.*
|
||||
import com.meowarex.rlmobile.util.getUserId
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
|
||||
// Based on https://gitlab.com/AuroraOSS/AuroraStore/-/blob/master/app/src/main/java/com/aurora/store/data/installer/RootInstaller.kt
|
||||
|
||||
/**
|
||||
* Installer based on using libsu to invoke `pm` with root.
|
||||
*
|
||||
* Errors from this installer will always be [UnknownInstallerError]
|
||||
* as it is impossible to extract meaningful information from shell installations.
|
||||
*/
|
||||
class RootInstaller(private val context: Context) : Installer {
|
||||
val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private suspend fun executeSU(command: String): List<String> = withContext(Dispatchers.IO) {
|
||||
val result = Shell.cmd(command).exec()
|
||||
if (result.code != 0) {
|
||||
val resultString = "Result code: ${result.code}. Stdout: '${result.out}'. Stderr: '${result.err}'."
|
||||
val message = "Root command '$command' failed. $resultString"
|
||||
throw ShellException(message)
|
||||
}
|
||||
result.out
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the main root shell and requests root permissions.
|
||||
* If they are not granted, throw an exception.
|
||||
*/
|
||||
private fun obtainRoot() {
|
||||
Shell.getShell().waitAndClose()
|
||||
Shell.getShell()
|
||||
|
||||
if (Shell.isAppGrantedRoot() != true)
|
||||
throw ShellException("Missing root permissions (denied)")
|
||||
}
|
||||
|
||||
private suspend fun createInstallSession(totalSize: Long): Int {
|
||||
val userId = context.getUserId()?.toString() ?: "all"
|
||||
val response = executeSU("pm install-create -i $PLAY_PACKAGE_NAME --user $userId -r -S $totalSize")
|
||||
val result = response[0]
|
||||
|
||||
val sessionIdMatch = Regex("""\d+""").find(result)
|
||||
checkNotNull(sessionIdMatch) { "Can't find session id with regex pattern. Output: $result" }
|
||||
|
||||
val sessionId = sessionIdMatch.groups[0]
|
||||
checkNotNull(sessionId) { "Can't find match group containing the session id. Output: $result" }
|
||||
|
||||
return sessionId.value.toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable ADB install verification (bypass useless Play Protect).
|
||||
*/
|
||||
private suspend fun disableAdbVerify() {
|
||||
executeSU("settings put global verifier_verify_adb_installs 0")
|
||||
}
|
||||
|
||||
override suspend fun install(apks: List<File>, silent: Boolean) {
|
||||
coroutineScope.launch { waitInstall(apks, silent) }
|
||||
}
|
||||
|
||||
override suspend fun waitInstall(
|
||||
apks: List<File>,
|
||||
silent: Boolean,
|
||||
onProgressUpdate: Installer.ProgressListener?,
|
||||
): InstallerResult {
|
||||
val invalidChars = """\W""".toRegex()
|
||||
for (apk in apks) {
|
||||
if (hasDangerousCharacter(apk.canonicalPath) || hasDangerousCharacter(apk.name))
|
||||
throw IllegalArgumentException("APK path or name has dangerous characters: ${apk.canonicalPath}")
|
||||
if (apk.nameWithoutExtension.contains(invalidChars))
|
||||
throw IllegalArgumentException("APK file name contains invalid characters: ${apk.nameWithoutExtension}")
|
||||
}
|
||||
|
||||
obtainRoot()
|
||||
disableAdbVerify()
|
||||
|
||||
val sessionId = createInstallSession(
|
||||
totalSize = apks.sumOf(File::length),
|
||||
)
|
||||
|
||||
return try {
|
||||
for (apk in apks) {
|
||||
executeSU("""cat "${apk.canonicalPath}" | pm install-write -S ${apk.length()} $sessionId "${apk.name}"""")
|
||||
}
|
||||
executeSU("""pm install-commit $sessionId""")
|
||||
|
||||
InstallerResult.Success
|
||||
} catch (t: Throwable) {
|
||||
executeSU("""pm install-abandon $sessionId""")
|
||||
|
||||
UnknownInstallerError(t)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun waitUninstall(packageName: String): InstallerResult {
|
||||
if (hasDangerousCharacter(packageName))
|
||||
throw IllegalArgumentException("packageName has dangerous characters!")
|
||||
|
||||
obtainRoot()
|
||||
|
||||
return try {
|
||||
val userFlag = context.getUserId()?.let { "--user $it" } ?: ""
|
||||
|
||||
executeSU("""pm uninstall $userFlag $packageName""")
|
||||
InstallerResult.Success
|
||||
} catch (t: Throwable) {
|
||||
UnknownInstallerError(t)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// We spoof Google Play Store to prevent unnecessary checks
|
||||
const val PLAY_PACKAGE_NAME = "com.android.vending"
|
||||
|
||||
/**
|
||||
* Checks if [value] has a dangerous character to put in a shell.
|
||||
* Paths and names should be checked with this.
|
||||
*/
|
||||
fun hasDangerousCharacter(value: String): Boolean = dangerousCharacters.containsMatchIn(value)
|
||||
|
||||
private val dangerousCharacters = """[`'()|<>*$&?!#:;{}\s"\[\]\\]""".toRegex()
|
||||
}
|
||||
}
|
||||
|
||||
private class ShellException(message: String) : Exception(message)
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package com.meowarex.rlmobile.installers.shizuku
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.*
|
||||
import com.meowarex.rlmobile.installers.Installer
|
||||
import com.meowarex.rlmobile.installers.InstallerResult
|
||||
import com.meowarex.rlmobile.installers.pm.PMUtils
|
||||
import com.meowarex.rlmobile.manager.ShizukuManager
|
||||
import com.meowarex.rlmobile.util.HiddenAPI
|
||||
import dev.rikka.tools.refine.Refine
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import rikka.shizuku.ShizukuBinderWrapper
|
||||
import rikka.shizuku.SystemServiceHelper
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
// Based on https://github.com/Tobi823/ffupdater/blob/9830452fe1cb3b77b28175833c68118a63d5ca69/ffupdater/src/main/java/de/marmaro/krt/ffupdater/installer/impl/ShizukuInstaller.kt
|
||||
|
||||
/**
|
||||
* The package name of Google Play Store.
|
||||
* We spoof our installer to this when installing through Shizuku to prevent
|
||||
* potentially unnecessary scans/checks.
|
||||
*/
|
||||
private const val PLAY_PACKAGE_NAME = "com.android.vending"
|
||||
|
||||
/**
|
||||
* Uses Shizuku to remotely invoke the [PackageInstaller] API from ADB.
|
||||
*/
|
||||
class ShizukuInstaller(
|
||||
private val context: Context,
|
||||
private val shizuku: ShizukuManager,
|
||||
) : Installer {
|
||||
/**
|
||||
* Gets the Shizuku API binder for [IPackageInstaller].
|
||||
*/
|
||||
private fun getPackageInstallerBinder(): IPackageInstaller {
|
||||
HiddenAPI.disable()
|
||||
|
||||
val iPackageManager = IPackageManager.Stub.asInterface(
|
||||
ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package"))
|
||||
)
|
||||
val iPackageInstaller = IPackageInstaller.Stub.asInterface(
|
||||
ShizukuBinderWrapper(iPackageManager.packageInstaller.asBinder())
|
||||
)
|
||||
|
||||
return iPackageInstaller
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens and binds a [PackageInstaller.Session] wrapper through Shizuku.
|
||||
*/
|
||||
fun openSession(sessionId: Int): PackageInstaller.Session {
|
||||
HiddenAPI.disable()
|
||||
|
||||
val iPackageInstaller = getPackageInstallerBinder()
|
||||
val iSession = IPackageInstallerSession.Stub.asInterface(
|
||||
ShizukuBinderWrapper(iPackageInstaller.openSession(sessionId).asBinder())
|
||||
)
|
||||
return Refine.unsafeCast(PackageInstallerHidden.SessionHidden(iSession))
|
||||
}
|
||||
|
||||
override suspend fun install(apks: List<File>, silent: Boolean) {
|
||||
if (!shizuku.requestPermissions())
|
||||
throw IllegalStateException("Shizuku is not available!")
|
||||
|
||||
ShizukuSettingsWrapper.disableAdbVerify(context)
|
||||
|
||||
// Construct install session and create it
|
||||
val params = PMUtils.createInstallSessionParams(silent = true)
|
||||
val packageInstaller = PMUtils.getPackageInstaller(
|
||||
context = context,
|
||||
iPackageInstaller = getPackageInstallerBinder(),
|
||||
installerPackageName = PLAY_PACKAGE_NAME,
|
||||
)
|
||||
val sessionId = packageInstaller.createSession(params)
|
||||
|
||||
PMUtils.startInstall(
|
||||
context = context,
|
||||
session = openSession(sessionId),
|
||||
sessionId = sessionId,
|
||||
apks = apks,
|
||||
relay = false,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun waitInstall(
|
||||
apks: List<File>,
|
||||
silent: Boolean,
|
||||
onProgressUpdate: Installer.ProgressListener?,
|
||||
): InstallerResult {
|
||||
if (!shizuku.requestPermissions())
|
||||
throw IllegalStateException("Shizuku is not available!")
|
||||
|
||||
ShizukuSettingsWrapper.disableAdbVerify(context)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
// Construct install session and create it
|
||||
val params = PMUtils.createInstallSessionParams(silent = true)
|
||||
val packageInstaller = PMUtils.getPackageInstaller(
|
||||
context = context,
|
||||
iPackageInstaller = getPackageInstallerBinder(),
|
||||
installerPackageName = PLAY_PACKAGE_NAME,
|
||||
)
|
||||
val sessionId = packageInstaller.createSession(params)
|
||||
|
||||
// Create and register a result receiver
|
||||
val relayReceiver = PMUtils.registerRelayReceiver(
|
||||
context = context,
|
||||
sessionId = sessionId,
|
||||
isUninstall = false,
|
||||
onResult = continuation::resume,
|
||||
)
|
||||
|
||||
// Create and register a progress callback
|
||||
val sessionCallback = onProgressUpdate?.let { onProgressUpdate ->
|
||||
PMUtils.registerSessionCallback(
|
||||
sessionId = sessionId,
|
||||
packageInstaller = packageInstaller,
|
||||
onProgressUpdate = onProgressUpdate,
|
||||
)
|
||||
}
|
||||
|
||||
// Unregister PMResultReceiver when this coroutine finishes or errors
|
||||
// Explicitly cancel the install session if it did not finish.
|
||||
continuation.invokeOnCancellation {
|
||||
context.unregisterReceiver(relayReceiver)
|
||||
sessionCallback?.let { packageInstaller.unregisterSessionCallback(it) }
|
||||
packageInstaller.abandonSession(sessionId)
|
||||
}
|
||||
|
||||
PMUtils.startInstall(
|
||||
context = context,
|
||||
session = openSession(sessionId),
|
||||
sessionId = sessionId,
|
||||
apks = apks,
|
||||
relay = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun waitUninstall(packageName: String): InstallerResult {
|
||||
if (!shizuku.requestPermissions())
|
||||
throw IllegalStateException("Shizuku is not available!")
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val packageInstaller = PMUtils.getPackageInstaller(
|
||||
context = context,
|
||||
iPackageInstaller = getPackageInstallerBinder(),
|
||||
installerPackageName = PLAY_PACKAGE_NAME,
|
||||
)
|
||||
|
||||
// Create and register a result receiver
|
||||
val relayReceiver = PMUtils.registerRelayReceiver(
|
||||
context = context,
|
||||
sessionId = -1,
|
||||
isUninstall = true,
|
||||
onResult = continuation::resume,
|
||||
)
|
||||
|
||||
// Unregister PMResultReceiver when this coroutine finishes or errors
|
||||
continuation.invokeOnCancellation {
|
||||
context.unregisterReceiver(relayReceiver)
|
||||
}
|
||||
|
||||
packageInstaller.uninstall(
|
||||
/* packageName = */ packageName,
|
||||
/* statusReceiver = */ PMUtils.createUninstallRelayingIntent(context).intentSender,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.meowarex.rlmobile.installers.shizuku
|
||||
|
||||
import android.content.*
|
||||
import android.os.*
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.meowarex.rlmobile.util.HiddenAPI
|
||||
import rikka.shizuku.Shizuku
|
||||
import rikka.shizuku.ShizukuBinderWrapper
|
||||
|
||||
// Based on https://github.com/vvb2060/PackageInstaller/blob/3d113a5e000c62a712e6165cb75cbca63fb912aa/app/src/main/java/io/github/vvb2060/packageinstaller/model/Hook.kt
|
||||
|
||||
/**
|
||||
* Wraps writing to Android's global settings through Shizuku.
|
||||
* This allows accessing Android secure settings.
|
||||
*/
|
||||
object ShizukuSettingsWrapper {
|
||||
/**
|
||||
* Disable ADB install verification (bypass useless Play Protect).
|
||||
*/
|
||||
fun disableAdbVerify(context: Context) {
|
||||
val settingName = "verifier_verify_adb_installs"
|
||||
val enabled = Settings.Global.getInt(context.contentResolver, settingName, 1) != 0
|
||||
if (enabled) {
|
||||
wrapGlobalSettings {
|
||||
val contextWrapper = ShizukuContext(context)
|
||||
val cr = object : ContentResolver(contextWrapper) {}
|
||||
Settings.Global.putInt(cr, settingName, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun wrapGlobalSettings(callback: () -> Unit) {
|
||||
HiddenAPI.disable()
|
||||
|
||||
val holder = Settings.Global::class.java.getDeclaredField("sProviderHolder")
|
||||
.apply { isAccessible = true }
|
||||
.get(null)
|
||||
val provider = holder::class.java.getDeclaredField("mContentProvider")
|
||||
.apply { isAccessible = true }
|
||||
.get(holder)
|
||||
|
||||
val remoteField = provider::class.java.getDeclaredField("mRemote")
|
||||
.apply { isAccessible = true }
|
||||
|
||||
val originalBinder = remoteField.get(provider) as IBinder
|
||||
remoteField.set(provider, ShizukuBinderWrapper(originalBinder))
|
||||
callback()
|
||||
remoteField.set(provider, originalBinder)
|
||||
}
|
||||
|
||||
private class ShizukuContext(context: Context) : ContextWrapper(context) {
|
||||
override fun getOpPackageName(): String = "com.android.shell"
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
override fun getAttributionSource(): AttributionSource {
|
||||
val builder = AttributionSource.Builder(Shizuku.getUid())
|
||||
.setPackageName("com.android.shell")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
builder.setPid(Process.INVALID_PID)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.meowarex.rlmobile.manager
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.util.showToast
|
||||
import com.rosan.dhizuku.api.Dhizuku
|
||||
import com.rosan.dhizuku.api.DhizukuRequestPermissionListener
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Handles setting up Dhizuku and obtaining permissions.
|
||||
*/
|
||||
class DhizukuManager(private val context: Context) {
|
||||
private var dhizukuPermissionLock = Mutex()
|
||||
private val dhizukuAvailable = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Determines whether Dhizuku is available and the binder has been retrieved.
|
||||
*/
|
||||
fun dhizukuAvailable(): Boolean {
|
||||
// Dhziuku requires at least Android 8.0
|
||||
if (Build.VERSION.SDK_INT < 26) return false
|
||||
|
||||
if (!dhizukuAvailable.get()) {
|
||||
return Dhizuku.init(context)
|
||||
.also(dhizukuAvailable::set)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether Dhizuku permissions have been granted to this app.
|
||||
*/
|
||||
fun checkPermissions(): Boolean {
|
||||
if (!dhizukuAvailable()) return false
|
||||
|
||||
return Dhizuku.isPermissionGranted()
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests and waits for Dhizuku permissions if they have not already been granted.
|
||||
*/
|
||||
suspend fun requestPermissions(): Boolean {
|
||||
if (!dhizukuAvailable()) return false
|
||||
|
||||
// Lock and check if the previous holder already obtained permissions
|
||||
dhizukuPermissionLock.lock()
|
||||
try {
|
||||
if (checkPermissions()) {
|
||||
dhizukuPermissionLock.unlock()
|
||||
return true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
dhizukuPermissionLock.unlock()
|
||||
}
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
Dhizuku.requestPermission(object : DhizukuRequestPermissionListener() {
|
||||
override fun onRequestPermission(grantResult: Int) {
|
||||
if (grantResult != PackageManager.PERMISSION_GRANTED)
|
||||
context.showToast(R.string.permissions_dhizuku_denied)
|
||||
|
||||
continuation.resume(grantResult == PackageManager.PERMISSION_GRANTED)
|
||||
dhizukuPermissionLock.unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.meowarex.rlmobile.manager
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.core.content.getSystemService
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Central manager for storing all attempted installations and
|
||||
* their associated logs/crashes (not including manager crashes themselves).
|
||||
*/
|
||||
class InstallLogManager(
|
||||
private val application: Application,
|
||||
private val prefs: PreferencesManager,
|
||||
private val json: Json,
|
||||
) {
|
||||
val logsDir = application.filesDir.resolve("install-logs").apply { mkdir() }
|
||||
|
||||
/**
|
||||
* Lists all the install data entries that exist on disk, sorted decreasing by
|
||||
* the file creation date.
|
||||
* @return List of installation ids, most recent installation first.
|
||||
*/
|
||||
fun fetchInstallDataEntries(): List<String> {
|
||||
val files = logsDir.listFiles { it.extension == "json" } ?: emptyArray()
|
||||
|
||||
return files
|
||||
.sortedByDescending { it.lastModified() }
|
||||
.map { it.nameWithoutExtension }
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the install log from disk, if it exists.
|
||||
*/
|
||||
fun fetchInstallData(id: String): InstallLogData? {
|
||||
val path = logsDir.resolve("$id.json")
|
||||
if (!path.exists()) return null
|
||||
|
||||
return try {
|
||||
json.decodeFromStream(path.inputStream())
|
||||
} catch (t: Throwable) {
|
||||
Log.e(BuildConfig.TAG, "Failed to open install log $id", t)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAllEntries() {
|
||||
logsDir.deleteRecursively()
|
||||
logsDir.mkdir()
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an install log entry to disk.
|
||||
*/
|
||||
suspend fun storeInstallData(
|
||||
id: String,
|
||||
installDate: Instant,
|
||||
installDuration: Duration,
|
||||
options: PatchOptions,
|
||||
log: String,
|
||||
error: Throwable?,
|
||||
) {
|
||||
val path = logsDir.resolve("$id.json")
|
||||
|
||||
val data = InstallLogData(
|
||||
id = id,
|
||||
installDate = installDate,
|
||||
installDuration = installDuration,
|
||||
installOptions = options,
|
||||
environmentInfo = getEnvironmentInfo(),
|
||||
installationLog = log,
|
||||
errorStacktrace = error?.let { Log.getStackTraceString(it).trimEnd() },
|
||||
)
|
||||
|
||||
try {
|
||||
path.writeText(json.encodeToString(data))
|
||||
} catch (e: IOException) {
|
||||
Log.e(BuildConfig.TAG, "Failed to write log to disk", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a list of details about the current installation environment.
|
||||
*/
|
||||
@Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
|
||||
@SuppressLint("UsableSpace")
|
||||
suspend fun getEnvironmentInfo(): String {
|
||||
val storageManager = application.getSystemService<StorageManager>()!!
|
||||
|
||||
val buildType = when {
|
||||
BuildConfig.RELEASE -> "(Release)"
|
||||
BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS -> "(Changes present)"
|
||||
else -> ""
|
||||
}
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) (Build.SOC_MANUFACTURER + ' ' + Build.SOC_MODEL) else "Unavailable"
|
||||
val playProtect = when (application.isPlayProtectEnabled()) {
|
||||
null -> "Unavailable"
|
||||
true -> "Enabled"
|
||||
false -> "Disabled"
|
||||
}
|
||||
|
||||
val diskFreeSize = application.filesDir.usableSpace
|
||||
val cacheQuotaSize = if (Build.VERSION.SDK_INT >= 26) {
|
||||
storageManager.getCacheQuotaBytes(storageManager.getUuidForPath(application.cacheDir))
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
|
||||
return """
|
||||
Radiant Lyrics Manager v${BuildConfig.VERSION_NAME}
|
||||
Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} $buildType
|
||||
Developer mode: ${if (prefs.devMode) "On" else "Off"}
|
||||
External storage: ${if (prefs.devMode || prefs.keepPatchedApks) "Yes" else "No"}
|
||||
|
||||
Disk Free: ${diskFreeSize.formatShortFileSize()}
|
||||
Cache Quota: ${cacheQuotaSize.formatShortFileSize()}
|
||||
|
||||
Android API: ${Build.VERSION.SDK_INT}
|
||||
Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()}
|
||||
ROM: Android ${Build.VERSION.RELEASE} (Patch ${Build.VERSION.SECURITY_PATCH})
|
||||
Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})
|
||||
Emulator: ${if (IS_PROBABLY_EMULATOR) "Yes" else "No"} (guess)
|
||||
Play Protect: $playProtect
|
||||
SOC: $soc
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class InstallLogData(
|
||||
val id: String,
|
||||
val installDate: Instant,
|
||||
val installDuration: Duration,
|
||||
val installOptions: PatchOptions,
|
||||
val environmentInfo: String,
|
||||
val installationLog: String,
|
||||
val errorStacktrace: String?,
|
||||
) {
|
||||
val isError: Boolean
|
||||
get() = errorStacktrace != null
|
||||
|
||||
fun getFormattedInstallDate(): String {
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
return SimpleDateFormat("yyyy-MM-dd HH-mm-ss", Locale.ENGLISH)
|
||||
.format(Date(installDate.toEpochMilliseconds()))
|
||||
}
|
||||
|
||||
fun getLogFileContents(): String = buildString {
|
||||
appendLine("////////////////// Environment Info //////////////////")
|
||||
appendLine(environmentInfo)
|
||||
|
||||
append("\n\n")
|
||||
appendLine("////////////////// Installation Info //////////////////")
|
||||
appendLine()
|
||||
append("Install ID: ")
|
||||
appendLine(id)
|
||||
append("Install time: ")
|
||||
appendLine(getFormattedInstallDate())
|
||||
append("Result: ")
|
||||
appendLine(if (isError) "Failure" else "Success")
|
||||
|
||||
append("\n\n")
|
||||
appendLine("////////////////// Error Stacktrace //////////////////")
|
||||
appendLine()
|
||||
appendLine(errorStacktrace ?: "None")
|
||||
|
||||
append("\n\n")
|
||||
appendLine("////////////////// Installation Log //////////////////")
|
||||
appendLine()
|
||||
appendLine(installationLog)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.meowarex.rlmobile.manager
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.installers.Installer
|
||||
import com.meowarex.rlmobile.installers.dhizuku.DhizukuInstaller
|
||||
import com.meowarex.rlmobile.installers.intent.IntentInstaller
|
||||
import com.meowarex.rlmobile.installers.pm.PMInstaller
|
||||
import com.meowarex.rlmobile.installers.root.RootInstaller
|
||||
import com.meowarex.rlmobile.installers.shizuku.ShizukuInstaller
|
||||
import org.koin.core.annotation.KoinInternalApi
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Handle providing the correct install manager based on preferences.
|
||||
*/
|
||||
class InstallerManager(
|
||||
private val prefs: PreferencesManager,
|
||||
) : KoinComponent {
|
||||
fun getActiveInstaller(): Installer =
|
||||
getInstaller(prefs.installer)
|
||||
|
||||
@OptIn(KoinInternalApi::class)
|
||||
fun getInstaller(type: InstallerSetting): Installer =
|
||||
getKoin().scopeRegistry.rootScope.get(clazz = type.installerClass)
|
||||
}
|
||||
|
||||
enum class InstallerSetting(val installerClass: KClass<out Installer>) {
|
||||
PackageInstaller(PMInstaller::class),
|
||||
Root(RootInstaller::class),
|
||||
Intent(IntentInstaller::class),
|
||||
Shizuku(ShizukuInstaller::class),
|
||||
Dhizuku(DhizukuInstaller::class);
|
||||
|
||||
@Composable
|
||||
fun title() = when (this) {
|
||||
PackageInstaller -> stringResource(R.string.installer_pm)
|
||||
Root -> stringResource(R.string.installer_root)
|
||||
Intent -> stringResource(R.string.installer_intent)
|
||||
Shizuku -> stringResource(R.string.installer_shizuku)
|
||||
Dhizuku -> stringResource(R.string.installer_dhizuku)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun description() = when (this) {
|
||||
PackageInstaller -> stringResource(R.string.installer_pm_desc)
|
||||
Root -> stringResource(R.string.installer_root_desc)
|
||||
Intent -> stringResource(R.string.installer_intent_desc)
|
||||
Shizuku -> stringResource(R.string.installer_shizuku_desc)
|
||||
Dhizuku -> stringResource(R.string.installer_dhizuku_desc)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun icon() = when (this) {
|
||||
PackageInstaller -> painterResource(R.drawable.ic_android)
|
||||
Root -> painterResource(R.drawable.ic_hashtag)
|
||||
Intent -> painterResource(R.drawable.ic_launch)
|
||||
Shizuku -> painterResource(R.drawable.ic_shizuku)
|
||||
Dhizuku -> painterResource(R.drawable.ic_dhizuku)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.meowarex.rlmobile.manager
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
typealias ResultComposable<R> = @Composable (onResult: (R) -> Unit) -> Unit
|
||||
|
||||
/**
|
||||
* This is used to display dialogs on top of the current activity at any point in the code.
|
||||
* The main use case for this is dialogs for which a result is needed during patching steps.
|
||||
*
|
||||
* The only other alternative to this setup is binding `Flow`s from the steps back to the patching screen model
|
||||
* and then displaying them in the UI, which is too much boilerplate.
|
||||
*/
|
||||
@Stable
|
||||
class OverlayManager {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Main) + SupervisorJob()
|
||||
private var overlays = mutableStateListOf<ResultComposable<Any?>>()
|
||||
private var overlayResults = MutableSharedFlow<Pair<ResultComposable<Any?>, Any?>>(extraBufferCapacity = 5)
|
||||
|
||||
/**
|
||||
* Display all the currently queued overlays.
|
||||
*/
|
||||
@Composable
|
||||
fun Overlays() {
|
||||
for (composable in overlays) {
|
||||
key(System.identityHashCode(composable)) {
|
||||
val composable by rememberUpdatedState(composable)
|
||||
|
||||
composable { result ->
|
||||
if (!overlayResults.tryEmit(composable to result))
|
||||
error("overlayResults flow full!")
|
||||
|
||||
overlays -= composable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a composable to the overlay stack which will be displayed over the top of any content.
|
||||
*
|
||||
* This content will be displayed until the `onResult` callback is called,
|
||||
* after which this method will finish suspending with the result from the invoked callback.
|
||||
*
|
||||
* If the coroutine scope this method was called in gets cancelled, then the overlay will be
|
||||
* removed and no result will be returned (cancelled).
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
suspend fun <R> startComposableForResult(composable: ResultComposable<R>): R {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val job = overlayResults
|
||||
.filter { (c, _) -> c === composable }
|
||||
.onEach { (_, result) -> continuation.resume(result as R) }
|
||||
.cancellable()
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
coroutineScope.launch {
|
||||
overlays -= composable
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
overlays += composable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.meowarex.rlmobile.manager
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Environment
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import java.io.File
|
||||
|
||||
class PathManager(
|
||||
private val context: Application,
|
||||
) {
|
||||
val rlMobileDir = Environment.getExternalStorageDirectory().resolve("RadiantLyrics")
|
||||
|
||||
val pluginsDir = rlMobileDir.resolve("plugins")
|
||||
|
||||
val coreSettingsFile = rlMobileDir.resolve("settings/RadiantLyrics.json")
|
||||
|
||||
val legacyKeystoreFile = rlMobileDir.resolve("ks.keystore")
|
||||
|
||||
val keystoreFile = context.filesDir.resolve("rlmobile.keystore")
|
||||
|
||||
val patchingDir = context.filesDir.resolve("patching")
|
||||
|
||||
val patchingDownloadDir = patchingDir.resolve("downloads")
|
||||
|
||||
val cacheDownloadDir = context.cacheDir.resolve("downloads")
|
||||
|
||||
val customComponentsDir = patchingDir.resolve("custom")
|
||||
|
||||
val customInjectorsDir = customComponentsDir.resolve("injector")
|
||||
|
||||
val customPatchesDir = customComponentsDir.resolve("patches")
|
||||
|
||||
val patchingWorkingDir = patchingDir.resolve("patched")
|
||||
|
||||
val patchedApk = patchingWorkingDir.resolve("patched.apk")
|
||||
|
||||
fun clearCache() {
|
||||
for (dir in arrayOf(patchingDir, cacheDownloadDir, context.cacheDir))
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
fun cachedTidalApk(version: Int, split: String = "base"): File = patchingDownloadDir
|
||||
.resolve("tidal/$version")
|
||||
.resolve("$split.apk")
|
||||
|
||||
fun cachedSmaliPatches(version: SemVer) = patchingDownloadDir
|
||||
.resolve("patches")
|
||||
.resolve("$version.zip")
|
||||
|
||||
fun customInjectors() = customInjectorsDir.listFiles()?.asList() ?: emptyList()
|
||||
|
||||
fun customSmaliPatches() = customPatchesDir.listFiles()?.asList() ?: emptyList()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.meowarex.rlmobile.manager
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.meowarex.rlmobile.manager.base.BasePreferenceManager
|
||||
import com.meowarex.rlmobile.ui.theme.Theme
|
||||
|
||||
@Stable
|
||||
class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager(preferences) {
|
||||
var theme by enumPreference("theme", Theme.System)
|
||||
var dynamicColor by booleanPreference("dynamic_color", true)
|
||||
var devMode by booleanPreference("dev_mode", false)
|
||||
var installer by enumPreference<InstallerSetting>("installer", InstallerSetting.PackageInstaller)
|
||||
var keepPatchedApks by booleanPreference("keep_patched_apks", false)
|
||||
var showNetworkWarning by booleanPreference("show_network_warning", true)
|
||||
var showPlayProtectWarning by booleanPreference("show_play_protect_warning", true)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.meowarex.rlmobile.manager
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.util.showToast
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import rikka.shizuku.Shizuku
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Handles setting up Shizuku and obtaining permissions.
|
||||
*/
|
||||
class ShizukuManager(private val context: Context) {
|
||||
private var shizukuPermissionLock = Mutex()
|
||||
private val shizukuAvailable = AtomicBoolean(false)
|
||||
|
||||
init {
|
||||
Shizuku.addBinderReceivedListenerSticky {
|
||||
shizukuAvailable.set(true)
|
||||
}
|
||||
Shizuku.addBinderDeadListener {
|
||||
shizukuAvailable.set(false)
|
||||
|
||||
if (shizukuPermissionLock.isLocked)
|
||||
shizukuPermissionLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether Shizuku is available and the binder has been retrieved.
|
||||
*/
|
||||
fun shizukuAvailable(): Boolean {
|
||||
if (!shizukuAvailable.get()) {
|
||||
return Shizuku.pingBinder()
|
||||
.also(shizukuAvailable::set)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether Shizuku permissions have been granted to this app.
|
||||
*/
|
||||
fun checkPermissions(): Boolean {
|
||||
if (!shizukuAvailable()) return false
|
||||
|
||||
// Old shizuku does not have permission checks
|
||||
if (Shizuku.isPreV11()) return true
|
||||
|
||||
return Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests and waits for Shizuku permissions if they have not already been granted.
|
||||
*/
|
||||
suspend fun requestPermissions(): Boolean {
|
||||
if (!shizukuAvailable()) return false
|
||||
|
||||
// Lock and check if the previous holder already obtained permissions
|
||||
shizukuPermissionLock.lock()
|
||||
try {
|
||||
if (checkPermissions()) {
|
||||
shizukuPermissionLock.unlock()
|
||||
return true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
shizukuPermissionLock.unlock()
|
||||
}
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val currentRequestCode = Random.nextInt()
|
||||
val onPermissionRequestResult =
|
||||
Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
|
||||
if (requestCode != currentRequestCode)
|
||||
return@OnRequestPermissionResultListener
|
||||
|
||||
if (grantResult == PackageManager.PERMISSION_DENIED)
|
||||
context.showToast(R.string.permissions_shizuku_denied)
|
||||
|
||||
continuation.resume(grantResult == PackageManager.PERMISSION_GRANTED)
|
||||
shizukuPermissionLock.unlock()
|
||||
}
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
Shizuku.removeRequestPermissionResultListener(onPermissionRequestResult)
|
||||
shizukuPermissionLock.unlock()
|
||||
}
|
||||
|
||||
Shizuku.addRequestPermissionResultListener(onPermissionRequestResult)
|
||||
Shizuku.requestPermission(currentRequestCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.meowarex.rlmobile.manager.base
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.core.content.edit
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
abstract class BasePreferenceManager(
|
||||
private val prefs: SharedPreferences,
|
||||
) {
|
||||
protected fun getString(key: String, defaultValue: String) = prefs.getString(key, defaultValue) ?: defaultValue
|
||||
private fun getBoolean(key: String, defaultValue: Boolean) = prefs.getBoolean(key, defaultValue)
|
||||
private fun getInt(key: String, defaultValue: Int) = prefs.getInt(key, defaultValue)
|
||||
private fun getFloat(key: String, defaultValue: Float) = prefs.getFloat(key, defaultValue)
|
||||
protected inline fun <reified E : Enum<E>> getEnum(key: String, defaultValue: E): E {
|
||||
return try {
|
||||
enumValueOf<E>(getString(key, defaultValue.name))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
protected fun putString(key: String, value: String?) = prefs.edit { putString(key, value) }
|
||||
private fun putBoolean(key: String, value: Boolean) = prefs.edit { putBoolean(key, value) }
|
||||
private fun putInt(key: String, value: Int) = prefs.edit { putInt(key, value) }
|
||||
private fun putFloat(key: String, value: Float) = prefs.edit { putFloat(key, value) }
|
||||
protected inline fun <reified E : Enum<E>> putEnum(key: String, value: E) = putString(key, value.name)
|
||||
|
||||
protected class Preference<T>(
|
||||
private val key: String,
|
||||
defaultValue: T,
|
||||
getter: (key: String, defaultValue: T) -> T,
|
||||
private val setter: (key: String, newValue: T) -> Unit,
|
||||
) {
|
||||
var value by mutableStateOf(getter(key, defaultValue))
|
||||
private set
|
||||
|
||||
operator fun getValue(thisRef: Any?, property: KProperty<*>) = value
|
||||
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T) {
|
||||
value = newValue
|
||||
setter(key, newValue)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
protected fun stringPreference(
|
||||
key: String,
|
||||
defaultValue: String,
|
||||
) = Preference(
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
getter = ::getString,
|
||||
setter = ::putString
|
||||
)
|
||||
|
||||
@Suppress("unused")
|
||||
protected fun booleanPreference(
|
||||
key: String,
|
||||
defaultValue: Boolean,
|
||||
) = Preference(
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
getter = ::getBoolean,
|
||||
setter = ::putBoolean
|
||||
)
|
||||
|
||||
@Suppress("unused")
|
||||
protected fun intPreference(
|
||||
key: String,
|
||||
defaultValue: Int,
|
||||
) = Preference(
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
getter = ::getInt,
|
||||
setter = ::putInt
|
||||
)
|
||||
|
||||
@Suppress("unused")
|
||||
protected fun floatPreference(
|
||||
key: String,
|
||||
defaultValue: Float,
|
||||
) = Preference(
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
getter = ::getFloat,
|
||||
setter = ::putFloat
|
||||
)
|
||||
|
||||
@Suppress("unused")
|
||||
protected inline fun <reified E : Enum<E>> enumPreference(
|
||||
key: String,
|
||||
defaultValue: E,
|
||||
) = Preference(
|
||||
key = key,
|
||||
defaultValue = defaultValue,
|
||||
getter = ::getEnum,
|
||||
setter = ::putEnum
|
||||
)
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package com.meowarex.rlmobile.manager.download
|
||||
|
||||
import android.app.Application
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.download.IDownloadManager.ProgressListener
|
||||
import com.meowarex.rlmobile.manager.download.IDownloadManager.Result
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Handle downloading remote urls to a path through the system's [DownloadManager].
|
||||
*/
|
||||
class AndroidDownloadManager(application: Application) : IDownloadManager {
|
||||
private val downloadManager = application.getSystemService<DownloadManager>()
|
||||
?: throw IllegalStateException("DownloadManager service is not available")
|
||||
|
||||
/**
|
||||
* Start a cancellable download with the system [IDownloadManager].
|
||||
* If the current [CoroutineScope] is cancelled, then the system download will be cancelled within 100ms.
|
||||
* @param url Remote src url
|
||||
* @param out Target path to download to. It is assumed that the application has write permissions to this path.
|
||||
* @param onProgressUpdate An optional [ProgressListener]
|
||||
*/
|
||||
override suspend fun download(url: String, out: File, onProgressUpdate: ProgressListener?): Result {
|
||||
onProgressUpdate?.onUpdate(null)
|
||||
out.parentFile?.mkdirs()
|
||||
|
||||
// Create and start a download in the system DownloadManager
|
||||
val downloadId = DownloadManager.Request(url.toUri())
|
||||
.setTitle("Radiant Lyrics Manager")
|
||||
.setDescription("Downloading ${out.name}...")
|
||||
.setDestinationUri(Uri.fromFile(out))
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
.addRequestHeader("User-Agent", "Radiant Lyrics Manager/${BuildConfig.VERSION_NAME}")
|
||||
.let(downloadManager::enqueue)
|
||||
|
||||
// Repeatedly request download state until it is finished
|
||||
while (true) {
|
||||
try {
|
||||
// Hand over control to a suspend function to check for cancellation
|
||||
// At the same time, delay 100ms to slow down the potentially infinite loop
|
||||
delay(100)
|
||||
} catch (_: CancellationException) {
|
||||
// If the running CoroutineScope has been cancelled, then gracefully cancel download
|
||||
downloadManager.remove(downloadId)
|
||||
return Result.Cancelled(systemTriggered = false)
|
||||
}
|
||||
|
||||
// Request download status
|
||||
val cursor = DownloadManager.Query()
|
||||
.setFilterById(downloadId)
|
||||
.let(downloadManager::query)
|
||||
|
||||
cursor.use {
|
||||
// No results in cursor, download was cancelled
|
||||
if (!cursor.moveToFirst()) {
|
||||
return Result.Cancelled(systemTriggered = true)
|
||||
}
|
||||
|
||||
val statusColumn = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val status = cursor.getInt(statusColumn)
|
||||
|
||||
when (status) {
|
||||
DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED ->
|
||||
onProgressUpdate?.onUpdate(null)
|
||||
|
||||
DownloadManager.STATUS_RUNNING ->
|
||||
onProgressUpdate?.onUpdate(getDownloadProgress(cursor))
|
||||
|
||||
DownloadManager.STATUS_SUCCESSFUL ->
|
||||
return Result.Success(out)
|
||||
|
||||
DownloadManager.STATUS_FAILED -> {
|
||||
val reasonColumn = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
val reason = cursor.getInt(reasonColumn)
|
||||
|
||||
return Error(reason)
|
||||
}
|
||||
|
||||
else -> throw Error("Unreachable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the download progress of the current row in a [DownloadManager.Query].
|
||||
* @return Download progress in the range of `[0,1]`
|
||||
*/
|
||||
private fun getDownloadProgress(queryCursor: Cursor): Float {
|
||||
val bytesColumn = queryCursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
val bytes = queryCursor.getLong(bytesColumn)
|
||||
|
||||
val totalBytesColumn = queryCursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val totalBytes = queryCursor.getLong(totalBytesColumn)
|
||||
|
||||
if (totalBytes <= 0) return 0f
|
||||
return bytes.toFloat() / totalBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Error returned by the system [DownloadManager].
|
||||
* @param reason The reason code returned by the [DownloadManager.COLUMN_REASON] column.
|
||||
*/
|
||||
data class Error(val reason: Int) : Result.Error() {
|
||||
/**
|
||||
* Convert a [DownloadManager.COLUMN_REASON] code into its name.
|
||||
*/
|
||||
override fun getDebugReason(): String = when (reason) {
|
||||
DownloadManager.ERROR_UNKNOWN -> "Unknown"
|
||||
DownloadManager.ERROR_FILE_ERROR -> "File Error"
|
||||
DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP code"
|
||||
DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP data error"
|
||||
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects"
|
||||
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient space"
|
||||
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Target file's device not found"
|
||||
DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume"
|
||||
DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File exists"
|
||||
/* DownloadManager.ERROR_BLOCKED */ 1010 -> "Network policy block"
|
||||
else -> "Unknown code ($reason)"
|
||||
}
|
||||
|
||||
override fun getLocalizedReason(context: Context): String {
|
||||
val string = when (reason) { // @formatter:off
|
||||
DownloadManager.ERROR_HTTP_DATA_ERROR,
|
||||
DownloadManager.ERROR_TOO_MANY_REDIRECTS,
|
||||
DownloadManager.ERROR_UNHANDLED_HTTP_CODE ->
|
||||
R.string.downloader_err_response
|
||||
|
||||
DownloadManager.ERROR_INSUFFICIENT_SPACE ->
|
||||
R.string.downloader_err_storage_space
|
||||
|
||||
DownloadManager.ERROR_FILE_ALREADY_EXISTS ->
|
||||
R.string.downloader_err_file_exists
|
||||
|
||||
else -> R.string.downloader_err_unknown
|
||||
} // @formatter:on
|
||||
|
||||
return context.getString(string)
|
||||
}
|
||||
|
||||
override fun toString(): String = getDebugReason()
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.meowarex.rlmobile.manager.download
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Common interface for different implementations of starting and managing the lifetime of downloads.
|
||||
*/
|
||||
interface IDownloadManager {
|
||||
/**
|
||||
* Start a cancellable download.
|
||||
* @param url Remote src url
|
||||
* @param out Target path to download to. It is assumed that the application has write permissions to this path.
|
||||
* @param onProgressUpdate An optional [ProgressListener] callback.
|
||||
*/
|
||||
suspend fun download(url: String, out: File, onProgressUpdate: ProgressListener? = null): Result
|
||||
|
||||
/**
|
||||
* A callback executed from a coroutine called every 100ms in order to provide
|
||||
* info about the current download. This should not perform long-running tasks as the delay will be offset.
|
||||
*/
|
||||
fun interface ProgressListener {
|
||||
/**
|
||||
* @param progress The current download progress in a `[0,1]` range. If null, then the download is either
|
||||
* paused, pending, or waiting to retry.
|
||||
*/
|
||||
fun onUpdate(progress: Float?)
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a download after execution has been completed and the system-level [DownloadManager] has been cleaned up.
|
||||
*/
|
||||
sealed interface Result {
|
||||
/**
|
||||
* The download succeeded successfully.
|
||||
* @param file The path that the download was downloaded to.
|
||||
*/
|
||||
data class Success(val file: File) : Result
|
||||
|
||||
/**
|
||||
* This download was interrupted and the in-progress file has been deleted.
|
||||
* @param systemTriggered Whether the cancellation happened from the system (ie. clicked cancel on the download notification)
|
||||
* Otherwise, this was caused by a coroutine cancellation.
|
||||
*/
|
||||
data class Cancelled(val systemTriggered: Boolean) : Result
|
||||
|
||||
/**
|
||||
* This download failed to complete due to an error.
|
||||
*/
|
||||
abstract class Error : Result {
|
||||
/**
|
||||
* The full internal error representation.
|
||||
*/
|
||||
abstract fun getDebugReason(): String
|
||||
|
||||
/**
|
||||
* Simplified + translatable user facing reason for the failure.
|
||||
* If null is returned, then the [getDebugReason] will be used instead.
|
||||
*/
|
||||
open fun getLocalizedReason(context: Context): String? = null
|
||||
|
||||
/**
|
||||
* Gets the underlying raw error (if available).
|
||||
*/
|
||||
open fun getError(): Throwable? = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package com.meowarex.rlmobile.manager.download
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.getSystemService
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.download.IDownloadManager.Result
|
||||
import com.meowarex.rlmobile.patcher.util.InsufficientStorageException
|
||||
import com.meowarex.rlmobile.util.IS_PROBABLY_EMULATOR
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.*
|
||||
import io.ktor.utils.io.readAvailable
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
/**
|
||||
* Handle downloading remote urls to a path with Ktor.
|
||||
* This is used as an alternative downloader option due to some bugs with the
|
||||
* system's DownloadManager that prevents its usage on some emulators and ROMs.
|
||||
*/
|
||||
class KtorDownloadManager(
|
||||
private val http: HttpClient,
|
||||
private val application: Application,
|
||||
) : IDownloadManager {
|
||||
override suspend fun download(url: String, out: File, onProgressUpdate: IDownloadManager.ProgressListener?): Result {
|
||||
onProgressUpdate?.onUpdate(null)
|
||||
out.parentFile?.mkdirs()
|
||||
|
||||
val tmpOut = out.resolveSibling(out.name + ".tmp")
|
||||
|
||||
try {
|
||||
val httpStmt = http.prepareGet(url) {
|
||||
header(HttpHeaders.CacheControl, "no-cache, no-store")
|
||||
|
||||
// Disable compression due to bug on emulators
|
||||
// This header cannot be set with Android's DownloadManager
|
||||
if (IS_PROBABLY_EMULATOR) {
|
||||
header(HttpHeaders.AcceptEncoding, null)
|
||||
}
|
||||
}
|
||||
|
||||
httpStmt.execute { resp ->
|
||||
if (!resp.status.isSuccess()) {
|
||||
val body = try {
|
||||
resp.bodyAsText().take(2048)
|
||||
} catch (e: Exception) {
|
||||
Log.e(BuildConfig.TAG, "Failed to read downloader error response", e)
|
||||
"<failed to read>"
|
||||
}
|
||||
|
||||
throw DownloadException(url = url, status = resp.status, body = body)
|
||||
}
|
||||
|
||||
val channel = resp.bodyAsChannel()
|
||||
val total = resp.contentLength() ?: 0
|
||||
var retrieved = 0L
|
||||
|
||||
val buf = ByteArray(1024 * 1024 * 1)
|
||||
var bufLen: Int
|
||||
|
||||
tmpOut.outputStream().use { stream ->
|
||||
// Preallocate space for this file
|
||||
if (total > 0 && Build.VERSION.SDK_INT >= 26) {
|
||||
val storageManager = application.getSystemService<StorageManager>()!!
|
||||
|
||||
try {
|
||||
storageManager.allocateBytes(stream.fd, total)
|
||||
} catch (e: IOException) {
|
||||
throw InsufficientStorageException(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
while (!channel.isClosedForRead) {
|
||||
bufLen = channel.readAvailable(buf)
|
||||
if (bufLen <= 0) break
|
||||
|
||||
stream.write(buf, 0, bufLen)
|
||||
stream.flush()
|
||||
|
||||
retrieved += bufLen
|
||||
|
||||
if (total > 0) {
|
||||
if (retrieved > total)
|
||||
throw IOException("Total bytes received exceeds header total!")
|
||||
|
||||
onProgressUpdate?.onUpdate(retrieved / total.toFloat())
|
||||
} else {
|
||||
onProgressUpdate?.onUpdate(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
tmpOut.delete()
|
||||
return Result.Cancelled(systemTriggered = false)
|
||||
} catch (e: DownloadException) {
|
||||
tmpOut.delete()
|
||||
return Error(
|
||||
error = e,
|
||||
localizedError = R.string.downloader_err_code,
|
||||
localizedErrorArgs = arrayOf(e.status.value),
|
||||
)
|
||||
} catch (e: SocketTimeoutException) {
|
||||
tmpOut.delete()
|
||||
return Error(e, localizedError = R.string.downloader_err_timeout)
|
||||
} catch (e: InsufficientStorageException) {
|
||||
tmpOut.delete()
|
||||
return Error(e, localizedError = R.string.downloader_err_storage_space)
|
||||
} catch (t: Throwable) {
|
||||
tmpOut.delete()
|
||||
return Error(t)
|
||||
}
|
||||
|
||||
tmpOut.renameTo(out)
|
||||
return Result.Success(out)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around an exception that occurred from invoking Ktor
|
||||
*/
|
||||
class Error(
|
||||
private val error: Throwable,
|
||||
@StringRes
|
||||
private val localizedError: Int? = null,
|
||||
private val localizedErrorArgs: Array<Any> = arrayOf(),
|
||||
) : Result.Error() {
|
||||
override fun toString(): String = error.stackTraceToString()
|
||||
override fun getDebugReason(): String = error.message ?: "Unknown exception"
|
||||
override fun getLocalizedReason(context: Context): String? =
|
||||
localizedError?.let { context.getString(it, *localizedErrorArgs) }
|
||||
|
||||
override fun getError(): Throwable? = error
|
||||
}
|
||||
|
||||
private class DownloadException(val url: String, val status: HttpStatusCode, val body: String) :
|
||||
IOException("Failed to download $url, received status code $status, response: $body")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.meowarex.rlmobile.network.models
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.meowarex.rlmobile.util.serialization.ImmutableListSerializer
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class Contributor(
|
||||
val username: String,
|
||||
val avatarUrl: String,
|
||||
val commits: Int,
|
||||
@Serializable(with = ImmutableListSerializer::class)
|
||||
val repositories: ImmutableList<Repository>,
|
||||
) {
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class Repository(
|
||||
val name: String,
|
||||
val commits: Int,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.meowarex.rlmobile.network.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GithubRelease(
|
||||
@SerialName("created_at")
|
||||
val createdAt: String,
|
||||
val assets: List<GithubReleaseAssets>,
|
||||
@SerialName("tag_name")
|
||||
val tagName: String,
|
||||
@SerialName("html_url")
|
||||
val htmlUrl: String,
|
||||
) {
|
||||
@Serializable
|
||||
data class GithubReleaseAssets(
|
||||
val name: String,
|
||||
@SerialName("browser_download_url")
|
||||
val browserDownloadUrl: String,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.meowarex.rlmobile.network.models
|
||||
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class RLBuildInfo(
|
||||
@SerialName("tidalVersionCode")
|
||||
val tidalVersionCode: Int,
|
||||
@SerialName("tidalApkUrl")
|
||||
val tidalApkUrl: String,
|
||||
@SerialName("patchesVersion")
|
||||
val patchesVersion: SemVer,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.meowarex.rlmobile.network.services
|
||||
|
||||
import android.util.Log
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.network.utils.*
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.request
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.isSuccess
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class HttpService(
|
||||
val json: Json,
|
||||
val http: HttpClient,
|
||||
) {
|
||||
suspend inline fun <reified T> request(
|
||||
crossinline builder: HttpRequestBuilder.() -> Unit = {},
|
||||
): ApiResponse<T> = withContext(Dispatchers.IO) request@{
|
||||
var body: String? = null
|
||||
|
||||
val response = try {
|
||||
val response = http.request(builder)
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
body = response.bodyAsText()
|
||||
|
||||
if (T::class == String::class) {
|
||||
return@request ApiResponse.Success(body as T)
|
||||
}
|
||||
|
||||
ApiResponse.Success(json.decodeFromString<T>(body))
|
||||
} else {
|
||||
body = try {
|
||||
response.bodyAsText()
|
||||
} catch (t: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
Log.e(BuildConfig.TAG, "Failed to fetch: API error, http status: ${response.status}, body: $body")
|
||||
ApiResponse.Error(ApiError(response.status, body))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.e(BuildConfig.TAG, "Failed to fetch: error: $t, body: $body")
|
||||
ApiResponse.Failure(ApiFailure(t, body))
|
||||
}
|
||||
return@request response
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.meowarex.rlmobile.network.services
|
||||
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.network.models.GithubRelease
|
||||
import com.meowarex.rlmobile.network.models.RLBuildInfo
|
||||
import com.meowarex.rlmobile.network.utils.ApiResponse
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.http.HttpHeaders
|
||||
|
||||
class RadiantLyricsGithubService(
|
||||
private val http: HttpService,
|
||||
) {
|
||||
/**
|
||||
* Fetches the latest release from meowarex/rl-mobile to determine current patch + TIDAL versions.
|
||||
*/
|
||||
suspend fun getLatestRelease(force: Boolean = false): ApiResponse<GithubRelease> =
|
||||
http.request {
|
||||
url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/releases/latest")
|
||||
if (force) {
|
||||
header(HttpHeaders.CacheControl, "no-cache")
|
||||
} else {
|
||||
header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches build metadata from the data.json asset in the latest GitHub release.
|
||||
* The data.json asset URL is obtained from [getLatestRelease].
|
||||
*/
|
||||
suspend fun getBuildInfo(dataJsonUrl: String, force: Boolean = false): ApiResponse<RLBuildInfo> =
|
||||
http.request {
|
||||
url(dataJsonUrl)
|
||||
if (force) {
|
||||
header(HttpHeaders.CacheControl, "no-cache")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches manager self-update releases.
|
||||
*/
|
||||
suspend fun getManagerReleases(): ApiResponse<List<GithubRelease>> =
|
||||
http.request {
|
||||
url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/releases")
|
||||
header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REPO_OWNER = BuildConfig.PATCHES_REPO_OWNER
|
||||
const val REPO_NAME = BuildConfig.PATCHES_REPO_NAME
|
||||
const val PATCHES_ASSET_NAME = "patches.zip"
|
||||
const val DATA_JSON_ASSET_NAME = "data.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
@file:Suppress("NOTHING_TO_INLINE")
|
||||
|
||||
package com.meowarex.rlmobile.network.utils
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
|
||||
sealed interface ApiResponse<T> {
|
||||
data class Success<T>(val data: T) : ApiResponse<T>
|
||||
data class Error<T>(val error: ApiError) : ApiResponse<T>
|
||||
data class Failure<T>(val error: ApiFailure) : ApiResponse<T>
|
||||
}
|
||||
|
||||
class ApiError(code: HttpStatusCode, body: String?) : Error("HTTP Code $code, Body: $body")
|
||||
|
||||
class ApiFailure(error: Throwable, body: String?) : Error(body, error)
|
||||
|
||||
inline fun <T, R> ApiResponse<T>.fold(
|
||||
success: (T) -> R,
|
||||
error: (ApiError) -> R,
|
||||
failure: (ApiFailure) -> R,
|
||||
): R {
|
||||
return when (this) {
|
||||
is ApiResponse.Success -> success(this.data)
|
||||
is ApiResponse.Error -> error(this.error)
|
||||
is ApiResponse.Failure -> failure(this.error)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T, R> ApiResponse<T>.fold(
|
||||
success: (T) -> R,
|
||||
fail: (Error) -> R,
|
||||
): R {
|
||||
return when (this) {
|
||||
is ApiResponse.Success -> success(data)
|
||||
is ApiResponse.Error -> fail(error)
|
||||
is ApiResponse.Failure -> fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <T, R> ApiResponse<T>.transform(block: (T) -> R): ApiResponse<R> {
|
||||
return if (this !is ApiResponse.Success) {
|
||||
// Error and Failure do not use the generic value
|
||||
this as ApiResponse<R>
|
||||
} else {
|
||||
ApiResponse.Success(block(data))
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> ApiResponse<T>.getOrThrow(): T {
|
||||
return fold(
|
||||
success = { it },
|
||||
fail = { throw it }
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <T> ApiResponse<T>.getOrNull(): T? {
|
||||
return fold(
|
||||
success = { it },
|
||||
fail = { null }
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <T, R> ApiResponse<T>.chain(block: (T) -> ApiResponse<R>): ApiResponse<R> {
|
||||
return if (this !is ApiResponse.Success) {
|
||||
// Error and Failure do not use the generic value
|
||||
this as ApiResponse<R>
|
||||
} else {
|
||||
block(data)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <T, R> ApiResponse<T>.chain(secondary: ApiResponse<R>): ApiResponse<R> {
|
||||
return if (secondary is ApiResponse.Success) {
|
||||
secondary
|
||||
} else {
|
||||
// Error and Failure do not use the generic value
|
||||
this as ApiResponse<R>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.meowarex.rlmobile.network.utils
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
|
||||
/**
|
||||
* Parses a Semantic version in the format of `v1.0.0` or `1.0.0`.
|
||||
* This always gets serialized and stringified without the `v` prefix.
|
||||
*/
|
||||
@Immutable
|
||||
@Parcelize
|
||||
@Serializable(SemVer.Serializer::class)
|
||||
data class SemVer(
|
||||
val major: Int,
|
||||
val minor: Int,
|
||||
val patch: Int,
|
||||
) : Comparable<SemVer>, Parcelable {
|
||||
override fun compareTo(other: SemVer): Int {
|
||||
var cmp = 0
|
||||
if (0 != major.compareTo(other.major).also { cmp = it })
|
||||
return cmp
|
||||
if (0 != minor.compareTo(other.minor).also { cmp = it })
|
||||
return cmp
|
||||
if (0 != patch.compareTo(other.patch).also { cmp = it })
|
||||
return cmp
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
val ver = other as? SemVer
|
||||
?: return false
|
||||
|
||||
return ver.major == major &&
|
||||
ver.minor == minor &&
|
||||
ver.patch == patch
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "$major.$minor.$patch"
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = major
|
||||
result = 31 * result + minor
|
||||
result = 31 * result + patch
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(version: String): SemVer = parseOrNull(version)
|
||||
?: throw IllegalArgumentException("Invalid semver string $version")
|
||||
|
||||
fun parseOrNull(version: String): SemVer? {
|
||||
val parts = version.removePrefix("v").split(".")
|
||||
|
||||
if (parts.size != 3)
|
||||
return null
|
||||
|
||||
val major = parts[0].toIntOrNull() ?: return null
|
||||
val minor = parts[1].toIntOrNull() ?: return null
|
||||
val patch = parts[2].toIntOrNull() ?: return null
|
||||
|
||||
return SemVer(major, minor, patch)
|
||||
}
|
||||
}
|
||||
|
||||
object Serializer : KSerializer<SemVer> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("SemVer", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder) =
|
||||
parse(decoder.decodeString())
|
||||
|
||||
override fun serialize(encoder: Encoder, value: SemVer) {
|
||||
encoder.encodeString(value.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.meowarex.rlmobile.patcher
|
||||
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class InstallMetadata(
|
||||
val customManager: Boolean,
|
||||
val managerVersion: SemVer,
|
||||
val patchesVersion: SemVer,
|
||||
val options: PatchOptions,
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.meowarex.rlmobile.patcher
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PreferencesManager
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.ui.util.InstallNotifications
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* The minimum time that is required to occur between step switches, to avoid
|
||||
* quickly switching the step groups in the UI. (very disorienting)
|
||||
* Larger delay leads to a perception that it's doing more work than it actually is.
|
||||
*/
|
||||
private const val MINIMUM_STEP_DELAY: Long = 600L
|
||||
|
||||
/**
|
||||
* ID used for showing error notifications emanating from this step runner.
|
||||
*/
|
||||
private const val ERROR_NOTIF_ID = 200002
|
||||
|
||||
abstract class StepRunner : KoinComponent {
|
||||
private val context: Context by inject()
|
||||
private val preferences: PreferencesManager by inject()
|
||||
private val paths: com.meowarex.rlmobile.manager.PathManager by inject()
|
||||
|
||||
private fun logApkState(stepName: String) {
|
||||
val apk = paths.patchedApk
|
||||
if (!apk.exists()) return
|
||||
val header = try {
|
||||
apk.inputStream().use { stream ->
|
||||
val buf = ByteArray(16)
|
||||
val n = stream.read(buf).coerceAtLeast(0)
|
||||
buf.copyOf(n)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
log("[apk-check] after $stepName: failed to read header: $t")
|
||||
return
|
||||
}
|
||||
val hex = header.joinToString(" ") { "%02x".format(it) }
|
||||
log("[apk-check] after $stepName: size=${apk.length()} header=$hex")
|
||||
}
|
||||
|
||||
/**
|
||||
* The log history from this runner and all steps.
|
||||
*/
|
||||
private val logEntries: MutableList<String> = mutableListOf()
|
||||
|
||||
/**
|
||||
* The steps to be run, defined by specific step runners.
|
||||
*/
|
||||
abstract val steps: ImmutableList<Step>
|
||||
|
||||
/**
|
||||
* Get a step that has already been successfully executed.
|
||||
* This is used to retrieve previously executed dependency steps from a later step.
|
||||
* @param completed Only match steps that have finished executing.
|
||||
*/
|
||||
inline fun <reified T : Step> getStep(completed: Boolean = true): T {
|
||||
val step = steps.asSequence()
|
||||
.filterIsInstance<T>()
|
||||
.filter { !completed || it.state.isFinished }
|
||||
.firstOrNull()
|
||||
|
||||
if (step == null) {
|
||||
throw IllegalArgumentException("No completed step ${T::class.simpleName} exists in container")
|
||||
}
|
||||
|
||||
return step
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a log entry to this installation run without any associated log level.
|
||||
*/
|
||||
fun log(text: String) {
|
||||
logEntries += text
|
||||
Log.i(BuildConfig.TAG, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines all the log entries into a single formatted log.
|
||||
*/
|
||||
fun getLog(): String = logEntries.joinToString(separator = "\n")
|
||||
|
||||
suspend fun executeAll(): Throwable? {
|
||||
log("Starting step runner")
|
||||
log("Registered steps: " + steps.joinToString { it.javaClass.simpleName })
|
||||
|
||||
for (step in steps) {
|
||||
val stepName = step.javaClass.simpleName
|
||||
|
||||
log("Running step: $stepName")
|
||||
val error = step.executeCatching(this@StepRunner)
|
||||
logApkState(stepName)
|
||||
|
||||
if (error != null) {
|
||||
log("Failed on step: $stepName after ${step.getDuration()}ms")
|
||||
maybeShowErrorNotification()
|
||||
return error
|
||||
}
|
||||
|
||||
// Skip minimum run time when in dev mode
|
||||
val duration = step.getDuration()
|
||||
if (!preferences.devMode && duration < MINIMUM_STEP_DELAY) {
|
||||
delay(MINIMUM_STEP_DELAY - duration)
|
||||
}
|
||||
|
||||
log("Completed step: $stepName in ${duration}ms")
|
||||
}
|
||||
|
||||
log("Successfully finished all steps in ${steps.sumOf { it.getDuration() }}ms")
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a system notification to notify the user that the installation failed,
|
||||
* if manager is currently backgrounded while the installation was running.
|
||||
*/
|
||||
private fun maybeShowErrorNotification() {
|
||||
if (ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED))
|
||||
return // Is currently foreground
|
||||
|
||||
InstallNotifications.createNotification(
|
||||
context = context,
|
||||
id = ERROR_NOTIF_ID,
|
||||
title = R.string.notif_install_fail_title,
|
||||
description = R.string.notif_install_fail_desc,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.meowarex.rlmobile.patcher
|
||||
|
||||
import com.meowarex.rlmobile.patcher.steps.download.*
|
||||
import com.meowarex.rlmobile.patcher.steps.install.*
|
||||
import com.meowarex.rlmobile.patcher.steps.patch.*
|
||||
import com.meowarex.rlmobile.patcher.steps.prepare.*
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
class TidalPatchRunner(
|
||||
options: PatchOptions,
|
||||
) : StepRunner() {
|
||||
override val steps = persistentListOf(
|
||||
// Prepare
|
||||
FetchInfoStep(),
|
||||
DowngradeCheckStep(options),
|
||||
RestoreDownloadsStep(),
|
||||
|
||||
// Download
|
||||
DownloadTidalStep(),
|
||||
DownloadPatchesStep(options.customPatches),
|
||||
CopyDependenciesStep(),
|
||||
|
||||
// Patch
|
||||
SmaliPatchStep(),
|
||||
ReorganizeDexStep(),
|
||||
PatchManifestStep(options),
|
||||
PatchCertsStep(),
|
||||
SaveMetadataStep(options),
|
||||
|
||||
// Install
|
||||
AlignmentStep(),
|
||||
SigningStep(options),
|
||||
InstallStep(options),
|
||||
CleanupStep(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.meowarex.rlmobile.patcher.steps
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.meowarex.rlmobile.R
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* A group of steps that is shown under one section in the patching UI.
|
||||
* This has no functional impact other than organization.
|
||||
*/
|
||||
@Immutable
|
||||
@Parcelize
|
||||
enum class StepGroup(
|
||||
/**
|
||||
* The UI name to display this group as
|
||||
*/
|
||||
@get:StringRes
|
||||
val localizedName: Int,
|
||||
) : Parcelable {
|
||||
Prepare(R.string.install_group_prepare),
|
||||
Download(R.string.install_group_download),
|
||||
Patch(R.string.install_group_patch),
|
||||
Install(R.string.install_group_install)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.base
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.download.IDownloadManager
|
||||
import com.meowarex.rlmobile.manager.download.KtorDownloadManager
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Stable
|
||||
abstract class DownloadStep<IVersion> : Step(), KoinComponent {
|
||||
private val context: Context by inject()
|
||||
private val downloader: KtorDownloadManager by inject()
|
||||
|
||||
/**
|
||||
* The version of the file that is/will be downloaded.
|
||||
* The return type is dynamic as different dependencies have varying version formats.
|
||||
*/
|
||||
abstract fun getVersion(container: StepRunner): IVersion
|
||||
|
||||
/**
|
||||
* The remote url to be downloaded to the [getStoredFile].
|
||||
*/
|
||||
abstract fun getRemoteUrl(container: StepRunner): String
|
||||
|
||||
/**
|
||||
* Target path to store the download in. If this file already exists at the time
|
||||
* of execution, then the cached version is used and the step is marked as skipped.
|
||||
*/
|
||||
abstract fun getStoredFile(container: StepRunner): File
|
||||
|
||||
/**
|
||||
* Verify that the download completely successfully without errors.
|
||||
* @throws Throwable If verification fails.
|
||||
*/
|
||||
@CallSuper
|
||||
open suspend fun verify(container: StepRunner) {
|
||||
val file = getStoredFile(container)
|
||||
|
||||
if (!file.exists())
|
||||
throw Error("Downloaded file is missing!")
|
||||
if (file.length() <= 0)
|
||||
throw Error("Downloaded file is empty!")
|
||||
}
|
||||
|
||||
override val group = StepGroup.Download
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val version = getVersion(container)
|
||||
val file = getStoredFile(container)
|
||||
val url = getRemoteUrl(container)
|
||||
|
||||
container.log("Checking if file cached: ${file.absolutePath}")
|
||||
if (file.exists()) {
|
||||
container.log("File exists, verifying...")
|
||||
|
||||
try {
|
||||
verify(container)
|
||||
state = StepState.Skipped
|
||||
container.log("File verified, skipping download")
|
||||
return
|
||||
} catch (t: Throwable) {
|
||||
file.delete()
|
||||
container.log("Verification error: " + Log.getStackTraceString(t))
|
||||
container.log("File failed verification, deleting and redownloading")
|
||||
}
|
||||
}
|
||||
|
||||
container.log("Downloading file version: $version at url: $url")
|
||||
var lastLogProgress = 0f
|
||||
val result = downloader.download(url, file) { newProgress ->
|
||||
progress = newProgress ?: -1f
|
||||
|
||||
newProgress?.let { newProgress ->
|
||||
if (newProgress > lastLogProgress + 0.1f) {
|
||||
container.log("Download progress: ${(newProgress * 100.0).toPrecision(0)}% after ${getDuration()}ms")
|
||||
}
|
||||
@Suppress("AssignedValueIsNeverRead") // incorrect
|
||||
lastLogProgress = newProgress
|
||||
}
|
||||
}
|
||||
|
||||
when (result) {
|
||||
is IDownloadManager.Result.Cancelled -> {
|
||||
state = StepState.Error
|
||||
container.log("Download cancelled!")
|
||||
}
|
||||
|
||||
is IDownloadManager.Result.Success -> {
|
||||
container.log("Successfully downloaded file, verifying...")
|
||||
|
||||
try {
|
||||
verify(container)
|
||||
} catch (e: CancellationException) {
|
||||
file.delete()
|
||||
throw e
|
||||
} catch (t: Throwable) {
|
||||
mainThread { context.showToast(R.string.installer_dl_verify_fail) }
|
||||
container.log("Failed to verify file, deleting...")
|
||||
file.delete()
|
||||
throw t
|
||||
}
|
||||
|
||||
container.log("Verified downloaded file")
|
||||
}
|
||||
|
||||
is IDownloadManager.Result.Error -> {
|
||||
withContext(Dispatchers.Main) {
|
||||
val toastText = result.getLocalizedReason(context)
|
||||
?: context.getString(R.string.downloader_err_unknown)
|
||||
|
||||
Toast.makeText(context, toastText, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
container.log("Failed to download file")
|
||||
throw Error("Failed to download: $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.base
|
||||
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
|
||||
interface IDexProvider {
|
||||
/**
|
||||
* The priority of the .dex files supplied by [getDexFiles].
|
||||
* Higher number leads to a higher overwrite priority.
|
||||
* .dex files already included in the APK have a priority of `0`.
|
||||
*/
|
||||
val dexPriority: Int
|
||||
|
||||
/**
|
||||
* The amount of files returned by [getDexFiles]
|
||||
*/
|
||||
val dexCount: Int
|
||||
|
||||
/**
|
||||
* Any dex files to be added into the APK.
|
||||
*/
|
||||
fun getDexFiles(container: StepRunner): List<ByteArray>
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.base
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.*
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.util.toPrecision
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
/**
|
||||
* A base install process step. Steps are single-use
|
||||
*/
|
||||
@Stable
|
||||
abstract class Step {
|
||||
/**
|
||||
* The group this step belongs to.
|
||||
*/
|
||||
abstract val group: StepGroup
|
||||
|
||||
/**
|
||||
* The UI name to display this step as
|
||||
*/
|
||||
@get:StringRes
|
||||
abstract val localizedName: Int
|
||||
|
||||
/**
|
||||
* Run the step's logic.
|
||||
* It can be assumed that this is executed in the correct order after other steps.
|
||||
*/
|
||||
protected abstract suspend fun execute(container: StepRunner)
|
||||
|
||||
/**
|
||||
* The current state of this step in the installation process.
|
||||
*/
|
||||
var state by mutableStateOf(StepState.Pending)
|
||||
protected set
|
||||
|
||||
/**
|
||||
* If the current state is [StepState.Running], then the progress of this step.
|
||||
* If the progress isn't currently measurable, then this should be set to `-1`.
|
||||
*/
|
||||
var progress by mutableFloatStateOf(-1f)
|
||||
protected set
|
||||
|
||||
private val durationSecs = mutableFloatStateOf(0f)
|
||||
private var startTime: Long? = null
|
||||
private var totalTimeMs: Long? = null
|
||||
|
||||
/**
|
||||
* The total amount of time this step has/was executed for in milliseconds.
|
||||
* If this step has not started executing then it will return `0`.
|
||||
*/
|
||||
fun getDuration(): Long {
|
||||
// Step hasn't started executing
|
||||
val startTime = startTime ?: return 0
|
||||
|
||||
// Step already finished executing
|
||||
totalTimeMs?.let { return it }
|
||||
|
||||
return System.currentTimeMillis() - startTime
|
||||
}
|
||||
|
||||
/**
|
||||
* The live execution time of this step in seconds.
|
||||
* The value is clamped to a resolution of 10ms updated every 50ms.
|
||||
*/
|
||||
@Composable
|
||||
fun collectDurationAsState(): State<Float> {
|
||||
if (state.isFinished)
|
||||
return durationSecs
|
||||
|
||||
LaunchedEffect(state) {
|
||||
while (true) {
|
||||
durationSecs.floatValue = (getDuration() / 1000.0)
|
||||
.toPrecision(2).toFloat()
|
||||
|
||||
delay(50)
|
||||
}
|
||||
}
|
||||
|
||||
return durationSecs
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper over [execute] but handling errors.
|
||||
* @return An exception if the step failed to execute.
|
||||
*/
|
||||
suspend fun executeCatching(container: StepRunner): Throwable? {
|
||||
if (state != StepState.Pending)
|
||||
throw IllegalStateException("Cannot execute a step that has already started")
|
||||
|
||||
state = StepState.Running
|
||||
startTime = System.currentTimeMillis()
|
||||
|
||||
// Execute this steps logic while timing it
|
||||
val (error, executionTime) = measureTimedValue {
|
||||
try {
|
||||
withContext(Dispatchers.Default) {
|
||||
execute(container)
|
||||
}
|
||||
|
||||
if (state != StepState.Skipped)
|
||||
state = StepState.Success
|
||||
|
||||
null
|
||||
} catch (t: Throwable) {
|
||||
state = StepState.Error
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
totalTimeMs = executionTime.inWholeMilliseconds
|
||||
durationSecs.floatValue = executionTime.inWholeMilliseconds / 1000f
|
||||
|
||||
return error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.base
|
||||
|
||||
enum class StepState {
|
||||
Pending,
|
||||
Running,
|
||||
Success,
|
||||
Error,
|
||||
Skipped;
|
||||
|
||||
val isFinished: Boolean
|
||||
get() = this == Success || this == Skipped || this == Error
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.download
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import androidx.core.content.getSystemService
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.util.InsufficientStorageException
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Step to duplicate the Tidal APK to be worked on.
|
||||
*/
|
||||
class CopyDependenciesStep : Step(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
private val application: Application by inject()
|
||||
|
||||
/**
|
||||
* The target APK file that will be modified during patching.
|
||||
*/
|
||||
val apk: File = paths.patchedApk
|
||||
|
||||
override val group = StepGroup.Download
|
||||
override val localizedName = R.string.patch_step_copy_deps
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val srcApk = container.getStep<DownloadTidalStep>().getStoredFile(container)
|
||||
|
||||
container.log("Clearing patched directory")
|
||||
if (!paths.patchingWorkingDir.deleteRecursively())
|
||||
throw Error("Failed to clear existing patched dir")
|
||||
|
||||
// Preallocate space for file copy and future patching operations
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val storageManager = application.getSystemService<StorageManager>()!!
|
||||
val targetFileStorageId = storageManager.getUuidForPath(apk)
|
||||
val fileSize = srcApk.length()
|
||||
|
||||
// We request 3.5x the size of the APK, to give space for the following:
|
||||
// 1) A copy of the APK
|
||||
// 2) Modifying the copied APK (whether this is necessary I'm not sure)
|
||||
// 2) Extracting native libs and other various operations
|
||||
val allocSize = (fileSize * 3.5).toLong()
|
||||
|
||||
try {
|
||||
storageManager.allocateBytes(targetFileStorageId, allocSize)
|
||||
} catch (e: IOException) {
|
||||
throw InsufficientStorageException(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
container.log("Copying patched apk from ${srcApk.absolutePath} to ${apk.absolutePath}")
|
||||
apk.parentFile!!.mkdirs()
|
||||
srcApk.copyTo(apk)
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.download
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.base.DownloadStep
|
||||
import com.meowarex.rlmobile.patcher.steps.base.StepState
|
||||
import com.meowarex.rlmobile.patcher.steps.prepare.FetchInfoStep
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
@Stable
|
||||
class DownloadPatchesStep(
|
||||
private val custom: PatchComponent?,
|
||||
) : DownloadStep<SemVer>(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
|
||||
override val localizedName = R.string.patch_step_dl_smali
|
||||
|
||||
override fun getRemoteUrl(container: StepRunner) =
|
||||
container.getStep<FetchInfoStep>().patchesAssetUrl
|
||||
|
||||
override fun getVersion(container: StepRunner) =
|
||||
custom?.version ?: container.getStep<FetchInfoStep>().data.patchesVersion
|
||||
|
||||
override fun getStoredFile(container: StepRunner) =
|
||||
custom?.getFile(paths) ?: paths.cachedSmaliPatches(getVersion(container))
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
if (custom != null) {
|
||||
container.log("Using custom patches with version ${custom.version} built ${custom.timestamp}")
|
||||
|
||||
if (!custom.getFile(paths).exists()) {
|
||||
throw FileNotFoundException(
|
||||
"Selected custom component does not exist on disk! If this is an update, " +
|
||||
"updates cannot occur when the originally selected custom component has been deleted."
|
||||
)
|
||||
}
|
||||
|
||||
state = StepState.Skipped
|
||||
return
|
||||
}
|
||||
|
||||
super.execute(container)
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.download
|
||||
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.base.DownloadStep
|
||||
import com.meowarex.rlmobile.patcher.steps.prepare.FetchInfoStep
|
||||
import com.android.apksig.ApkVerifier
|
||||
import okio.ByteString.Companion.decodeHex
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
|
||||
@Stable
|
||||
class DownloadTidalStep : DownloadStep<Int>(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
|
||||
override val localizedName = R.string.patch_step_dl_tidal_apk
|
||||
|
||||
override fun getVersion(container: StepRunner) =
|
||||
container.getStep<FetchInfoStep>().data.tidalVersionCode
|
||||
|
||||
override fun getRemoteUrl(container: StepRunner) =
|
||||
container.getStep<FetchInfoStep>().data.tidalApkUrl
|
||||
|
||||
override fun getStoredFile(container: StepRunner) =
|
||||
paths.cachedTidalApk(getVersion(container))
|
||||
|
||||
override suspend fun verify(container: StepRunner) {
|
||||
super.verify(container)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
container.log("Verifying APK signature")
|
||||
verifySignature(getStoredFile(container))
|
||||
} else {
|
||||
container.log("Skipping APK signature verification, API level too old")
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.P)
|
||||
private fun verifySignature(apk: File) {
|
||||
val verifier = ApkVerifier.Builder(apk).build()
|
||||
val result = try {
|
||||
verifier.verify()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to verify APK! It may have been corrupted or tampered with.", e)
|
||||
}
|
||||
|
||||
if (!result.isVerified)
|
||||
throw SignatureVerificationException(result.allErrors)
|
||||
|
||||
if (TIDAL_CERTIFICATE_SHA256 != null) {
|
||||
if (result.signerCertificates.singleOrNull()
|
||||
?.let { it.encoded.toByteString().sha256() == TIDAL_CERTIFICATE_SHA256.decodeHex() } != true
|
||||
) {
|
||||
throw VerifyError("Failed to verify TIDAL APK signatures! This is an unoriginal APK that has been tampered with.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// TODO: populate with actual TIDAL signing certificate SHA-256
|
||||
// Run: apksigner verify --print-certs tidal.apk
|
||||
val TIDAL_CERTIFICATE_SHA256: String? = null
|
||||
|
||||
fun getStoredFilePath(paths: PathManager, version: Int): File =
|
||||
paths.cachedTidalApk(version)
|
||||
}
|
||||
|
||||
private class SignatureVerificationException(errors: List<ApkVerifier.IssueWithParams>) : Exception(
|
||||
"Failed to verify APK signatures! " +
|
||||
"This is an unoriginal APK that has been tampered with. " +
|
||||
"Verification errors: " + errors.joinToString()
|
||||
)
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.install
|
||||
|
||||
import android.os.Build
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
|
||||
import com.github.diamondminer88.zip.*
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Align certain files in the APK to the necessary boundaries.
|
||||
*/
|
||||
class AlignmentStep : Step(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
|
||||
override val group = StepGroup.Install
|
||||
override val localizedName = R.string.patch_step_alignment
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val currentDeviceArch = Build.SUPPORTED_ABIS.first()
|
||||
val apk = container.getStep<CopyDependenciesStep>().apk
|
||||
|
||||
var resourcesArscBytes: ByteArray? = null
|
||||
var dexCount: Int = -1
|
||||
|
||||
// Align resources.arsc due to targeting API 30 for silent install
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
container.log("Extracting resources.arsc to be aligned later")
|
||||
resourcesArscBytes = ZipReader(apk)
|
||||
.use { it.openEntry("resources.arsc")?.read() }
|
||||
?: throw IllegalArgumentException("APK is missing resources.arsc")
|
||||
}
|
||||
|
||||
// Align dex files due to using useEmbeddedDex (ref. ManifestPatcher)
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
container.log("Extracting all dex files to be aligned later")
|
||||
ZipReader(apk).use { zip ->
|
||||
// Count the amount of dex files currently in the apk
|
||||
dexCount = zip.entryNames.count { it.endsWith(".dex") }
|
||||
|
||||
// Copy all the dex files that need to be moved out of the apk
|
||||
for (idx in 0..<dexCount) {
|
||||
val bytes = zip.openEntry(getDexName(idx))!!.read()
|
||||
val file = paths.patchingWorkingDir.resolve(getDexName(idx))
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
container.log("Extracted $dexCount dex files")
|
||||
}
|
||||
|
||||
// Align native libs due to using extractNativeLibs
|
||||
container.log("Extracting native libraries to be aligned later")
|
||||
val nativeLibPaths = ZipReader(apk).use { zip ->
|
||||
val libPaths = zip.entryNames.filter { it.endsWith(".so") }
|
||||
|
||||
// Extract to disk temporarily
|
||||
for ((idx, path) in libPaths.withIndex()) {
|
||||
// Ignore lib architectures that don't match this device
|
||||
if (!path.startsWith("lib/$currentDeviceArch")) {
|
||||
container.log("Skipping native lib $path due to incompatible architecture")
|
||||
continue
|
||||
}
|
||||
|
||||
// Index is just used as a placeholder id to cache on disk
|
||||
val bytes = zip.openEntry(path)!!.read()
|
||||
val file = paths.patchingWorkingDir.resolve("$idx.so")
|
||||
file.writeBytes(bytes)
|
||||
container.log("Extracted native lib $file")
|
||||
}
|
||||
|
||||
libPaths
|
||||
}
|
||||
|
||||
container.log("Writing entries back aligned")
|
||||
ZipWriter(apk, /* append = */ true).use { zip ->
|
||||
// Delete all the unaligned files from APK
|
||||
container.log("Deleting resources.arsc")
|
||||
if (resourcesArscBytes != null)
|
||||
zip.deleteEntry("resources.arsc")
|
||||
|
||||
container.log("Deleting $dexCount dex files")
|
||||
for (i in 0..<dexCount)
|
||||
zip.deleteEntry(getDexName(i))
|
||||
|
||||
container.log("Deleting native libraries: $nativeLibPaths")
|
||||
for (path in nativeLibPaths)
|
||||
zip.deleteEntry(path)
|
||||
|
||||
// Write all the files back aligned this time
|
||||
if (resourcesArscBytes != null) {
|
||||
container.log("Writing resources.arsc uncompressed aligned to 4 bytes")
|
||||
zip.writeEntry("resources.arsc", resourcesArscBytes, ZipCompression.NONE, 4)
|
||||
}
|
||||
|
||||
container.log("Writing dex files uncompressed aligned to 4 bytes")
|
||||
for (idx in 0..<dexCount) {
|
||||
val file = paths.patchingWorkingDir.resolve(getDexName(idx))
|
||||
val bytes = file.readBytes()
|
||||
zip.writeEntry(getDexName(idx), bytes, ZipCompression.NONE, 4)
|
||||
}
|
||||
|
||||
// Write back native libraries aligned to 16KiB page boundary
|
||||
for ((idx, path) in nativeLibPaths.withIndex()) {
|
||||
// Ignore lib architectures that don't match this device
|
||||
if (!path.startsWith("lib/$currentDeviceArch"))
|
||||
continue
|
||||
|
||||
val file = paths.patchingWorkingDir.resolve("$idx.so")
|
||||
val bytes = file.readBytes()
|
||||
|
||||
container.log("Writing $path uncompressed aligned to 16KiB")
|
||||
zip.writeEntry(path, bytes, ZipCompression.NONE, 16384)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDexName(idx: Int) = "classes${if (idx == 0) "" else (idx + 1)}.dex"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.install
|
||||
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.manager.PreferencesManager
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Cleanup patching working directory once the installation has completed.
|
||||
*/
|
||||
class CleanupStep : Step(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
private val prefs: PreferencesManager by inject()
|
||||
|
||||
override val group = StepGroup.Install
|
||||
override val localizedName = R.string.patch_step_cleanup
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
container.log("Moving downloads back to cache")
|
||||
paths.patchingDownloadDir.renameTo(paths.cacheDownloadDir)
|
||||
|
||||
if (prefs.keepPatchedApks) {
|
||||
container.log("keepPatchedApks enabled, keeping working dir")
|
||||
} else {
|
||||
container.log("Deleting patching working dir")
|
||||
if (!paths.patchingWorkingDir.deleteRecursively())
|
||||
throw IllegalStateException("Failed to delete patching working dir")
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.install
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.*
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.installers.InstallerResult
|
||||
import com.meowarex.rlmobile.installers.root.RootInstaller
|
||||
import com.meowarex.rlmobile.installers.shizuku.ShizukuInstaller
|
||||
import com.meowarex.rlmobile.manager.*
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.base.StepState
|
||||
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
|
||||
import com.meowarex.rlmobile.ui.components.dialogs.PlayProtectDialog
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.meowarex.rlmobile.ui.util.InstallNotifications
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* ID used for showing ready notifications if the activity is currently minimized when having reached this step.
|
||||
*/
|
||||
private const val READY_NOTIF_ID = 200001
|
||||
|
||||
/**
|
||||
* Install the final APK with the system's PackageManager.
|
||||
*/
|
||||
class InstallStep(private val options: PatchOptions) : Step(), KoinComponent {
|
||||
private val context: Context by inject()
|
||||
private val installers: InstallerManager by inject()
|
||||
private val prefs: PreferencesManager by inject()
|
||||
private val overlays: OverlayManager by inject()
|
||||
|
||||
override val group = StepGroup.Install
|
||||
override val localizedName = R.string.patch_step_install
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().apk
|
||||
|
||||
// If app backgrounded, show notification
|
||||
if (ProcessLifecycleOwner.get().lifecycle.currentState == Lifecycle.State.CREATED) {
|
||||
InstallNotifications.createNotification(
|
||||
context = context,
|
||||
id = READY_NOTIF_ID,
|
||||
title = R.string.notif_install_ready_title,
|
||||
description = R.string.notif_install_ready_desc,
|
||||
)
|
||||
|
||||
container.log("Waiting until manager is resumed to continue installation")
|
||||
}
|
||||
|
||||
// Wait until app resumed
|
||||
ProcessLifecycleOwner.get().lifecycle.withResumed {}
|
||||
|
||||
// Retrieve configured installer
|
||||
container.log("Retrieving configured installer ${prefs.installer}")
|
||||
val installer = installers.getActiveInstaller()
|
||||
|
||||
// Show [PlayProtectDialog] and wait until it gets dismissed
|
||||
if (installer !is ShizukuInstaller &&
|
||||
installer !is RootInstaller
|
||||
&& prefs.showPlayProtectWarning
|
||||
&& !prefs.devMode
|
||||
&& !context.isPackageInstalled(options.packageName)
|
||||
&& context.isPlayProtectEnabled() == true
|
||||
) {
|
||||
container.log("Showing play protect warning dialog")
|
||||
val neverShowAgain = overlays.startComposableForResult { onResult ->
|
||||
PlayProtectDialog(onDismiss = onResult)
|
||||
}
|
||||
prefs.showPlayProtectWarning = !neverShowAgain
|
||||
}
|
||||
|
||||
container.log("Installing ${apk.absolutePath}, silent: ${!prefs.devMode}")
|
||||
var lastProgress = 0f
|
||||
val result = installer.waitInstall(
|
||||
apks = listOf(apk),
|
||||
silent = !prefs.devMode,
|
||||
onProgressUpdate = { newProgress ->
|
||||
this@InstallStep.progress = newProgress
|
||||
|
||||
if (newProgress > lastProgress + 0.1f) {
|
||||
container.log("Install progress: ${(newProgress * 100.0).toPrecision(0)}% after ${getDuration()}ms")
|
||||
}
|
||||
@Suppress("AssignedValueIsNeverRead") // incorrect
|
||||
lastProgress = newProgress
|
||||
},
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is InstallerResult.Error -> {
|
||||
container.log("Installation failed")
|
||||
throw Error("Failed to install APKs: ${result.getDebugReason()}")
|
||||
}
|
||||
|
||||
is InstallerResult.Cancelled -> {
|
||||
// The install screen is automatically closed immediately once cleanup finishes
|
||||
state = StepState.Skipped
|
||||
container.log("Installation was cancelled by user")
|
||||
}
|
||||
|
||||
InstallerResult.Success ->
|
||||
container.log("Installation successful")
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.install
|
||||
|
||||
import android.app.Application
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.android.apksig.ApkSigner
|
||||
import com.android.apksig.KeyConfig
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.github.diamondminer88.zip.ZipWriter
|
||||
import org.bouncycastle.asn1.x500.X500Name
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.*
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
// TODO: prompt user to uninstall Radiant Lyrics if signing keystore is unavailable/corrupt and CorePatch isn't installed
|
||||
|
||||
/**
|
||||
* Sign the APK with a keystore generated on-device.
|
||||
*/
|
||||
class SigningStep(
|
||||
private val options: PatchOptions,
|
||||
) : Step(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
private val context: Application by inject()
|
||||
|
||||
override val group = StepGroup.Install
|
||||
override val localizedName = R.string.patch_step_signing
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().apk
|
||||
val tmpApk = apk.resolveSibling(apk.name + ".tmp")
|
||||
|
||||
container.log("Building signing config and storing keystore")
|
||||
|
||||
val (keystore, keystoreBytes) = getKeystore(options.packageName)
|
||||
val keyAlias = keystore.aliases().nextElement()
|
||||
val signingConfig = ApkSigner.SignerConfig.Builder(
|
||||
/* name = */ "Radiant Lyrics Manager",
|
||||
/* keyConfig = */ KeyConfig.Jca(keystore.getKey(keyAlias, LEGACY_KEYSTORE_PASSWORD) as PrivateKey),
|
||||
/* certificates = */ listOf(keystore.getCertificate(keyAlias) as X509Certificate)
|
||||
).build()
|
||||
|
||||
ZipWriter(apk, /* append = */ true).use { zip ->
|
||||
zip.writeEntry("rlmobile.keystore", keystoreBytes)
|
||||
}
|
||||
|
||||
container.log("Signing apk at ${apk.absolutePath}")
|
||||
|
||||
ApkSigner.Builder(listOf(signingConfig))
|
||||
.setV1SigningEnabled(false) // TODO: enable so api <24 devices can work, however zip-alignment breaks
|
||||
.setV2SigningEnabled(true)
|
||||
.setV3SigningEnabled(true)
|
||||
.setInputApk(apk)
|
||||
.setOutputApk(tmpApk)
|
||||
.build()
|
||||
.sign()
|
||||
|
||||
tmpApk.renameTo(apk)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load or generate a signing keystore with various priorities.
|
||||
*
|
||||
* 1. If the specified Radiant Lyrics installation for [packageName] exists,
|
||||
* then attempt to load the signing keystore that was embedded into it.
|
||||
* 3. If external storage permissions are granted and the legacy keystore exists,
|
||||
* then move it to Manager's internal storage and use that.
|
||||
* 2. If Manager has a signing key already stored in internal storage
|
||||
* (that isn't persisted between reinstallations of Manager) then use that.
|
||||
* 4. Otherwise, generate a new keystore in Manager's internal storage.
|
||||
*
|
||||
* Returns the loaded keystore along with its byte representation.
|
||||
*/
|
||||
private fun getKeystore(packageName: String): Pair<KeyStore, ByteArray> {
|
||||
val embeddedKeystoreRaw = try {
|
||||
val applicationInfo = context.packageManager.getApplicationInfo(packageName, 0)
|
||||
ZipReader(applicationInfo.publicSourceDir)
|
||||
.use { it.openEntry("rlmobile.keystore")?.read() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (embeddedKeystoreRaw != null) {
|
||||
try {
|
||||
val keystore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||
load(embeddedKeystoreRaw.inputStream(), LEGACY_KEYSTORE_PASSWORD)
|
||||
}
|
||||
return keystore to embeddedKeystoreRaw
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Embedded existing signing key is corrupted! Please uninstall the app and retry!", e)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (paths.legacyKeystoreFile.exists()) {
|
||||
paths.legacyKeystoreFile.copyTo(paths.keystoreFile, overwrite = true)
|
||||
paths.legacyKeystoreFile.delete()
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
if (!paths.keystoreFile.exists()) {
|
||||
createKeystore(LEGACY_KEYSTORE_PASSWORD)
|
||||
.also { paths.keystoreFile.writeBytes(it) }
|
||||
}
|
||||
|
||||
val keystoreBytes = paths.keystoreFile.readBytes()
|
||||
val keystore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||
load(keystoreBytes.inputStream(), LEGACY_KEYSTORE_PASSWORD)
|
||||
}
|
||||
return keystore to keystoreBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a keystore with a new keyset and protects it with a password.
|
||||
*/
|
||||
private fun createKeystore(password: CharArray): ByteArray {
|
||||
// Generate keys + certificate
|
||||
val keys = KeyPairGenerator.getInstance("RSA").run {
|
||||
initialize(2048)
|
||||
generateKeyPair()
|
||||
}
|
||||
val signer = JcaContentSignerBuilder("SHA1withRSA")
|
||||
.build(keys.private)
|
||||
val certificate = X509v3CertificateBuilder(
|
||||
/* issuer = */ X500Name("CN=Radiant Lyrics Manager"),
|
||||
/* serial = */ abs(Random().nextInt()).toBigInteger(),
|
||||
/* notBefore = */ Date(System.currentTimeMillis() - 365.days.inWholeMilliseconds),
|
||||
/* notAfter = */ Date(System.currentTimeMillis() + (100 * 365.days.inWholeMilliseconds)),
|
||||
/* dateLocale = */ Locale.ENGLISH,
|
||||
/* subject = */ X500Name("CN=Radiant Lyrics Manager"),
|
||||
/* publicKeyInfo = */ SubjectPublicKeyInfo.getInstance(keys.public.encoded),
|
||||
).build(signer)
|
||||
|
||||
val publicKey = JcaX509CertificateConverter().getCertificate(certificate)
|
||||
val privateKey = keys.private
|
||||
|
||||
val keystoreBytes = ByteArrayOutputStream()
|
||||
val keystore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||
load(null, password)
|
||||
setKeyEntry(
|
||||
/* alias = */ "alias",
|
||||
/* key = */ privateKey,
|
||||
/* password = */ password,
|
||||
/* chain = */ arrayOf(publicKey),
|
||||
)
|
||||
}
|
||||
keystore.store(keystoreBytes, password)
|
||||
return keystoreBytes.toByteArray()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// TODO: Figure out a way to get a unique and private key/identifier that is only available to Manager
|
||||
// and is persistable across multiple installations.
|
||||
|
||||
/**
|
||||
* This password was used to secure the old keystore stored in external storage at
|
||||
* `/storage/emulated/0/Radiant Lyrics/ks.keystore`
|
||||
*/
|
||||
val LEGACY_KEYSTORE_PASSWORD = "password".toCharArray()
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.patch
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.base.StepState
|
||||
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
|
||||
import com.meowarex.rlmobile.patcher.util.ArscUtil
|
||||
import com.meowarex.rlmobile.patcher.util.ArscUtil.addResource
|
||||
import com.meowarex.rlmobile.patcher.util.ArscUtil.getMainArscChunk
|
||||
import com.meowarex.rlmobile.patcher.util.ArscUtil.getPackageChunk
|
||||
import com.meowarex.rlmobile.patcher.util.ArscUtil.getResourceFileNames
|
||||
import com.meowarex.rlmobile.patcher.util.AxmlUtil.getMainAxmlChunk
|
||||
import com.meowarex.rlmobile.util.find
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.github.diamondminer88.zip.ZipWriter
|
||||
import com.google.devrel.gmscore.tools.apk.arsc.*
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Adds a network security config that manually adds new CA root certificates.
|
||||
* This is useful for old Android devices that do not have updated root certs.
|
||||
*/
|
||||
class PatchCertsStep : Step(), KoinComponent {
|
||||
private val context: Application by inject()
|
||||
|
||||
override val group = StepGroup.Patch
|
||||
override val localizedName = R.string.patch_step_patch_certs
|
||||
|
||||
// Manager's (this application) network security config is used as a template
|
||||
// to inject into Radiant Lyrics, except with the resource ids pointing to certificate files changed
|
||||
// to new ones injected into the patched app's arsc
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().apk
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
container.log("Modern device detected, skipping injecting root certs")
|
||||
state = StepState.Skipped
|
||||
return
|
||||
}
|
||||
|
||||
container.log("Parsing resources.arsc")
|
||||
val arsc = ArscUtil.readArsc(apk)
|
||||
val resourcesChunk = arsc.getMainArscChunk()
|
||||
val packageChunk = arsc.getPackageChunk()
|
||||
|
||||
container.log("Creating new raw resources in arsc")
|
||||
val certificateIds = CERTIFICATES.keys.map { certificateName ->
|
||||
packageChunk.addResource(
|
||||
typeName = "raw",
|
||||
resourceName = certificateName,
|
||||
configurations = { it.isDefault },
|
||||
valueType = BinaryResourceValue.Type.STRING,
|
||||
valueData = resourcesChunk.stringPool.addString("res/$certificateName.der"),
|
||||
)
|
||||
}
|
||||
|
||||
container.log("Generating new network security config AXML")
|
||||
val newNetworkSecurityConfigBytes = generateNetworkConfig(certificateIds)
|
||||
|
||||
container.log("Parsing existing AndroidManifest.xml")
|
||||
val networkSecurityConfigId = getNetworkSecurityConfigResourceId(apk)
|
||||
val networkSecurityConfigPath = resourcesChunk.getResourceFileNames(
|
||||
resourceId = networkSecurityConfigId,
|
||||
configurations = { it.isDefault },
|
||||
).single()
|
||||
|
||||
ZipWriter(apk, /* append = */ true).use { zip ->
|
||||
zip.deleteEntries(networkSecurityConfigPath, "resources.arsc")
|
||||
|
||||
container.log("Writing new network security config AXML")
|
||||
zip.writeEntry(networkSecurityConfigPath, newNetworkSecurityConfigBytes)
|
||||
|
||||
container.log("Writing new arsc")
|
||||
zip.writeEntry("resources.arsc", arsc.toByteArray())
|
||||
|
||||
for ((name, id) in CERTIFICATES) {
|
||||
container.log("Writing $name CA certificate to apk")
|
||||
|
||||
val bytes = context.resources.openRawResource(id).use { it.readBytes() }
|
||||
zip.writeEntry("res/$name.der", bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* From an APK, read the manifest's `android:networkSecurityConfig` references to a resource.
|
||||
* This is then used to get the filename of the resource from `resources.arsc`.
|
||||
*/
|
||||
fun getNetworkSecurityConfigResourceId(apk: File): BinaryResourceIdentifier {
|
||||
val manifestBytes = ZipReader(apk).use {
|
||||
it.openEntry("AndroidManifest.xml")?.read()
|
||||
} ?: error("APK missing manifest")
|
||||
val manifest = BinaryResourceFile(manifestBytes)
|
||||
val mainChunk = manifest.getMainAxmlChunk()
|
||||
|
||||
// Prefetch string indexes to avoid parsing the entire string pool
|
||||
val networkSecurityConfigStringIdx = mainChunk.stringPool.indexOf("networkSecurityConfig")
|
||||
val applicationStringIdx = mainChunk.stringPool.indexOf("application")
|
||||
|
||||
val applicationChunk = mainChunk.chunks
|
||||
.find { it is XmlStartElementChunk && it.nameIndex == applicationStringIdx } as? XmlStartElementChunk
|
||||
?: error("Unable to find <application> in manifest")
|
||||
val networkSecurityConfig = applicationChunk.attributes
|
||||
.find { it.nameIndex() == networkSecurityConfigStringIdx }
|
||||
?: error("Unable to find android:networkSecurityConfig in manifest")
|
||||
|
||||
assert(networkSecurityConfig.typedValue().type() == BinaryResourceValue.Type.REFERENCE)
|
||||
|
||||
return BinaryResourceIdentifier.create(networkSecurityConfig.typedValue().data())
|
||||
}
|
||||
|
||||
/**
|
||||
* This generates a binary AXML representation a network security config similar to the one
|
||||
* manager uses [R.xml.network_security_config], except with resource IDs generated for the patched APK.
|
||||
*/
|
||||
private fun generateNetworkConfig(certificateIds: List<BinaryResourceIdentifier>): ByteArray {
|
||||
val axml = BinaryResourceFile(byteArrayOf())
|
||||
val xmlChunk = XmlChunk(null)
|
||||
val strings = StringPoolChunk(xmlChunk)
|
||||
|
||||
axml.appendChunk(xmlChunk)
|
||||
xmlChunk.appendChunk(strings)
|
||||
xmlChunk.appendChunk(XmlResourceMapChunk(intArrayOf(), xmlChunk))
|
||||
|
||||
// Nested chunks
|
||||
// @formatter:off
|
||||
val chunkNames = arrayOf("network-security-config", "base-config", "trust-anchors")
|
||||
for (chunkName in chunkNames) {
|
||||
xmlChunk.appendChunk(XmlStartElementChunk(
|
||||
/* namespaceIndex = */ -1,
|
||||
/* nameIndex = */ strings.addString(chunkName),
|
||||
/* idIndex = */ -1,
|
||||
/* classIndex = */ -1,
|
||||
/* styleIndex = */ -1,
|
||||
/* attributes = */ emptyList(),
|
||||
/* parent = */ xmlChunk,
|
||||
))
|
||||
}
|
||||
|
||||
// Allow "system" certificates
|
||||
run {
|
||||
val certificateChunk = XmlStartElementChunk(
|
||||
/* namespaceIndex = */ -1,
|
||||
/* nameIndex = */ strings.addString("certificates", /* deduplicate = */ true),
|
||||
/* idIndex = */ -1,
|
||||
/* classIndex = */ -1,
|
||||
/* styleIndex = */ -1,
|
||||
/* attributes = */ listOf(),
|
||||
/* parent = */ xmlChunk,
|
||||
)
|
||||
certificateChunk.attributes += XmlAttribute(
|
||||
/* namespaceIndex = */ -1,
|
||||
/* nameIndex = */ xmlChunk.stringPool.addString("src", /* deduplicate = */ true),
|
||||
/* rawValueIndex = */ xmlChunk.stringPool.addString("system"),
|
||||
/* typedValue = */ BinaryResourceValue(
|
||||
/* type = */ BinaryResourceValue.Type.STRING,
|
||||
/* data = */ xmlChunk.stringPool.addString("system", /* deduplicate = */ true),
|
||||
),
|
||||
/* parent = */ certificateChunk,
|
||||
)
|
||||
|
||||
xmlChunk.appendChunk(certificateChunk)
|
||||
xmlChunk.appendChunk(XmlEndElementChunk(
|
||||
/* namespaceIndex = */ -1,
|
||||
/* nameIndex = */ strings.addString("certificates", /* deduplicate = */ true),
|
||||
/* parent = */ xmlChunk,
|
||||
))
|
||||
}
|
||||
|
||||
// Add custom certificate references
|
||||
for (certificateId in certificateIds) {
|
||||
val certificateChunk = XmlStartElementChunk(
|
||||
/* namespaceIndex = */ -1,
|
||||
/* nameIndex = */ strings.addString("certificates", /* deduplicate = */ true),
|
||||
/* idIndex = */ -1,
|
||||
/* classIndex = */ -1,
|
||||
/* styleIndex = */ -1,
|
||||
/* attributes = */ listOf(),
|
||||
/* parent = */ xmlChunk,
|
||||
)
|
||||
certificateChunk.attributes += XmlAttribute(
|
||||
/* namespaceIndex = */ -1,
|
||||
/* nameIndex = */ xmlChunk.stringPool.addString("src", /* deduplicate = */ true),
|
||||
/* rawValueIndex = */ -1,
|
||||
/* typedValue = */ BinaryResourceValue(
|
||||
/* type = */ BinaryResourceValue.Type.REFERENCE,
|
||||
/* data = */ certificateId.resourceId(),
|
||||
),
|
||||
/* parent = */ certificateChunk,
|
||||
)
|
||||
|
||||
xmlChunk.appendChunk(certificateChunk)
|
||||
xmlChunk.appendChunk(XmlEndElementChunk(
|
||||
/* namespaceIndex = */ -1,
|
||||
/* nameIndex = */ strings.addString("certificates", /* deduplicate = */ true),
|
||||
/* parent = */ xmlChunk,
|
||||
))
|
||||
}
|
||||
|
||||
// Reverse nested chunks
|
||||
for (chunkName in chunkNames.reversed()) {
|
||||
xmlChunk.appendChunk(XmlEndElementChunk(
|
||||
/* namespaceIndex = */ -1,
|
||||
/* nameIndex = */ strings.addString(chunkName, /* deduplicate = */ true),
|
||||
/* parent = */ xmlChunk,
|
||||
))
|
||||
}
|
||||
// @formatter:on
|
||||
|
||||
return axml.toByteArray()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val CERTIFICATES = mapOf(
|
||||
"globalsign_root_r4" to R.raw.globalsign_root_r4,
|
||||
"gts_root_r1" to R.raw.gts_root_r1,
|
||||
"gts_root_r2" to R.raw.gts_root_r2,
|
||||
"gts_root_r3" to R.raw.gts_root_r3,
|
||||
"gts_root_r4" to R.raw.gts_root_r4,
|
||||
"isrg_root_x1" to R.raw.isrg_root_x1,
|
||||
"isrg_root_x2" to R.raw.isrg_root_x2,
|
||||
)
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.patch
|
||||
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
|
||||
import com.meowarex.rlmobile.patcher.util.ManifestPatcher
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.github.diamondminer88.zip.ZipWriter
|
||||
|
||||
/**
|
||||
* Patch the APK's AndroidManifest.xml
|
||||
*/
|
||||
class PatchManifestStep(private val options: PatchOptions) : Step() {
|
||||
override val group = StepGroup.Patch
|
||||
override val localizedName = R.string.patch_step_patch_manifests
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().apk
|
||||
|
||||
container.log("Reading manifest from apk")
|
||||
val manifest = ZipReader(apk)
|
||||
.use { zip -> zip.openEntry("AndroidManifest.xml")?.read() }
|
||||
?: throw IllegalArgumentException("No manifest found in APK")
|
||||
|
||||
container.log("Patching manifest")
|
||||
val patchedManifest = ManifestPatcher.patchManifest(
|
||||
manifestBytes = manifest,
|
||||
packageName = options.packageName,
|
||||
appName = options.appName,
|
||||
debuggable = options.debuggable,
|
||||
)
|
||||
|
||||
container.log("Repacking apk with patched manifest")
|
||||
val repacked = apk.resolveSibling(apk.name + ".repack")
|
||||
repacked.delete()
|
||||
|
||||
ZipReader(apk).use { reader ->
|
||||
ZipWriter(repacked, /* append = */ false).use { writer ->
|
||||
for (name in reader.entryNames) {
|
||||
val bytes = if (name == "AndroidManifest.xml") {
|
||||
patchedManifest
|
||||
} else {
|
||||
reader.openEntry(name)!!.read()
|
||||
}
|
||||
writer.writeEntry(name, bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!apk.delete() || !repacked.renameTo(apk))
|
||||
throw Error("Failed to replace apk with repacked manifest variant")
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.patch
|
||||
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.IDexProvider
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
|
||||
import com.github.diamondminer88.zip.*
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Insert the dex files produced by [IDexProvider] steps into the APK.
|
||||
* Higher priority dex files are placed first so that their class definitions
|
||||
* shadow the originals when loaded by ART.
|
||||
*/
|
||||
class ReorganizeDexStep : Step(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
|
||||
override val group = StepGroup.Patch
|
||||
override val localizedName = R.string.patch_step_reorganize_dex
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().apk
|
||||
val dexProviders = container.steps
|
||||
.filterIsInstance<IDexProvider>()
|
||||
.sortedByDescending { it.dexPriority }
|
||||
val priorityDexCount = dexProviders
|
||||
.filter { it.dexPriority > 0 }
|
||||
.sumOf { it.dexCount }
|
||||
|
||||
container.log("dexProviders: " + dexProviders.joinToString { it.javaClass.simpleName })
|
||||
container.log("priorityDexCount: $priorityDexCount")
|
||||
|
||||
var dexCount = 0
|
||||
|
||||
ZipReader(apk).use { zip ->
|
||||
// Count the amount of dex files currently in the apk
|
||||
dexCount = zip.entryNames.count { it.endsWith(".dex") }
|
||||
container.log("Existing dex files in apk: $dexCount")
|
||||
|
||||
// Copy all the dex files that need to be moved out of the apk,
|
||||
// to ensure there's space for our higher priority dex files
|
||||
container.log("Copying dex files out of apk to be moved to a lesser priority")
|
||||
for (idx in 0..<priorityDexCount) {
|
||||
// Not enough dex files to move
|
||||
if (idx + 1 > dexCount) break
|
||||
|
||||
container.log("Extracting ${getDexName(idx)} from apk")
|
||||
val bytes = zip.openEntry(getDexName(idx))!!.read()
|
||||
val file = paths.patchingWorkingDir.resolve(getDexName(idx))
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
ZipWriter(apk, /* append = */ true).use { zip ->
|
||||
container.log("Deleting dex files to be replaced with a higher priorty dex file")
|
||||
// Delete all the old dex files from the apk
|
||||
for (idx in 0..<priorityDexCount) {
|
||||
// Not enough dex files to move
|
||||
if (idx + 1 > dexCount) break
|
||||
|
||||
container.log("Deleting ${getDexName(idx)} from apk")
|
||||
zip.deleteEntry(getDexName(idx))
|
||||
}
|
||||
|
||||
// Copy all of the high priority dex files to the apk
|
||||
var idx = 0
|
||||
for (dexProvider in dexProviders) {
|
||||
if (dexProvider.dexPriority <= 0) continue
|
||||
|
||||
container.log(
|
||||
"Writing custom high priority dex files from step: " +
|
||||
"${dexProvider.javaClass.simpleName} with priority of ${dexProvider.dexPriority}"
|
||||
)
|
||||
|
||||
for (dexBytes in dexProvider.getDexFiles(container)) {
|
||||
container.log("Writing dex file ${getDexName(idx)} unaligned uncompressed")
|
||||
zip.writeEntry(getDexName(idx++), dexBytes, ZipCompression.NONE)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy back the dex files that were moved out of the apk
|
||||
for (idx in 0..<priorityDexCount) {
|
||||
// Not enough dex files to move
|
||||
if (idx + 1 > dexCount) break
|
||||
|
||||
container.log("Moving old low priority dex file back into apk unaligned uncompressed: " + getDexName(dexCount + idx))
|
||||
|
||||
val file = paths.patchingWorkingDir.resolve(getDexName(idx))
|
||||
val bytes = file.readBytes()
|
||||
zip.writeEntry(getDexName(dexCount + idx), bytes, ZipCompression.NONE)
|
||||
}
|
||||
|
||||
dexCount += idx
|
||||
|
||||
// Copy the rest of the injected dex files
|
||||
for (dexProvider in dexProviders) {
|
||||
if (dexProvider.dexPriority > 0) continue
|
||||
|
||||
container.log(
|
||||
"Writing remaining low priority dex files into apk from step: " +
|
||||
"${dexProvider.javaClass.simpleName} with priority of ${dexProvider.dexPriority}"
|
||||
)
|
||||
|
||||
for (dexBytes in dexProvider.getDexFiles(container)) {
|
||||
container.log("Writing dex file ${getDexName(idx)} unaligned uncompressed")
|
||||
zip.writeEntry(getDexName(dexCount++), dexBytes, ZipCompression.NONE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDexName(idx: Int) = "classes${if (idx == 0) "" else (idx + 1)}.dex"
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.patch
|
||||
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import com.meowarex.rlmobile.patcher.InstallMetadata
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
|
||||
import com.meowarex.rlmobile.patcher.steps.download.DownloadPatchesStep
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.github.diamondminer88.zip.ZipWriter
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class SaveMetadataStep(private val options: PatchOptions) : Step(), KoinComponent {
|
||||
private val json: Json by inject()
|
||||
|
||||
override val group = StepGroup.Patch
|
||||
override val localizedName = R.string.patch_step_save_metadata
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().apk
|
||||
val patches = container.getStep<DownloadPatchesStep>()
|
||||
|
||||
val metadata = InstallMetadata(
|
||||
customManager = !BuildConfig.RELEASE,
|
||||
managerVersion = SemVer.parse(BuildConfig.VERSION_NAME),
|
||||
patchesVersion = patches.getVersion(container),
|
||||
options = options,
|
||||
)
|
||||
|
||||
container.log("Writing serialized install metadata to APK")
|
||||
ZipWriter(apk, /* append = */ true).use {
|
||||
it.writeEntry("rlmobile.json", json.encodeToString<InstallMetadata>(metadata))
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.patch
|
||||
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.IDexProvider
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
|
||||
import com.meowarex.rlmobile.patcher.steps.download.DownloadPatchesStep
|
||||
import com.android.tools.smali.baksmali.Baksmali
|
||||
import com.android.tools.smali.baksmali.BaksmaliOptions
|
||||
import com.android.tools.smali.dexlib2.Opcodes
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||
import com.android.tools.smali.smali.Smali
|
||||
import com.android.tools.smali.smali.SmaliOptions
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.github.difflib.DiffUtils
|
||||
import com.github.difflib.UnifiedDiffUtils
|
||||
import com.github.difflib.patch.Patch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.*
|
||||
|
||||
class SmaliPatchStep : Step(), IDexProvider, KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
|
||||
override val group = StepGroup.Patch
|
||||
override val localizedName = R.string.patch_step_patch_smali
|
||||
|
||||
private val coreCount = Runtime.getRuntime().availableProcessors()
|
||||
private val smaliDir = paths.patchingWorkingDir.resolve("smali")
|
||||
private val outDex = smaliDir.resolve("patched.dex")
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
val apk = container.getStep<CopyDependenciesStep>().apk
|
||||
val patchesZip = container.getStep<DownloadPatchesStep>().getStoredFile(container)
|
||||
|
||||
val patches = mutableListOf<LoadedPatch>()
|
||||
|
||||
// Load and parse all the patches from the smali patch archive
|
||||
container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}")
|
||||
ZipReader(patchesZip).use { zip ->
|
||||
for (patchFile in zip.entryNames) {
|
||||
container.log("Parsing patch file $patchFile")
|
||||
if (!patchFile.endsWith(".patch")) continue
|
||||
|
||||
val lines = zip.openEntry(patchFile)!!.read()
|
||||
.decodeToString()
|
||||
.replace("\r\n", "\n") // Replace CRLF endings with LF endings to be sure here
|
||||
.trimEnd { it == '\n' } // Remove trailing new lines to work with diff output properly
|
||||
.split('\n')
|
||||
|
||||
try {
|
||||
val targetLine = lines.firstOrNull { it.startsWith("--- a/") }
|
||||
?: throw Error("Patch $patchFile is missing a '--- a/...' header")
|
||||
val fullClassName = targetLine
|
||||
.removePrefix("--- a/")
|
||||
.removeSuffix(".smali")
|
||||
.trim()
|
||||
val patch = LoadedPatch(
|
||||
fullClassName = fullClassName,
|
||||
patch = UnifiedDiffUtils.parseUnifiedDiff(lines),
|
||||
)
|
||||
patches.add(patch)
|
||||
container.log("Loaded patch file $patchFile for class ${patch.fullClassName}")
|
||||
} catch (t: Throwable) {
|
||||
throw Error("Failed to parse patch file $patchFile", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disassemble all the classes we have patches for from all the dex files
|
||||
container.log("Disassembling target classes in APK")
|
||||
ZipReader(apk).use { zip ->
|
||||
for (file in zip.entryNames) {
|
||||
if (!file.endsWith(".dex")) continue
|
||||
container.log("Disassembling dex $file")
|
||||
|
||||
val dexFile = try {
|
||||
DexBackedDexFile(
|
||||
/* opcodes = */ Opcodes.getDefault(),
|
||||
/* buf = */ zip.openEntry(file)!!.read(),
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
throw Error("Failed to parse dex $file", t)
|
||||
}
|
||||
|
||||
val result = try {
|
||||
Baksmali.disassembleDexFile(
|
||||
/* dexFile = */ dexFile,
|
||||
/* outputDir = */ smaliDir,
|
||||
/* jobs = */ coreCount - 1,
|
||||
/* options = */ BaksmaliOptions().apply { localsDirective = true },
|
||||
/* classes = */ patches.map { "L${it.fullClassName};" },
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
throw Error("Failed to disassemble dex $file", t)
|
||||
}
|
||||
|
||||
assert(result) { "Failed to disassemble dex $file (unknown reason)" }
|
||||
container.log("Disassembled dex file for potential target classes")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all the patches to the smali files
|
||||
container.log("Applying smali patches to disassembled files")
|
||||
for ((fullClassName, patch) in patches) {
|
||||
container.log("Applying patch to class $fullClassName")
|
||||
|
||||
val smaliFile = smaliDir.resolve("$fullClassName.smali")
|
||||
if (!smaliFile.exists()) {
|
||||
throw FileNotFoundException("Target smali file $fullClassName.smali not found for patching!")
|
||||
}
|
||||
|
||||
val patched = try {
|
||||
DiffUtils.patch(smaliFile.readLines(), patch)
|
||||
} catch (t: Throwable) {
|
||||
throw Error("Failed to smali patch $fullClassName", t)
|
||||
}
|
||||
|
||||
smaliFile.bufferedWriter().use { writer ->
|
||||
patched.forEach(writer::appendLine)
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble the patched classes back into a single dex
|
||||
container.log("Reassembling patches smali classes into new dex")
|
||||
smaliDir.mkdir()
|
||||
|
||||
// Capture stdout/stderr while assembling smali
|
||||
val originalStdout = System.out
|
||||
val originalStderr = System.err
|
||||
val captured = ByteArrayOutputStream()
|
||||
System.setOut(PrintStream(captured))
|
||||
System.setErr(PrintStream(captured))
|
||||
val success = Smali.assemble(
|
||||
SmaliOptions().apply {
|
||||
this.jobs = coreCount - 1
|
||||
this.outputDexFile = outDex.absolutePath
|
||||
},
|
||||
listOf(smaliDir.absolutePath),
|
||||
)
|
||||
System.setOut(originalStdout)
|
||||
System.setErr(originalStderr)
|
||||
|
||||
if (!success) {
|
||||
container.log(captured.toString("UTF-8").trim())
|
||||
throw Exception("Failed to assemble patched smali!")
|
||||
}
|
||||
}
|
||||
|
||||
override val dexPriority = 2
|
||||
override val dexCount = 1
|
||||
override fun getDexFiles(container: StepRunner) = listOf(outDex.readBytes())
|
||||
}
|
||||
|
||||
private data class LoadedPatch(
|
||||
val fullClassName: String,
|
||||
val patch: Patch<String>,
|
||||
)
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.prepare
|
||||
|
||||
import android.app.Application
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.installers.InstallerResult
|
||||
import com.meowarex.rlmobile.manager.InstallerManager
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.base.StepState
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Prompt the user to uninstall a previous version of Radiant Lyrics if it has a larger version code.
|
||||
* (Prevent conflicts from downgrading)
|
||||
*/
|
||||
class DowngradeCheckStep(private val options: PatchOptions) : Step(), KoinComponent {
|
||||
private val context: Application by inject()
|
||||
private val installers: InstallerManager by inject()
|
||||
|
||||
override val group = StepGroup.Prepare
|
||||
override val localizedName = R.string.patch_step_downgrade_check
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
container.log("Fetching version of package ${options.packageName}")
|
||||
val (_, currentVersion) = try {
|
||||
context.getPackageVersion(options.packageName)
|
||||
}
|
||||
// Package is not installed
|
||||
catch (_: NameNotFoundException) {
|
||||
state = StepState.Skipped
|
||||
container.log("Package not uninstalled, skipping check")
|
||||
return
|
||||
}
|
||||
container.log("Version of installed TIDAL app: $currentVersion")
|
||||
|
||||
val targetVersion = container
|
||||
.getStep<FetchInfoStep>()
|
||||
.data.tidalVersionCode
|
||||
|
||||
container.log("Target TIDAL version: $targetVersion")
|
||||
|
||||
if (currentVersion > targetVersion) {
|
||||
container.log("Current installed version is greater than target, forcing uninstallation")
|
||||
mainThread { context.showToast(R.string.installer_uninstall_new) }
|
||||
|
||||
when (val result = installers.getActiveInstaller().waitUninstall(options.packageName)) {
|
||||
is InstallerResult.Error -> throw Error("Failed to uninstall app: ${result.getDebugReason()}")
|
||||
is InstallerResult.Cancelled -> {
|
||||
mainThread { context.showToast(R.string.installer_uninstall_new) }
|
||||
throw Error("Newer versions of TIDAL must be uninstalled prior to installing an older version")
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.prepare
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.network.models.RLBuildInfo
|
||||
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
|
||||
import com.meowarex.rlmobile.network.utils.getOrThrow
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
@Stable
|
||||
class FetchInfoStep : Step(), KoinComponent {
|
||||
private val github: RadiantLyricsGithubService by inject()
|
||||
|
||||
override val group = StepGroup.Prepare
|
||||
override val localizedName = R.string.patch_step_fetch_info
|
||||
|
||||
lateinit var data: RLBuildInfo
|
||||
private set
|
||||
|
||||
lateinit var patchesAssetUrl: String
|
||||
private set
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
container.log("Fetching latest release from ${RadiantLyricsGithubService.REPO_OWNER}/${RadiantLyricsGithubService.REPO_NAME}")
|
||||
val release = github.getLatestRelease(force = true).getOrThrow()
|
||||
|
||||
val dataJsonUrl = release.assets
|
||||
.find { it.name == RadiantLyricsGithubService.DATA_JSON_ASSET_NAME }
|
||||
?.browserDownloadUrl
|
||||
?: throw IllegalStateException("No ${RadiantLyricsGithubService.DATA_JSON_ASSET_NAME} asset found in latest release ${release.tagName}")
|
||||
|
||||
patchesAssetUrl = release.assets
|
||||
.find { it.name == RadiantLyricsGithubService.PATCHES_ASSET_NAME }
|
||||
?.browserDownloadUrl
|
||||
?: throw IllegalStateException("No ${RadiantLyricsGithubService.PATCHES_ASSET_NAME} asset found in latest release ${release.tagName}")
|
||||
|
||||
container.log("Fetching build info from $dataJsonUrl")
|
||||
data = github.getBuildInfo(dataJsonUrl, force = true).getOrThrow()
|
||||
container.log("Fetched build info: $data")
|
||||
container.log("Patches asset URL: $patchesAssetUrl")
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.meowarex.rlmobile.patcher.steps.prepare
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.base.StepState
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Restores downloaded files necessary for patching from the cache dir.
|
||||
* Refer to [PathManager.patchingDownloadDir] and [PathManager.cacheDownloadDir]
|
||||
* for more information.
|
||||
*/
|
||||
@Stable
|
||||
class RestoreDownloadsStep : Step(), KoinComponent {
|
||||
private val paths: PathManager by inject()
|
||||
|
||||
override val group = StepGroup.Prepare
|
||||
override val localizedName = R.string.patch_step_restore_cache
|
||||
|
||||
override suspend fun execute(container: StepRunner) {
|
||||
if (paths.cacheDownloadDir.exists()) {
|
||||
container.log("Moving downloads from cache to permanent storage")
|
||||
paths.patchingDownloadDir.deleteRecursively()
|
||||
paths.cacheDownloadDir.renameTo(paths.patchingDownloadDir)
|
||||
} else {
|
||||
container.log("No download cache present")
|
||||
state = StepState.Skipped
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.meowarex.rlmobile.patcher.util
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.google.devrel.gmscore.tools.apk.arsc.*
|
||||
import java.io.File
|
||||
|
||||
object ArscUtil {
|
||||
/**
|
||||
* Read and parse `resources.arsc` from an APK.
|
||||
*/
|
||||
fun readArsc(apk: File): BinaryResourceFile {
|
||||
val bytes = ZipReader(apk).use { it.openEntry("resources.arsc")?.read() }
|
||||
?: error("APK missing resources.arsc")
|
||||
|
||||
return try {
|
||||
BinaryResourceFile(bytes)
|
||||
} catch (t: Throwable) {
|
||||
throw Error("Failed to parse resources.arsc", t)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the only top-level chunk in an arsc file.
|
||||
*/
|
||||
fun BinaryResourceFile.getMainArscChunk(): ResourceTableChunk {
|
||||
if (this.chunks.size > 1)
|
||||
error("More than 1 top level chunk in resources.arsc")
|
||||
|
||||
return this.chunks.first() as? ResourceTableChunk
|
||||
?: error("Invalid top-level resources.arsc chunk")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a singular package chunk in an arsc file.
|
||||
*/
|
||||
fun BinaryResourceFile.getPackageChunk(): PackageChunk {
|
||||
return this.getMainArscChunk().packages.singleOrNull()
|
||||
?: error("resources.arsc must contain exactly 1 package chunk")
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new color resource to all configuration variants in an arsc package.
|
||||
*
|
||||
* @param name The new resource name.
|
||||
* @param color The value of the new color resource.
|
||||
* @return The resource ID of the newly added resource.
|
||||
*/
|
||||
fun PackageChunk.addColorResource(
|
||||
name: String,
|
||||
color: Color,
|
||||
): BinaryResourceIdentifier {
|
||||
return this.addResource(
|
||||
typeName = "color",
|
||||
resourceName = name,
|
||||
configurations = { it.isDefault },
|
||||
valueType = BinaryResourceValue.Type.INT_COLOR_ARGB8,
|
||||
valueData = color.toArgb(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new color resource to the matching configuration variants in an arsc package.
|
||||
*
|
||||
* @param typeName The type of the resource (ex: `mipmap`, `drawable`, etc.)
|
||||
* @param resourceName The new resource name.
|
||||
* @param configurations A predicate whether to add the value into a matching type chunk.
|
||||
* @param valueType The type of the resource value.
|
||||
* @param valueData The raw data of the resource value.
|
||||
* @return The resource ID of the newly added resource.
|
||||
*/
|
||||
fun PackageChunk.addResource(
|
||||
typeName: String,
|
||||
resourceName: String,
|
||||
configurations: (BinaryResourceConfiguration) -> Boolean,
|
||||
valueType: BinaryResourceValue.Type,
|
||||
valueData: Int,
|
||||
): BinaryResourceIdentifier {
|
||||
// Add a new resource entry to the "type spec chunk" and,
|
||||
// a new resource entry to all matching "type chunks"
|
||||
|
||||
val specChunk = this.getTypeSpecChunk(typeName)
|
||||
val typeChunks = this.getTypeChunks(typeName)
|
||||
|
||||
// Add a new string to the pool to be used as a key
|
||||
val resourceNameIdx = this.keyStringPool.addString(resourceName, /* deduplicate = */ true)
|
||||
|
||||
// Add a new resource entry to the type spec chunk
|
||||
val resourceIdx = specChunk.addResource(/* flags = */ 0)
|
||||
|
||||
for (typeChunk in typeChunks) {
|
||||
// If no matching config, add a null entry and try next chunk
|
||||
if (!configurations(typeChunk.configuration)) {
|
||||
typeChunk.addEntry(null)
|
||||
continue
|
||||
}
|
||||
|
||||
val entry = TypeChunk.Entry(
|
||||
/* headerSize = */ 8,
|
||||
/* flags = */ 0,
|
||||
/* keyIndex = */ resourceNameIdx,
|
||||
/* value = */
|
||||
BinaryResourceValue(
|
||||
/* type = */ valueType,
|
||||
/* data = */ valueData,
|
||||
),
|
||||
/* values = */ null, // not a complex resource
|
||||
/* parentEntry = */ 0, // not a complex resource
|
||||
/* parent = */ typeChunk,
|
||||
)
|
||||
|
||||
typeChunk.addEntry(entry)
|
||||
}
|
||||
|
||||
return BinaryResourceIdentifier.create(
|
||||
/* packageId = */ this.id,
|
||||
/* typeId = */ specChunk.id,
|
||||
/* entryId = */ resourceIdx,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* In an arsc file, for a specific resource in a configuration, get it's value.
|
||||
*
|
||||
* @param resourceId The target resource id.
|
||||
* @param configurationName The target configuration variant of the resource. (ex: `anydpi-v26`, `xxhdpi`, `ldtrl-mpi`, etc.)
|
||||
* @return The string value of the resource for the specified configuration.
|
||||
*/
|
||||
fun ResourceTableChunk.getResourceFileName(
|
||||
resourceId: BinaryResourceIdentifier,
|
||||
configurationName: String,
|
||||
): String {
|
||||
return getResourceFileNames(
|
||||
resourceId = resourceId,
|
||||
configurations = { it.toString() == configurationName },
|
||||
).single()
|
||||
}
|
||||
|
||||
/**
|
||||
* In an arsc file, for a specific resource in all matching configurations, get the values.
|
||||
*
|
||||
* @param resourceId The target resource id.
|
||||
* @param configurations A predicate whether to add the value into a matching type chunk.
|
||||
* @return The string values of the resources, for each configuration.
|
||||
*/
|
||||
fun ResourceTableChunk.getResourceFileNames(
|
||||
resourceId: BinaryResourceIdentifier,
|
||||
configurations: (BinaryResourceConfiguration) -> Boolean,
|
||||
): List<String> {
|
||||
val packageChunk = this.packages.find { it.id == resourceId.packageId() }
|
||||
?: error("Unable to find target resource")
|
||||
|
||||
val typeChunks = packageChunk.getTypeChunks(resourceId.typeId())
|
||||
.filter { configurations(it.configuration) }
|
||||
|
||||
val entries = typeChunks.map { typeChunk ->
|
||||
val entry = typeChunk.getEntry(resourceId.entryId())
|
||||
?: error("Unable to find target resource in type chunk " + typeChunk.configuration)
|
||||
|
||||
if (entry.isComplex || entry.value().type() != BinaryResourceValue.Type.STRING)
|
||||
error("Target resource value type in type chunk ${typeChunk.configuration} is not STRING")
|
||||
|
||||
entry
|
||||
}
|
||||
|
||||
return entries.map { entry ->
|
||||
val valueIdx = entry.value().data()
|
||||
this.stringPool.getString(valueIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.meowarex.rlmobile.patcher.util
|
||||
|
||||
import com.meowarex.rlmobile.patcher.util.AxmlUtil.getMainAxmlChunk
|
||||
import com.meowarex.rlmobile.util.find
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.github.diamondminer88.zip.ZipWriter
|
||||
import com.google.devrel.gmscore.tools.apk.arsc.*
|
||||
import java.io.File
|
||||
|
||||
object AxmlUtil {
|
||||
/**
|
||||
* Read and parse a specific axml resource inside an APK
|
||||
* @param apk The source apk
|
||||
* @param resourcePath The full path to the axml file inside the apk, which may be flattened.
|
||||
*/
|
||||
private fun readAxml(apk: File, resourcePath: String): BinaryResourceFile {
|
||||
val bytes = ZipReader(apk).use { it.openEntry(resourcePath)?.read() }
|
||||
?: error("APK missing resource file at $resourcePath")
|
||||
|
||||
return try {
|
||||
BinaryResourceFile(bytes)
|
||||
} catch (t: Throwable) {
|
||||
throw Error("Failed to parse axml at $resourcePath", t)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the only top-level chunk in an axml file.
|
||||
*/
|
||||
fun BinaryResourceFile.getMainAxmlChunk(): XmlChunk {
|
||||
if (this.chunks.size > 1)
|
||||
error("More than 1 top level chunk in axml")
|
||||
|
||||
return this.chunks.first() as? XmlChunk
|
||||
?: error("Invalid top-level axml chunk")
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first chunk with a matching [name] in a flattened chunk list.
|
||||
* @receiver The top level XmlChunk ([getMainAxmlChunk])
|
||||
*/
|
||||
private fun XmlChunk.getStartElementChunk(name: String): XmlStartElementChunk? {
|
||||
val nameIdx = this.stringPool.indexOf(name)
|
||||
|
||||
return this.chunks
|
||||
.find { it is XmlStartElementChunk && it.nameIndex == nameIdx }
|
||||
as? XmlStartElementChunk
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first attribute with a matching name (ignoring namespace)
|
||||
* in a starting element chunk.
|
||||
*/
|
||||
private fun XmlStartElementChunk.getAttribute(name: String): XmlAttribute {
|
||||
val nameIdx = (this.parent as XmlChunk).stringPool.indexOf(name)
|
||||
|
||||
return this.attributes
|
||||
.find { it.nameIndex() == nameIdx }
|
||||
?: error("Failed to find $name attribute in an axml chunk")
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches an <adaptive-icon> axml file to change the `background`, `foreground`, and `monochrome` resource references.
|
||||
* If any of the following are not null, then they will be patched.
|
||||
* @param backgroundColor A color resource id to replace <background> with.
|
||||
*
|
||||
* @param foregroundIcon A drawable resource id to replace <foreground> with.
|
||||
* @param monochromeIcon A drawable resource id to add or replace <monochrome> with.
|
||||
*/
|
||||
fun patchAdaptiveIcon(
|
||||
apk: File,
|
||||
resourcePath: String,
|
||||
backgroundColor: BinaryResourceIdentifier? = null,
|
||||
foregroundIcon: BinaryResourceIdentifier? = null,
|
||||
monochromeIcon: BinaryResourceIdentifier? = null,
|
||||
) {
|
||||
val xml = readAxml(apk, resourcePath)
|
||||
val xmlChunk = xml.getMainAxmlChunk()
|
||||
|
||||
// Patch the background color resource reference
|
||||
if (backgroundColor != null) {
|
||||
val chunk = xmlChunk.getStartElementChunk("background")!!
|
||||
val attribute = chunk.getAttribute("drawable")
|
||||
attribute.typedValue().setValue(
|
||||
/* type = */ BinaryResourceValue.Type.REFERENCE,
|
||||
/* data = */ backgroundColor.resourceId(),
|
||||
)
|
||||
}
|
||||
|
||||
// Patch the foreground drawable reference
|
||||
if (foregroundIcon != null) {
|
||||
val chunk = xmlChunk.getStartElementChunk("foreground")!!
|
||||
val attribute = chunk.getAttribute("drawable")
|
||||
attribute.typedValue().setValue(
|
||||
/* type = */ BinaryResourceValue.Type.REFERENCE,
|
||||
/* data = */ foregroundIcon.resourceId(),
|
||||
)
|
||||
}
|
||||
|
||||
// Add or replace the monochrome drawable reference
|
||||
if (monochromeIcon != null) {
|
||||
// <monochrome> already exists, patch existing chunk
|
||||
val existingChunk = xmlChunk.getStartElementChunk("monochrome")
|
||||
if (existingChunk != null) {
|
||||
val attribute = existingChunk.getAttribute("drawable")
|
||||
attribute.typedValue().setValue(
|
||||
/* type = */ BinaryResourceValue.Type.REFERENCE,
|
||||
/* data = */ monochromeIcon.resourceId(),
|
||||
)
|
||||
}
|
||||
// Add a new start & end chunk since they don't exist
|
||||
// `<monochrome android:drawable="@drawable/xyz"></monochrome>
|
||||
else {
|
||||
val iconEndChunkIdx = xmlChunk.chunks
|
||||
.indexOfLast { it is XmlEndElementChunk && it.name == "adaptive-icon" }
|
||||
|
||||
val namespaceIdx = xmlChunk.stringPool.indexOf("http://schemas.android.com/apk/res/android")
|
||||
val drawableIdx = xmlChunk.stringPool.indexOf("drawable")
|
||||
val monochromeIdx = xmlChunk.stringPool.addString("monochrome")
|
||||
|
||||
val startChunk = XmlStartElementChunk(
|
||||
/* namespaceIndex = */ -1,
|
||||
/* nameIndex = */ monochromeIdx,
|
||||
/* idIndex = */ -1,
|
||||
/* classIndex = */ -1,
|
||||
/* styleIndex = */ -1,
|
||||
/* attributes = */
|
||||
listOf(
|
||||
XmlAttribute(
|
||||
/* namespaceIndex = */ namespaceIdx,
|
||||
/* nameIndex = */ drawableIdx,
|
||||
/* rawValueIndex = */ -1,
|
||||
/* typedValue = */
|
||||
BinaryResourceValue(
|
||||
/* type = */ BinaryResourceValue.Type.REFERENCE,
|
||||
/* data = */ monochromeIcon.resourceId(),
|
||||
),
|
||||
// This is wrong but it doesn't matter here as long as this attribute isn't stringified
|
||||
/* parent = */ null,
|
||||
)
|
||||
),
|
||||
/* parent = */ xmlChunk,
|
||||
)
|
||||
val endChunk = XmlEndElementChunk(
|
||||
/* namespaceIndex = */ -1,
|
||||
/* nameIndex = */ monochromeIdx,
|
||||
/* parent = */ xmlChunk,
|
||||
)
|
||||
|
||||
xmlChunk.addChunk(iconEndChunkIdx, startChunk)
|
||||
xmlChunk.addChunk(iconEndChunkIdx + 1, endChunk)
|
||||
}
|
||||
}
|
||||
|
||||
ZipWriter(apk, /* append = */ true).use { zip ->
|
||||
zip.deleteEntry(resourcePath)
|
||||
zip.writeEntry(resourcePath, xml.toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* From an APK, read the manifest's `icon` and `roundIcon` references to a resource.
|
||||
* This is then used to get the filename of the resource from `resources.arsc`.
|
||||
*/
|
||||
fun readManifestIconInfo(apk: File): ManifestIconInfo {
|
||||
val manifestBytes = ZipReader(apk).use {
|
||||
it.openEntry("AndroidManifest.xml")?.read()
|
||||
} ?: error("APK missing manifest")
|
||||
val manifest = BinaryResourceFile(manifestBytes)
|
||||
val mainChunk = manifest.getMainAxmlChunk()
|
||||
|
||||
// Prefetch string indexes to avoid parsing the entire string pool
|
||||
val iconStringIdx = mainChunk.stringPool.indexOf("icon")
|
||||
val roundIconStringIdx = mainChunk.stringPool.indexOf("roundIcon")
|
||||
val applicationStringIdx = mainChunk.stringPool.indexOf("application")
|
||||
|
||||
val applicationChunk = mainChunk.chunks
|
||||
.find { it is XmlStartElementChunk && it.nameIndex == applicationStringIdx } as? XmlStartElementChunk
|
||||
?: error("Unable to find <application> in manifest")
|
||||
|
||||
val squareIcon = applicationChunk.attributes
|
||||
.find { it.nameIndex() == iconStringIdx }
|
||||
?: error("Unable to find android:icon in manifest")
|
||||
|
||||
val roundIcon = applicationChunk.attributes
|
||||
.find { it.nameIndex() == roundIconStringIdx }
|
||||
?: squareIcon // TIDAL has no android:roundIcon; fall back to icon
|
||||
|
||||
assert(squareIcon.typedValue().type() == BinaryResourceValue.Type.REFERENCE)
|
||||
assert(roundIcon.typedValue().type() == BinaryResourceValue.Type.REFERENCE)
|
||||
|
||||
return ManifestIconInfo(
|
||||
// Resource IDs into resources.arsc
|
||||
squareIcon = BinaryResourceIdentifier.create(squareIcon.typedValue().data()),
|
||||
roundIcon = BinaryResourceIdentifier.create(roundIcon.typedValue().data()),
|
||||
)
|
||||
}
|
||||
|
||||
data class ManifestIconInfo(
|
||||
val squareIcon: BinaryResourceIdentifier,
|
||||
val roundIcon: BinaryResourceIdentifier,
|
||||
)
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.meowarex.rlmobile.patcher.util
|
||||
|
||||
/**
|
||||
* Used to indicate that pre-allocating storage space via [android.os.storage.StorageManager.allocateBytes]
|
||||
* failed due to insufficient storage space, or cache space able to be cleared.
|
||||
*/
|
||||
class InsufficientStorageException(
|
||||
message: String?,
|
||||
) : Exception() {
|
||||
override val message = "Failed to preallocate sufficient storage space: $message"
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.meowarex.rlmobile.patcher.util
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import pxb.android.axml.*
|
||||
|
||||
object ManifestPatcher {
|
||||
private const val ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android"
|
||||
private const val DEBUGGABLE = "debuggable"
|
||||
private const val VM_SAFE_MODE = "vmSafeMode"
|
||||
private const val USE_EMBEDDED_DEX = "useEmbeddedDex"
|
||||
private const val EXTRACT_NATIVE_LIBS = "extractNativeLibs"
|
||||
private const val REQUEST_LEGACY_EXTERNAL_STORAGE = "requestLegacyExternalStorage"
|
||||
private const val LABEL = "label"
|
||||
private const val PACKAGE = "package"
|
||||
private const val COMPILE_SDK_VERSION = "compileSdkVersion"
|
||||
private const val COMPILE_SDK_VERSION_CODENAME = "compileSdkVersionCodename"
|
||||
|
||||
fun patchManifest(
|
||||
manifestBytes: ByteArray,
|
||||
packageName: String,
|
||||
appName: String,
|
||||
debuggable: Boolean,
|
||||
): ByteArray {
|
||||
val reader = AxmlReader(manifestBytes)
|
||||
val writer = AxmlWriter()
|
||||
|
||||
reader.accept(object : AxmlVisitor(writer) {
|
||||
// Without this, decompiling the finished manifest has the android namespace
|
||||
// under an autogenerated name like axml_00 or something.
|
||||
override fun ns(prefix: String?, uri: String?, ln: Int) {
|
||||
val realUri = uri ?: ANDROID_NAMESPACE
|
||||
super.ns(prefix, realUri, ln)
|
||||
}
|
||||
|
||||
override fun child(ns: String?, name: String?) =
|
||||
object : ReplaceAttrsVisitor(
|
||||
super.child(ns, name),
|
||||
mapOf(
|
||||
PACKAGE to packageName,
|
||||
COMPILE_SDK_VERSION to 23,
|
||||
COMPILE_SDK_VERSION_CODENAME to "6.0-2438415"
|
||||
)
|
||||
) {
|
||||
private var addExternalStoragePerm = false
|
||||
|
||||
override fun child(ns: String?, name: String): NodeVisitor {
|
||||
val nv = super.child(ns, name)
|
||||
|
||||
// Add MANAGE_EXTERNAL_STORAGE when necessary
|
||||
if (addExternalStoragePerm) {
|
||||
super
|
||||
.child(null, "uses-permission")
|
||||
.attr(ANDROID_NAMESPACE, "name", android.R.attr.name, TYPE_STRING, Manifest.permission.MANAGE_EXTERNAL_STORAGE)
|
||||
addExternalStoragePerm = false
|
||||
}
|
||||
|
||||
return when (name) {
|
||||
"uses-permission" -> object : NodeVisitor(nv) {
|
||||
override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) {
|
||||
if (name != "maxSdkVersion") {
|
||||
super.attr(ns, name, resourceId, type, value)
|
||||
}
|
||||
|
||||
if (name == "name" && value == Manifest.permission.READ_EXTERNAL_STORAGE) {
|
||||
addExternalStoragePerm = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"uses-sdk" -> object : NodeVisitor(nv) {
|
||||
override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) {
|
||||
if (name == "targetSdkVersion") {
|
||||
val version = if (Build.VERSION.SDK_INT >= 31) 30 else 28
|
||||
super.attr(ns, name, resourceId, type, version)
|
||||
} else {
|
||||
super.attr(ns, name, resourceId, type, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"permission" -> object : NodeVisitor(nv) {
|
||||
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
|
||||
super.attr(
|
||||
ns, name, resourceId, type,
|
||||
when (name) {
|
||||
"name" -> (value as String).replace("com.tidal.android", packageName)
|
||||
else -> value
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"application" -> object : ReplaceAttrsVisitor(
|
||||
nv,
|
||||
mapOf(
|
||||
LABEL to appName,
|
||||
DEBUGGABLE to debuggable,
|
||||
REQUEST_LEGACY_EXTERNAL_STORAGE to true,
|
||||
VM_SAFE_MODE to true,
|
||||
USE_EMBEDDED_DEX to true,
|
||||
EXTRACT_NATIVE_LIBS to false,
|
||||
)
|
||||
) {
|
||||
private var addDebuggable = debuggable
|
||||
private var addLegacyStorage = true
|
||||
private var addUseEmbeddedDex = true
|
||||
private var addExtractNativeLibs = true
|
||||
private var addMetadata = true
|
||||
|
||||
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
|
||||
if (name == REQUEST_LEGACY_EXTERNAL_STORAGE) addLegacyStorage = false
|
||||
if (name == USE_EMBEDDED_DEX) addUseEmbeddedDex = false
|
||||
if (name == EXTRACT_NATIVE_LIBS) addExtractNativeLibs = false
|
||||
if (name == DEBUGGABLE) addDebuggable = false
|
||||
super.attr(ns, name, resourceId, type, value)
|
||||
}
|
||||
|
||||
override fun child(ns: String?, name: String): NodeVisitor {
|
||||
val visitor = super.child(ns, name)
|
||||
|
||||
// Adds a <meta-data> tag to make multi-install detection by HomeScreen work
|
||||
if (addMetadata) {
|
||||
addMetadata = false
|
||||
super.child(ANDROID_NAMESPACE, "meta-data").apply {
|
||||
attr(ANDROID_NAMESPACE, "name", android.R.attr.name, TYPE_STRING, "isRadiantLyrics")
|
||||
attr(ANDROID_NAMESPACE, "value", android.R.attr.value, TYPE_INT_BOOLEAN, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return when (name) {
|
||||
"activity" -> ReplaceAttrsVisitor(visitor, mapOf("label" to appName))
|
||||
"provider" -> object : NodeVisitor(visitor) {
|
||||
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
|
||||
super.attr(
|
||||
ns, name, resourceId, type,
|
||||
if (name == "authorities") {
|
||||
(value as String).replace("com.tidal.android", packageName)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> visitor
|
||||
}
|
||||
}
|
||||
|
||||
override fun end() {
|
||||
if (addLegacyStorage && Build.VERSION.SDK_INT >= 29) super.attr(
|
||||
ANDROID_NAMESPACE,
|
||||
REQUEST_LEGACY_EXTERNAL_STORAGE,
|
||||
android.R.attr.requestLegacyExternalStorage,
|
||||
TYPE_INT_BOOLEAN,
|
||||
1
|
||||
)
|
||||
if (addDebuggable) super.attr(ANDROID_NAMESPACE, DEBUGGABLE, android.R.attr.debuggable, TYPE_INT_BOOLEAN, 1)
|
||||
|
||||
// Disable AOT (Necessary for AOSP Android 15)
|
||||
if (Build.VERSION.SDK_INT >= 29 && addUseEmbeddedDex) {
|
||||
super.attr(ANDROID_NAMESPACE, USE_EMBEDDED_DEX, android.R.attr.useEmbeddedDex, TYPE_INT_BOOLEAN, 1)
|
||||
}
|
||||
|
||||
if (addExtractNativeLibs) super.attr(
|
||||
ANDROID_NAMESPACE,
|
||||
EXTRACT_NATIVE_LIBS,
|
||||
android.R.attr.extractNativeLibs,
|
||||
TYPE_INT_BOOLEAN,
|
||||
0
|
||||
)
|
||||
|
||||
super.end()
|
||||
}
|
||||
}
|
||||
|
||||
else -> nv
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
private open class ReplaceAttrsVisitor(
|
||||
nv: NodeVisitor,
|
||||
private val attrs: Map<String, Any>,
|
||||
) : NodeVisitor(nv) {
|
||||
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
|
||||
val replace = attrs.containsKey(name)
|
||||
val newValue = attrs[name]
|
||||
|
||||
super.attr(ns, name, resourceId, if (newValue is String) TYPE_STRING else type, if (replace) newValue else value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.util.back
|
||||
|
||||
/**
|
||||
* Standalone back button for interacting with the current navigator.
|
||||
*/
|
||||
@Composable
|
||||
fun BackButton() {
|
||||
val navigator = LocalNavigator.current
|
||||
val activity = LocalActivity.current
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator?.back(activity)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_back),
|
||||
contentDescription = stringResource(R.string.navigation_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.network.models.Contributor
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
|
||||
@Composable
|
||||
fun ContributorCommitsItem(
|
||||
user: Contributor,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier
|
||||
.clickable { uriHandler.openUri("https://github.com/${user.username}") }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
SubcomposeAsyncImage(
|
||||
model = user.avatarUrl,
|
||||
contentDescription = user.username,
|
||||
error = {
|
||||
Surface(
|
||||
content = {},
|
||||
tonalElevation = 2.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.shimmer(),
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(top = 6.dp)
|
||||
.size(45.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = user.username,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.contributors_contributions, user.commits),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = user.repositories.joinToString { it.name },
|
||||
fontStyle = FontStyle.Italic,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
.copy(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2025 zt64
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
// Adapted from compose-pipette:
|
||||
// https://github.com/zt64/compose-pipette/blob/3e9fd958a315dceb142bf30250b4614ecde4e723/sample/src/commonMain/kotlin/dev/zt64/compose/pipette/sample/SampleSlider.kt
|
||||
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.center
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun InteractiveSlider(
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
brush: Brush,
|
||||
thumbColor: Color = MaterialTheme.colorScheme.primary,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember(::MutableInteractionSource)
|
||||
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
thumb = {
|
||||
val interactions = remember { mutableStateListOf<Interaction>() }
|
||||
|
||||
LaunchedEffect(interactionSource) {
|
||||
interactionSource.interactions.collect { interaction ->
|
||||
when (interaction) {
|
||||
is PressInteraction.Press -> interactions.add(interaction)
|
||||
is PressInteraction.Release -> interactions.remove(interaction.press)
|
||||
is PressInteraction.Cancel -> interactions.remove(interaction.press)
|
||||
is DragInteraction.Start -> interactions.add(interaction)
|
||||
is DragInteraction.Stop -> interactions.remove(interaction.start)
|
||||
is DragInteraction.Cancel -> interactions.remove(interaction.start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.hoverable(interactionSource = interactionSource),
|
||||
) {
|
||||
val visualSize by remember {
|
||||
derivedStateOf {
|
||||
if (interactions.isNotEmpty()) 28.dp else 24.dp
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.size(visualSize)
|
||||
.align(Alignment.Center)
|
||||
.background(thumbColor, CircleShape),
|
||||
)
|
||||
}
|
||||
},
|
||||
track = {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 700.dp)
|
||||
.height(12.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
drawLine(
|
||||
brush = brush,
|
||||
start = Offset(0f, size.center.y),
|
||||
end = Offset(size.width, size.center.y),
|
||||
strokeWidth = size.height,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
},
|
||||
valueRange = valueRange,
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.ui.util.thenIf
|
||||
|
||||
@Composable
|
||||
fun Label(
|
||||
name: String,
|
||||
description: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.thenIf(description == null) { padding(bottom = 4.dp) },
|
||||
)
|
||||
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.alpha(.7f)
|
||||
.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun LoadFailure(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_warning),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(34.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.network_load_fail),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun MainActionButton(
|
||||
text: String,
|
||||
icon: Painter,
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
colors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = colors,
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(46.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun ProjectHeader(modifier: Modifier = Modifier) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.rlmobile),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = 26.sp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.app_description),
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = .6f)
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(onClick = { uriHandler.openUri("https://github.com/meowarex/rl-mobile") }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_account_github_white_24dp),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = ButtonDefaults.IconSpacing),
|
||||
)
|
||||
Text(text = stringResource(R.string.github))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.util.mirrorVertically
|
||||
|
||||
@Composable
|
||||
fun ResetToDefaultButton(
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = enabled,
|
||||
enter = fadeIn() + slideInHorizontally(),
|
||||
exit = fadeOut() + slideOutHorizontally(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_refresh),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
contentDescription = stringResource(R.string.action_reset_default),
|
||||
modifier = Modifier
|
||||
.mirrorVertically()
|
||||
.size(26.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun RowScope.SegmentedButton(
|
||||
icon: Painter,
|
||||
iconColor: Color = MaterialTheme.colorScheme.primary,
|
||||
iconDescription: String? = null,
|
||||
text: String,
|
||||
textColor: Color = MaterialTheme.colorScheme.primary,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.weight(1f)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = iconDescription,
|
||||
tint = iconColor,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.basicMarquee(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun TextDivider(
|
||||
text: String,
|
||||
style: TextStyle = MaterialTheme.typography.bodyMedium,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
HorizontalDivider(Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = style,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import com.meowarex.rlmobile.ui.util.TidalVersion
|
||||
|
||||
@Composable
|
||||
fun VersionDisplay(
|
||||
version: TidalVersion,
|
||||
prefix: (@Composable AnnotatedString.Builder.() -> Unit)? = null,
|
||||
style: TextStyle = MaterialTheme.typography.labelLarge,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
prefix?.invoke(this)
|
||||
|
||||
if (version is TidalVersion.Existing) {
|
||||
append(version.name)
|
||||
append(" - ")
|
||||
}
|
||||
append(version.toDisplayName())
|
||||
},
|
||||
style = style,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
|
||||
/**
|
||||
* Maintain an active screen wakelock as long as [active] is true and this component is in scope.
|
||||
*/
|
||||
@Composable
|
||||
fun Wakelock(active: Boolean = false) {
|
||||
val window = LocalActivity.currentOrThrow.window
|
||||
DisposableEffect(active) {
|
||||
if (active) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.meowarex.rlmobile.ui.components.dialogs
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun InstallerAbortDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
FilledTonalButton(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.action_exit_anyways))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.installer_abort_title))
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.installer_abort_body),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_warning),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
iconContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textContentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.meowarex.rlmobile.ui.components.dialogs
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun NetworkWarningDialog(
|
||||
onConfirm: (neverShow: Boolean) -> Unit,
|
||||
onDismiss: (neverShow: Boolean) -> Unit,
|
||||
) {
|
||||
val interactionSource = remember(::MutableInteractionSource)
|
||||
var neverShow by rememberSaveable { mutableStateOf(false) }
|
||||
val rememberedNeverShow by rememberUpdatedState(neverShow)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismiss(rememberedNeverShow) },
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
),
|
||||
confirmButton = {
|
||||
FilledTonalButton(
|
||||
onClick = { onConfirm(rememberedNeverShow) },
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.action_continue))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { onDismiss(rememberedNeverShow) },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.navigation_back))
|
||||
}
|
||||
},
|
||||
title = { Text(stringResource(R.string.network_warning_title)) },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.network_warning_body),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = { neverShow = !rememberedNeverShow },
|
||||
)
|
||||
.padding(end = 16.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = neverShow,
|
||||
onCheckedChange = { neverShow = it },
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.network_warning_disable))
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_warning),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
iconContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.meowarex.rlmobile.ui.components.dialogs
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.theme.customColors
|
||||
|
||||
@Composable
|
||||
fun PlayProtectDialog(
|
||||
onDismiss: (neverShow: Boolean) -> Unit,
|
||||
) {
|
||||
val activity = LocalActivity.currentOrThrow
|
||||
val interactionSource = remember(::MutableInteractionSource)
|
||||
var neverShow by rememberSaveable { mutableStateOf(false) }
|
||||
val rememberedNeverShow by rememberUpdatedState(neverShow)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismiss(rememberedNeverShow) },
|
||||
dismissButton = {
|
||||
FilledTonalButton(onClick = activity::launchPlayProtect) {
|
||||
Text(stringResource(R.string.play_protect_warning_open_gpp))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FilledTonalButton(
|
||||
onClick = { onDismiss(rememberedNeverShow) },
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.play_protect_warning_ok))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_protect_warning),
|
||||
tint = MaterialTheme.customColors.warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
},
|
||||
title = { Text(stringResource(R.string.play_protect_warning_title)) },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.play_protect_warning_desc),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = { neverShow = !rememberedNeverShow },
|
||||
)
|
||||
.padding(end = 16.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = neverShow,
|
||||
onCheckedChange = { neverShow = it },
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.play_protect_warning_disable))
|
||||
}
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(25.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Activity.launchPlayProtect() {
|
||||
Intent("com.google.android.gms.settings.VERIFY_APPS_SETTINGS")
|
||||
.setPackage("com.google.android.gms")
|
||||
.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||
.also(::startActivity)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.meowarex.rlmobile.ui.components.settings
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.ui.components.TextDivider
|
||||
|
||||
@Composable
|
||||
fun SettingsHeader(
|
||||
text: String,
|
||||
) {
|
||||
TextDivider(
|
||||
text = text,
|
||||
modifier = Modifier.padding(18.dp, 20.dp, 18.dp, 10.dp)
|
||||
)
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.meowarex.rlmobile.ui.components.settings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SettingsItem(
|
||||
text: @Composable () -> Unit,
|
||||
secondaryText: @Composable (() -> Unit) = { },
|
||||
icon: @Composable (() -> Unit) = { },
|
||||
modifier: Modifier = Modifier,
|
||||
trailing: @Composable (() -> Unit) = { },
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.heightIn(min = 64.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.weight(2f, true)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.size(20.dp)) {
|
||||
icon()
|
||||
}
|
||||
|
||||
ProvideTextStyle(MaterialTheme.typography.titleSmall) {
|
||||
text()
|
||||
}
|
||||
}
|
||||
|
||||
ProvideTextStyle(
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(0.6f)
|
||||
)
|
||||
) {
|
||||
secondaryText()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(0.05f, true))
|
||||
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.meowarex.rlmobile.ui.components.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun SettingsSwitch(
|
||||
label: String,
|
||||
secondaryLabel: String? = null,
|
||||
disabled: Boolean = false,
|
||||
icon: @Composable () -> Unit = {},
|
||||
pref: Boolean,
|
||||
onPrefChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
SettingsItem(
|
||||
modifier = modifier.clickable(enabled = !disabled) { onPrefChange(!pref) },
|
||||
text = { Text(text = label, softWrap = true) },
|
||||
icon = icon,
|
||||
secondaryText = {
|
||||
secondaryLabel?.let {
|
||||
Text(text = it)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Switch(
|
||||
checked = pref,
|
||||
enabled = !disabled,
|
||||
onCheckedChange = { onPrefChange(!pref) }
|
||||
)
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.meowarex.rlmobile.ui.components.settings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SettingsTextField(
|
||||
label: String,
|
||||
disabled: Boolean = false,
|
||||
pref: String,
|
||||
error: Boolean = false,
|
||||
onPrefChange: (String) -> Unit,
|
||||
) {
|
||||
Box(modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp)) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = pref,
|
||||
onValueChange = onPrefChange,
|
||||
enabled = !disabled,
|
||||
label = { Text(label) },
|
||||
isError = error,
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.meowarex.rlmobile.ui.previews
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.patching.components.TextBanner
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import com.meowarex.rlmobile.ui.theme.customColors
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun ButtonVotePreview(
|
||||
@PreviewParameter(TextBannerParametersProvider::class)
|
||||
parameters: TextBannerParameters,
|
||||
) {
|
||||
ManagerTheme {
|
||||
TextBanner(
|
||||
text = parameters.text(),
|
||||
icon = parameters.icon(),
|
||||
iconColor = parameters.iconColor(),
|
||||
outlineColor = parameters.outlineColor(),
|
||||
containerColor = parameters.containerColor(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class TextBannerParameters(
|
||||
val text: @Composable () -> String,
|
||||
val icon: @Composable () -> Painter,
|
||||
val iconColor: @Composable () -> Color,
|
||||
val outlineColor: @Composable () -> Color?,
|
||||
val containerColor: @Composable () -> Color,
|
||||
)
|
||||
|
||||
private class TextBannerParametersProvider : PreviewParameterProvider<TextBannerParameters> {
|
||||
override val values = sequenceOf(
|
||||
TextBannerParameters(
|
||||
text = { stringResource(R.string.installer_banner_minimization) },
|
||||
icon = { painterResource(R.drawable.ic_warning) },
|
||||
iconColor = { MaterialTheme.customColors.onWarningContainer },
|
||||
outlineColor = { MaterialTheme.customColors.warning },
|
||||
containerColor = { MaterialTheme.customColors.warningContainer },
|
||||
),
|
||||
TextBannerParameters(
|
||||
text = { stringResource(R.string.installer_banner_failure) },
|
||||
icon = { painterResource(R.drawable.ic_warning) },
|
||||
iconColor = { MaterialTheme.colorScheme.error },
|
||||
outlineColor = { null },
|
||||
containerColor = { MaterialTheme.colorScheme.errorContainer },
|
||||
),
|
||||
TextBannerParameters(
|
||||
text = { stringResource(R.string.installer_banner_success) },
|
||||
icon = { painterResource(R.drawable.ic_check_circle) },
|
||||
iconColor = { Color(0xFF59B463) },
|
||||
outlineColor = { MaterialTheme.colorScheme.surfaceVariant },
|
||||
containerColor = { MaterialTheme.colorScheme.surfaceContainerHigh },
|
||||
)
|
||||
)
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.logs.components.dialogs.DeleteLogsDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun DeleteLogsDialogPreview() {
|
||||
ManagerTheme {
|
||||
DeleteLogsDialog(
|
||||
onConfirm = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.components.dialogs.InstallerAbortDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun InstallerAbortDialogPreview() {
|
||||
ManagerTheme {
|
||||
InstallerAbortDialog(
|
||||
onConfirm = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.components.dialogs.NetworkWarningDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun NetworkWarningDialogPreview() {
|
||||
ManagerTheme {
|
||||
NetworkWarningDialog(
|
||||
onConfirm = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.components.dialogs.PlayProtectDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun PlayProtectDialogPreview() {
|
||||
ManagerTheme {
|
||||
PlayProtectDialog(onDismiss = {})
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.settings.components.ThemeDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import com.meowarex.rlmobile.ui.theme.Theme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun ThemeDialogPreview() {
|
||||
val (theme, setTheme) = remember { mutableStateOf(Theme.System) }
|
||||
|
||||
ManagerTheme {
|
||||
ThemeDialog(
|
||||
currentTheme = theme,
|
||||
onDismiss = {},
|
||||
onConfirm = setTheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.components.dialogs.UninstallPluginDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun UninstallPluginDialogPreview() {
|
||||
ManagerTheme {
|
||||
UninstallPluginDialog(
|
||||
pluginName = "FakeNitro",
|
||||
onConfirm = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsScreenContent
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
// This preview has scrollable/interactable content that cannot be tested from an IDE preview
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun ComponentOptionsScreenPreview(
|
||||
@PreviewParameter(ComponentOptionsParametersProvider::class)
|
||||
parameters: ComponentOptionsParameters,
|
||||
) {
|
||||
ManagerTheme {
|
||||
ComponentOptionsScreenContent(
|
||||
componentType = parameters.componentType,
|
||||
components = parameters.components,
|
||||
selected = parameters.selected,
|
||||
onSelectComponent = {},
|
||||
onDeleteComponent = {},
|
||||
onBackPressed = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ComponentOptionsParameters(
|
||||
val componentType: PatchComponent.Type,
|
||||
val components: ImmutableList<PatchComponent>,
|
||||
val selected: PatchComponent?,
|
||||
)
|
||||
|
||||
private class ComponentOptionsParametersProvider : PreviewParameterProvider<ComponentOptionsParameters> {
|
||||
private val components = persistentListOf(
|
||||
PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(1, 2, 3),
|
||||
timestamp = Clock.System.now(),
|
||||
),
|
||||
PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(2, 3, 1),
|
||||
timestamp = Clock.System.now() - 10.minutes,
|
||||
),
|
||||
PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(2, 3, 1),
|
||||
timestamp = Clock.System.now() - 1.days,
|
||||
),
|
||||
PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(0, 0, 1),
|
||||
timestamp = Clock.System.now() - 10.hours,
|
||||
),
|
||||
PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(3, 0, 2),
|
||||
timestamp = Clock.System.now() - 7.days,
|
||||
),
|
||||
)
|
||||
|
||||
override val values = sequenceOf(
|
||||
ComponentOptionsParameters(
|
||||
componentType = PatchComponent.Type.Injector,
|
||||
components = components,
|
||||
selected = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.*
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import kotlin.time.Clock
|
||||
|
||||
// This preview has scrollable/interactable content that cannot be tested from an IDE preview
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun PatchOptionsScreenPreview(
|
||||
@PreviewParameter(PatchOptionsParametersProvider::class)
|
||||
parameters: PatchOptionsParameters,
|
||||
) {
|
||||
ManagerTheme {
|
||||
PatchOptionsScreenContent(
|
||||
isUpdate = parameters.isUpdate,
|
||||
isDevMode = parameters.isDevMode,
|
||||
debuggable = parameters.debuggable,
|
||||
setDebuggable = {},
|
||||
appName = parameters.appName,
|
||||
appNameIsError = parameters.appNameIsError,
|
||||
setAppName = {},
|
||||
packageName = parameters.packageName,
|
||||
packageNameState = parameters.packageNameState,
|
||||
setPackageName = {},
|
||||
customInjector = parameters.customInjector,
|
||||
onSelectCustomInjector = {},
|
||||
customPatches = parameters.customPatches,
|
||||
onSelectCustomPatches = {},
|
||||
isConfigValid = parameters.isConfigValid,
|
||||
onInstall = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class PatchOptionsParameters(
|
||||
val isUpdate: Boolean,
|
||||
val isDevMode: Boolean,
|
||||
val debuggable: Boolean,
|
||||
val appName: String,
|
||||
val appNameIsError: Boolean,
|
||||
val packageName: String,
|
||||
val packageNameState: PackageNameState,
|
||||
val customInjector: PatchComponent?,
|
||||
val customPatches: PatchComponent?,
|
||||
val isConfigValid: Boolean,
|
||||
)
|
||||
|
||||
private class PatchOptionsParametersProvider : PreviewParameterProvider<PatchOptionsParameters> {
|
||||
override val values = sequenceOf(
|
||||
PatchOptionsParameters(
|
||||
isUpdate = false,
|
||||
isDevMode = false,
|
||||
debuggable = false,
|
||||
appName = PatchOptions.Default.appName,
|
||||
appNameIsError = false,
|
||||
packageName = PatchOptions.Default.packageName,
|
||||
packageNameState = PackageNameState.Ok,
|
||||
customInjector = null,
|
||||
customPatches = null,
|
||||
isConfigValid = true,
|
||||
),
|
||||
PatchOptionsParameters(
|
||||
isUpdate = true,
|
||||
isDevMode = false,
|
||||
debuggable = false,
|
||||
appName = "an invalid app name.",
|
||||
appNameIsError = true,
|
||||
packageName = "a b",
|
||||
packageNameState = PackageNameState.Invalid,
|
||||
customInjector = null,
|
||||
customPatches = null,
|
||||
isConfigValid = false,
|
||||
),
|
||||
PatchOptionsParameters(
|
||||
isUpdate = false,
|
||||
isDevMode = true,
|
||||
debuggable = true,
|
||||
appName = PatchOptions.Default.appName,
|
||||
appNameIsError = false,
|
||||
packageName = PatchOptions.Default.packageName,
|
||||
packageNameState = PackageNameState.Taken,
|
||||
customInjector = PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(1, 2, 3),
|
||||
timestamp = Clock.System.now(),
|
||||
),
|
||||
customPatches = null,
|
||||
isConfigValid = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.manager.InstallerSetting
|
||||
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreenContent
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
fun PermissionsScreenPreview() {
|
||||
ManagerTheme {
|
||||
PermissionsScreenContent(
|
||||
installer = InstallerSetting.PackageInstaller,
|
||||
openInstallersDialog = {},
|
||||
storagePermsGranted = true,
|
||||
onGrantStoragePerms = {},
|
||||
unknownSourcesPermsGranted = true,
|
||||
onGrantUnknownSourcesPerms = {},
|
||||
notificationsPermsGranted = false,
|
||||
onGrantNotificationsPerms = {},
|
||||
batteryPermsGranted = false,
|
||||
onGrantBatteryPerms = {},
|
||||
canContinue = true,
|
||||
onContinue = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.about
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenState
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun AboutScreenFailedPreview() {
|
||||
ManagerTheme {
|
||||
AboutScreenContent(
|
||||
state = remember { mutableStateOf(AboutScreenState.Failure) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.about
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import com.meowarex.rlmobile.network.models.Contributor
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenState
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import com.meowarex.rlmobile.ui.util.emptyImmutableList
|
||||
import com.meowarex.rlmobile.util.serialization.ImmutableListSerializer
|
||||
import kotlinx.collections.immutable.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// This preview has scrollable content that cannot be properly viewed from an IDE preview
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun AboutScreenLoadedPreview(
|
||||
@PreviewParameter(ContributorsProvider::class)
|
||||
contributors: ImmutableList<Contributor>,
|
||||
) {
|
||||
ManagerTheme {
|
||||
AboutScreenContent(
|
||||
state = remember { mutableStateOf(AboutScreenState.Loaded(contributors)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ContributorsProvider : PreviewParameterProvider<ImmutableList<Contributor>> {
|
||||
@Suppress("unused")
|
||||
private val realDataRaw =
|
||||
"[{\"username\":\"meowarex\",\"avatarUrl\":\"https://avatars.githubusercontent.com/u/0?v=4\",\"commits\":1,\"repositories\":[{\"name\":\"Radiant Lyrics\",\"commits\":1}]}]"
|
||||
private val realData = Json.decodeFromString(ImmutableListSerializer(Contributor.serializer()), realDataRaw)
|
||||
|
||||
override val values = sequenceOf(
|
||||
emptyImmutableList<Contributor>(),
|
||||
persistentListOf(
|
||||
Contributor(
|
||||
username = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
|
||||
avatarUrl = "UNUSED",
|
||||
commits = Int.MAX_VALUE,
|
||||
repositories = (realData[0].repositories + realData[0].repositories).toImmutableList(),
|
||||
)
|
||||
),
|
||||
realData,
|
||||
)
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.about
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenState
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
// This preview cannot be properly viewed from an IDE
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun AboutScreenFailedPreview() {
|
||||
ManagerTheme {
|
||||
AboutScreenContent(
|
||||
state = remember { mutableStateOf(AboutScreenState.Loading) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.home
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.home.HomeScreenFailureContent
|
||||
import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun HomeScreenFailedPreview() {
|
||||
ManagerTheme {
|
||||
Scaffold(
|
||||
topBar = { HomeAppBar() },
|
||||
) { padding ->
|
||||
HomeScreenFailureContent(padding = padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user