Custom Patch Selection

This commit is contained in:
2026-05-21 20:50:15 +10:00
parent e5f186b264
commit c3d9ce2a39
15 changed files with 429 additions and 36 deletions
@@ -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
@@ -22,7 +22,7 @@ class TidalPatchRunner(
CopyDependenciesStep(),
// Patch
SmaliPatchStep(),
SmaliPatchStep(options),
ReorganizeDexStep(),
PatchManifestStep(options),
PatchCertsStep(),
@@ -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<DownloadPatchesStep>().getStoredFile(container)
val patches = mutableListOf<LoadedPatch>()
val localsBumps = mutableMapOf<Pair<String, String>, 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<String>,
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<String>, val previous: Int, val changed: Boolean)
private fun applyPatchFuzzy(target: List<String>, patch: Patch<String>): List<String> {
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<String>, sourceLines: List<String>, 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<String>, sourceLines: List<String>, 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(
@@ -34,6 +34,9 @@ private fun PatchOptionsScreenPreview(
onSelectCustomInjector = {},
customPatches = parameters.customPatches,
onSelectCustomPatches = {},
enabledPatchCount = KnownPatch.All.size,
isPatchEnabled = { true },
onTogglePatch = { _, _ -> },
isConfigValid = parameters.isConfigValid,
onInstall = {},
)
@@ -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
@@ -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<String>,
@StringRes val titleRes: Int,
@StringRes val descRes: Int,
val requires: List<KnownPatch> = 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<KnownPatch> = entries.sortedBy { it.fileNames.first() }
}
}
@@ -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<String> = emptySet(),
) : Parcelable {
companion object {
val Default = PatchOptions(
@@ -42,6 +44,7 @@ data class PatchOptions(
debuggable = false,
customInjector = null,
customPatches = null,
disabledPatches = emptySet(),
)
}
}
@@ -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<PatchComponent?>(null)
private set
@@ -93,6 +132,7 @@ class PatchOptionsModel(
debuggable = debuggable,
customInjector = customInjector,
customPatches = customPatches,
disabledPatches = disabledPatches,
)
}
@@ -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),
@@ -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),
)
}
}
}
@@ -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() {
@@ -219,6 +219,18 @@
<string name="patchopts_custom_patches_desc">A custom smali patch bundle that was imported by Manager.</string>
<string name="patchopts_divider_basic">Basic</string>
<string name="patchopts_divider_advanced">Advanced</string>
<string name="patchopts_patches_title">Patches</string>
<string name="patchopts_patches_desc">Toggle which patches are applied during installation. Unchecked patches are skipped entirely.</string>
<string name="patchopts_patches_summary">%1$d of %2$d enabled</string>
<string name="patch_lyrics_disable_cover_title">Disable Lyrics Cover</string>
<string name="patch_lyrics_disable_cover_desc">Prevents the album cover from being hidden when lyrics are showing.</string>
<string name="patch_lyrics_progress_pill_title">Lyrics Progress Pill</string>
<string name="patch_lyrics_progress_pill_desc">Shows a progress pill while the upcoming lyric line loads in, with a soft fade above and below the lyrics list.</string>
<string name="patch_lyrics_replace_button_title">Replace Lyrics Button</string>
<string name="patch_lyrics_replace_button_desc">Replaces the Lyrics button with the RL Sparkle!</string>
<string name="patch_player_backdrop_title">Player Backdrop</string>
<string name="patch_player_backdrop_desc">Restores the legacy translucent backdrop blur behind the player.</string>
<string name="componentopts_screen_title">Custom Component (%s)</string>
<string name="componentopts_screen_desc">Select a custom build that was imported by Manager.</string>
@@ -1,3 +1,4 @@
# 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 @@
@@ -1,14 +1,6 @@
# 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;
+1 -9
View File
@@ -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