From 79b47fc354d49b15c5169759e8dd03704b717cc2 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 07:32:26 +0800 Subject: [PATCH] feat: persist global UI preferences --- android/app/build.gradle.kts | 1 + android/core/preferences/build.gradle.kts | 32 ++++ .../DataStoreUiPreferencesRepository.kt | 82 ++++++++++ .../abox/manager/preferences/UiPreferences.kt | 35 +++++ .../preferences/UiPreferencesRepository.kt | 16 ++ .../DataStoreUiPreferencesRepositoryTest.kt | 145 ++++++++++++++++++ settings.gradle.kts | 1 + 7 files changed, 312 insertions(+) create mode 100644 android/core/preferences/build.gradle.kts create mode 100644 android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt create mode 100644 android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt create mode 100644 android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferencesRepository.kt create mode 100644 android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index aa9f30a..9fce481 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -66,6 +66,7 @@ kapt { dependencies { implementation(project(":domain")) implementation(project(":android:core:designsystem")) + implementation(project(":android:core:preferences")) implementation(project(":android:core:session")) implementation(project(":android:feature:infrared")) implementation(project(":absdk-bridge")) diff --git a/android/core/preferences/build.gradle.kts b/android/core/preferences/build.gradle.kts new file mode 100644 index 0000000..4888cea --- /dev/null +++ b/android/core/preferences/build.gradle.kts @@ -0,0 +1,32 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.flagship.abox.manager.preferences" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } +} + +dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.datastore.preferences) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt new file mode 100644 index 0000000..cffed9c --- /dev/null +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt @@ -0,0 +1,82 @@ +package com.flagship.abox.manager.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map + +private const val UI_PREFERENCES_DATA_STORE_NAME = "ui_preferences" + +private val Context.uiPreferencesDataStore: DataStore by preferencesDataStore( + name = UI_PREFERENCES_DATA_STORE_NAME, + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, +) + +class DataStoreUiPreferencesRepository internal constructor( + private val dataStore: DataStore, +) : UiPreferencesRepository { + constructor(context: Context) : this(context.uiPreferencesDataStore) + override fun observe(): Flow = dataStore.data + .map(::preferencesFrom) + .catch { cause -> + if (cause is CancellationException) throw cause + emit(UiPreferences()) + } + + override suspend fun update( + transform: (UiPreferences) -> UiPreferences, + ): UiPreferencesWriteResult = try { + dataStore.edit { storedPreferences -> + storedPreferences.putAll(transform(preferencesFrom(storedPreferences))) + } + UiPreferencesWriteResult.Success + } catch (cause: CancellationException) { + throw cause + } catch (_: IOException) { + UiPreferencesWriteResult.Failure + } + + private fun preferencesFrom(preferences: Preferences): UiPreferences = UiPreferences( + renderer = preferences[RENDERER]?.enumOrDefault(DesignRenderer.MATERIAL3) + ?: DesignRenderer.MATERIAL3, + themeMode = preferences[THEME_MODE]?.enumOrDefault(AppThemeMode.SYSTEM) + ?: AppThemeMode.SYSTEM, + dynamicColorEnabled = preferences[DYNAMIC_COLOR] ?: true, + blurEnabled = preferences[BLUR] ?: false, + navigationPresentation = preferences[NAVIGATION]?.enumOrDefault(NavigationPresentation.STANDARD) + ?: NavigationPresentation.STANDARD, + hapticsEnabled = preferences[HAPTICS] ?: true, + ) + + private fun MutablePreferences.putAll(preferences: UiPreferences) { + this[RENDERER] = preferences.renderer.name + this[THEME_MODE] = preferences.themeMode.name + this[DYNAMIC_COLOR] = preferences.dynamicColorEnabled + this[BLUR] = preferences.blurEnabled + this[NAVIGATION] = preferences.navigationPresentation.name + this[HAPTICS] = preferences.hapticsEnabled + } + + private inline fun > String.enumOrDefault(default: T): T = + enumValues().firstOrNull { it.name == this } ?: default + + private companion object { + val RENDERER = stringPreferencesKey("renderer") + val THEME_MODE = stringPreferencesKey("theme_mode") + val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") + val BLUR = booleanPreferencesKey("blur") + val NAVIGATION = stringPreferencesKey("navigation") + val HAPTICS = booleanPreferencesKey("haptics") + } +} diff --git a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt new file mode 100644 index 0000000..429f7c3 --- /dev/null +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt @@ -0,0 +1,35 @@ +package com.flagship.abox.manager.preferences + +enum class DesignRenderer { + MATERIAL3, + MIUIX, +} + +enum class AppThemeMode { + SYSTEM, + LIGHT, + DARK, +} + +enum class NavigationPresentation { + STANDARD, + FLOATING, +} + +enum class PreferenceField { + RENDERER, + THEME_MODE, + DYNAMIC_COLOR, + BLUR, + NAVIGATION, + HAPTICS, +} + +data class UiPreferences( + val renderer: DesignRenderer = DesignRenderer.MATERIAL3, + val themeMode: AppThemeMode = AppThemeMode.SYSTEM, + val dynamicColorEnabled: Boolean = true, + val blurEnabled: Boolean = false, + val navigationPresentation: NavigationPresentation = NavigationPresentation.STANDARD, + val hapticsEnabled: Boolean = true, +) diff --git a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferencesRepository.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferencesRepository.kt new file mode 100644 index 0000000..12d4309 --- /dev/null +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferencesRepository.kt @@ -0,0 +1,16 @@ +package com.flagship.abox.manager.preferences + +import kotlinx.coroutines.flow.Flow + +sealed interface UiPreferencesWriteResult { + data object Success : UiPreferencesWriteResult + data object Failure : UiPreferencesWriteResult +} + +interface UiPreferencesRepository { + fun observe(): Flow + + suspend fun update( + transform: (UiPreferences) -> UiPreferences, + ): UiPreferencesWriteResult +} diff --git a/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt b/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt new file mode 100644 index 0000000..25fd0bd --- /dev/null +++ b/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt @@ -0,0 +1,145 @@ +package com.flagship.abox.manager.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class DataStoreUiPreferencesRepositoryTest { + @Test + fun defaultsAreEmitted() = runTest { + val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore()) + + assertEquals(UiPreferences(), repository.observe().first()) + } + + @Test + fun eachFieldPersistsIndependently() = runTest { + val dataStore = InMemoryPreferencesDataStore() + val repository = DataStoreUiPreferencesRepository(dataStore) + + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(renderer = DesignRenderer.MIUIX) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(themeMode = AppThemeMode.DARK) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(dynamicColorEnabled = false) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(blurEnabled = true) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(navigationPresentation = NavigationPresentation.FLOATING) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(hapticsEnabled = false) + }) + + assertEquals( + UiPreferences( + renderer = DesignRenderer.MIUIX, + themeMode = AppThemeMode.DARK, + dynamicColorEnabled = false, + blurEnabled = true, + navigationPresentation = NavigationPresentation.FLOATING, + hapticsEnabled = false, + ), + repository.observe().first(), + ) + } + + @Test + fun unknownEnumFallsBackToDefault() = runTest { + val dataStore = InMemoryPreferencesDataStore() + dataStore.edit { preferences -> + preferences[stringPreferencesKey("renderer")] = "RETIRED_STYLE" + } + val repository = DataStoreUiPreferencesRepository(dataStore) + + assertEquals(DesignRenderer.MATERIAL3, repository.observe().first().renderer) + } + + @Test + fun corruptDataFallsBackWithoutThrowing() = runTest { + val repository = DataStoreUiPreferencesRepository(CorruptPreferencesDataStore()) + + assertEquals(UiPreferences(), repository.observe().first()) + } + + @Test + fun updateReturnsFailureWhenStorageWriteFails() = runTest { + val repository = DataStoreUiPreferencesRepository(FailingWritePreferencesDataStore()) + + assertEquals( + UiPreferencesWriteResult.Failure, + repository.update { it.copy(blurEnabled = true) }, + ) + } + + @Test + fun updateRethrowsTransformExceptions() = runTest { + val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore()) + val failure = IllegalStateException("invalid transform") + + val actual = runCatching { + repository.update { throw failure } + }.exceptionOrNull() + + assertEquals(failure, actual) + } + + @Test + fun updateRethrowsCancellationException() = runTest { + val cancellation = CancellationException("cancelled") + val repository = DataStoreUiPreferencesRepository( + FailingWritePreferencesDataStore(cancellation), + ) + + val actual = runCatching { + repository.update { it.copy(blurEnabled = true) } + }.exceptionOrNull() + + assertEquals(cancellation, actual) + } + + private class InMemoryPreferencesDataStore : DataStore { + private val state = MutableStateFlow(emptyPreferences()) + + override val data: Flow = state + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = transform(state.value).also(state::value::set) + } + + private class CorruptPreferencesDataStore : DataStore { + override val data: Flow = kotlinx.coroutines.flow.flow { + throw IOException("corrupt preferences") + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = error("not used") + } + + private class FailingWritePreferencesDataStore( + private val failure: Throwable = IOException("write failed"), + ) : DataStore { + override val data: Flow = MutableStateFlow(emptyPreferences()) + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = throw failure + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 984f670..8ed9912 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,6 +31,7 @@ include(":runtime:simulation") include(":absdk-bridge") include(":testing:fakes") include(":android:core:designsystem") +include(":android:core:preferences") include(":android:core:session") include(":android:feature:infrared") include(":android:app")