feat: persist global UI preferences

This commit is contained in:
2026-08-26 07:42:55 +08:00
parent b39b3f95c7
commit 79b47fc354
7 changed files with 312 additions and 0 deletions
+1
View File
@@ -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"))
+32
View File
@@ -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)
}
@@ -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<Preferences> by preferencesDataStore(
name = UI_PREFERENCES_DATA_STORE_NAME,
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)
class DataStoreUiPreferencesRepository internal constructor(
private val dataStore: DataStore<Preferences>,
) : UiPreferencesRepository {
constructor(context: Context) : this(context.uiPreferencesDataStore)
override fun observe(): Flow<UiPreferences> = 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 <reified T : Enum<T>> String.enumOrDefault(default: T): T =
enumValues<T>().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")
}
}
@@ -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,
)
@@ -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<UiPreferences>
suspend fun update(
transform: (UiPreferences) -> UiPreferences,
): UiPreferencesWriteResult
}
@@ -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<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = transform(state.value).also(state::value::set)
}
private class CorruptPreferencesDataStore : DataStore<Preferences> {
override val data: Flow<Preferences> = 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<Preferences> {
override val data: Flow<Preferences> = MutableStateFlow(emptyPreferences())
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = throw failure
}
}
+1
View File
@@ -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")