Manager UI Updates & New patch logic <3

This commit is contained in:
2026-05-24 01:20:42 +10:00
parent 6a9b3f9d05
commit 691bcaa2b8
24 changed files with 229 additions and 205 deletions
@@ -102,8 +102,6 @@ class ManagerApplication : Application() {
.build()
}
// Schedule periodic update check only when the user has opted in,
// so the disabled state survives app restarts instead of being re-enqueued.
if (get<PreferencesManager>().autoUpdateCheck) {
UpdateCheckWorker.schedule(this)
} else {
@@ -47,8 +47,6 @@ fun Scope.provideHttpClient() = HttpClient(OkHttp) {
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 {
@@ -77,7 +75,7 @@ fun Scope.provideHttpClient() = HttpClient(OkHttp) {
// Default storage is in-memory
}
// Custom plugin to allow overriding response cache headers, and force caching
// Custom plugin to allow overriding response cache headers (force caching)
install("OverrideCacheControl") {
receivePipeline.intercept(HttpReceivePipeline.Before) { response ->
val customCacheControl = response.call.attributes.getOrNull(CustomCacheControl)
@@ -107,8 +107,7 @@ class DhizukuInstaller(
)
}
// Unregister PMResultReceiver when this coroutine finishes or errors
// Explicitly cancel the install session if it did not finish.
// cancel the install session if it did not finish.
continuation.invokeOnCancellation {
context.unregisterReceiver(relayReceiver)
sessionCallback?.let { packageInstaller.unregisterSessionCallback(it) }
@@ -39,8 +39,6 @@ class PMResultReceiver(
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)
}
@@ -116,7 +116,7 @@ class RootInstaller(private val context: Context) : Installer {
}
private companion object {
// We spoof Google Play Store to prevent unnecessary checks
// spoof Google Play Store to prevent unnecessary checks
const val PLAY_PACKAGE_NAME = "com.android.vending"
/**
@@ -18,7 +18,7 @@ import kotlin.coroutines.resume
/**
* The package name of Google Play Store.
* We spoof our installer to this when installing through Shizuku to prevent
* spoof our installer to this when installing through Shizuku to prevent
* potentially unnecessary scans/checks.
*/
private const val PLAY_PACKAGE_NAME = "com.android.vending"
@@ -43,7 +43,7 @@ class CopyDependenciesStep : Step(), KoinComponent {
val targetFileStorageId = storageManager.getUuidForPath(apk)
val fileSize = srcApk.length()
// We request 3.5x the size of the APK, to give space for the following:
// 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
@@ -272,7 +272,7 @@ class SmaliPatchStep(
val matchPos = findContextMatch(result, sourceLines, delta.source.position)
?: throw Exception(
"Fuzzy match failed: could not locate context for hunk near line " +
"${delta.source.position + 1} (${sourceLines.size} lines)"
"${delta.source.position + 1} (${sourceLines.size} lines)"
)
repeat(sourceLines.size) { result.removeAt(matchPos) }
@@ -288,7 +288,7 @@ class SmaliPatchStep(
// Try at the exact hint first.
if (matchesAt(target, sourceLines, hint)) return hint
// Walk outward from the hint, alternating below/above, until we find a unique match.
// Walk outward from the hint, alternating below/above, until finds a unique match.
val maxRadius = target.size
for (offset in 1..maxRadius) {
val below = hint + offset
@@ -25,7 +25,7 @@ object ManifestPatcher {
appName: String,
debuggable: Boolean,
): ByteArray {
// Extract original package name so we can rewrite every reference to it
// Extract original package name to rewrite every reference to it
// (permissions, provider authorities) to the new packageName.
var originalPackage: String? = null
AxmlReader(manifestBytes).accept(object : AxmlVisitor() {
@@ -58,11 +58,12 @@ object ManifestPatcher {
COMPILE_SDK_VERSION_CODENAME to "6.0-2438415"
)
) {
// Drop split-only manifest attributes — we merged all splits into one APK
// Drop split-only manifest attributes because merged all splits into one APK
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
if (name == IS_SPLIT_REQUIRED || name == REQUIRED_SPLIT_TYPES || name == SPLIT_TYPES) return
super.attr(ns, name, resourceId, type, value)
}
private var addExternalStoragePerm = false
override fun child(ns: String?, name: String): NodeVisitor {
@@ -72,7 +73,13 @@ object ManifestPatcher {
if (addExternalStoragePerm) {
super
.child(null, "uses-permission")
.attr(ANDROID_NAMESPACE, "name", android.R.attr.name, TYPE_STRING, Manifest.permission.MANAGE_EXTERNAL_STORAGE)
.attr(
ANDROID_NAMESPACE,
"name",
android.R.attr.name,
TYPE_STRING,
Manifest.permission.MANAGE_EXTERNAL_STORAGE
)
addExternalStoragePerm = false
}
@@ -141,7 +148,13 @@ object ManifestPatcher {
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,
"name",
android.R.attr.name,
TYPE_STRING,
"isRadiantLyrics"
)
attr(ANDROID_NAMESPACE, "value", android.R.attr.value, TYPE_INT_BOOLEAN, 1)
}
}
@@ -149,7 +162,13 @@ object ManifestPatcher {
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?) {
override fun attr(
ns: String?,
name: String,
resourceId: Int,
type: Int,
value: Any?
) {
super.attr(
ns, name, resourceId, type,
if (name == "authorities") {
@@ -173,11 +192,23 @@ object ManifestPatcher {
TYPE_INT_BOOLEAN,
1
)
if (addDebuggable) super.attr(ANDROID_NAMESPACE, DEBUGGABLE, android.R.attr.debuggable, 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)
super.attr(
ANDROID_NAMESPACE,
USE_EMBEDDED_DEX,
android.R.attr.useEmbeddedDex,
TYPE_INT_BOOLEAN,
1
)
}
if (addExtractNativeLibs) super.attr(
@@ -209,7 +240,13 @@ object ManifestPatcher {
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)
super.attr(
ns,
name,
resourceId,
if (newValue is String) TYPE_STRING else type,
if (replace) newValue else value
)
}
}
}
@@ -150,15 +150,13 @@ class PatchingScreenModel(
.toUnsafeImmutable()
mainThread { steps = newSteps }
// Intentionally delay to show the state change of the first step when it runs in the UI.
// Without this, on a fast internet connection the step just immediately shows as "Success".
// Tiny delay so the first step's progress is visible on fast connections
delay(400)
// Execute all the steps and catch any errors
val error = when (val error = runner.executeAll()) {
null -> {
// If install step is marked skipped then the installation was manually aborted
// and if so, immediately close install screen
// Skipped install step means user aborted, close screen
if (runner.getStep<InstallStep>().state == StepState.Skipped) {
mutableState.value = PatchingScreenState.CloseScreen
@@ -215,6 +213,7 @@ class PatchingScreenModel(
R.string.fun_fact_10,
R.string.fun_fact_11,
R.string.fun_fact_12,
R.string.fun_fact_13,
)
}
}
@@ -8,37 +8,64 @@ enum class KnownPatch(
@StringRes val titleRes: Int,
@StringRes val descRes: Int,
val requires: List<KnownPatch> = emptyList(),
val disables: List<KnownPatch> = emptyList(),
) {
// Dependency-first order (later refs need backward resolution)
LyricsDisableCover(
fileNames = listOf("lyrics-disable-cover.patch"),
titleRes = R.string.patch_lyrics_disable_cover_title,
descRes = R.string.patch_lyrics_disable_cover_desc,
),
LyricsProgressPill(
LyricsReplaceLyricsButton(
fileNames = listOf(
"lyrics-progress-pill.patch",
"lyrics-fade-region.patch",
"lyrics-active-line-only.patch",
),
titleRes = R.string.patch_lyrics_progress_pill_title,
descRes = R.string.patch_lyrics_progress_pill_desc,
requires = listOf(LyricsDisableCover),
),
LyricsReplaceLyricButton(
fileNames = listOf(
"lyrics-replace-lyric-button-1-remove.patch",
"lyrics-replace-lyric-button-2-sparkle.patch",
"lyrics-replace-lyrics-button.patch",
"lyrics-sparkle-conditional-visibility.patch",
),
titleRes = R.string.patch_lyrics_replace_button_title,
descRes = R.string.patch_lyrics_replace_button_desc,
),
LyricsReplaceShareButton(
fileNames = listOf("lyrics-replace-share-button.patch"),
titleRes = R.string.patch_lyrics_replace_share_button_title,
descRes = R.string.patch_lyrics_replace_share_button_desc,
),
PlayerBackdrop(
fileNames = listOf("player-backdrop.patch"),
titleRes = R.string.patch_player_backdrop_title,
descRes = R.string.patch_player_backdrop_desc,
),
DebugMenuUnlock(
fileNames = listOf("debug-menu-unlock.patch"),
titleRes = R.string.patch_debug_menu_unlock_title,
descRes = R.string.patch_debug_menu_unlock_desc,
),
LyricsProgressPill(
fileNames = listOf(
"lyrics-progress-pill.patch",
"lyrics-fade-region.patch",
),
titleRes = R.string.patch_lyrics_progress_pill_title,
descRes = R.string.patch_lyrics_progress_pill_desc,
requires = listOf(LyricsDisableCover, LyricsReplaceShareButton),
),
EnableLegacyUi(
fileNames = listOf("enable-legacy-ui.patch"),
titleRes = R.string.patch_enable_legacy_ui_title,
descRes = R.string.patch_enable_legacy_ui_desc,
requires = listOf(DebugMenuUnlock),
disables = listOf(
LyricsDisableCover,
LyricsReplaceLyricsButton,
LyricsReplaceShareButton,
PlayerBackdrop,
LyricsProgressPill,
),
);
companion object {
val All: List<KnownPatch> = entries.sortedBy { it.fileNames.first() }
// Alphabetical by first filename, but pin DebugMenuUnlock to the bottom
val All: List<KnownPatch> = entries.sortedWith(
compareBy({ it == DebugMenuUnlock }, { it.fileNames.first() })
)
}
}
@@ -18,7 +18,6 @@ class PatchOptionsModel(
private val context: Context,
private val prefs: PreferencesManager,
) : ScreenModel {
// ---------- Package name state ----------
var packageName by mutableStateOf(prefilledOptions.packageName)
private set
@@ -30,7 +29,6 @@ class PatchOptionsModel(
fetchPkgNameStateDebounced()
}
// ---------- App name state ----------
var appName by mutableStateOf(prefilledOptions.appName)
private set
@@ -42,7 +40,6 @@ class PatchOptionsModel(
appNameIsError = newAppName.length !in (1..150)
}
// ---------- Debuggable state ----------
var debuggable by mutableStateOf(prefilledOptions.debuggable)
private set
@@ -50,7 +47,6 @@ class PatchOptionsModel(
debuggable = value
}
// ---------- Patch selection state ----------
var disabledPatches by mutableStateOf(prefilledOptions.disabledPatches)
private set
@@ -58,38 +54,33 @@ class PatchOptionsModel(
patch.fileNames.none { it in disabledPatches }
fun setPatchEnabled(patch: KnownPatch, enabled: Boolean) {
val units = if (enabled) {
fun closure(seed: KnownPatch, step: (KnownPatch) -> List<KnownPatch>): Set<KnownPatch> =
buildSet {
fun addWithDeps(p: KnownPatch) {
if (add(p)) p.requires.forEach(::addWithDeps)
}
addWithDeps(patch)
fun walk(p: KnownPatch) { if (add(p)) step(p).forEach(::walk) }
walk(seed)
}
val enableUnits: Set<KnownPatch>
val disableUnits: Set<KnownPatch>
if (enabled) {
enableUnits = closure(patch) { it.requires }
disableUnits = enableUnits.flatMap { it.disables }
.flatMapTo(mutableSetOf()) { d ->
closure(d) { dep -> KnownPatch.All.filter { dep in it.requires } }
}
} else {
buildSet {
fun addWithDependents(p: KnownPatch) {
if (add(p)) {
KnownPatch.All
.filter { p in it.requires }
.forEach(::addWithDependents)
}
}
addWithDependents(patch)
}
enableUnits = emptySet()
disableUnits = closure(patch) { p -> KnownPatch.All.filter { p in it.requires } }
}
val affectedFiles = units.flatMap { it.fileNames }.toSet()
disabledPatches = if (enabled) {
disabledPatches - affectedFiles
} else {
disabledPatches + affectedFiles
}
val enableFiles = enableUnits.flatMap { it.fileNames }.toSet()
val disableFiles = disableUnits.flatMap { it.fileNames }.toSet()
disabledPatches = (disabledPatches - enableFiles) + disableFiles
}
val enabledPatchCount: Int
get() = KnownPatch.All.count { isPatchEnabled(it) }
// ---------- Custom components state ----------
var customInjector by mutableStateOf<PatchComponent?>(null)
private set
var customPatches by mutableStateOf<PatchComponent?>(null)
@@ -113,7 +104,6 @@ class PatchOptionsModel(
)
}
// ---------- Config generation ----------
val isConfigValid by derivedStateOf {
val invalidChecks = arrayOf(
packageNameState == PackageNameState.Invalid,
@@ -136,11 +126,9 @@ class PatchOptionsModel(
)
}
// ---------- Other ----------
val isDevMode: Boolean
get() = prefs.devMode
// A throttled variant of fetchPkgNameState()
private val fetchPkgNameStateDebounced: () -> Unit =
screenModelScope.debounce(100L, function = ::fetchPkgNameState)
@@ -1,5 +1,6 @@
package com.meowarex.rlmobile.ui.screens.patchopts.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
@@ -62,7 +62,7 @@ fun InstallersDialog(
}
InstallerSetting.Intent -> {
// We don't know whether this device supports this method until we try.
// don't know whether this device supports this method
}
InstallerSetting.Shizuku -> {
@@ -147,7 +147,7 @@ class UpdaterViewModel(
// Fetch releases from GitHub (60s local cache)
val releases = github.getManagerReleases().getOrThrow()
// Find the latest release — version is parsed from the release title (e.g. "v1.0.5")
// Find the latest release by parsed version
val (version, release, apkUrl) = releases
.mapNotNull { release ->
val version = SemVer.parseOrNull(release.name?.removePrefix("v") ?: "")