diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt index 8e4361e..e97e626 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/patcher/steps/patch/SmaliPatchStep.kt @@ -95,7 +95,19 @@ class SmaliPatchStep( val entry = zip.openEntry(patchFile) ?: throw FileNotFoundException("Missing zip entry: $patchFile") out.canonicalFile.parentFile?.mkdirs() - out.writeBytes(entry.read()) + var smaliText = entry.read() + .decodeToString() + .replace("\r\n", "\n") + for ((token, value) in substitutions) { + if (smaliText.contains(token)) { + smaliText = smaliText.replace(token, value) + container.log("Applied substitution $token -> $value in $patchFile") + } + } + UNRESOLVED_TOKEN.find(smaliText)?.let { match -> + throw Error("Unresolved option placeholder ${match.value} in $patchFile") + } + out.writeText(smaliText) container.log("Extracted extension smali: $relative") continue } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/KnownPatch.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/KnownPatch.kt index fce43e7..5bb3193 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/KnownPatch.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/KnownPatch.kt @@ -155,6 +155,12 @@ enum class KnownPatch( defaultIndex = 0, token = "RL_WAZE_ROOT_ID", ), + PatchOption.Color( + key = "accent_color", + titleRes = R.string.patch_waze_accent_color_title, + default = -16747037, // Waze default #ff0075e3 + token = "RL_WAZE_THEME_COLOR", + ), ), ), LyricsProgressPill( diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOption.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOption.kt index 78d895b..9fcabf2 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOption.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOption.kt @@ -65,6 +65,14 @@ sealed interface PatchOption { val requiresOption: String? = null, val token: String? = null, ) : PatchOption + + data class Color( + override val key: String, + @StringRes override val titleRes: Int, + @StringRes override val descRes: Int = 0, + val default: Int, + val token: String? = null, + ) : PatchOption } data class ChoiceEntry( diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt index 96a1292..243a9d0 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptions.kt @@ -56,6 +56,9 @@ data class PatchOptions( (optionInts["${spec.id}/${option.key}"] ?: option.defaultIndex) .coerceIn(0, option.entries.lastIndex.coerceAtLeast(0)) + fun colorValue(spec: PatchSpec, option: OptionSpec.Color): Int = + optionInts["${spec.id}/${option.key}"] ?: option.default + fun isToggleOn(spec: PatchSpec, option: OptionSpec.Toggle): Boolean = optionBools["${spec.id}/${option.key}"] ?: option.default @@ -135,6 +138,10 @@ data class PatchOptions( val index = if (gatedOff) 0 else choiceIndex(spec, option) put("__${token}__", option.values.getOrNull(index) ?: continue) } + is OptionSpec.Color -> { + val token = option.token ?: continue + put("__${token}__", colorValue(spec, option).toString()) + } } } } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt index 3f6462e..8065d4e 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchOptionsModel.kt @@ -153,11 +153,19 @@ class PatchOptionsModel( optionInts = optionInts + (keyOf(spec, option) to index) } + fun colorValue(spec: PatchSpec, option: OptionSpec.Color): Int = + optionInts[keyOf(spec, option)] ?: option.default + + fun setColorValue(spec: PatchSpec, option: OptionSpec.Color, value: Int) { + optionInts = optionInts + (keyOf(spec, option) to value) + } + 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 + is OptionSpec.Color -> colorValue(spec, option) != option.default } } @@ -175,6 +183,8 @@ class PatchOptionsModel( setSlider = ::setSliderValue, choice = ::choiceValue, setChoice = ::setChoiceValue, + color = ::colorValue, + setColor = ::setColorValue, isModified = ::isAdvancedModified, reset = ::resetAdvanced, ) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt index c0bca4c..12bb65e 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/PatchSpec.kt @@ -106,6 +106,18 @@ sealed interface OptionSpec { val requiresOption: String? = null, val token: String? = null, ) : OptionSpec + + @Immutable + @Serializable + @SerialName("color") + data class Color( + override val key: String, + override val title: String = "", + override val description: String = "", + val default: Int = 0, + /** Placeholder name (without the surrounding `__`) baked into the `.patch`/extension files. */ + val token: String? = null, + ) : OptionSpec } @Serializable @@ -194,6 +206,14 @@ private fun PatchOption.toSpec(resolve: (Int) -> String): OptionSpec = when (thi requiresOption = requiresOption, token = token, ) + + is PatchOption.Color -> OptionSpec.Color( + key = key, + title = resolve(titleRes), + description = if (descRes != 0) resolve(descRes) else "", + default = default, + token = token, + ) } @Immutable @@ -258,6 +278,8 @@ class PatchOptionState( val setSlider: (PatchSpec, OptionSpec.Slider, Float) -> Unit, val choice: (PatchSpec, OptionSpec.Choice) -> Int, val setChoice: (PatchSpec, OptionSpec.Choice, Int) -> Unit, + val color: (PatchSpec, OptionSpec.Color) -> Int, + val setColor: (PatchSpec, OptionSpec.Color, Int) -> Unit, val isModified: (PatchSpec) -> Boolean, val reset: (PatchSpec) -> Unit, ) { @@ -270,6 +292,8 @@ class PatchOptionState( setSlider = { _, _, _ -> }, choice = { _, option -> option.defaultIndex }, setChoice = { _, _, _ -> }, + color = { _, option -> option.default }, + setColor = { _, _, _ -> }, isModified = { false }, reset = {}, ) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchAdvancedOptionsSheet.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchAdvancedOptionsSheet.kt index f21d4d5..20e1ee7 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchAdvancedOptionsSheet.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/patchopts/components/PatchAdvancedOptionsSheet.kt @@ -7,6 +7,8 @@ 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.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* @@ -15,11 +17,15 @@ 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.graphics.Color +import androidx.compose.ui.graphics.toArgb 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.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -196,6 +202,12 @@ fun PatchAdvancedOptionsSheet( onSelect = { state.setChoice(patch, option, it) }, ) } + + is OptionSpec.Color -> ColorOptionRow( + title = option.title, + color = state.color(patch, option), + onColorChange = { state.setColor(patch, option, it) }, + ) } } @@ -433,6 +445,150 @@ private fun ChoiceOptionRow( } } +@Composable +private fun ColorOptionRow( + title: String, + color: Int, + onColorChange: (Int) -> Unit, +) { + var showPicker by rememberSaveable { mutableStateOf(false) } + val selectedColor = color.withOpaqueAlpha() + val materialYouColor = MaterialTheme.colorScheme.primary.toArgb().withOpaqueAlpha() + val presets = listOf( + stringResource(R.string.patch_waze_color_waze_default), + stringResource(R.string.patch_waze_color_tidal_cyan), + stringResource(R.string.patch_waze_color_material_you), + ) + val presetColors = listOf(WAZE_DEFAULT, TIDAL_CYAN, materialYouColor) + val selectedPreset = presetColors.indexOf(selectedColor) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable { showPicker = true } + .padding(vertical = 4.dp), + ) { + ColorSwatch(color = selectedColor) + Text( + text = selectedColor.toRgbHex(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + presets.forEachIndexed { index, label -> + SegmentedButton( + selected = index == selectedPreset, + onClick = { onColorChange(presetColors[index]) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = presets.size), + icon = {}, + label = { + Text( + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + ) + }, + ) + } + } + } + + if (showPicker) { + ColorPickerDialog( + initialColor = selectedColor, + onDismiss = { showPicker = false }, + onColorSelected = { + onColorChange(it) + showPicker = false + }, + ) + } +} + +@Composable +private fun ColorSwatch(color: Int, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(32.dp) + .clip(CircleShape) + .background(Color(color)) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape), + ) +} + +@Composable +private fun ColorPickerDialog( + initialColor: Int, + onDismiss: () -> Unit, + onColorSelected: (Int) -> Unit, +) { + var color by rememberSaveable(initialColor) { mutableIntStateOf(initialColor.withOpaqueAlpha()) } + var hex by rememberSaveable(initialColor) { mutableStateOf(color.toRgbHex()) } + val parsedHex = parseRgbHex(hex) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.patch_color_picker_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ColorSwatch(color = parsedHex ?: color, modifier = Modifier.size(40.dp)) + OutlinedTextField( + value = hex, + onValueChange = { value -> + hex = value + parseRgbHex(value)?.let { color = it } + }, + label = { Text(stringResource(R.string.patch_color_picker_hex)) }, + singleLine = true, + isError = parsedHex == null, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + keyboardType = KeyboardType.Ascii, + ), + supportingText = if (parsedHex == null) { + { Text(stringResource(R.string.patch_color_picker_hex_error)) } + } else null, + modifier = Modifier.weight(1f), + ) + } + } + }, + confirmButton = { + TextButton( + enabled = parsedHex != null, + onClick = { parsedHex?.let(onColorSelected) }, + ) { + Text(stringResource(R.string.action_done)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + @Composable private fun OptionText( title: String, @@ -465,6 +621,26 @@ private fun formatSliderValue(option: OptionSpec.Slider, value: Float): String { } } +private const val OPAQUE_ALPHA = -0x1000000 +private const val WAZE_DEFAULT = -16747037 // #ff0075e3 +private const val TIDAL_CYAN = -14549268 // #ff21feec + +private fun Int.withOpaqueAlpha(): Int = (this and 0x00FFFFFF) or OPAQUE_ALPHA + +private fun Int.toRgbHex(): String = "#" + + (this and 0x00FFFFFF).toString(16).uppercase().padStart(6, '0') + +private fun parseRgbHex(value: String): Int? { + val clean = value.trim() + .removePrefix("#") + .removePrefix("0x") + .removePrefix("0X") + if (clean.length != 6 && clean.length != 8) return null + val parsed = clean.toLongOrNull(16) ?: return null + val rgb = if (clean.length == 8) parsed and 0x00FFFFFFL else parsed + return (0xFF000000L or rgb).toInt() +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun OptionLockDialog(lock: OptionLock, onDismiss: () -> Unit) { diff --git a/Manager/app/src/main/res/values/strings.xml b/Manager/app/src/main/res/values/strings.xml index 3241b18..f1aa74a 100644 --- a/Manager/app/src/main/res/values/strings.xml +++ b/Manager/app/src/main/res/values/strings.xml @@ -289,9 +289,16 @@ Waze Integration Control TIDAL playback from the Waze app Media Menu + Accent Color + Waze Default + TIDAL Cyan + Material You Auto Menu Home Recents + Custom color + Hex color + Use #RRGGBB or #AARRGGBB. Enable Legacy UI putInt(Ljava/lang/String;I)V diff --git a/patches/manifest.json b/patches/manifest.json index 4b81738..23bfda2 100644 --- a/patches/manifest.json +++ b/patches/manifest.json @@ -233,6 +233,13 @@ ], "defaultIndex": 0, "token": "RL_WAZE_ROOT_ID" + }, + { + "type": "color", + "key": "accent_color", + "title": "Accent Color", + "default": -16747037, + "token": "RL_WAZE_THEME_COLOR" } ] },