From fc615e46229db2ee518ebd498ae3397375e58cf8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 11 Sep 2026 12:47:26 +0200 Subject: [PATCH] feat(prefs): app preferences over the kit's PrefStore SettingsPrefs exposes the appearance slice; DataModule builds the DataStore on the kit's @IoDispatcher. The store is created with a corruption handler that replaces an unreadable file with empty preferences, so a truncated clockula_prefs.preferences_pb cannot leave the app crashing on every launch with no recovery short of clearing app data. Co-Authored-By: Claude Opus 5 --- .../clockula/data/di/DataModule.kt | 73 ++++++++++++ .../clockula/data/prefs/SettingsPrefs.kt | 31 +++++ .../clockula/data/di/DataModuleTest.kt | 53 +++++++++ .../clockula/data/prefs/SettingsPrefsTest.kt | 108 ++++++++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/data/di/DataModuleTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt new file mode 100644 index 0000000..eaeca73 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt @@ -0,0 +1,73 @@ +package de.jeanlucmakiola.clockula.data.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStoreFile +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.floret.di.IoDispatcher +import de.jeanlucmakiola.floret.prefs.PrefStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import java.io.File +import javax.inject.Singleton + +/** + * Clockula's storage bindings. M1 wires the preference half only; the Room + * database and its DAOs join this module in M2. + */ +@Module +@InstallIn(SingletonComponent::class) +object DataModule { + + /** App-local: the store's name stays out of floret-kit, by its roadmap's rule. */ + private const val PREFS_NAME = "clockula_prefs" + + /** + * Built explicitly rather than through the `preferencesDataStore` delegate so + * the store's own coroutine scope runs on the kit's IO dispatcher. + * `@Singleton` gives the same one-instance-per-file guarantee the delegate + * exists to provide. + */ + @Provides + @Singleton + fun provideDataStore( + @ApplicationContext context: Context, + @IoDispatcher io: CoroutineDispatcher, + ): DataStore = createDataStore( + scope = CoroutineScope(io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile(PREFS_NAME) }, + ) + + @Provides + @Singleton + fun providePrefStore(dataStore: DataStore): PrefStore = PrefStore(dataStore) +} + +/** + * The store's construction, free of [Context] so it can be exercised from a + * plain JVM unit test. + * + * A half-written `clockula_prefs.preferences_pb` — a kill during a write, a + * bad restore — otherwise throws `CorruptionException` out of + * `dataStore.data` on every single launch, and nothing between here and the + * theme catches it. Replacing an unreadable file with empty preferences + * costs the user their two appearance choices and keeps the app startable; + * the alternative is an app that can only be fixed by clearing its data. + */ +internal fun createDataStore( + scope: CoroutineScope, + produceFile: () -> File, +): DataStore = PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, + scope = scope, + produceFile = produceFile, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt new file mode 100644 index 0000000..df7fb12 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.clockula.data.prefs + +import de.jeanlucmakiola.floret.prefs.Appearance +import de.jeanlucmakiola.floret.prefs.AppearancePrefs +import de.jeanlucmakiola.floret.prefs.PrefStore +import de.jeanlucmakiola.floret.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.appearance +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Clockula's app preferences. M1 covers the appearance slice only; M2 adds the + * clock defaults and the stopwatch running state to this same class. + */ +@Singleton +class SettingsPrefs @Inject constructor(private val store: PrefStore) { + + // Built once, not per access: a collector keyed on the flow instance (as + // `collectAsStateWithLifecycle` is) would otherwise tear down and restart + // the DataStore collection on every recomposition. + val appearance: Flow = store.appearance() + + val themeMode: Flow = store.flow(AppearancePrefs.themeMode) + + val dynamicColor: Flow = store.flow(AppearancePrefs.dynamicColor) + + suspend fun setThemeMode(mode: ThemeMode) = store.set(AppearancePrefs.themeMode, mode) + + suspend fun setDynamicColor(enabled: Boolean) = store.set(AppearancePrefs.dynamicColor, enabled) +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/di/DataModuleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/di/DataModuleTest.kt new file mode 100644 index 0000000..46a4cdc --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/di/DataModuleTest.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.clockula.data.di + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.floret.prefs.PrefStore +import de.jeanlucmakiola.floret.prefs.ThemeMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path + +/** + * The store as `DataModule` builds it. The preference file feeds the theme + * before the first frame, so an unreadable one must degrade to defaults rather + * than throw `CorruptionException` out of every launch. + */ +class DataModuleTest { + + private fun TestScope.newStore(file: File) = createDataStore( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + produceFile = { file }, + ) + + @Test + fun `a corrupted preference file reads back as the defaults`(@TempDir tempDir: Path) = runTest { + val file = tempDir.resolve("clockula_prefs.preferences_pb").toFile() + file.writeBytes(byteArrayOf(0x07, 0x2A, 0x13, 0x37, 0x00, 0x42)) + + val prefs = SettingsPrefs(PrefStore(newStore(file))) + + assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM) + assertThat(prefs.dynamicColor.first()).isTrue() + } + + @Test + fun `the store is writable again after replacing a corrupted file`(@TempDir tempDir: Path) = runTest { + val file = tempDir.resolve("clockula_prefs.preferences_pb").toFile() + file.writeBytes(byteArrayOf(0x07, 0x2A, 0x13, 0x37, 0x00, 0x42)) + val prefs = SettingsPrefs(PrefStore(newStore(file))) + + prefs.setThemeMode(ThemeMode.DARK) + + assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK) + // ...and the replacement is a real file, so the next launch reads it back. + assertThat(file.length()).isGreaterThan(0L) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt new file mode 100644 index 0000000..e2cd662 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -0,0 +1,108 @@ +package de.jeanlucmakiola.clockula.data.prefs + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.floret.prefs.Appearance +import de.jeanlucmakiola.floret.prefs.PrefStore +import de.jeanlucmakiola.floret.prefs.ThemeMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * Clockula's appearance preferences over a real DataStore, one temp file per + * test. These values feed the theme before the first frame, so a missing or + * corrupted one must resolve rather than throw. + */ +class SettingsPrefsTest { + + private fun TestScope.newDataStore(tempDir: Path): DataStore = + PreferenceDataStoreFactory.create( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() }, + ) + + @Test + fun `a fresh install follows the system theme with dynamic colour on`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + val appearance = listOf(prefs.themeMode.first(), prefs.dynamicColor.first()) + + assertThat(appearance).containsExactly(ThemeMode.SYSTEM, true).inOrder() + } + + @Test + fun `the theme mode round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.setThemeMode(ThemeMode.DARK) + + assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK) + } + + @Test + fun `the dynamic colour choice round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.setDynamicColor(false) + + assertThat(prefs.dynamicColor.first()).isFalse() + } + + @Test + fun `appearance carries both stored choices`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + prefs.setThemeMode(ThemeMode.DARK) + prefs.setDynamicColor(false) + + val appearance = prefs.appearance.first() + + assertThat(appearance).isEqualTo(Appearance(ThemeMode.DARK, dynamicColor = false)) + } + + @Test + fun `the theme mode is written under the family's key name`(@TempDir tempDir: Path) = runTest { + val dataStore = newDataStore(tempDir) + val prefs = SettingsPrefs(PrefStore(dataStore)) + + prefs.setThemeMode(ThemeMode.LIGHT) + + assertThat(dataStore.data.first()[stringPreferencesKey("theme_mode")]).isEqualTo("LIGHT") + } + + @Test + fun `a corrupted theme mode falls back to following the system`(@TempDir tempDir: Path) = runTest { + val dataStore = newDataStore(tempDir) + val prefs = SettingsPrefs(PrefStore(dataStore)) + + dataStore.updateData { stored -> + stored.toMutablePreferences().apply { this[stringPreferencesKey("theme_mode")] = "FUNDAY" } + } + + assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `the theme mode does not re-emit when an unrelated preference is written`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.themeMode.test { + assertThat(awaitItem()).isEqualTo(ThemeMode.SYSTEM) + + prefs.setDynamicColor(true) + + expectNoEvents() + cancel() + } + } +}