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