feat(waze patch): customizable themeColor

This commit is contained in:
Se7en-dev
2026-07-02 22:56:09 +02:00
parent 03271bf26d
commit 50b99b6193
10 changed files with 259 additions and 2 deletions
@@ -95,7 +95,19 @@ class SmaliPatchStep(
val entry = zip.openEntry(patchFile) val entry = zip.openEntry(patchFile)
?: throw FileNotFoundException("Missing zip entry: $patchFile") ?: throw FileNotFoundException("Missing zip entry: $patchFile")
out.canonicalFile.parentFile?.mkdirs() 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") container.log("Extracted extension smali: $relative")
continue continue
} }
@@ -155,6 +155,12 @@ enum class KnownPatch(
defaultIndex = 0, defaultIndex = 0,
token = "RL_WAZE_ROOT_ID", 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( LyricsProgressPill(
@@ -65,6 +65,14 @@ sealed interface PatchOption {
val requiresOption: String? = null, val requiresOption: String? = null,
val token: String? = null, val token: String? = null,
) : PatchOption ) : 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( data class ChoiceEntry(
@@ -56,6 +56,9 @@ data class PatchOptions(
(optionInts["${spec.id}/${option.key}"] ?: option.defaultIndex) (optionInts["${spec.id}/${option.key}"] ?: option.defaultIndex)
.coerceIn(0, option.entries.lastIndex.coerceAtLeast(0)) .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 = fun isToggleOn(spec: PatchSpec, option: OptionSpec.Toggle): Boolean =
optionBools["${spec.id}/${option.key}"] ?: option.default optionBools["${spec.id}/${option.key}"] ?: option.default
@@ -135,6 +138,10 @@ data class PatchOptions(
val index = if (gatedOff) 0 else choiceIndex(spec, option) val index = if (gatedOff) 0 else choiceIndex(spec, option)
put("__${token}__", option.values.getOrNull(index) ?: continue) put("__${token}__", option.values.getOrNull(index) ?: continue)
} }
is OptionSpec.Color -> {
val token = option.token ?: continue
put("__${token}__", colorValue(spec, option).toString())
}
} }
} }
} }
@@ -153,11 +153,19 @@ class PatchOptionsModel(
optionInts = optionInts + (keyOf(spec, option) to index) 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 -> fun isAdvancedModified(spec: PatchSpec): Boolean = spec.advancedOptions.any { option ->
when (option) { when (option) {
is OptionSpec.Toggle -> toggleValue(spec, option) != option.default is OptionSpec.Toggle -> toggleValue(spec, option) != option.default
is OptionSpec.Slider -> sliderValue(spec, option) != option.default is OptionSpec.Slider -> sliderValue(spec, option) != option.default
is OptionSpec.Choice -> choiceValue(spec, option) != option.defaultIndex is OptionSpec.Choice -> choiceValue(spec, option) != option.defaultIndex
is OptionSpec.Color -> colorValue(spec, option) != option.default
} }
} }
@@ -175,6 +183,8 @@ class PatchOptionsModel(
setSlider = ::setSliderValue, setSlider = ::setSliderValue,
choice = ::choiceValue, choice = ::choiceValue,
setChoice = ::setChoiceValue, setChoice = ::setChoiceValue,
color = ::colorValue,
setColor = ::setColorValue,
isModified = ::isAdvancedModified, isModified = ::isAdvancedModified,
reset = ::resetAdvanced, reset = ::resetAdvanced,
) )
@@ -106,6 +106,18 @@ sealed interface OptionSpec {
val requiresOption: String? = null, val requiresOption: String? = null,
val token: String? = null, val token: String? = null,
) : OptionSpec ) : 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 @Serializable
@@ -194,6 +206,14 @@ private fun PatchOption.toSpec(resolve: (Int) -> String): OptionSpec = when (thi
requiresOption = requiresOption, requiresOption = requiresOption,
token = token, token = token,
) )
is PatchOption.Color -> OptionSpec.Color(
key = key,
title = resolve(titleRes),
description = if (descRes != 0) resolve(descRes) else "",
default = default,
token = token,
)
} }
@Immutable @Immutable
@@ -258,6 +278,8 @@ class PatchOptionState(
val setSlider: (PatchSpec, OptionSpec.Slider, Float) -> Unit, val setSlider: (PatchSpec, OptionSpec.Slider, Float) -> Unit,
val choice: (PatchSpec, OptionSpec.Choice) -> Int, val choice: (PatchSpec, OptionSpec.Choice) -> Int,
val setChoice: (PatchSpec, OptionSpec.Choice, Int) -> Unit, 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 isModified: (PatchSpec) -> Boolean,
val reset: (PatchSpec) -> Unit, val reset: (PatchSpec) -> Unit,
) { ) {
@@ -270,6 +292,8 @@ class PatchOptionState(
setSlider = { _, _, _ -> }, setSlider = { _, _, _ -> },
choice = { _, option -> option.defaultIndex }, choice = { _, option -> option.defaultIndex },
setChoice = { _, _, _ -> }, setChoice = { _, _, _ -> },
color = { _, option -> option.default },
setColor = { _, _, _ -> },
isModified = { false }, isModified = { false },
reset = {}, reset = {},
) )
@@ -7,6 +7,8 @@ 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.rememberScrollState 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.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -15,11 +17,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip 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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -196,6 +202,12 @@ fun PatchAdvancedOptionsSheet(
onSelect = { state.setChoice(patch, option, it) }, 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 @Composable
private fun OptionText( private fun OptionText(
title: String, 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun OptionLockDialog(lock: OptionLock, onDismiss: () -> Unit) { private fun OptionLockDialog(lock: OptionLock, onDismiss: () -> Unit) {
@@ -289,9 +289,16 @@
<string name="patch_waze_integration_title">Waze Integration</string> <string name="patch_waze_integration_title">Waze Integration</string>
<string name="patch_waze_integration_desc">Control TIDAL playback from the Waze app</string> <string name="patch_waze_integration_desc">Control TIDAL playback from the Waze app</string>
<string name="patch_waze_browse_root_title">Media Menu</string> <string name="patch_waze_browse_root_title">Media Menu</string>
<string name="patch_waze_accent_color_title">Accent Color</string>
<string name="patch_waze_color_waze_default">Waze Default</string>
<string name="patch_waze_color_tidal_cyan">TIDAL Cyan</string>
<string name="patch_waze_color_material_you">Material You</string>
<string name="patch_waze_root_auto">Auto Menu</string> <string name="patch_waze_root_auto">Auto Menu</string>
<string name="patch_waze_root_home">Home</string> <string name="patch_waze_root_home">Home</string>
<string name="patch_waze_root_recents">Recents</string> <string name="patch_waze_root_recents">Recents</string>
<string name="patch_color_picker_title">Custom color</string>
<string name="patch_color_picker_hex">Hex color</string>
<string name="patch_color_picker_hex_error">Use #RRGGBB or #AARRGGBB.</string>
<string name="patch_enable_legacy_ui_title">Enable Legacy UI</string> <string name="patch_enable_legacy_ui_title">Enable Legacy UI</string>
<string <string
name="patch_enable_legacy_ui_desc" name="patch_enable_legacy_ui_desc"
@@ -97,7 +97,7 @@
const-string v1, "themeColor" const-string v1, "themeColor"
const v2, -0xde0114 # TIDAL cyan #ff21feec const v2, __RL_WAZE_THEME_COLOR__ # Waze SDK themeColor
invoke-virtual {v0, v1, v2}, Landroid/os/Bundle;->putInt(Ljava/lang/String;I)V invoke-virtual {v0, v1, v2}, Landroid/os/Bundle;->putInt(Ljava/lang/String;I)V
+7
View File
@@ -233,6 +233,13 @@
], ],
"defaultIndex": 0, "defaultIndex": 0,
"token": "RL_WAZE_ROOT_ID" "token": "RL_WAZE_ROOT_ID"
},
{
"type": "color",
"key": "accent_color",
"title": "Accent Color",
"default": -16747037,
"token": "RL_WAZE_THEME_COLOR"
} }
] ]
}, },