Advanced Options + New Patch Options <3

This commit is contained in:
2026-06-25 01:03:28 +10:00
parent 90b49cc571
commit c89827c7b2
18 changed files with 1542 additions and 179 deletions
@@ -8,7 +8,9 @@ import com.meowarex.rlmobile.patcher.steps.base.IDexProvider
import com.meowarex.rlmobile.patcher.steps.base.Step import com.meowarex.rlmobile.patcher.steps.base.Step
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
import com.meowarex.rlmobile.patcher.steps.download.DownloadPatchesStep import com.meowarex.rlmobile.patcher.steps.download.DownloadPatchesStep
import com.meowarex.rlmobile.ui.screens.patchopts.PatchManifest
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
import com.meowarex.rlmobile.ui.screens.patchopts.builtinPatchSpecs
import com.android.tools.smali.baksmali.Baksmali import com.android.tools.smali.baksmali.Baksmali
import com.android.tools.smali.baksmali.BaksmaliOptions import com.android.tools.smali.baksmali.BaksmaliOptions
import com.android.tools.smali.dexlib2.Opcodes import com.android.tools.smali.dexlib2.Opcodes
@@ -19,6 +21,7 @@ import com.github.diamondminer88.zip.ZipReader
import com.github.difflib.DiffUtils import com.github.difflib.DiffUtils
import com.github.difflib.UnifiedDiffUtils import com.github.difflib.UnifiedDiffUtils
import com.github.difflib.patch.Patch import com.github.difflib.patch.Patch
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import java.io.* import java.io.*
@@ -27,6 +30,7 @@ class SmaliPatchStep(
private val options: PatchOptions, private val options: PatchOptions,
) : Step(), IDexProvider, KoinComponent { ) : Step(), IDexProvider, KoinComponent {
private val paths: PathManager by inject() private val paths: PathManager by inject()
private val json: Json by inject()
override val group = StepGroup.Patch override val group = StepGroup.Patch
override val localizedName = R.string.patch_step_patch_smali override val localizedName = R.string.patch_step_patch_smali
@@ -41,9 +45,29 @@ class SmaliPatchStep(
val patches = mutableListOf<LoadedPatch>() val patches = mutableListOf<LoadedPatch>()
val localsBumps = mutableMapOf<Pair<String, String>, Int>() val localsBumps = mutableMapOf<Pair<String, String>, Int>()
val disabledFiles = options.disabledPatchFiles()
val knownExtensionFiles = options.knownExtensionFiles() // The patch list/options metadata lives in the zip's manifest.json
val enabledExtensionFiles = options.enabledExtensionFiles() val manifestSpecs = try {
ZipReader(patchesZip).use { it.openEntry("manifest.json")?.read() }
?.let { json.decodeFromString(PatchManifest.serializer(), it.decodeToString()).patches }
?.takeIf { it.isNotEmpty() }
} catch (t: Throwable) {
container.log("Failed to parse manifest.json (${t.message}); using built-in patch specs")
null
}
val specs = manifestSpecs ?: builtinPatchSpecs { "" }
container.log(
"Loaded ${specs.size} patch specs from " +
if (manifestSpecs != null) "manifest.json" else "built-in list"
)
val disabledFiles = options.disabledPatchFiles(specs)
val knownExtensionFiles = options.knownExtensionFiles(specs)
val enabledExtensionFiles = options.enabledExtensionFiles(specs)
val substitutions = options.smaliSubstitutions(specs)
if (substitutions.isNotEmpty()) {
container.log("Patch option substitutions: ${substitutions.entries.joinToString { "${it.key}=${it.value}" }}")
}
// Load and parse all the patches from the smali archive. // Load and parse all the patches from the smali archive.
container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}") container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}")
@@ -56,11 +80,11 @@ class SmaliPatchStep(
if (patchFile.endsWith(".smali") && patchFile.startsWith("extension/")) { if (patchFile.endsWith(".smali") && patchFile.startsWith("extension/")) {
val relative = patchFile.removePrefix("extension/") val relative = patchFile.removePrefix("extension/")
// Only bundle helper smali patch/variant/sub-option is enabled
if (relative in knownExtensionFiles && relative !in enabledExtensionFiles) { if (relative in knownExtensionFiles && relative !in enabledExtensionFiles) {
container.log("Skipping disabled extension smali: $relative") container.log("Skipping disabled extension smali: $relative")
continue continue
} }
val out = smaliDir.resolve(relative) val out = smaliDir.resolve(relative)
// Guard against zip-slip: a crafted entry could otherwise escape smaliDir. // Guard against zip-slip: a crafted entry could otherwise escape smaliDir.
val baseCanonical = smaliDir.canonicalPath + File.separator val baseCanonical = smaliDir.canonicalPath + File.separator
@@ -84,11 +108,34 @@ class SmaliPatchStep(
continue continue
} }
val lines = zip.openEntry(patchFile)!!.read() var patchText = zip.openEntry(patchFile)!!.read()
.decodeToString() .decodeToString()
.replace("\r\n", "\n") // Replace CRLF endings with LF .replace("\r\n", "\n") // Replace CRLF endings with LF
.trimEnd { it == '\n' } // Remove trailing new lines .trimEnd { it == '\n' } // Remove trailing new lines
.split('\n')
// Bake advanced option values into the patch
for ((token, value) in substitutions) {
if (patchText.contains(token)) {
patchText = patchText.replace(token, value)
container.log("Applied substitution $token -> $value in $patchFile")
}
}
// Fail fast
UNRESOLVED_TOKEN.find(patchText)?.let { match ->
throw Error("Unresolved option placeholder ${match.value} in $patchFile")
}
// Skip a patch that references a helper class which won't be bundled
val missingHelper = (knownExtensionFiles - enabledExtensionFiles).firstOrNull { rel ->
patchText.contains("L${rel.removeSuffix(".smali")};")
}
if (missingHelper != null) {
container.log("Skipping $patchFile: references gated-off helper class $missingHelper")
continue
}
val lines = patchText.split('\n')
try { try {
for (directive in lines) { for (directive in lines) {
@@ -328,6 +375,7 @@ class SmaliPatchStep(
private companion object { private companion object {
val LOCALS_DIRECTIVE = Regex("""^#\s*rl-locals:\s+(\S+)\s+(\S+)\s+(\d+)\s*$""") val LOCALS_DIRECTIVE = Regex("""^#\s*rl-locals:\s+(\S+)\s+(\S+)\s+(\d+)\s*$""")
val UNRESOLVED_TOKEN = Regex("""__RL_[A-Z0-9_]+__""")
} }
/** /**
@@ -2,6 +2,8 @@ package com.meowarex.rlmobile.ui.previews.screens
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.* import androidx.compose.ui.tooling.preview.*
import com.meowarex.rlmobile.network.utils.SemVer import com.meowarex.rlmobile.network.utils.SemVer
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
@@ -18,6 +20,9 @@ private fun PatchOptionsScreenPreview(
@PreviewParameter(PatchOptionsParametersProvider::class) @PreviewParameter(PatchOptionsParametersProvider::class)
parameters: PatchOptionsParameters, parameters: PatchOptionsParameters,
) { ) {
val context = LocalContext.current
val specs = remember { builtinPatchSpecs { context.getString(it) } }
ManagerTheme { ManagerTheme {
PatchOptionsScreenContent( PatchOptionsScreenContent(
isUpdate = parameters.isUpdate, isUpdate = parameters.isUpdate,
@@ -34,14 +39,14 @@ private fun PatchOptionsScreenPreview(
onSelectCustomTidalApk = {}, onSelectCustomTidalApk = {},
customPatches = parameters.customPatches, customPatches = parameters.customPatches,
onSelectCustomPatches = {}, onSelectCustomPatches = {},
enabledPatchCount = KnownPatch.All.size, specs = specs,
enabledPatchCount = specs.size,
isPatchEnabled = { true }, isPatchEnabled = { true },
onTogglePatch = { _, _ -> }, onTogglePatch = { _, _ -> },
patchLockState = { PatchLock.Free }, patchLockState = { PatchLock.Free },
variantIndex = { 0 }, variantIndex = { 0 },
onSelectVariant = { _, _ -> }, onSelectVariant = { _, _ -> },
isSubOptionEnabled = { _, _ -> true }, optionState = PatchOptionState.Preview,
onToggleSubOption = { _, _, _ -> },
isConfigValid = parameters.isConfigValid, isConfigValid = parameters.isConfigValid,
onInstall = {}, onInstall = {},
) )
@@ -8,23 +8,14 @@ import com.meowarex.rlmobile.ui.screens.patchopts.PatchDefault.Enabled
data class PatchVariant( data class PatchVariant(
@StringRes val titleRes: Int, @StringRes val titleRes: Int,
val fileNames: List<String>, val fileNames: List<String>,
val extensionFileNames: List<String> = emptyList(), val extensionFiles: List<String> = emptyList(),
)
data class PatchSubOption(
val key: String,
@StringRes val titleRes: Int,
@StringRes val descRes: Int,
val fileNames: List<String>,
val default: PatchDefault,
val extensionFileNames: List<String> = emptyList(),
) )
enum class KnownPatch( enum class KnownPatch(
val order: Int, // Patch order in the UI List (lower = higher up) [Main Patches: multiples of 10 | Sub Patches: multiples of 1] val order: Int, // Patch order in the UI List (lower = higher up) [Main Patches: multiples of 10 | Sub Patches: multiples of 1]
val fileNames: List<String>, val fileNames: List<String>,
val extensionFileNames: List<String> = emptyList(), val extensionFiles: List<String> = emptyList(),
@StringRes val titleRes: Int, @StringRes val titleRes: Int,
@StringRes val descRes: Int, @StringRes val descRes: Int,
val default: PatchDefault, // Default state of the patch in the UI List (enabled/disabled) val default: PatchDefault, // Default state of the patch in the UI List (enabled/disabled)
@@ -32,7 +23,7 @@ enum class KnownPatch(
val disables: List<KnownPatch> = emptyList(), val disables: List<KnownPatch> = emptyList(),
val variants: List<PatchVariant> = emptyList(), val variants: List<PatchVariant> = emptyList(),
val defaultVariantIndex: Int = 0, val defaultVariantIndex: Int = 0,
val subOptions: List<PatchSubOption> = emptyList(), val advancedOptions: List<PatchOption> = emptyList(),
) { ) {
LyricsDisableCover( LyricsDisableCover(
order = 41, order = 41,
@@ -81,6 +72,30 @@ enum class KnownPatch(
titleRes = R.string.patch_player_backdrop_title, titleRes = R.string.patch_player_backdrop_title,
descRes = R.string.patch_player_backdrop_desc, descRes = R.string.patch_player_backdrop_desc,
default = Enabled, default = Enabled,
advancedOptions = listOf(
PatchOption.Slider(
key = "blur_strength",
titleRes = R.string.patch_opt_backdrop_blur_title,
descRes = R.string.patch_opt_backdrop_blur_desc,
default = 50f,
valueRange = 0f..100f,
steps = 19, // dots every 5% (0,5,…,100); snap only when locked
displayAsPercent = true,
token = "RL_BLUR_BITS",
encode = SmaliEncode(EncodeKind.FloatBits, scale = 1.8f),
),
PatchOption.Slider(
key = "dimming",
titleRes = R.string.patch_opt_backdrop_dimming_title,
descRes = R.string.patch_opt_backdrop_dimming_desc,
default = 50f, // 50% == the original -0x80000000
valueRange = 0f..100f,
steps = 19, // dots every 5%
displayAsPercent = true,
token = "RL_SCRIM_ARGB",
encode = SmaliEncode(EncodeKind.ArgbAlpha),
),
),
), ),
QualityBadgeColors( QualityBadgeColors(
order = 36, order = 36,
@@ -133,40 +148,64 @@ enum class KnownPatch(
default = Disabled, default = Disabled,
defaultVariantIndex = 2, defaultVariantIndex = 2,
variants = listOf( variants = listOf(
PatchVariant( PatchVariant( // 0: Floating — stock rounded pill (no patch; the progress border is an option)
titleRes = R.string.patch_mini_player_variant_floating_title, titleRes = R.string.patch_mini_player_variant_floating_title,
fileNames = listOf("mini-player-floating.patch"), fileNames = emptyList(),
), ),
PatchVariant( PatchVariant( // 1: Grey — square, theme background (shown as "Legacy" when Dynamic BG is on)
titleRes = R.string.patch_mini_player_variant_square_grey_title, titleRes = R.string.patch_mini_player_variant_square_grey_title,
fileNames = listOf("mini-player-grey.patch"), fileNames = listOf("mini-player-grey.patch"),
), ),
PatchVariant( PatchVariant( // 2: Black — square, forced black background (hidden when Dynamic BG is on)
titleRes = R.string.patch_mini_player_variant_square_black_title, titleRes = R.string.patch_mini_player_variant_square_black_title,
fileNames = listOf("mini-player-black.patch"), fileNames = listOf("mini-player-black.patch"),
), ),
), ),
), advancedOptions = listOf(
MiniPlayerGestures( PatchOption.Toggle(
order = 52, key = "dynamic_bg",
fileNames = listOf("mini-player-gestures.patch"), titleRes = R.string.patch_mini_player_dynamic_bg_title,
extensionFileNames = listOf( descRes = R.string.patch_mini_player_dynamic_bg_desc,
"radiant/MiniPlayerGestures.smali", default = false,
"radiant/MiniPlayerGestures\$Gesture.smali", inline = true,
"radiant/MiniPlayerGestures\$RootGesture.smali", fileNames = listOf("mini-player-dynamic-bg.patch"),
"radiant/MiniPlayerGestures\$ApplyPending.smali", extensionFiles = listOf("radiant/MiniPlayerBackground.smali"),
), hidesVariants = listOf(2),
titleRes = R.string.patch_mini_player_gestures_title, relabelVariants = mapOf(1 to R.string.patch_mini_player_variant_legacy_title),
descRes = R.string.patch_mini_player_gestures_desc, ),
default = Enabled, // Animated progress border around the floating pill
subOptions = listOf( PatchOption.Toggle(
PatchSubOption( key = "border",
key = "MiniPlayerGestures.LeftRight", titleRes = R.string.patch_mini_player_border_title,
descRes = R.string.patch_mini_player_border_desc,
default = false,
requiresVariant = 0,
fileNames = listOf("mini-player-floating-border.patch"),
extensionFiles = listOf("radiant/MiniSeekerFloating.smali"),
),
// Swipe up to open the full player
PatchOption.Toggle(
key = "gestures",
titleRes = R.string.patch_mini_player_gestures_title,
descRes = R.string.patch_mini_player_gestures_desc,
default = true,
fileNames = listOf("mini-player-gestures.patch"),
extensionFiles = listOf(
"radiant/MiniPlayerGestures.smali",
"radiant/MiniPlayerGestures\$Gesture.smali",
"radiant/MiniPlayerGestures\$RootGesture.smali",
"radiant/MiniPlayerGestures\$ApplyPending.smali",
),
),
// Swipe left/right to skip
PatchOption.Toggle(
key = "next_prev",
titleRes = R.string.patch_mini_player_left_right_gestures_title, titleRes = R.string.patch_mini_player_left_right_gestures_title,
descRes = R.string.patch_mini_player_left_right_gestures_desc, descRes = R.string.patch_mini_player_left_right_gestures_desc,
default = false,
requiresOption = "gestures",
fileNames = listOf("mini-player-gestures-left-right.patch"), fileNames = listOf("mini-player-gestures-left-right.patch"),
default = Disabled, extensionFiles = listOf(
extensionFileNames = listOf(
"radiant/MiniPlayerTrackGestures.smali", "radiant/MiniPlayerTrackGestures.smali",
"radiant/MiniPlayerTrackGestures\$Gesture.smali", "radiant/MiniPlayerTrackGestures\$Gesture.smali",
"radiant/MiniPlayerTrackGestures\$OffsetLayer.smali", "radiant/MiniPlayerTrackGestures\$OffsetLayer.smali",
@@ -177,13 +216,6 @@ enum class KnownPatch(
), ),
), ),
), ),
MiniPlayerDynamicBackground(
order = 51,
fileNames = listOf("mini-player-dynamic-bg.patch"),
titleRes = R.string.patch_mini_player_dynamic_bg_title,
descRes = R.string.patch_mini_player_dynamic_bg_desc,
default = Disabled,
),
EnableLegacyUi( EnableLegacyUi(
order = 10, order = 10,
fileNames = listOf("enable-legacy-ui.patch"), fileNames = listOf("enable-legacy-ui.patch"),
@@ -200,9 +232,8 @@ enum class KnownPatch(
PlayerBackdrop, PlayerBackdrop,
QualityBadgeColors, QualityBadgeColors,
LyricsProgressPill, LyricsProgressPill,
MiniPlayerDynamicBackground,
CoverEverywhere, CoverEverywhere,
MiniPlayerGestures, MiniPlayerRedesign,
), ),
); );
@@ -0,0 +1,68 @@
package com.meowarex.rlmobile.ui.screens.patchopts
import androidx.annotation.StringRes
sealed interface PatchOption {
/** Stable identifier, unique within a single patch. */
val key: String
@get:StringRes
val titleRes: Int
@get:StringRes
val descRes: Int
/** A simple on/off switch. */
data class Toggle(
override val key: String,
@StringRes override val titleRes: Int,
@StringRes override val descRes: Int,
val default: Boolean,
/** Patch files applied only while this toggle is on (and its patch is enabled). */
val fileNames: List<String> = emptyList(),
/** Helper smali extracted only while this toggle is on. */
val extensionFiles: List<String> = emptyList(),
/** Render inline beneath the variant selector instead of inside the advanced sheet. */
val inline: Boolean = false,
/** Greyed out (with the lock dialog) unless this variant index is the selected one. */
val requiresVariant: Int? = null,
/** Greyed out unless the sibling option with this key is currently on. */
val requiresOption: String? = null,
/** Variant indices hidden from the picker while this toggle is on. */
val hidesVariants: List<Int> = emptyList(),
/** Variant title overrides (index -> @StringRes) applied while this toggle is on. */
val relabelVariants: Map<Int, Int> = emptyMap(),
) : PatchOption
/** A continuous (or stepped) numeric value within [valueRange]. */
data class Slider(
override val key: String,
@StringRes override val titleRes: Int,
@StringRes override val descRes: Int,
val default: Float,
val valueRange: ClosedFloatingPointRange<Float>,
/** Number of discrete steps between the range ends (0 = continuous). */
val steps: Int = 0,
/** Render the value as a rounded percentage (e.g. "50%"). */
val displayAsPercent: Boolean = false,
/** Optional unit suffix shown after the value (e.g. "dp"). */
@StringRes val unitRes: Int? = null,
/** Placeholder name (without the surrounding `__`) baked into the `.patch` files. */
val token: String? = null,
/** How the chosen value becomes the smali literal that replaces the [token]. */
val encode: SmaliEncode? = null,
) : PatchOption
/** A single choice out of a small list, rendered as a segmented control. */
data class Choice(
override val key: String,
@StringRes override val titleRes: Int,
@StringRes override val descRes: Int,
val entries: List<ChoiceEntry>,
val defaultIndex: Int = 0,
) : PatchOption
}
data class ChoiceEntry(
@StringRes val labelRes: Int,
)
@@ -35,54 +35,86 @@ data class PatchOptions(
val patchStates: Map<String, Boolean> = emptyMap(), val patchStates: Map<String, Boolean> = emptyMap(),
val selectedVariants: Map<String, Int> = emptyMap(), val selectedVariants: Map<String, Int> = emptyMap(),
val optionFloats: Map<String, Float> = emptyMap(),
val optionBools: Map<String, Boolean> = emptyMap(),
val optionInts: Map<String, Int> = emptyMap(),
) : Parcelable { ) : Parcelable {
fun isEnabled(patch: KnownPatch): Boolean = fun isEnabled(spec: PatchSpec): Boolean =
patchStates[patch.name] ?: patch.default.isEnabled patchStates[spec.id] ?: spec.defaultEnabled
fun isEnabled(subOption: PatchSubOption): Boolean = fun variantIndex(spec: PatchSpec): Int {
patchStates[subOption.key] ?: subOption.default.isEnabled val stored = (selectedVariants[spec.id] ?: spec.defaultVariantIndex)
.coerceIn(0, spec.variants.lastIndex.coerceAtLeast(0))
return spec.resolveVariantIndex(stored) { isToggleOn(spec, it) }
}
fun disabledPatchFiles(): Set<String> = buildSet<String> { fun sliderValue(spec: PatchSpec, option: OptionSpec.Slider): Float =
for (patch in KnownPatch.All) { (optionFloats["${spec.id}/${option.key}"] ?: option.default).coerceIn(option.min, option.max)
val enabled = isEnabled(patch)
if (patch.variants.isEmpty()) { fun isToggleOn(spec: PatchSpec, option: OptionSpec.Toggle): Boolean =
if (!enabled) addAll(patch.fileNames) optionBools["${spec.id}/${option.key}"] ?: option.default
fun isToggleActive(spec: PatchSpec, option: OptionSpec.Toggle): Boolean {
if (!isToggleOn(spec, option)) return false
option.requiresVariant?.let { if (variantIndex(spec) != it) return false }
option.requiresOption?.let { key ->
val required = spec.advancedOptions.filterIsInstance<OptionSpec.Toggle>()
.firstOrNull { it.key == key }
if (required != null && !isToggleOn(spec, required)) return false
}
return true
}
fun disabledPatchFiles(specs: List<PatchSpec>): Set<String> = buildSet {
for (spec in specs) {
val enabled = isEnabled(spec)
if (spec.variants.isEmpty()) {
if (!enabled) addAll(spec.fileNames)
} else { } else {
val selected = (selectedVariants[patch.name] ?: patch.defaultVariantIndex) val selected = variantIndex(spec)
.coerceIn(0, patch.variants.lastIndex) spec.variants.forEachIndexed { index, variant ->
patch.variants.forEachIndexed { index, variant ->
if (!enabled || index != selected) addAll(variant.fileNames) if (!enabled || index != selected) addAll(variant.fileNames)
} }
} }
for (subOption in patch.subOptions) { // Toggle sub-options that gate patch files.
if (!enabled || !isEnabled(subOption)) addAll(subOption.fileNames) for (option in spec.advancedOptions) {
if (option is OptionSpec.Toggle && option.fileNames.isNotEmpty()) {
if (!enabled || !isToggleActive(spec, option)) addAll(option.fileNames)
}
} }
} }
} }
fun knownExtensionFiles(): Set<String> = buildSet { fun knownExtensionFiles(specs: List<PatchSpec>): Set<String> = buildSet {
for (patch in KnownPatch.All) { for (spec in specs) {
addAll(patch.extensionFileNames) addAll(spec.extensionFiles)
patch.variants.forEach { addAll(it.extensionFileNames) } spec.variants.forEach { addAll(it.extensionFiles) }
patch.subOptions.forEach { addAll(it.extensionFileNames) } spec.advancedOptions.forEach { if (it is OptionSpec.Toggle) addAll(it.extensionFiles) }
} }
} }
fun enabledExtensionFiles(): Set<String> = buildSet { fun enabledExtensionFiles(specs: List<PatchSpec>): Set<String> = buildSet {
for (patch in KnownPatch.All) { for (spec in specs) {
if (!isEnabled(patch)) continue if (!isEnabled(spec)) continue
addAll(spec.extensionFiles)
addAll(patch.extensionFileNames) if (spec.variants.isNotEmpty()) {
spec.variants.getOrNull(variantIndex(spec))?.let { addAll(it.extensionFiles) }
if (patch.variants.isNotEmpty()) {
val selected = (selectedVariants[patch.name] ?: patch.defaultVariantIndex)
.coerceIn(0, patch.variants.lastIndex)
addAll(patch.variants[selected].extensionFileNames)
} }
for (option in spec.advancedOptions) {
if (option is OptionSpec.Toggle && isToggleActive(spec, option)) addAll(option.extensionFiles)
}
}
}
for (subOption in patch.subOptions) { fun smaliSubstitutions(specs: List<PatchSpec>): Map<String, String> = buildMap {
if (isEnabled(subOption)) addAll(subOption.extensionFileNames) for (spec in specs) {
if (!isEnabled(spec)) continue
for (option in spec.advancedOptions) {
if (option !is OptionSpec.Slider) continue
val token = option.token ?: continue
val encode = option.encode ?: continue
put("__${token}__", encode.encode(sliderValue(spec, option)))
} }
} }
} }
@@ -2,21 +2,35 @@ package com.meowarex.rlmobile.ui.screens.patchopts
import android.content.Context import android.content.Context
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
import android.util.Log
import androidx.compose.runtime.* import androidx.compose.runtime.*
import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.Navigator
import com.github.diamondminer88.zip.ZipReader
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.manager.PathManager
import com.meowarex.rlmobile.manager.PreferencesManager import com.meowarex.rlmobile.manager.PreferencesManager
import com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsScreen import com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsScreen
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
import com.meowarex.rlmobile.ui.util.pushForResult import com.meowarex.rlmobile.ui.util.pushForResult
import com.meowarex.rlmobile.util.* import com.meowarex.rlmobile.util.*
import com.meowarex.rlmobile.manager.download.IDownloadManager
import com.meowarex.rlmobile.manager.download.KtorDownloadManager
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
import com.meowarex.rlmobile.network.utils.getOrThrow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.io.File
class PatchOptionsModel( class PatchOptionsModel(
prefilledOptions: PatchOptions, prefilledOptions: PatchOptions,
private val context: Context, private val context: Context,
private val prefs: PreferencesManager, private val prefs: PreferencesManager,
private val paths: PathManager,
private val json: Json,
private val github: RadiantLyricsGithubService,
private val downloader: KtorDownloadManager,
) : ScreenModel { ) : ScreenModel {
var packageName by mutableStateOf(prefilledOptions.packageName) var packageName by mutableStateOf(prefilledOptions.packageName)
private set private set
@@ -47,84 +61,153 @@ class PatchOptionsModel(
debuggable = value debuggable = value
} }
// The accordion renders from [specs]. It defaults to the compiled-in (localized) list and is
// rebuilt from a custom zip's manifest.json whenever a custom patch set is selected. (Rminder for others about the new custom patch selection flow)
private val builtinSpecs: List<PatchSpec> = builtinPatchSpecs { context.getString(it) }
var specs by mutableStateOf(builtinSpecs)
private set
var specsLoading by mutableStateOf(false)
private set
var patchStates by mutableStateOf(prefilledOptions.patchStates) var patchStates by mutableStateOf(prefilledOptions.patchStates)
private set private set
var selectedVariants by mutableStateOf(prefilledOptions.selectedVariants) var selectedVariants by mutableStateOf(prefilledOptions.selectedVariants)
private set private set
fun variantIndex(patch: KnownPatch): Int = selectedVariants[patch.name] fun variantIndex(spec: PatchSpec): Int =
?.coerceIn(0, patch.variants.lastIndex.coerceAtLeast(0)) (selectedVariants[spec.id] ?: spec.defaultVariantIndex)
?: patch.defaultVariantIndex.coerceIn(0, patch.variants.lastIndex.coerceAtLeast(0)) .coerceIn(0, spec.variants.lastIndex.coerceAtLeast(0))
fun isPatchEnabled(patch: KnownPatch): Boolean = fun isPatchEnabled(spec: PatchSpec): Boolean =
patchStates[patch.name] ?: patch.default.isEnabled patchStates[spec.id] ?: spec.defaultEnabled
fun isSubOptionEnabled(patch: KnownPatch, subOption: PatchSubOption): Boolean = fun setPatchEnabled(spec: PatchSpec, enabled: Boolean) {
isPatchEnabled(patch) && (patchStates[subOption.key] ?: subOption.default.isEnabled) val byId = specs.associateBy { it.id }
fun closure(seed: PatchSpec, step: (PatchSpec) -> List<PatchSpec>): Set<PatchSpec> =
fun setSubOptionEnabled(patch: KnownPatch, subOption: PatchSubOption, enabled: Boolean) {
if (subOption !in patch.subOptions) return
patchStates = patchStates + (subOption.key to enabled)
}
fun setPatchEnabled(patch: KnownPatch, enabled: Boolean) {
fun closure(seed: KnownPatch, step: (KnownPatch) -> List<KnownPatch>): Set<KnownPatch> =
buildSet { buildSet {
fun walk(p: KnownPatch) { if (add(p)) step(p).forEach(::walk) } fun walk(p: PatchSpec) { if (add(p)) step(p).forEach(::walk) }
walk(seed) walk(seed)
} }
val enableUnits: Set<KnownPatch> val requiresOf = { p: PatchSpec -> p.requires.mapNotNull(byId::get) }
val disableUnits: Set<KnownPatch> val dependentsOf = { p: PatchSpec -> specs.filter { p.id in it.requires } }
val enableUnits: Set<PatchSpec>
val disableUnits: Set<PatchSpec>
if (enabled) { if (enabled) {
enableUnits = closure(patch) { it.requires } enableUnits = closure(spec, requiresOf)
disableUnits = enableUnits.flatMap { it.disables } disableUnits = enableUnits.flatMap { it.disables }
.flatMapTo(mutableSetOf()) { d -> .mapNotNull(byId::get)
closure(d) { dep -> KnownPatch.All.filter { dep in it.requires } } .flatMapTo(mutableSetOf()) { d -> closure(d, dependentsOf) }
}
} else { } else {
enableUnits = emptySet() enableUnits = emptySet()
disableUnits = closure(patch) { p -> KnownPatch.All.filter { p in it.requires } } disableUnits = closure(spec, dependentsOf)
} }
patchStates = patchStates.toMutableMap().apply { patchStates = patchStates.toMutableMap().apply {
enableUnits.forEach { this[it.name] = true } enableUnits.forEach { this[it.id] = true }
disableUnits.forEach { this[it.name] = false } disableUnits.forEach { this[it.id] = false }
} }
} }
fun selectVariant(patch: KnownPatch, index: Int) { fun selectVariant(spec: PatchSpec, index: Int) {
if (patch.variants.isEmpty() || index !in patch.variants.indices) return if (spec.variants.isEmpty() || index !in spec.variants.indices) return
selectedVariants = selectedVariants + (patch.name to index) selectedVariants = selectedVariants + (spec.id to index)
} }
fun lockState(patch: KnownPatch): PatchLock { // Advanced (per-patch) options
if (patch.variants.isNotEmpty()) return PatchLock.Free // Values are seeded from the prefilled config, edited here, and written back out in
// generateConfig(). At patch time PatchOptions.smaliSubstitutions() turns slider values into
// the literals baked into the matching `.patch` files (see SmaliPatchStep). ()
fun closure(seed: KnownPatch, step: (KnownPatch) -> List<KnownPatch>): Set<KnownPatch> = private var optionBools by mutableStateOf(prefilledOptions.optionBools)
private var optionFloats by mutableStateOf(prefilledOptions.optionFloats)
private var optionInts by mutableStateOf(prefilledOptions.optionInts)
private fun keyOf(spec: PatchSpec, option: OptionSpec): String = "${spec.id}/${option.key}"
fun toggleValue(spec: PatchSpec, option: OptionSpec.Toggle): Boolean =
optionBools[keyOf(spec, option)] ?: option.default
fun setToggleValue(spec: PatchSpec, option: OptionSpec.Toggle, value: Boolean) {
optionBools = optionBools + (keyOf(spec, option) to value)
}
fun sliderValue(spec: PatchSpec, option: OptionSpec.Slider): Float =
(optionFloats[keyOf(spec, option)] ?: option.default).coerceIn(option.min, option.max)
fun setSliderValue(spec: PatchSpec, option: OptionSpec.Slider, value: Float) {
optionFloats = optionFloats + (keyOf(spec, option) to value)
}
fun choiceValue(spec: PatchSpec, option: OptionSpec.Choice): Int =
(optionInts[keyOf(spec, option)] ?: option.defaultIndex)
.coerceIn(0, option.entries.lastIndex.coerceAtLeast(0))
fun setChoiceValue(spec: PatchSpec, option: OptionSpec.Choice, index: Int) {
if (index !in option.entries.indices) return
optionInts = optionInts + (keyOf(spec, option) to index)
}
fun isAdvancedModified(spec: PatchSpec): Boolean = spec.advancedOptions.any { option ->
when (option) {
is OptionSpec.Toggle -> toggleValue(spec, option) != option.default
is OptionSpec.Slider -> sliderValue(spec, option) != option.default
is OptionSpec.Choice -> choiceValue(spec, option) != option.defaultIndex
}
}
fun resetAdvanced(spec: PatchSpec) {
val prefix = "${spec.id}/"
optionBools = optionBools.filterKeys { !it.startsWith(prefix) }
optionFloats = optionFloats.filterKeys { !it.startsWith(prefix) }
optionInts = optionInts.filterKeys { !it.startsWith(prefix) }
}
val optionState: PatchOptionState = PatchOptionState(
toggle = ::toggleValue,
setToggle = ::setToggleValue,
slider = ::sliderValue,
setSlider = ::setSliderValue,
choice = ::choiceValue,
setChoice = ::setChoiceValue,
isModified = ::isAdvancedModified,
reset = ::resetAdvanced,
)
fun lockState(spec: PatchSpec): PatchLock {
if (spec.variants.isNotEmpty()) return PatchLock.Free
val byId = specs.associateBy { it.id }
fun closure(seed: PatchSpec, step: (PatchSpec) -> List<PatchSpec>): Set<PatchSpec> =
buildSet { buildSet {
fun walk(p: KnownPatch) { if (add(p)) step(p).forEach(::walk) } fun walk(p: PatchSpec) { if (add(p)) step(p).forEach(::walk) }
walk(seed) walk(seed)
} }
for (other in KnownPatch.All) { val requiresOf = { p: PatchSpec -> p.requires.mapNotNull(byId::get) }
if (other == patch || !isPatchEnabled(other)) continue val dependentsOf = { p: PatchSpec -> specs.filter { p.id in it.requires } }
val requiresClosure = closure(other) { it.requires } for (other in specs) {
if (patch in requiresClosure - other) return PatchLock.LockedOn(other) if (other.id == spec.id || !isPatchEnabled(other)) continue
val requiresClosure = closure(other, requiresOf)
if (spec in requiresClosure - other) return PatchLock.LockedOn(other)
val disablesClosure = requiresClosure.flatMap { it.disables } val disablesClosure = requiresClosure.flatMap { it.disables }
.flatMapTo(mutableSetOf()) { d -> .mapNotNull(byId::get)
closure(d) { dep -> KnownPatch.All.filter { dep in it.requires } } .flatMapTo(mutableSetOf()) { d -> closure(d, dependentsOf) }
} if (spec in disablesClosure) return PatchLock.LockedOff(other)
if (patch in disablesClosure) return PatchLock.LockedOff(other)
} }
return PatchLock.Free return PatchLock.Free
} }
val enabledPatchCount: Int val enabledPatchCount: Int
get() = KnownPatch.All.count { isPatchEnabled(it) } get() = specs.count { isPatchEnabled(it) }
var customTidalApk by mutableStateOf<PatchComponent?>(null) var customTidalApk by mutableStateOf<PatchComponent?>(null)
private set private set
@@ -147,6 +230,79 @@ class PatchOptionsModel(
componentType = PatchComponent.Type.Patches, componentType = PatchComponent.Type.Patches,
) )
) )
reloadSpecs(customPatches)
}
/**
* Rebuilds [specs] from the selected patch set
*/
private fun reloadSpecs(component: PatchComponent?) = screenModelScope.launchIO {
mainThread { specsLoading = true }
val loaded = if (component == null) builtinSpecs else loadManifestSpecs(component) ?: builtinSpecs
mainThread {
specs = loaded
validatePatchSelection()
specsLoading = false
}
}
private fun loadManifestSpecs(component: PatchComponent): List<PatchSpec>? =
loadManifestSpecs(component.getFile(paths))
private fun loadManifestSpecs(file: File): List<PatchSpec>? {
if (!file.exists()) return null
return try {
val bytes = ZipReader(file).use { it.openEntry(MANIFEST_NAME)?.read() } ?: return null
json.decodeFromString(PatchManifest.serializer(), bytes.decodeToString())
.patches
.takeIf { it.isNotEmpty() }
} catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to parse $MANIFEST_NAME; using built-in list", t)
null
}
}
/**
* Pulls the patch list from the latest GitHub release's patches.zip manifest
*/
private suspend fun loadLatestReleaseSpecs(): List<PatchSpec>? = try {
val release = github.getLatestRelease().getOrThrow()
val url = release.assets
.find { it.name == RadiantLyricsGithubService.PATCHES_ASSET_NAME }
?.browserDownloadUrl
if (url == null) {
Log.w(BuildConfig.TAG, "Latest release ${release.tagName} has no patches.zip asset; using built-in list")
null
} else {
// Cached per release tag so re-opening the screen doesn't re-download the same zip.
val dest = paths.cacheDownloadDir.resolve("manifest-${release.tagName}.zip")
.apply { parentFile?.mkdirs() }
if (dest.exists() || downloader.download(url, dest) is IDownloadManager.Result.Success) {
loadManifestSpecs(dest).also { loaded ->
if (loaded != null) {
Log.i(BuildConfig.TAG, "Loaded ${loaded.size} patches from latest release ${release.tagName} manifest")
} else {
Log.w(BuildConfig.TAG, "Latest release ${release.tagName} ships no manifest.json; using built-in list")
}
}
} else {
null
}
}
} catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to load latest release manifest; using built-in list", t)
null
}
/** Default (non-custom) source: the latest release manifest, falling back to the built-in list. */
private fun loadDefaultSpecs() = screenModelScope.launchIO {
mainThread { specsLoading = true }
val loaded = loadLatestReleaseSpecs() ?: builtinSpecs
mainThread {
specs = loaded
validatePatchSelection()
specsLoading = false
}
} }
val isConfigValid by derivedStateOf { val isConfigValid by derivedStateOf {
@@ -169,6 +325,9 @@ class PatchOptionsModel(
customPatches = customPatches, customPatches = customPatches,
patchStates = patchStates, patchStates = patchStates,
selectedVariants = selectedVariants, selectedVariants = selectedVariants,
optionFloats = optionFloats,
optionBools = optionBools,
optionInts = optionInts,
) )
} }
@@ -194,9 +353,9 @@ class PatchOptionsModel(
} }
private fun validatePatchSelection() { private fun validatePatchSelection() {
for (patch in KnownPatch.All) { for (spec in specs) {
if (isPatchEnabled(patch)) { if (isPatchEnabled(spec)) {
setPatchEnabled(patch, true) setPatchEnabled(spec, true)
} }
} }
} }
@@ -204,9 +363,13 @@ class PatchOptionsModel(
init { init {
validatePatchSelection() validatePatchSelection()
screenModelScope.launchBlock { fetchPkgNameState() } screenModelScope.launchBlock { fetchPkgNameState() }
// Default source is the latest release's manifest; custom selections drive themselves.
if (customPatches == null) loadDefaultSpecs()
} }
companion object { companion object {
private const val MANIFEST_NAME = "manifest.json"
private val PACKAGE_REGEX = """^[a-z]\w*(\.[a-z]\w*)+$""" private val PACKAGE_REGEX = """^[a-z]\w*(\.[a-z]\w*)+$"""
.toRegex(RegexOption.IGNORE_CASE) .toRegex(RegexOption.IGNORE_CASE)
} }
@@ -219,7 +382,7 @@ enum class PackageNameState {
} }
sealed class PatchLock { sealed class PatchLock {
object Free : PatchLock() data object Free : PatchLock()
data class LockedOn(val by: KnownPatch) : PatchLock() data class LockedOn(val by: PatchSpec) : PatchLock()
data class LockedOff(val by: KnownPatch) : PatchLock() data class LockedOff(val by: PatchSpec) : PatchLock()
} }
@@ -60,14 +60,14 @@ class PatchOptionsScreen(
onSelectCustomTidalApk = { model.selectCustomTidalApk(navigator) }, onSelectCustomTidalApk = { model.selectCustomTidalApk(navigator) },
onSelectCustomPatches = { model.selectCustomPatches(navigator) }, onSelectCustomPatches = { model.selectCustomPatches(navigator) },
specs = model.specs,
enabledPatchCount = model.enabledPatchCount, enabledPatchCount = model.enabledPatchCount,
isPatchEnabled = model::isPatchEnabled, isPatchEnabled = model::isPatchEnabled,
onTogglePatch = model::setPatchEnabled, onTogglePatch = model::setPatchEnabled,
patchLockState = model::lockState, patchLockState = model::lockState,
variantIndex = model::variantIndex, variantIndex = model::variantIndex,
onSelectVariant = model::selectVariant, onSelectVariant = model::selectVariant,
isSubOptionEnabled = model::isSubOptionEnabled, optionState = model.optionState,
onToggleSubOption = model::setSubOptionEnabled,
isConfigValid = model.isConfigValid, isConfigValid = model.isConfigValid,
onInstall = { onInstall = {
@@ -98,14 +98,14 @@ fun PatchOptionsScreenContent(
customPatches: PatchComponent?, customPatches: PatchComponent?,
onSelectCustomPatches: () -> Unit, onSelectCustomPatches: () -> Unit,
specs: List<PatchSpec>,
enabledPatchCount: Int, enabledPatchCount: Int,
isPatchEnabled: (KnownPatch) -> Boolean, isPatchEnabled: (PatchSpec) -> Boolean,
onTogglePatch: (KnownPatch, Boolean) -> Unit, onTogglePatch: (PatchSpec, Boolean) -> Unit,
patchLockState: (KnownPatch) -> PatchLock, patchLockState: (PatchSpec) -> PatchLock,
variantIndex: (KnownPatch) -> Int, variantIndex: (PatchSpec) -> Int,
onSelectVariant: (KnownPatch, Int) -> Unit, onSelectVariant: (PatchSpec, Int) -> Unit,
isSubOptionEnabled: (KnownPatch, PatchSubOption) -> Boolean, optionState: PatchOptionState,
onToggleSubOption: (KnownPatch, PatchSubOption, Boolean) -> Unit,
isConfigValid: Boolean, isConfigValid: Boolean,
onInstall: () -> Unit, onInstall: () -> Unit,
@@ -168,15 +168,15 @@ fun PatchOptionsScreenContent(
} }
PatchSelectionAccordion( PatchSelectionAccordion(
specs = specs,
enabledCount = enabledPatchCount, enabledCount = enabledPatchCount,
totalCount = KnownPatch.All.size, totalCount = specs.size,
isEnabled = isPatchEnabled, isEnabled = isPatchEnabled,
onToggle = onTogglePatch, onToggle = onTogglePatch,
lockState = patchLockState, lockState = patchLockState,
variantIndex = variantIndex, variantIndex = variantIndex,
onSelectVariant = onSelectVariant, onSelectVariant = onSelectVariant,
isSubOptionEnabled = isSubOptionEnabled, optionState = optionState,
onToggleSubOption = onToggleSubOption,
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )
@@ -0,0 +1,268 @@
package com.meowarex.rlmobile.ui.screens.patchopts
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.math.roundToInt
@Serializable
data class PatchManifest(
val version: Int = 1,
val patches: List<PatchSpec> = emptyList(),
)
@Immutable
@Serializable
data class PatchSpec(
val id: String,
val order: Int = 0,
val fileNames: List<String> = emptyList(),
val extensionFiles: List<String> = emptyList(),
val title: String,
val description: String = "",
@SerialName("default") val defaultEnabled: Boolean = false,
/** ids of patches that get force-enabled when this one is enabled. */
val requires: List<String> = emptyList(),
/** ids of patches that get force-disabled when this one is enabled. */
val disables: List<String> = emptyList(),
val variants: List<VariantSpec> = emptyList(),
val defaultVariantIndex: Int = 0,
val advancedOptions: List<OptionSpec> = emptyList(),
)
@Immutable
@Serializable
data class VariantSpec(
val title: String,
val fileNames: List<String> = emptyList(),
val extensionFiles: List<String> = emptyList(),
)
/** A single advanced option. Discriminated by `"type"` in JSON. */
@Serializable
sealed interface OptionSpec {
val key: String
val title: String
val description: String
@Immutable
@Serializable
@SerialName("toggle")
data class Toggle(
override val key: String,
override val title: String,
override val description: String = "",
val default: Boolean = false,
/** Patch files applied only while this toggle is on (and its patch is enabled). */
val fileNames: List<String> = emptyList(),
/** Helper smali extracted only while this toggle is on. */
val extensionFiles: List<String> = emptyList(),
/** Render inline beneath the variant selector instead of inside the advanced sheet. */
val inline: Boolean = false,
/** Greyed out (with the lock dialog) unless this variant index is the selected one. */
val requiresVariant: Int? = null,
/** Greyed out unless the sibling option with this key is currently on. */
val requiresOption: String? = null,
/** Variant indices hidden from the picker while this toggle is on. */
val hidesVariants: List<Int> = emptyList(),
/** Variant title overrides (index -> title) applied while this toggle is on. */
val relabelVariants: Map<Int, String> = emptyMap(),
) : OptionSpec
@Immutable
@Serializable
@SerialName("slider")
data class Slider(
override val key: String,
override val title: String,
override val description: String = "",
val default: Float = 0f,
val min: Float = 0f,
val max: Float = 100f,
val steps: Int = 0,
val displayAsPercent: Boolean = false,
val unit: String? = null,
/** Placeholder name (without the surrounding `__`) baked into the `.patch` files. */
val token: String? = null,
/** How the chosen value becomes the smali literal that replaces the token. */
val encode: SmaliEncode? = null,
) : OptionSpec {
val valueRange: ClosedFloatingPointRange<Float> get() = min..max
}
@Immutable
@Serializable
@SerialName("choice")
data class Choice(
override val key: String,
override val title: String,
override val description: String = "",
val entries: List<String> = emptyList(),
val defaultIndex: Int = 0,
) : OptionSpec
}
@Serializable
enum class EncodeKind {
@SerialName("floatBits")
FloatBits,
@SerialName("argbAlpha")
ArgbAlpha,
@SerialName("int")
IntValue,
}
@Immutable
@Serializable
data class SmaliEncode(
val kind: EncodeKind,
val scale: Float = 1f,
) {
fun encode(value: Float): String = when (kind) {
// Float dp bit-pattern: const vX, <bits>; Dp.constructor-impl(F)F
EncodeKind.FloatBits -> (value * scale).toRawBits().toString()
// ARGB int with black RGB and a percentage-derived alpha: const vX, <argb>
EncodeKind.ArgbAlpha -> ((value / 100f * 255f).roundToInt() shl 24).toString()
// Plain (optionally scaled) integer literal.
EncodeKind.IntValue -> (value * scale).roundToInt().toString()
}
}
fun builtinPatchSpecs(resolve: (Int) -> String): List<PatchSpec> =
KnownPatch.All.map { patch ->
PatchSpec(
id = patch.name,
order = patch.order,
fileNames = patch.fileNames,
extensionFiles = patch.extensionFiles,
title = resolve(patch.titleRes),
description = resolve(patch.descRes),
defaultEnabled = patch.default.isEnabled,
requires = patch.requires.map { it.name },
disables = patch.disables.map { it.name },
variants = patch.variants.map { VariantSpec(resolve(it.titleRes), it.fileNames, it.extensionFiles) },
defaultVariantIndex = patch.defaultVariantIndex,
advancedOptions = patch.advancedOptions.map { it.toSpec(resolve) },
)
}
private fun PatchOption.toSpec(resolve: (Int) -> String): OptionSpec = when (this) {
is PatchOption.Toggle -> OptionSpec.Toggle(
key = key,
title = resolve(titleRes),
description = resolve(descRes),
default = default,
fileNames = fileNames,
extensionFiles = extensionFiles,
inline = inline,
requiresVariant = requiresVariant,
requiresOption = requiresOption,
hidesVariants = hidesVariants,
relabelVariants = relabelVariants.mapValues { resolve(it.value) },
)
is PatchOption.Slider -> OptionSpec.Slider(
key = key,
title = resolve(titleRes),
description = resolve(descRes),
default = default,
min = valueRange.start,
max = valueRange.endInclusive,
steps = steps,
displayAsPercent = displayAsPercent,
unit = unitRes?.let(resolve),
token = token,
encode = encode,
)
is PatchOption.Choice -> OptionSpec.Choice(
key = key,
title = resolve(titleRes),
description = resolve(descRes),
entries = entries.map { resolve(it.labelRes) },
defaultIndex = defaultIndex,
)
}
@Immutable
data class EffectiveVariant(val originalIndex: Int, val title: String)
fun PatchSpec.effectiveVariants(isOptionOn: (OptionSpec.Toggle) -> Boolean): List<EffectiveVariant> {
if (variants.isEmpty()) return emptyList()
val hidden = mutableSetOf<Int>()
val relabel = mutableMapOf<Int, String>()
for (option in advancedOptions) {
if (option is OptionSpec.Toggle && isOptionOn(option)) {
hidden += option.hidesVariants
relabel += option.relabelVariants
}
}
return variants.mapIndexedNotNull { index, variant ->
if (index in hidden) null else EffectiveVariant(index, relabel[index] ?: variant.title)
}
}
fun PatchSpec.resolveVariantIndex(stored: Int, isOptionOn: (OptionSpec.Toggle) -> Boolean): Int {
val visible = effectiveVariants(isOptionOn)
if (visible.isEmpty() || visible.any { it.originalIndex == stored }) return stored
val relabelKeys = buildSet {
for (option in advancedOptions) {
if (option is OptionSpec.Toggle && isOptionOn(option)) addAll(option.relabelVariants.keys)
}
}
return visible.firstOrNull { it.originalIndex in relabelKeys }?.originalIndex
?: visible.first().originalIndex
}
sealed interface OptionLock {
data object Free : OptionLock
data class NeedsVariant(val title: String) : OptionLock
data class NeedsOption(val title: String) : OptionLock
}
fun PatchSpec.optionLock(
option: OptionSpec,
selectedVariant: Int,
isOptionOn: (OptionSpec.Toggle) -> Boolean,
): OptionLock {
if (option !is OptionSpec.Toggle) return OptionLock.Free
option.requiresVariant?.let { required ->
if (selectedVariant != required) {
variants.getOrNull(required)?.let { return OptionLock.NeedsVariant(it.title) }
}
}
option.requiresOption?.let { key ->
val required = advancedOptions.filterIsInstance<OptionSpec.Toggle>().firstOrNull { it.key == key }
if (required != null && !isOptionOn(required)) return OptionLock.NeedsOption(required.title)
}
return OptionLock.Free
}
@Stable
class PatchOptionState(
val toggle: (PatchSpec, OptionSpec.Toggle) -> Boolean,
val setToggle: (PatchSpec, OptionSpec.Toggle, Boolean) -> Unit,
val slider: (PatchSpec, OptionSpec.Slider) -> Float,
val setSlider: (PatchSpec, OptionSpec.Slider, Float) -> Unit,
val choice: (PatchSpec, OptionSpec.Choice) -> Int,
val setChoice: (PatchSpec, OptionSpec.Choice, Int) -> Unit,
val isModified: (PatchSpec) -> Boolean,
val reset: (PatchSpec) -> Unit,
) {
companion object {
/** Read-only stub returning defaults — for Compose previews. */
val Preview = PatchOptionState(
toggle = { _, option -> option.default },
setToggle = { _, _, _ -> },
slider = { _, option -> option.default },
setSlider = { _, _, _ -> },
choice = { _, option -> option.defaultIndex },
setChoice = { _, _, _ -> },
isModified = { false },
reset = {},
)
}
}
@@ -0,0 +1,386 @@
package com.meowarex.rlmobile.ui.screens.patchopts.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.ResetToDefaultButton
import com.meowarex.rlmobile.ui.screens.patchopts.OptionLock
import com.meowarex.rlmobile.ui.screens.patchopts.OptionSpec
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionState
import com.meowarex.rlmobile.ui.screens.patchopts.optionLock
import com.meowarex.rlmobile.ui.screens.patchopts.PatchSpec
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PatchAdvancedOptionsSheet(
patch: PatchSpec,
state: PatchOptionState,
selectedVariant: Int,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
var lockDialog by remember { mutableStateOf<OptionLock?>(null) }
fun dismiss() {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) onDismiss()
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(R.string.patchopts_advanced_sheet_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = patch.title,
style = MaterialTheme.typography.headlineSmall,
)
}
ResetToDefaultButton(
enabled = state.isModified(patch),
onClick = { state.reset(patch) },
)
}
HorizontalDivider()
val sheetOptions = patch.advancedOptions.filter { it !is OptionSpec.Toggle || !it.inline }
for (option in sheetOptions) key(option.key) {
val lock = patch.optionLock(option, selectedVariant) { state.toggle(patch, it) }
when (option) {
is OptionSpec.Toggle -> ToggleOptionRow(
title = option.title,
description = option.description,
checked = state.toggle(patch, option),
onCheckedChange = { state.setToggle(patch, option, it) },
locked = lock != OptionLock.Free,
onLockedTap = { lockDialog = lock },
)
is OptionSpec.Slider -> SliderOptionRow(
title = option.title,
description = option.description,
value = state.slider(patch, option),
valueLabel = formatSliderValue(option, state.slider(patch, option)),
valueRange = option.valueRange,
steps = option.steps,
onValueChange = { state.setSlider(patch, option, it) },
)
is OptionSpec.Choice -> ChoiceOptionRow(
title = option.title,
description = option.description,
entries = option.entries,
selectedIndex = state.choice(patch, option),
onSelect = { state.setChoice(patch, option, it) },
)
}
}
FilledTonalButton(
onClick = { dismiss() },
colors = ButtonDefaults.filledTonalButtonColors(
contentColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier
.align(Alignment.End)
.padding(top = 4.dp),
) {
Text(stringResource(R.string.action_done))
}
}
}
lockDialog?.let { lock ->
OptionLockDialog(lock = lock, onDismiss = { lockDialog = null })
}
}
@Composable
private fun ToggleOptionRow(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
locked: Boolean = false,
onLockedTap: () -> Unit = {},
) {
val interactionSource = remember(::MutableInteractionSource)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = interactionSource,
indication = null,
role = Role.Switch,
) { if (locked) onLockedTap() else onCheckedChange(!checked) }
.alpha(if (locked) 0.45f else 1f),
) {
OptionText(
title = title,
description = description,
modifier = Modifier.weight(1f),
)
Box {
Switch(
checked = checked,
enabled = !locked,
onCheckedChange = onCheckedChange,
interactionSource = interactionSource,
)
if (locked) {
Box(
modifier = Modifier
.matchParentSize()
.clickable(
interactionSource = remember(::MutableInteractionSource),
indication = null,
role = Role.Switch,
) { onLockedTap() }
)
}
}
}
}
@Composable
private fun SliderOptionRow(
title: String,
description: String,
value: Float,
valueLabel: String,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int,
onValueChange: (Float) -> Unit,
) {
// Whether the slider snaps to the step dots
var snap by rememberSaveable { mutableStateOf(true) }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Text(
text = valueLabel,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
if (steps > 0) {
IconButton(
onClick = {
snap = !snap
if (snap) onValueChange(snapToNearestStep(value, valueRange, steps))
},
modifier = Modifier.size(32.dp),
) {
Icon(
painter = painterResource(
if (snap) R.drawable.ic_lock else R.drawable.ic_lock_open,
),
contentDescription = stringResource(R.string.patchopts_toggle_snap),
tint = if (snap) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(.7f),
)
// Locked -> native stepped slider (dots + snapping), left exactly as-is
// Unlocked -> plain continuous slider with the trailing stop-indicator dot removed
if (snap) {
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
)
} else {
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
track = { SliderDefaults.Track(sliderState = it, drawStopIndicator = null) },
)
}
}
}
private fun snapToNearestStep(
value: Float,
range: ClosedFloatingPointRange<Float>,
steps: Int,
): Float {
if (steps <= 0) return value
val stepSize = (range.endInclusive - range.start) / (steps + 1)
if (stepSize <= 0f) return value
val snapped = range.start + Math.round((value - range.start) / stepSize) * stepSize
return snapped.coerceIn(range.start, range.endInclusive)
}
@Composable
private fun ChoiceOptionRow(
title: String,
description: String,
entries: List<String>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OptionText(title = title, description = description)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = index == selectedIndex,
onClick = { onSelect(index) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = entries.size),
icon = {},
label = {
Text(
text = entry,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
},
)
}
}
}
}
@Composable
private fun OptionText(
title: String,
description: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(.7f),
)
}
}
private fun formatSliderValue(option: OptionSpec.Slider, value: Float): String {
val rounded = value.roundToInt()
val unit = option.unit
return when {
option.displayAsPercent -> "$rounded%"
unit != null -> "$rounded $unit"
else -> rounded.toString()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun OptionLockDialog(lock: OptionLock, onDismiss: () -> Unit) {
val message = when (lock) {
is OptionLock.NeedsVariant -> stringResource(R.string.patch_opt_lock_needs_variant, lock.title)
is OptionLock.NeedsOption -> stringResource(R.string.patch_opt_lock_needs_option, lock.title)
OptionLock.Free -> return
}
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp,
) {
Column(
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 20.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringResource(R.string.patch_opt_lock_title),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = AnnotatedString.fromHtml(message),
style = MaterialTheme.typography.titleMedium,
)
Box(modifier = Modifier.fillMaxWidth()) {
TextButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.CenterEnd),
) {
Text(stringResource(R.string.action_got_it))
}
}
}
}
}
}
@@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -22,23 +23,26 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.patchopts.KnownPatch import com.meowarex.rlmobile.ui.screens.patchopts.OptionSpec
import com.meowarex.rlmobile.ui.screens.patchopts.PatchLock import com.meowarex.rlmobile.ui.screens.patchopts.PatchLock
import com.meowarex.rlmobile.ui.screens.patchopts.PatchSubOption import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionState
import com.meowarex.rlmobile.ui.screens.patchopts.PatchSpec
import com.meowarex.rlmobile.ui.screens.patchopts.effectiveVariants
import com.meowarex.rlmobile.ui.screens.patchopts.resolveVariantIndex
private data class LockInfo(val patch: KnownPatch, val lock: PatchLock) private data class LockInfo(val patch: PatchSpec, val lock: PatchLock)
@Composable @Composable
fun PatchSelectionAccordion( fun PatchSelectionAccordion(
specs: List<PatchSpec>,
enabledCount: Int, enabledCount: Int,
totalCount: Int, totalCount: Int,
isEnabled: (KnownPatch) -> Boolean, isEnabled: (PatchSpec) -> Boolean,
onToggle: (KnownPatch, Boolean) -> Unit, onToggle: (PatchSpec, Boolean) -> Unit,
lockState: (KnownPatch) -> PatchLock, lockState: (PatchSpec) -> PatchLock,
variantIndex: (KnownPatch) -> Int, variantIndex: (PatchSpec) -> Int,
onSelectVariant: (KnownPatch, Int) -> Unit, onSelectVariant: (PatchSpec, Int) -> Unit,
isSubOptionEnabled: (KnownPatch, PatchSubOption) -> Boolean, optionState: PatchOptionState,
onToggleSubOption: (KnownPatch, PatchSubOption, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var expanded by rememberSaveable { mutableStateOf(false) } var expanded by rememberSaveable { mutableStateOf(false) }
@@ -48,6 +52,7 @@ fun PatchSelectionAccordion(
) )
var lockInfo by remember { mutableStateOf<LockInfo?>(null) } var lockInfo by remember { mutableStateOf<LockInfo?>(null) }
var advancedFor by remember { mutableStateOf<PatchSpec?>(null) }
Column( Column(
modifier = modifier modifier = modifier
@@ -107,12 +112,12 @@ fun PatchSelectionAccordion(
.padding(bottom = 8.dp), .padding(bottom = 8.dp),
) )
for (patch in KnownPatch.All) key(patch) { for (patch in specs) key(patch.id) {
val checked = isEnabled(patch) val checked = isEnabled(patch)
val lock = lockState(patch) val lock = lockState(patch)
PatchSwitchRow( PatchSwitchRow(
title = stringResource(patch.titleRes), title = patch.title,
description = stringResource(patch.descRes), description = patch.description,
checked = checked, checked = checked,
lock = lock, lock = lock,
onCheckedChange = { onToggle(patch, it) }, onCheckedChange = { onToggle(patch, it) },
@@ -126,37 +131,62 @@ fun PatchSelectionAccordion(
.fillMaxWidth() .fillMaxWidth()
.padding(start = 4.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), .padding(start = 4.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
) { ) {
val effective = patch.effectiveVariants { optionState.toggle(patch, it) }
val resolved = patch.resolveVariantIndex(variantIndex(patch)) {
optionState.toggle(patch, it)
}
PatchVariantSelector( PatchVariantSelector(
variants = patch.variants, variants = effective,
selectedIndex = variantIndex(patch), selectedIndex = effective
onSelect = { idx -> onSelectVariant(patch, idx) }, .indexOfFirst { it.originalIndex == resolved }
.coerceAtLeast(0),
onSelect = { pos ->
effective.getOrNull(pos)?.let { onSelectVariant(patch, it.originalIndex) }
},
) )
} }
} }
} }
if (patch.subOptions.isNotEmpty()) { val inlineToggles = patch.advancedOptions
.filterIsInstance<OptionSpec.Toggle>()
.filter { it.inline }
if (inlineToggles.isNotEmpty()) {
AnimatedVisibility(visible = checked) { AnimatedVisibility(visible = checked) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), .padding(horizontal = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
for (subOption in patch.subOptions) key(subOption) { for (option in inlineToggles) key(option.key) {
PatchSwitchRow( InlineToggleRow(
title = stringResource(subOption.titleRes), title = option.title,
description = stringResource(subOption.descRes), description = option.description,
checked = isSubOptionEnabled(patch, subOption), checked = optionState.toggle(patch, option),
lock = PatchLock.Free, onCheckedChange = { optionState.setToggle(patch, option, it) },
onCheckedChange = {
onToggleSubOption(patch, subOption, it)
},
onLockedTap = {},
) )
} }
} }
} }
} }
val hasSheetOptions = patch.advancedOptions.any { it !is OptionSpec.Toggle || !it.inline }
if (hasSheetOptions) {
AnimatedVisibility(visible = checked) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
AdvancedOptionsButton(
modified = optionState.isModified(patch),
onClick = { advancedFor = patch },
)
}
}
}
} }
} }
} }
@@ -169,6 +199,84 @@ fun PatchSelectionAccordion(
onDismiss = { lockInfo = null }, onDismiss = { lockInfo = null },
) )
} }
advancedFor?.let { patch ->
PatchAdvancedOptionsSheet(
patch = patch,
state = optionState,
selectedVariant = patch.resolveVariantIndex(variantIndex(patch)) { optionState.toggle(patch, it) },
onDismiss = { advancedFor = null },
)
}
}
@Composable
private fun AdvancedOptionsButton(
modified: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FilledTonalButton(
onClick = onClick,
modifier = modifier,
) {
Icon(
painter = painterResource(R.drawable.ic_tune),
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.patchopts_advanced_button))
if (modified) {
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
}
@Composable
private fun InlineToggleRow(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
val interactionSource = remember(::MutableInteractionSource)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = interactionSource,
indication = null,
role = Role.Switch,
) { onCheckedChange(!checked) }
.padding(vertical = 4.dp),
) {
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),
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
interactionSource = interactionSource,
)
}
} }
@Composable @Composable
@@ -238,7 +346,7 @@ private fun PatchSwitchRow(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun PatchLockDialog( private fun PatchLockDialog(
thisPatch: KnownPatch, thisPatch: PatchSpec,
lock: PatchLock, lock: PatchLock,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@@ -246,12 +354,12 @@ private fun PatchLockDialog(
is PatchLock.LockedOn -> Triple( is PatchLock.LockedOn -> Triple(
R.string.patch_lock_required_title, R.string.patch_lock_required_title,
R.string.patch_lock_required_msg, R.string.patch_lock_required_msg,
stringResource(lock.by.titleRes), lock.by.title,
) )
is PatchLock.LockedOff -> Triple( is PatchLock.LockedOff -> Triple(
R.string.patch_lock_blocked_title, R.string.patch_lock_blocked_title,
R.string.patch_lock_blocked_msg, R.string.patch_lock_blocked_msg,
stringResource(lock.by.titleRes), lock.by.title,
) )
PatchLock.Free -> return PatchLock.Free -> return
} }
@@ -4,15 +4,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import com.meowarex.rlmobile.ui.screens.patchopts.PatchVariant import com.meowarex.rlmobile.ui.screens.patchopts.EffectiveVariant
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PatchVariantSelector( fun PatchVariantSelector(
variants: List<PatchVariant>, variants: List<EffectiveVariant>,
selectedIndex: Int, selectedIndex: Int,
onSelect: (Int) -> Unit, onSelect: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -31,7 +30,7 @@ fun PatchVariantSelector(
icon = {}, icon = {},
label = { label = {
Text( Text(
text = stringResource(variant.titleRes), text = variant.title,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#e8eaed"
android:pathData="M18,8h-1V6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2H6c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V10c0,-1.1 -0.9,-2 -2,-2zM12,17c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM15.1,8H8.9V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#e8eaed"
android:pathData="M18,8h-1V6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1s3.1,1.39 3.1,3.1v2H6c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V10c0,-1.1 -0.9,-2 -2,-2zM18,20H6V10h12v10zM12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#e8eaed"
android:pathData="M3,17v2h6v-2H3zM3,5v2h10V5H3zM13,21v-2h8v-2h-8v-2h-2v6h2zM7,9v2H3v2h4v2h2V9H7zM21,13v-2H11v2h10zM15,7h2V5h4V3h-4V1h-2v6z" />
</vector>
@@ -22,6 +22,7 @@
<string name="action_retry_install">Retry installation</string> <string name="action_retry_install">Retry installation</string>
<string name="action_open_error_log">Open error log</string> <string name="action_open_error_log">Open error log</string>
<string name="action_apply">Apply</string> <string name="action_apply">Apply</string>
<string name="action_done">Done</string>
<string name="action_confirm">Confirm</string> <string name="action_confirm">Confirm</string>
<string name="action_dismiss">Dismiss</string> <string name="action_dismiss">Dismiss</string>
<string name="action_install">Install</string> <string name="action_install">Install</string>
@@ -251,6 +252,9 @@
<string name="patchopts_patches_title">Patches</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_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="patchopts_patches_summary">%1$d of %2$d enabled</string>
<string name="patchopts_advanced_button">Advanced Options</string>
<string name="patchopts_advanced_sheet_label">Advanced Options</string>
<string name="patchopts_toggle_snap">Toggle snapping to steps</string>
<string name="patch_lyrics_disable_cover_title">Disable Lyrics Cover</string> <string name="patch_lyrics_disable_cover_title">Disable Lyrics Cover</string>
<string name="patch_lyrics_disable_cover_desc">Removes the mini track cover from the lyrics screen.</string> <string name="patch_lyrics_disable_cover_desc">Removes the mini track cover from the lyrics screen.</string>
@@ -298,11 +302,23 @@
<string name="patch_mini_player_variant_floating_title">Floating</string> <string name="patch_mini_player_variant_floating_title">Floating</string>
<string name="patch_mini_player_variant_square_grey_title">Grey</string> <string name="patch_mini_player_variant_square_grey_title">Grey</string>
<string name="patch_mini_player_variant_square_black_title">Black</string> <string name="patch_mini_player_variant_square_black_title">Black</string>
<string name="patch_mini_player_variant_legacy_title">Legacy</string>
<string name="patch_mini_player_border_title">Progress Border</string>
<string name="patch_mini_player_border_desc">Draws an animated progress ring around the floating mini-player. Requires the Floating style.</string>
<!-- Advanced option labels -->
<string name="patch_opt_backdrop_blur_title">Blur Strength</string>
<string name="patch_opt_backdrop_blur_desc">How strongly the album art behind the player is blurred.</string>
<string name="patch_opt_backdrop_dimming_title">Backdrop Dimming</string>
<string name="patch_opt_backdrop_dimming_desc">Adjusts the level of darkening on the backdrop to keep foreground controls legible.</string>
<string name="patch_lock_required_title">Patch Required!</string> <string name="patch_lock_required_title">Patch Required!</string>
<string name="patch_lock_blocked_title">Patch Blocked!</string> <string name="patch_lock_blocked_title">Patch Blocked!</string>
<string name="patch_lock_required_msg">Patch Required by &lt;b>%s&lt;/b>.</string> <string name="patch_lock_required_msg">Patch Required by &lt;b>%s&lt;/b>.</string>
<string name="patch_lock_blocked_msg">Patch Blocked by &lt;b>%s&lt;/b>.</string> <string name="patch_lock_blocked_msg">Patch Blocked by &lt;b>%s&lt;/b>.</string>
<string name="patch_opt_lock_title">Option Locked</string>
<string name="patch_opt_lock_needs_variant">Requires the &lt;b>%s&lt;/b> style.</string>
<string name="patch_opt_lock_needs_option">Requires the &lt;b>%s&lt;/b> option.</string>
<string name="action_got_it">Got it</string> <string name="action_got_it">Got it</string>
<string name="componentopts_screen_title">Custom Component (%s)</string> <string name="componentopts_screen_title">Custom Component (%s)</string>
+212
View File
@@ -0,0 +1,212 @@
{
"version": 1,
"patches": [
{
"id": "EnableLegacyUi",
"order": 10,
"fileNames": ["enable-legacy-ui.patch"],
"title": "Enable Legacy UI",
"description": "[This patch will stop working soon] - Replaces the New Compose based UI with the Legacy UI (disables player-market-ui feature flag)",
"default": false,
"requires": ["DebugMenuUnlock"],
"disables": [
"LyricsDisableCover",
"LyricsReplaceLyricsButton",
"LyricsReplaceShareButton",
"LyricsRlApi",
"LyricsKeepControlsVisible",
"PlayerBackdrop",
"QualityBadgeColors",
"LyricsProgressPill",
"CoverEverywhere",
"MiniPlayerRedesign"
]
},
{
"id": "LyricsRlApi",
"order": 20,
"fileNames": ["lyrics-rl-api.patch", "lyrics-rl-api-observer.patch"],
"title": "Radiant Lyrics API - WIP",
"description": "Use Radiant Lyrics API to fetch Lyrics (Higher quality & More providers)",
"default": false
},
{
"id": "PlayerBackdrop",
"order": 30,
"fileNames": ["player-backdrop.patch"],
"title": "Player Backdrop",
"description": "Restores the legacy translucent backdrop blur behind the player.",
"default": true,
"advancedOptions": [
{
"type": "slider",
"key": "blur_strength",
"title": "Blur Strength",
"description": "How strongly the album art behind the player is blurred. 0% is sharp, 50% matches the original look.",
"default": 50,
"min": 0,
"max": 100,
"steps": 19,
"displayAsPercent": true,
"token": "RL_BLUR_BITS",
"encode": { "kind": "floatBits", "scale": 1.8 }
},
{
"type": "slider",
"key": "dimming",
"title": "Backdrop Dimming",
"description": "Darkens the backdrop so the foreground controls stay legible.",
"default": 50,
"min": 0,
"max": 100,
"steps": 19,
"displayAsPercent": true,
"token": "RL_SCRIM_ARGB",
"encode": { "kind": "argbAlpha" }
}
]
},
{
"id": "CoverEverywhere",
"order": 35,
"fileNames": ["home-backdrop.patch", "collection-backdrop.patch", "cover-capture.patch"],
"title": "Cover Everywhere - WIP",
"description": "Applies the blurred backdrop to every Compose based page (Home & Collection)",
"default": false
},
{
"id": "QualityBadgeColors",
"order": 36,
"fileNames": ["player-quality-badge-colors.patch"],
"title": "Quality Badge Colors",
"description": "Brings color back to the player quality badge.",
"default": true
},
{
"id": "PlayerOneHanded",
"order": 37,
"fileNames": ["player-one-handed.patch"],
"title": "One-Handed Mode",
"description": "Mirrors the bottom player buttons left-to-right for easier one-handed reach.",
"default": false
},
{
"id": "LyricsProgressPill",
"order": 40,
"fileNames": ["lyrics-progress-pill.patch", "lyrics-fade-region.patch"],
"title": "Lyrics Progress Pill",
"description": "Restores the old Track Progress Pill in the top of the Lyrics screen!",
"default": true,
"requires": ["LyricsDisableCover", "LyricsReplaceLyricsButton", "LyricsReplaceShareButton"]
},
{
"id": "LyricsDisableCover",
"order": 41,
"fileNames": ["lyrics-disable-cover.patch"],
"title": "Disable Lyrics Cover",
"description": "Removes the mini track cover from the lyrics screen.",
"default": true
},
{
"id": "LyricsReplaceLyricsButton",
"order": 42,
"fileNames": ["lyrics-replace-lyrics-button.patch", "lyrics-sparkle-conditional-visibility.patch"],
"title": "Replace Lyrics Button",
"description": "Replaces the Lyrics button with the RL Sparkle!",
"default": true
},
{
"id": "LyricsReplaceShareButton",
"order": 43,
"fileNames": ["lyrics-replace-share-button.patch"],
"title": "Replace Share Button",
"description": "Replaces the share button with the Tidal Connect button from the top of the screen. (it's in the menu already)",
"default": true
},
{
"id": "MiniPlayerRedesign",
"order": 50,
"fileNames": [],
"title": "Redesigned Mini-Player",
"description": "Integrates the Seekbar into the control panel at the bottom of the screen.",
"default": false,
"defaultVariantIndex": 2,
"variants": [
{ "title": "Floating", "fileNames": [] },
{ "title": "Grey", "fileNames": ["mini-player-grey.patch"] },
{ "title": "Black", "fileNames": ["mini-player-black.patch"] }
],
"advancedOptions": [
{
"type": "toggle",
"key": "dynamic_bg",
"title": "Mini-Player Dynamic Background",
"description": "Colorizes the Mini-Player depending on the currently playing track.",
"default": false,
"inline": true,
"fileNames": ["mini-player-dynamic-bg.patch"],
"extensionFiles": ["radiant/MiniPlayerBackground.smali"],
"hidesVariants": [2],
"relabelVariants": { "1": "Legacy" }
},
{
"type": "toggle",
"key": "border",
"title": "Progress Border",
"description": "Draws an animated progress ring around the floating mini-player. Requires the Floating style.",
"default": false,
"requiresVariant": 0,
"fileNames": ["mini-player-floating-border.patch"],
"extensionFiles": ["radiant/MiniSeekerFloating.smali"]
},
{
"type": "toggle",
"key": "gestures",
"title": "Mini-Player Gestures",
"description": "Swipe up on the mini-player to open the full player.",
"default": true,
"fileNames": ["mini-player-gestures.patch"],
"extensionFiles": [
"radiant/MiniPlayerGestures.smali",
"radiant/MiniPlayerGestures$Gesture.smali",
"radiant/MiniPlayerGestures$RootGesture.smali",
"radiant/MiniPlayerGestures$ApplyPending.smali"
]
},
{
"type": "toggle",
"key": "next_prev",
"title": "Next/Previous Gestures",
"description": "Swipe left or right on the mini-player to skip tracks.",
"default": false,
"requiresOption": "gestures",
"fileNames": ["mini-player-gestures-left-right.patch"],
"extensionFiles": [
"radiant/MiniPlayerTrackGestures.smali",
"radiant/MiniPlayerTrackGestures$Gesture.smali",
"radiant/MiniPlayerTrackGestures$OffsetLayer.smali",
"radiant/MiniPlayerTrackGestures$ResetAnimator.smali",
"radiant/MiniPlayerTrackGestures$TextDraw.smali",
"com/tidal/android/feature/appscaffold/ui/q$c.smali"
]
}
]
},
{
"id": "LyricsKeepControlsVisible",
"order": 60,
"fileNames": ["lyrics-keep-controls-visible.patch"],
"title": "Keep Controls Visible",
"description": "Inverts the auto-hide behavior on the lyrics screen, playback controls stay visible by default and only hide when you tap them off.",
"default": true
},
{
"id": "DebugMenuUnlock",
"order": 100,
"fileNames": ["debug-menu-unlock.patch"],
"title": "Unlock Debug Menu",
"description": "Reveals TIDAL's hidden Debug Menu in Settings. (Unlocks Feature Flags & the legacy Debug Options)",
"default": false
}
]
}
+2 -2
View File
@@ -70,7 +70,7 @@
+ +
+ move-result-object v5 # filled modifier + move-result-object v5 # filled modifier
+ +
+ const/high16 v7, 0x42b40000 # 90f blur dp + const v7, __RL_BLUR_BITS__ # blur dp bits (Advanced Options: Blur Strength)
+ +
+ invoke-static {v7}, Landroidx/compose/ui/unit/Dp;->constructor-impl(F)F # to Dp + invoke-static {v7}, Landroidx/compose/ui/unit/Dp;->constructor-impl(F)F # to Dp
+ +
@@ -126,7 +126,7 @@
+ +
+ move-result-object v3 # fullscreen modifier + move-result-object v3 # fullscreen modifier
+ +
+ const v6, -0x80000000 # 50% black ARGB + const v6, __RL_SCRIM_ARGB__ # scrim ARGB (Advanced Options: Backdrop Dimming)
+ +
+ invoke-static {v6}, Landroidx/compose/ui/graphics/ColorKt;->Color(I)J # pack color long + invoke-static {v6}, Landroidx/compose/ui/graphics/ColorKt;->Color(I)J # pack color long
+ +