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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Preferences> = createDataStore(
|
||||
scope = CoroutineScope(io + SupervisorJob()),
|
||||
produceFile = { context.preferencesDataStoreFile(PREFS_NAME) },
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePrefStore(dataStore: DataStore<Preferences>): 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<Preferences> = PreferenceDataStoreFactory.create(
|
||||
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
|
||||
scope = scope,
|
||||
produceFile = produceFile,
|
||||
)
|
||||
@@ -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<Appearance> = store.appearance()
|
||||
|
||||
val themeMode: Flow<ThemeMode> = store.flow(AppearancePrefs.themeMode)
|
||||
|
||||
val dynamicColor: Flow<Boolean> = store.flow(AppearancePrefs.dynamicColor)
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) = store.set(AppearancePrefs.themeMode, mode)
|
||||
|
||||
suspend fun setDynamicColor(enabled: Boolean) = store.set(AppearancePrefs.dynamicColor, enabled)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Preferences> =
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user