Skip to content

Commit 7073291

Browse files
authored
R568: harden custom accent preference schema and migration safety (#598)
Co-authored-by: ryacub <ryacub@users.noreply.github.com>
1 parent e068ca7 commit 7073291

2 files changed

Lines changed: 282 additions & 4 deletions

File tree

app/src/main/java/eu/kanade/domain/ui/UiPreferences.kt

Lines changed: 177 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ import eu.kanade.domain.ui.model.TabletUiMode
77
import eu.kanade.domain.ui.model.ThemeMode
88
import eu.kanade.tachiyomi.util.system.DeviceUtil
99
import eu.kanade.tachiyomi.util.system.isDynamicColorAvailable
10+
import kotlinx.coroutines.CoroutineScope
11+
import kotlinx.coroutines.flow.Flow
12+
import kotlinx.coroutines.flow.SharingStarted
13+
import kotlinx.coroutines.flow.StateFlow
14+
import kotlinx.coroutines.flow.distinctUntilChanged
15+
import kotlinx.coroutines.flow.map
16+
import kotlinx.coroutines.flow.merge
17+
import kotlinx.coroutines.flow.stateIn
18+
import tachiyomi.core.common.preference.Preference
1019
import tachiyomi.core.common.preference.PreferenceStore
1120
import tachiyomi.core.common.preference.getEnum
1221
import java.time.format.DateTimeFormatter
@@ -19,9 +28,12 @@ class UiPreferences(
1928

2029
companion object {
2130
const val CUSTOM_THEME_ACCENT_SEED_UNSET = Int.MIN_VALUE
31+
private const val PREF_CUSTOM_THEME_ACCENT_SEED = "pref_custom_theme_accent_seed"
32+
private const val PREF_CUSTOM_THEME_ACCENT_SCHEMA = "pref_custom_theme_accent_schema"
2233
private const val PREF_CUSTOM_THEME_RECENT_ACCENT_SEEDS = "pref_custom_theme_recent_accent_seeds"
2334
private const val MAX_RECENT_CUSTOM_THEME_ACCENT_SEEDS = 5
2435
private const val OPAQUE_ALPHA_MASK = 0xFF000000.toInt()
36+
private const val CUSTOM_THEME_ACCENT_SCHEMA_VERSION = 1
2537
fun dateFormat(format: String): DateTimeFormatter = when (format) {
2638
"" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
2739
else -> DateTimeFormatter.ofPattern(format, Locale.getDefault())
@@ -37,6 +49,14 @@ class UiPreferences(
3749
.toList()
3850
}
3951

52+
private fun normalizeAccentSeed(seed: Int): Int {
53+
return if (seed == CUSTOM_THEME_ACCENT_SEED_UNSET) {
54+
CUSTOM_THEME_ACCENT_SEED_UNSET
55+
} else {
56+
seed or OPAQUE_ALPHA_MASK
57+
}
58+
}
59+
4060
internal fun upsertRecentAccentSeed(
4161
existing: List<Int>,
4262
appliedSeed: Int,
@@ -47,6 +67,8 @@ class UiPreferences(
4767
}
4868
}
4969

70+
private val customThemeAccentSeedPreference by lazy { CustomThemeAccentSeedPreference() }
71+
5072
fun themeMode() = preferenceStore.getEnum("pref_theme_mode_key", ThemeMode.SYSTEM)
5173

5274
fun appTheme() = preferenceStore.getEnum(
@@ -60,10 +82,7 @@ class UiPreferences(
6082

6183
fun themeDarkAmoled() = preferenceStore.getBoolean("pref_theme_dark_amoled_key", false)
6284
fun dynamicEntryCoverTheming() = preferenceStore.getBoolean("pref_dynamic_entry_cover_theming", false)
63-
fun customThemeAccentSeed() = preferenceStore.getInt(
64-
"pref_custom_theme_accent_seed",
65-
CUSTOM_THEME_ACCENT_SEED_UNSET,
66-
)
85+
fun customThemeAccentSeed(): Preference<Int> = customThemeAccentSeedPreference
6786

6887
fun customThemeRecentAccentSeeds() = preferenceStore.getObject(
6988
key = PREF_CUSTOM_THEME_RECENT_ACCENT_SEEDS,
@@ -109,4 +128,158 @@ class UiPreferences(
109128
fun startScreen() = preferenceStore.getEnum("start_screen", StartScreen.ANIME)
110129

111130
fun navStyle() = preferenceStore.getEnum("bottom_rail_nav_style", NavStyle.MOVE_HISTORY_TO_MORE)
131+
132+
private enum class AccentSchemaState {
133+
VALID,
134+
UNKNOWN_VERSION,
135+
MALFORMED,
136+
}
137+
138+
private data class CustomAccentSchemaPayload(
139+
val state: AccentSchemaState,
140+
val seed: Int?,
141+
) {
142+
companion object {
143+
fun fromPersisted(raw: String): CustomAccentSchemaPayload {
144+
val trimmed = raw.trim()
145+
val separator = trimmed.indexOf(':')
146+
if (!trimmed.startsWith("v") || separator <= 1 || separator >= trimmed.lastIndex) {
147+
return CustomAccentSchemaPayload(
148+
state = AccentSchemaState.MALFORMED,
149+
seed = null,
150+
)
151+
}
152+
153+
val version = trimmed.substring(1, separator).toIntOrNull()
154+
?: return CustomAccentSchemaPayload(
155+
state = AccentSchemaState.MALFORMED,
156+
seed = null,
157+
)
158+
val token = trimmed.substring(separator + 1).trim()
159+
if (version != CUSTOM_THEME_ACCENT_SCHEMA_VERSION) {
160+
return CustomAccentSchemaPayload(
161+
state = AccentSchemaState.UNKNOWN_VERSION,
162+
seed = null,
163+
)
164+
}
165+
166+
if (token.equals("unset", ignoreCase = true)) {
167+
return CustomAccentSchemaPayload(
168+
state = AccentSchemaState.VALID,
169+
seed = null,
170+
)
171+
}
172+
173+
val parsed = token.toUIntOrNull(16)?.toInt()
174+
?: return CustomAccentSchemaPayload(
175+
state = AccentSchemaState.MALFORMED,
176+
seed = null,
177+
)
178+
val normalized = normalizeAccentSeed(parsed)
179+
return CustomAccentSchemaPayload(
180+
state = AccentSchemaState.VALID,
181+
seed = normalized.takeUnless { it == CUSTOM_THEME_ACCENT_SEED_UNSET },
182+
)
183+
}
184+
185+
fun forSeed(seed: Int): CustomAccentSchemaPayload {
186+
val normalized = normalizeAccentSeed(seed)
187+
return CustomAccentSchemaPayload(
188+
state = AccentSchemaState.VALID,
189+
seed = normalized.takeUnless { it == CUSTOM_THEME_ACCENT_SEED_UNSET },
190+
)
191+
}
192+
}
193+
}
194+
195+
private inner class CustomThemeAccentSeedPreference : Preference<Int> {
196+
private val legacyPreference = preferenceStore.getInt(
197+
PREF_CUSTOM_THEME_ACCENT_SEED,
198+
CUSTOM_THEME_ACCENT_SEED_UNSET,
199+
)
200+
private val schemaPreference = preferenceStore.getObject(
201+
key = PREF_CUSTOM_THEME_ACCENT_SCHEMA,
202+
defaultValue = CustomAccentSchemaPayload(
203+
state = AccentSchemaState.MALFORMED,
204+
seed = null,
205+
),
206+
serializer = { payload ->
207+
val version = if (payload.state == AccentSchemaState.VALID) {
208+
CUSTOM_THEME_ACCENT_SCHEMA_VERSION
209+
} else {
210+
CUSTOM_THEME_ACCENT_SCHEMA_VERSION
211+
}
212+
val seedToken = payload.seed
213+
?.let { normalized ->
214+
normalizeAccentSeed(normalized).toUInt().toString(16).padStart(8, '0')
215+
}
216+
?: "unset"
217+
"v$version:$seedToken"
218+
},
219+
deserializer = { encoded ->
220+
CustomAccentSchemaPayload.fromPersisted(encoded)
221+
},
222+
)
223+
private val derivedChanges = merge(
224+
legacyPreference.changes().map { Unit },
225+
schemaPreference.changes().map { Unit },
226+
)
227+
.map { resolveCurrentSeed(migrate = false) }
228+
.distinctUntilChanged()
229+
230+
override fun key(): String = PREF_CUSTOM_THEME_ACCENT_SEED
231+
232+
override fun get(): Int {
233+
return resolveCurrentSeed(migrate = true)
234+
}
235+
236+
override fun set(value: Int) {
237+
val normalized = normalizeAccentSeed(value)
238+
legacyPreference.set(normalized)
239+
schemaPreference.set(CustomAccentSchemaPayload.forSeed(normalized))
240+
}
241+
242+
override fun isSet(): Boolean = legacyPreference.isSet() || schemaPreference.isSet()
243+
244+
override fun delete() {
245+
legacyPreference.delete()
246+
schemaPreference.delete()
247+
}
248+
249+
override fun defaultValue(): Int = CUSTOM_THEME_ACCENT_SEED_UNSET
250+
251+
override fun changes(): Flow<Int> = derivedChanges
252+
253+
override fun stateIn(scope: CoroutineScope): StateFlow<Int> {
254+
return changes().stateIn(
255+
scope = scope,
256+
started = SharingStarted.Eagerly,
257+
initialValue = get(),
258+
)
259+
}
260+
261+
private fun resolveCurrentSeed(migrate: Boolean): Int {
262+
val legacySeed = normalizeAccentSeed(legacyPreference.get())
263+
val schemaSet = schemaPreference.isSet()
264+
val schemaPayload = schemaPreference.get()
265+
return when (schemaPayload.state) {
266+
AccentSchemaState.VALID -> {
267+
val resolved = schemaPayload.seed ?: CUSTOM_THEME_ACCENT_SEED_UNSET
268+
if (migrate && legacySeed != resolved) {
269+
legacyPreference.set(resolved)
270+
}
271+
resolved
272+
}
273+
AccentSchemaState.UNKNOWN_VERSION -> legacySeed
274+
AccentSchemaState.MALFORMED -> {
275+
if (migrate && schemaSet) {
276+
schemaPreference.set(CustomAccentSchemaPayload.forSeed(legacySeed))
277+
} else if (migrate && legacySeed != CUSTOM_THEME_ACCENT_SEED_UNSET) {
278+
schemaPreference.set(CustomAccentSchemaPayload.forSeed(legacySeed))
279+
}
280+
legacySeed
281+
}
282+
}
283+
}
284+
}
112285
}

app/src/test/java/eu/kanade/domain/ui/UiPreferencesTest.kt

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,129 @@ import eu.kanade.domain.ui.model.ThemeMode
55
import eu.kanade.presentation.more.settings.widget.normalizeAccentSeed
66
import eu.kanade.presentation.more.settings.widget.resolvePickerResultSeed
77
import kotlinx.coroutines.CoroutineScope
8+
import kotlinx.coroutines.cancelAndJoin
89
import kotlinx.coroutines.flow.Flow
910
import kotlinx.coroutines.flow.MutableStateFlow
1011
import kotlinx.coroutines.flow.StateFlow
1112
import kotlinx.coroutines.flow.asStateFlow
13+
import kotlinx.coroutines.launch
14+
import kotlinx.coroutines.test.advanceUntilIdle
15+
import kotlinx.coroutines.test.runTest
1216
import org.junit.jupiter.api.Assertions.assertEquals
1317
import org.junit.jupiter.api.Test
1418
import tachiyomi.core.common.preference.Preference
1519
import tachiyomi.core.common.preference.PreferenceStore
1620

1721
class UiPreferencesTest {
1822

23+
@Test
24+
fun `schema seed takes precedence over legacy seed`() {
25+
val preferences = UiPreferences(
26+
MutablePreferenceStore(
27+
initialValues = mapOf(
28+
"pref_custom_theme_accent_seed" to 0xFF112233.toInt(),
29+
"pref_custom_theme_accent_schema" to "v1:80445566",
30+
),
31+
),
32+
)
33+
34+
assertEquals(0xFF445566.toInt(), preferences.customThemeAccentSeed().get())
35+
}
36+
37+
@Test
38+
fun `invalid schema falls back to unset when legacy missing`() {
39+
val preferences = UiPreferences(
40+
MutablePreferenceStore(
41+
initialValues = mapOf(
42+
"pref_custom_theme_accent_schema" to "v999:not-a-seed",
43+
),
44+
),
45+
)
46+
47+
assertEquals(UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET, preferences.customThemeAccentSeed().get())
48+
}
49+
50+
@Test
51+
fun `malformed schema falls back to legacy seed`() {
52+
val preferences = UiPreferences(
53+
MutablePreferenceStore(
54+
initialValues = mapOf(
55+
"pref_custom_theme_accent_seed" to 0xFF336699.toInt(),
56+
"pref_custom_theme_accent_schema" to "broken",
57+
),
58+
),
59+
)
60+
61+
assertEquals(0xFF336699.toInt(), preferences.customThemeAccentSeed().get())
62+
}
63+
64+
@Test
65+
fun `unknown schema version falls back to legacy seed`() {
66+
val preferences = UiPreferences(
67+
MutablePreferenceStore(
68+
initialValues = mapOf(
69+
"pref_custom_theme_accent_seed" to 0xFF778899.toInt(),
70+
"pref_custom_theme_accent_schema" to "v2:ffaa5500",
71+
),
72+
),
73+
)
74+
75+
assertEquals(0xFF778899.toInt(), preferences.customThemeAccentSeed().get())
76+
}
77+
78+
@Test
79+
fun `setting schema-backed preference keeps legacy key for downgrade safety`() {
80+
val store = MutablePreferenceStore()
81+
val preferences = UiPreferences(store)
82+
83+
preferences.customThemeAccentSeed().set(0x00224466)
84+
85+
assertEquals(0xFF224466.toInt(), store.getAll()["pref_custom_theme_accent_seed"])
86+
}
87+
88+
@Test
89+
fun `deleting schema-backed preference resets to unset sentinel`() {
90+
val preferences = UiPreferences(MutablePreferenceStore())
91+
92+
preferences.customThemeAccentSeed().set(0xFF334455.toInt())
93+
preferences.customThemeAccentSeed().delete()
94+
95+
assertEquals(UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET, preferences.customThemeAccentSeed().get())
96+
assertEquals(false, preferences.customThemeAccentSeed().isSet())
97+
}
98+
99+
@Test
100+
fun `active changes collector does not resurrect deleted accent value`() = runTest {
101+
val store = MutablePreferenceStore()
102+
val preferences = UiPreferences(store)
103+
val preference = preferences.customThemeAccentSeed()
104+
105+
val collector = launch { preference.changes().collect {} }
106+
preference.set(0xFF445566.toInt())
107+
preference.delete()
108+
advanceUntilIdle()
109+
collector.cancelAndJoin()
110+
111+
assertEquals(false, preference.isSet())
112+
assertEquals(null, store.getAll()["pref_custom_theme_accent_seed"])
113+
}
114+
19115
@Test
20116
fun `custom theme accent seed defaults to unset sentinel`() {
21117
val preferences = UiPreferences(MutablePreferenceStore())
22118

23119
assertEquals(UiPreferences.CUSTOM_THEME_ACCENT_SEED_UNSET, preferences.customThemeAccentSeed().get())
24120
}
25121

122+
@Test
123+
fun `custom theme accent seed is not marked as set on clean read`() {
124+
val preferences = UiPreferences(MutablePreferenceStore())
125+
126+
preferences.customThemeAccentSeed().get()
127+
128+
assertEquals(false, preferences.customThemeAccentSeed().isSet())
129+
}
130+
26131
@Test
27132
fun `custom theme accent seed persists chosen value`() {
28133
val preferences = UiPreferences(MutablePreferenceStore())

0 commit comments

Comments
 (0)