From e5f186b264a654f68bb7ba312915af9592d5eebf Mon Sep 17 00:00:00 2001 From: meowarex Date: Thu, 21 May 2026 14:58:15 +1000 Subject: [PATCH 1/3] Update Comments --- .../patcher/steps/patch/SmaliPatchStep.kt | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt index 74be1e0..51de1c7 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt @@ -38,16 +38,11 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { val patches = mutableListOf() - // Load and parse all the patches from the smali patch archive. - // Extension classes (extension/**/*.smali) are extracted into smaliDir - // so they get assembled into the new dex alongside patched classes. + // Load and parse all the patches from the smali archive. container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}") smaliDir.mkdirs() ZipReader(patchesZip).use { zip -> - // Iterate in filename order so patches apply deterministically, matching - // the apply-order contract in patches/README. Zip iteration order would - // otherwise depend on archive layout and could break ordered patches that - // share a target file. + // Iterate in filename order so patches apply deterministically for (patchFile in zip.entryNames.sorted()) { container.log("Parsing patch file $patchFile") if (patchFile.endsWith("/")) continue @@ -73,8 +68,8 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { 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 + .replace("\r\n", "\n") // Replace CRLF endings with LF + .trimEnd { it == '\n' } // Remove trailing new lines .split('\n') try { @@ -96,7 +91,7 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { } } - // Disassemble all the classes we have patches for from all the dex files + // Disassemble all the classes container.log("Disassembling target classes in APK") ZipReader(apk).use { zip -> for (file in zip.entryNames) { @@ -117,11 +112,10 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { /* dexFile = */ dexFile, /* outputDir = */ smaliDir, /* jobs = */ coreCount - 1, - /* options = */ BaksmaliOptions().apply { + /* options = */ + BaksmaliOptions().apply { localsDirective = true - // Match apktool's label naming (:cond_0, :cond_1, ...) so patches - // authored from `apktool d` decompilation apply cleanly. Default - // would emit offset-based labels like :cond_8de. + // Match apktool label naming sequentialLabels = true }, /* classes = */ patches.map { "L${it.fullClassName};" }, @@ -135,7 +129,7 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { } } - // Apply all the patches to the smali files + // Apply all the patches to smali files container.log("Applying smali patches to disassembled files") for ((fullClassName, patch) in patches) { container.log("Applying patch to class $fullClassName") @@ -156,11 +150,11 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { } } - // Assemble the patched classes back into a single dex + // Assemble patched dex container.log("Reassembling patches smali classes into new dex") smaliDir.mkdir() - // Capture stdout/stderr while assembling smali + // Capture stdout/stderr assembling smali val originalStdout = System.out val originalStderr = System.err val captured = ByteArrayOutputStream() From c3d9ce2a39c9b52769df1dcd4d39f5e9b4e0639b Mon Sep 17 00:00:00 2001 From: meowarex Date: Thu, 21 May 2026 20:50:15 +1000 Subject: [PATCH 2/3] Custom Patch Selection --- .../meowarex/rlmobile/manager/PathManager.kt | 11 +- .../rlmobile/patcher/TidalPatchRunner.kt | 2 +- .../patcher/steps/patch/SmaliPatchStep.kt | 141 +++++++++++++++- .../screens/PatchOptionsScreenPreview.kt | 3 + .../screens/patching/PatchingScreenModel.kt | 4 +- .../ui/screens/patchopts/KnownPatch.kt | 44 +++++ .../ui/screens/patchopts/PatchOptions.kt | 3 + .../ui/screens/patchopts/PatchOptionsModel.kt | 40 +++++ .../screens/patchopts/PatchOptionsScreen.kt | 17 ++ .../components/PatchSelectionAccordion.kt | 150 ++++++++++++++++++ .../ui/screens/settings/SettingsModel.kt | 9 +- Manager/app/src/main/res/values/strings.xml | 12 ++ ...yrics-replace-lyric-button-1-remove.patch} | 5 +- ...rics-replace-lyric-button-2-sparkle.patch} | 14 +- patches/player-backdrop.patch | 10 +- 15 files changed, 429 insertions(+), 36 deletions(-) create mode 100644 Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/KnownPatch.kt create mode 100644 Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt rename patches/{lyrics-button-remove.patch => lyrics-replace-lyric-button-1-remove.patch} (89%) rename patches/{sparkle-button.patch => lyrics-replace-lyric-button-2-sparkle.patch} (72%) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PathManager.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PathManager.kt index ab95de5..cf91260 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PathManager.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/manager/PathManager.kt @@ -35,8 +35,15 @@ class PathManager( val patchedApk = patchingWorkingDir.resolve("patched.apk") fun clearCache() { - for (dir in arrayOf(patchingDir, cacheDownloadDir, context.cacheDir)) - dir.deleteRecursively() + val targets = arrayOf( + patchingDownloadDir, + patchingWorkingDir, + cacheDownloadDir, + context.cacheDir, + ) + for (dir in targets) { + if (dir.exists()) dir.deleteRecursively() + } } fun cachedTidalApk(version: Int, split: String = "base"): File = patchingDownloadDir diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/TidalPatchRunner.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/TidalPatchRunner.kt index cf4b4cc..87ffe0e 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/TidalPatchRunner.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/TidalPatchRunner.kt @@ -22,7 +22,7 @@ class TidalPatchRunner( CopyDependenciesStep(), // Patch - SmaliPatchStep(), + SmaliPatchStep(options), ReorganizeDexStep(), PatchManifestStep(options), PatchCertsStep(), diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt index 51de1c7..e59c200 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt @@ -8,6 +8,7 @@ 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.meowarex.rlmobile.ui.screens.patchopts.PatchOptions import com.android.tools.smali.baksmali.Baksmali import com.android.tools.smali.baksmali.BaksmaliOptions import com.android.tools.smali.dexlib2.Opcodes @@ -22,7 +23,9 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.* -class SmaliPatchStep : Step(), IDexProvider, KoinComponent { +class SmaliPatchStep( + private val options: PatchOptions, +) : Step(), IDexProvider, KoinComponent { private val paths: PathManager by inject() override val group = StepGroup.Patch @@ -37,6 +40,7 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { val patchesZip = container.getStep().getStoredFile(container) val patches = mutableListOf() + val localsBumps = mutableMapOf, Int>() // Load and parse all the patches from the smali archive. container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}") @@ -66,6 +70,12 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { if (!patchFile.endsWith(".patch")) continue + val basename = patchFile.substringAfterLast('/') + if (basename in options.disabledPatches) { + container.log("Skipping disabled patch $patchFile") + continue + } + val lines = zip.openEntry(patchFile)!!.read() .decodeToString() .replace("\r\n", "\n") // Replace CRLF endings with LF @@ -73,6 +83,15 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { .split('\n') try { + for (directive in lines) { + val match = LOCALS_DIRECTIVE.matchEntire(directive) ?: continue + val (smaliPath, methodSubstring, value) = match.destructured + val key = smaliPath.removeSuffix(".smali") to methodSubstring + val newValue = value.toInt() + localsBumps[key] = maxOf(localsBumps[key] ?: 0, newValue) + container.log("Recorded rl-locals bump: $smaliPath method≈\"$methodSubstring\" >= $newValue") + } + val targetLine = lines.firstOrNull { it.startsWith("--- a/") } ?: throw Error("Patch $patchFile is missing a '--- a/...' header") val fullClassName = targetLine @@ -129,6 +148,31 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { } } + if (localsBumps.isNotEmpty()) { + container.log("Applying ${localsBumps.size} rl-locals bump(s)") + for ((key, minLocals) in localsBumps) { + val (classPath, methodSubstring) = key + val smaliFile = smaliDir.resolve("$classPath.smali") + if (!smaliFile.exists()) { + throw FileNotFoundException("rl-locals target $classPath.smali not found") + } + val applied = bumpLocals( + lines = smaliFile.readLines(), + methodSubstring = methodSubstring, + minLocals = minLocals, + ) ?: throw Error("rl-locals: no .locals line found in $classPath for method≈\"$methodSubstring\"") + + if (applied.changed) { + smaliFile.bufferedWriter().use { writer -> + applied.lines.forEach(writer::appendLine) + } + container.log("Bumped .locals in $classPath (method≈\"$methodSubstring\") ${applied.previous} -> $minLocals") + } else { + container.log("Skipped .locals bump in $classPath (method≈\"$methodSubstring\"): already ${applied.previous} >= $minLocals") + } + } + } + // Apply all the patches to smali files container.log("Applying smali patches to disassembled files") for ((fullClassName, patch) in patches) { @@ -139,10 +183,16 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { throw FileNotFoundException("Target smali file $fullClassName.smali not found for patching!") } + val source = smaliFile.readLines() val patched = try { - DiffUtils.patch(smaliFile.readLines(), patch) + DiffUtils.patch(source, patch) } catch (t: Throwable) { - throw Error("Failed to smali patch $fullClassName", t) + container.log("Strict apply failed for $fullClassName, retrying with fuzzy context match: ${t.message}") + try { + applyPatchFuzzy(source, patch) + } catch (fuzzy: Throwable) { + throw Error("Failed to smali patch $fullClassName", fuzzy) + } } smaliFile.bufferedWriter().use { writer -> @@ -179,6 +229,91 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent { override val dexPriority = 2 override val dexCount = 1 override fun getDexFiles(container: StepRunner) = listOf(outDex.readBytes()) + + private fun bumpLocals( + lines: List, + methodSubstring: String, + minLocals: Int, + ): BumpResult? { + val result = lines.toMutableList() + var inTargetMethod = false + for (i in result.indices) { + val raw = result[i] + val trimmed = raw.trimStart() + + if (trimmed.startsWith(".method ")) { + inTargetMethod = methodSubstring in trimmed + continue + } + + if (!inTargetMethod) continue + + if (trimmed.startsWith(".locals ")) { + val current = trimmed.removePrefix(".locals ").substringBefore(' ').toInt() + if (current >= minLocals) return BumpResult(lines, current, changed = false) + val indent = raw.substring(0, raw.length - trimmed.length) + result[i] = "$indent.locals $minLocals # rl-locals bump (was $current)" + return BumpResult(result, current, changed = true) + } + + if (trimmed.startsWith(".end method")) return null + } + return null + } + + private data class BumpResult(val lines: List, val previous: Int, val changed: Boolean) + + private fun applyPatchFuzzy(target: List, patch: Patch): List { + val result = target.toMutableList() + val deltas = patch.deltas.sortedByDescending { it.source.position } + + for (delta in deltas) { + val sourceLines = delta.source.lines + 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)" + ) + + repeat(sourceLines.size) { result.removeAt(matchPos) } + result.addAll(matchPos, delta.target.lines) + } + + return result + } + + private fun findContextMatch(target: List, sourceLines: List, hint: Int): Int? { + if (sourceLines.isEmpty()) return hint.coerceIn(0, target.size) + + // 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. + val maxRadius = target.size + for (offset in 1..maxRadius) { + val below = hint + offset + if (below + sourceLines.size <= target.size && matchesAt(target, sourceLines, below)) { + return below + } + val above = hint - offset + if (above >= 0 && matchesAt(target, sourceLines, above)) { + return above + } + } + return null + } + + private fun matchesAt(target: List, sourceLines: List, pos: Int): Boolean { + if (pos < 0 || pos + sourceLines.size > target.size) return false + for ((i, line) in sourceLines.withIndex()) { + if (target[pos + i] != line) return false + } + return true + } + + private companion object { + val LOCALS_DIRECTIVE = Regex("""^#\s*rl-locals:\s+(\S+)\s+(\S+)\s+(\d+)\s*$""") + } } private data class LoadedPatch( diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PatchOptionsScreenPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PatchOptionsScreenPreview.kt index 86f70f4..377065a 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PatchOptionsScreenPreview.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/PatchOptionsScreenPreview.kt @@ -34,6 +34,9 @@ private fun PatchOptionsScreenPreview( onSelectCustomInjector = {}, customPatches = parameters.customPatches, onSelectCustomPatches = {}, + enabledPatchCount = KnownPatch.All.size, + isPatchEnabled = { true }, + onTogglePatch = { _, _ -> }, isConfigValid = parameters.isConfigValid, onInstall = {}, ) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreenModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreenModel.kt index f7a4fbe..905cdd3 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreenModel.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patching/PatchingScreenModel.kt @@ -87,9 +87,9 @@ class PatchingScreenModel( } } - fun clearCache() = screenModelScope.launchIO { + fun clearCache() { paths.clearCache() - mainThread { application.showToast(R.string.action_cleared_cache) } + application.showToast(R.string.action_cleared_cache) } fun getCurrentInstallId(): String? = installId diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/KnownPatch.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/KnownPatch.kt new file mode 100644 index 0000000..df972f6 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/KnownPatch.kt @@ -0,0 +1,44 @@ +package com.meowarex.rlmobile.ui.screens.patchopts + +import androidx.annotation.StringRes +import com.meowarex.rlmobile.R + +enum class KnownPatch( + val fileNames: List, + @StringRes val titleRes: Int, + @StringRes val descRes: Int, + val requires: List = emptyList(), +) { + LyricsDisableCover( + fileNames = listOf("lyrics-disable-cover.patch"), + titleRes = R.string.patch_lyrics_disable_cover_title, + descRes = R.string.patch_lyrics_disable_cover_desc, + ), + LyricsProgressPill( + 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", + ), + titleRes = R.string.patch_lyrics_replace_button_title, + descRes = R.string.patch_lyrics_replace_button_desc, + ), + PlayerBackdrop( + fileNames = listOf("player-backdrop.patch"), + titleRes = R.string.patch_player_backdrop_title, + descRes = R.string.patch_player_backdrop_desc, + ); + + companion object { + val All: List = entries.sortedBy { it.fileNames.first() } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt index 9e5bf27..f57a81a 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt @@ -34,6 +34,8 @@ data class PatchOptions( * A custom smali patches bundle that was used rather than the latest. */ val customPatches: PatchComponent? = null, + + val disabledPatches: Set = emptySet(), ) : Parcelable { companion object { val Default = PatchOptions( @@ -42,6 +44,7 @@ data class PatchOptions( debuggable = false, customInjector = null, customPatches = null, + disabledPatches = emptySet(), ) } } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt index e46f996..f561165 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt @@ -50,6 +50,45 @@ class PatchOptionsModel( debuggable = value } + // ---------- Patch selection state ---------- + var disabledPatches by mutableStateOf(prefilledOptions.disabledPatches) + private set + + fun isPatchEnabled(patch: KnownPatch): Boolean = + patch.fileNames.none { it in disabledPatches } + + fun setPatchEnabled(patch: KnownPatch, enabled: Boolean) { + val units = if (enabled) { + buildSet { + fun addWithDeps(p: KnownPatch) { + if (add(p)) p.requires.forEach(::addWithDeps) + } + addWithDeps(patch) + } + } else { + buildSet { + fun addWithDependents(p: KnownPatch) { + if (add(p)) { + KnownPatch.All + .filter { p in it.requires } + .forEach(::addWithDependents) + } + } + addWithDependents(patch) + } + } + + val affectedFiles = units.flatMap { it.fileNames }.toSet() + disabledPatches = if (enabled) { + disabledPatches - affectedFiles + } else { + disabledPatches + affectedFiles + } + } + + val enabledPatchCount: Int + get() = KnownPatch.All.count { isPatchEnabled(it) } + // ---------- Custom components state ---------- var customInjector by mutableStateOf(null) private set @@ -93,6 +132,7 @@ class PatchOptionsModel( debuggable = debuggable, customInjector = customInjector, customPatches = customPatches, + disabledPatches = disabledPatches, ) } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsScreen.kt index 43cede0..489f520 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsScreen.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsScreen.kt @@ -21,6 +21,7 @@ import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen import com.meowarex.rlmobile.ui.screens.patchopts.components.PackageNameStateLabel import com.meowarex.rlmobile.ui.screens.patchopts.components.PatchOptionsAppBar +import com.meowarex.rlmobile.ui.screens.patchopts.components.PatchSelectionAccordion import com.meowarex.rlmobile.ui.screens.patchopts.components.options.* import com.meowarex.rlmobile.ui.util.spacedByLastAtBottom import kotlinx.parcelize.IgnoredOnParcel @@ -59,6 +60,10 @@ class PatchOptionsScreen( onSelectCustomInjector = { model.selectCustomInjector(navigator) }, onSelectCustomPatches = { model.selectCustomPatches(navigator) }, + enabledPatchCount = model.enabledPatchCount, + isPatchEnabled = model::isPatchEnabled, + onTogglePatch = model::setPatchEnabled, + isConfigValid = model.isConfigValid, onInstall = { navigator.push(PatchingScreen(model.generateConfig())) @@ -88,6 +93,10 @@ fun PatchOptionsScreenContent( customPatches: PatchComponent?, onSelectCustomPatches: () -> Unit, + enabledPatchCount: Int, + isPatchEnabled: (KnownPatch) -> Boolean, + onTogglePatch: (KnownPatch, Boolean) -> Unit, + isConfigValid: Boolean, onInstall: () -> Unit, ) { @@ -148,6 +157,14 @@ fun PatchOptionsScreenContent( } } + PatchSelectionAccordion( + enabledCount = enabledPatchCount, + totalCount = KnownPatch.All.size, + isEnabled = isPatchEnabled, + onToggle = onTogglePatch, + modifier = Modifier.padding(top = 4.dp), + ) + if (isDevMode) { TextDivider( text = stringResource(R.string.patchopts_divider_advanced), diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt new file mode 100644 index 0000000..e0ecba9 --- /dev/null +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchSelectionAccordion.kt @@ -0,0 +1,150 @@ +package com.meowarex.rlmobile.ui.screens.patchopts.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.meowarex.rlmobile.R +import com.meowarex.rlmobile.ui.screens.patchopts.KnownPatch + +@Composable +fun PatchSelectionAccordion( + enabledCount: Int, + totalCount: Int, + isEnabled: (KnownPatch) -> Boolean, + onToggle: (KnownPatch, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + val rotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "patch-accordion-arrow", + ) + + Column( + modifier = modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button) { expanded = !expanded } + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.patchopts_patches_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource( + R.string.patchopts_patches_summary, + enabledCount, + totalCount, + ), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(.7f), + ) + } + + Icon( + painter = painterResource(R.drawable.ic_arrow_down_small), + contentDescription = stringResource( + if (expanded) R.string.action_collapse else R.string.action_expand, + ), + modifier = Modifier.rotate(rotation), + ) + } + + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background.copy(0.4f)) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = stringResource(R.string.patchopts_patches_desc), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .alpha(.7f) + .padding(bottom = 8.dp), + ) + + for (patch in KnownPatch.All) key(patch) { + PatchCheckboxRow( + title = stringResource(patch.titleRes), + description = stringResource(patch.descRes), + checked = isEnabled(patch), + onCheckedChange = { onToggle(patch, it) }, + ) + } + } + } + } +} + +@Composable +private fun PatchCheckboxRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + val interactionSource = remember(::MutableInteractionSource) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Checkbox, + ) { onCheckedChange(!checked) } + .padding(vertical = 4.dp), + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + interactionSource = interactionSource, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.weight(1f), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(.7f), + ) + } + } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsModel.kt index 7bea216..8163da7 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsModel.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/settings/SettingsModel.kt @@ -64,13 +64,10 @@ class SettingsModel( if (value) UpdateCheckWorker.schedule(application) else UpdateCheckWorker.cancel(application) } - fun clearCache() = screenModelScope.launchIO { + fun clearCache() { paths.clearCache() - - mainThread { - patchedApkExists = false - application.showToast(R.string.action_cleared_cache) - } + patchedApkExists = false + application.showToast(R.string.action_cleared_cache) } fun copyInstallInfo() { diff --git a/Manager/app/src/main/res/values/strings.xml b/Manager/app/src/main/res/values/strings.xml index fb4a489..52b3744 100644 --- a/Manager/app/src/main/res/values/strings.xml +++ b/Manager/app/src/main/res/values/strings.xml @@ -219,6 +219,18 @@ A custom smali patch bundle that was imported by Manager. Basic Advanced + Patches + Toggle which patches are applied during installation. Unchecked patches are skipped entirely. + %1$d of %2$d enabled + + Disable Lyrics Cover + Prevents the album cover from being hidden when lyrics are showing. + Lyrics Progress Pill + Shows a progress pill while the upcoming lyric line loads in, with a soft fade above and below the lyrics list. + Replace Lyrics Button + Replaces the Lyrics button with the RL Sparkle! + Player Backdrop + Restores the legacy translucent backdrop blur behind the player. Custom Component (%s) Select a custom build that was imported by Manager. diff --git a/patches/lyrics-button-remove.patch b/patches/lyrics-replace-lyric-button-1-remove.patch similarity index 89% rename from patches/lyrics-button-remove.patch rename to patches/lyrics-replace-lyric-button-1-remove.patch index 411439e..946bb16 100644 --- a/patches/lyrics-button-remove.patch +++ b/patches/lyrics-replace-lyric-button-1-remove.patch @@ -1,8 +1,9 @@ +# rl-locals: com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali e( 79 --- a/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali +++ b/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali @@ -4931,7 +4931,11 @@ const/4 v10, 0x0 - + .line 226 - invoke-static {v10, v9, v4, v2, v7}, Lcom/tidal/android/feature/playerscreen/ui/composables/h1;->a(Landroidx/compose/ui/Modifier;Ltl0/a;ZLandroidx/compose/runtime/Composer;I)V + const v10, 0x52414448 # empty group key @@ -10,6 +11,6 @@ + invoke-interface {v2, v10}, Landroidx/compose/runtime/Composer;->startReplaceGroup(I)V # open empty + + invoke-interface {v2}, Landroidx/compose/runtime/Composer;->endReplaceGroup()V # close empty - + .line 227 invoke-interface {v2}, Landroidx/compose/runtime/Composer;->endReplaceGroup()V diff --git a/patches/sparkle-button.patch b/patches/lyrics-replace-lyric-button-2-sparkle.patch similarity index 72% rename from patches/sparkle-button.patch rename to patches/lyrics-replace-lyric-button-2-sparkle.patch index b6b3af7..3d990fb 100644 --- a/patches/sparkle-button.patch +++ b/patches/lyrics-replace-lyric-button-2-sparkle.patch @@ -1,18 +1,10 @@ +# rl-locals: com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali e( 79 --- a/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali +++ b/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali -@@ -2966,7 +2966,7 @@ - .end method - - .method public static final e(ILandroidx/compose/runtime/Composer;Lcom/tidal/android/feature/playerscreen/ui/k;Lcom/tidal/android/feature/playerscreen/ui/r$a;Ltl0/a;Ltl0/l;Z)V -- .locals 71 # extra regs for blur backdrop -+ .locals 89 # extra regs for sparkle button - .annotation build Landroidx/compose/runtime/Composable; - .end annotation - @@ -5838,6 +5838,22 @@ :cond_51 check-cast v4, Ltl0/a; - + + new-instance v74, Lc8/j; # build lyrics-toggle lambda (same one h1 used) + + move-object/from16 v75, p5 # p5 holds the lambda receiver @@ -30,5 +22,5 @@ + invoke-static/range {v71 .. v74}, Lradiant/SparkleButton;->a(ILandroidx/compose/runtime/Composer;Landroidx/compose/ui/Modifier;Ltl0/a;)V # render bottom-left sparkle + const/4 v2, 0x0 - + invoke-static {v13, v7, v2, v4}, Lcom/tidal/android/feature/playerscreen/ui/composables/h3;->a(ILandroidx/compose/runtime/Composer;Landroidx/compose/ui/Modifier;Ltl0/a;)V diff --git a/patches/player-backdrop.patch b/patches/player-backdrop.patch index 216fb23..7567e6d 100644 --- a/patches/player-backdrop.patch +++ b/patches/player-backdrop.patch @@ -1,14 +1,6 @@ +# rl-locals: com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali e( 71 --- a/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali +++ b/com/tidal/android/feature/playerscreen/ui/PlayerScreenKt.smali -@@ -2966,7 +2966,7 @@ - .end method - - .method public static final e(ILandroidx/compose/runtime/Composer;Lcom/tidal/android/feature/playerscreen/ui/k;Lcom/tidal/android/feature/playerscreen/ui/r$a;Ltl0/a;Ltl0/l;Z)V -- .locals 61 -+ .locals 71 # extra regs for blur backdrop - .annotation build Landroidx/compose/runtime/Composable; - .end annotation - @@ -4164,6 +4164,133 @@ invoke-static {v5, v3, v4}, Landroidx/compose/runtime/Updater;->set-impl(Landroidx/compose/runtime/Composer;Ljava/lang/Object;Ltl0/p;)V From a4e4183c39f312f81098a85a004edb38d8c7aeab Mon Sep 17 00:00:00 2001 From: meowarex Date: Thu, 21 May 2026 20:59:58 +1000 Subject: [PATCH 3/3] Manager UI Tweaks <3 --- .../rlmobile/network/utils/CommitsPagingSource.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/CommitsPagingSource.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/CommitsPagingSource.kt index bd9c32b..c6f2589 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/CommitsPagingSource.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/CommitsPagingSource.kt @@ -8,6 +8,8 @@ import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService class CommitsPagingSource( private val github: RadiantLyricsGithubService, ) : PagingSource() { + private val seenShas = mutableSetOf() + private val seenTitles = mutableSetOf() override fun getRefreshKey(state: PagingState): Int? = state.anchorPosition?.let { @@ -19,7 +21,15 @@ class CommitsPagingSource( val page = params.key ?: 0 return when (val r = github.getCommits(page)) { is ApiResponse.Success -> LoadResult.Page( - data = r.data, + data = r.data.filter { commit -> + val title = commit.commit.message.lineSequence().first().trim() + when { + title.startsWith("Merge ") -> false + !seenShas.add(commit.sha) -> false + !seenTitles.add(title) -> false + else -> true + } + }, prevKey = if (page > 0) page - 1 else null, nextKey = if (r.data.isNotEmpty()) page + 1 else null, )