diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt new file mode 100644 index 0000000..b8618aa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.clockula.data.prefs + +import de.jeanlucmakiola.clockula.domain.DismissChallenge +import de.jeanlucmakiola.clockula.domain.Zones +import de.jeanlucmakiola.floret.prefs.Pref +import de.jeanlucmakiola.floret.prefs.booleanPref +import de.jeanlucmakiola.floret.prefs.enumPref +import de.jeanlucmakiola.floret.prefs.intPref +import de.jeanlucmakiola.floret.prefs.longPref +import de.jeanlucmakiola.floret.prefs.nullableStringPref +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * The clock defaults' on-disk keys. Reads and writes both clamp through + * [Pref.map], so a value the user could never choose — a hand-edited file, a + * restored backup from a future version — cannot come back out of the store. + */ +object ClockPrefs { + + private val MIN_TIMER_DURATION = 1.seconds + private val MAX_TIMER_DURATION = 24.hours + + val snoozeMinutes: Pref = intPref("default_snooze_minutes", 10).clampedTo(1..60) + + val snoozeLimit: Pref = intPref("default_snooze_limit", 3).clampedTo(0..10) + + val vibrate: Pref = booleanPref("default_vibrate", true) + + val volumeRampSeconds: Pref = intPref("default_volume_ramp_seconds", 15).clampedTo(0..60) + + val alarmRingtoneUri: Pref = nullableStringPref("default_alarm_ringtone_uri").blankAsAbsent() + + val timerRingtoneUri: Pref = nullableStringPref("default_timer_ringtone_uri").blankAsAbsent() + + val dismissChallenge: Pref = + enumPref("default_dismiss_challenge", DismissChallenge.NONE) + + val timerDurationMillis: Pref = longPref( + "default_timer_duration_millis", + 5.minutes.inWholeMilliseconds, + ).map( + decode = { it.milliseconds.coerceIn(MIN_TIMER_DURATION, MAX_TIMER_DURATION) }, + encode = { it.coerceIn(MIN_TIMER_DURATION, MAX_TIMER_DURATION).inWholeMilliseconds }, + ) + + /** Normalised through [Zones] both ways, so a zone the device dropped reads as absent. */ + val homeZoneId: Pref = nullableStringPref("home_zone_id") + .map(decode = Zones::normalise, encode = Zones::normalise) + + private fun Pref.clampedTo(range: IntRange): Pref = + map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) }) + + private fun Pref.blankAsAbsent(): Pref { + val trim: (String?) -> String? = { it?.trim()?.takeIf(String::isNotEmpty) } + return map(decode = trim, encode = trim) + } +} 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 index df7fb12..d35a5cf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -1,17 +1,24 @@ package de.jeanlucmakiola.clockula.data.prefs +import de.jeanlucmakiola.clockula.domain.ClockDefaults +import de.jeanlucmakiola.clockula.domain.DismissChallenge 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 kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlin.time.Duration 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. + * Clockula's app preferences: the appearance choices (M1) and the clock + * defaults every alarm and timer inherits (M2). The stopwatch's running state + * lives next door in `StopwatchStateStore`. */ @Singleton class SettingsPrefs @Inject constructor(private val store: PrefStore) { @@ -28,4 +35,61 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setThemeMode(mode: ThemeMode) = store.set(AppearancePrefs.themeMode, mode) suspend fun setDynamicColor(enabled: Boolean) = store.set(AppearancePrefs.dynamicColor, enabled) + + // --- clock defaults (M2) --- + + /** + * Distinct-until-changed; built once, for the same reason [appearance] is. + * Composed from the individual keys rather than one snapshot read, so a + * write to an unrelated preference cannot re-emit it. + */ + val defaults: Flow = combine( + combine( + store.flow(ClockPrefs.snoozeMinutes), + store.flow(ClockPrefs.snoozeLimit), + store.flow(ClockPrefs.vibrate), + store.flow(ClockPrefs.volumeRampSeconds), + store.flow(ClockPrefs.dismissChallenge), + ) { snoozeMinutes, snoozeLimit, vibrate, volumeRampSeconds, dismissChallenge -> + ClockDefaults( + snoozeMinutes = snoozeMinutes, + snoozeLimit = snoozeLimit, + vibrate = vibrate, + volumeRampSeconds = volumeRampSeconds, + dismissChallenge = dismissChallenge, + ) + }, + store.flow(ClockPrefs.alarmRingtoneUri), + store.flow(ClockPrefs.timerRingtoneUri), + store.flow(ClockPrefs.timerDurationMillis), + store.flow(ClockPrefs.homeZoneId), + ) { partial, alarmRingtoneUri, timerRingtoneUri, timerDuration, homeZoneId -> + partial.copy( + alarmRingtoneUri = alarmRingtoneUri, + timerRingtoneUri = timerRingtoneUri, + timerDuration = timerDuration, + homeZoneId = homeZoneId, + ) + }.distinctUntilChanged() + + suspend fun currentDefaults(): ClockDefaults = defaults.first() + + suspend fun setSnoozeMinutes(minutes: Int) = store.set(ClockPrefs.snoozeMinutes, minutes) + + suspend fun setSnoozeLimit(limit: Int) = store.set(ClockPrefs.snoozeLimit, limit) + + suspend fun setVibrate(enabled: Boolean) = store.set(ClockPrefs.vibrate, enabled) + + suspend fun setVolumeRampSeconds(seconds: Int) = store.set(ClockPrefs.volumeRampSeconds, seconds) + + suspend fun setAlarmRingtoneUri(uri: String?) = store.set(ClockPrefs.alarmRingtoneUri, uri) + + suspend fun setTimerRingtoneUri(uri: String?) = store.set(ClockPrefs.timerRingtoneUri, uri) + + suspend fun setDismissChallenge(challenge: DismissChallenge) = + store.set(ClockPrefs.dismissChallenge, challenge) + + suspend fun setTimerDuration(duration: Duration) = store.set(ClockPrefs.timerDurationMillis, duration) + + suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/StopwatchPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/StopwatchPrefs.kt new file mode 100644 index 0000000..1fb0fed --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/StopwatchPrefs.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.clockula.data.prefs + +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.floret.prefs.toEnum +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * The stopwatch's running state — a single record, in DataStore rather than + * Room (docs/PLAN.md §5). Every value here is monotonic: no key holds a + * wall-clock instant, because a stopwatch that spans a reboot has lost time + * nobody can reconstruct and guessing at it would be a lie. + * + * The four keys are read and written as **one record**, not as four independent + * `Pref`s: the state and its anchor only mean anything together, so a reader + * must never see "RUNNING with no anchor" because a multi-key write was caught + * half-done. + */ +object StopwatchPrefs { + + private val state = stringPreferencesKey("stopwatch_state") + private val startedAtElapsed = longPreferencesKey("stopwatch_started_elapsed_millis") + private val accumulated = longPreferencesKey("stopwatch_accumulated_millis") + private val lastLapCumulative = longPreferencesKey("stopwatch_last_lap_cumulative_millis") + + /** One consistent snapshot to one record. Never throws: a bad value degrades. */ + fun read(prefs: Preferences): StopwatchRun = StopwatchRun( + state = prefs[state].toEnum(StopwatchState.IDLE), + accumulated = prefs.elapsed(accumulated), + startedAtElapsedRealtime = prefs[startedAtElapsed]?.milliseconds, + lastLapCumulative = prefs.elapsed(lastLapCumulative), + ) + + /** + * The whole record into one [MutablePreferences], so [read] sees all of it + * or none of it. An absent start anchor removes its key rather than storing + * a sentinel for it. + */ + fun write(prefs: MutablePreferences, run: StopwatchRun) { + prefs[state] = run.state.name + prefs[accumulated] = run.accumulated.clampedMillis() + prefs[lastLapCumulative] = run.lastLapCumulative.clampedMillis() + val startedAt = run.startedAtElapsedRealtime + if (startedAt == null) prefs.remove(startedAtElapsed) else prefs[startedAtElapsed] = startedAt.inWholeMilliseconds + } + + /** A stored elapsed value, clamped: a negative one is corruption, not a duration. */ + private fun Preferences.elapsed(key: Preferences.Key): Duration = + (this[key] ?: 0L).coerceAtLeast(0L).milliseconds + + private fun Duration.clampedMillis(): Long = coerceAtLeast(Duration.ZERO).inWholeMilliseconds +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchStateStore.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchStateStore.kt new file mode 100644 index 0000000..02fa2af --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchStateStore.kt @@ -0,0 +1,28 @@ +package de.jeanlucmakiola.clockula.data.stopwatch + +import de.jeanlucmakiola.clockula.data.prefs.StopwatchPrefs +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.floret.prefs.PrefStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** The DataStore half of the stopwatch. Nothing else touches [StopwatchPrefs]. */ +@Singleton +class StopwatchStateStore @Inject constructor(private val store: PrefStore) { + + /** + * Distinct-until-changed; built once, so a collector does not restart it. + * Read from one snapshot rather than composed from four key flows, so an + * emission is always a whole record. + */ + val run: Flow = store.data.map(StopwatchPrefs::read).distinctUntilChanged() + + suspend fun current(): StopwatchRun = run.first() + + /** One transaction for all four keys: a torn record is never persisted or observed. */ + suspend fun set(run: StopwatchRun) = store.edit { StopwatchPrefs.write(it, run) } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt new file mode 100644 index 0000000..db30250 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt @@ -0,0 +1,154 @@ +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.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.ClockDefaults +import de.jeanlucmakiola.clockula.domain.DismissChallenge +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 +import kotlin.time.Duration.Companion.seconds + +/** + * The clock defaults every alarm and timer inherits. A value the user could + * never choose must not come back out of a hand-edited or restored file, so + * both writes and reads clamp. + */ +class ClockPrefsTest { + + 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 reads the documented defaults`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + assertThat(prefs.defaults.first()).isEqualTo(ClockDefaults()) + } + + @Test + fun `every default round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.setSnoozeMinutes(5) + prefs.setSnoozeLimit(1) + prefs.setVibrate(false) + prefs.setVolumeRampSeconds(0) + prefs.setAlarmRingtoneUri("content://a") + prefs.setTimerRingtoneUri("content://t") + prefs.setDismissChallenge(DismissChallenge.HOLD) + prefs.setTimerDuration(90.seconds) + prefs.setHomeZoneId("Europe/Berlin") + + assertThat(prefs.defaults.first()).isEqualTo( + ClockDefaults( + snoozeMinutes = 5, + snoozeLimit = 1, + vibrate = false, + volumeRampSeconds = 0, + alarmRingtoneUri = "content://a", + timerRingtoneUri = "content://t", + dismissChallenge = DismissChallenge.HOLD, + timerDuration = 90.seconds, + homeZoneId = "Europe/Berlin", + ), + ) + } + + @Test + fun `the defaults do not re-emit when the same value is written again`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.defaults.test { + assertThat(awaitItem()).isEqualTo(ClockDefaults()) + + prefs.setSnoozeMinutes(ClockDefaults().snoozeMinutes) + + expectNoEvents() + cancel() + } + } + + @Test + fun `snooze minutes clamp on write`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.setSnoozeMinutes(0) + assertThat(prefs.defaults.first().snoozeMinutes).isEqualTo(1) + + prefs.setSnoozeMinutes(999) + assertThat(prefs.defaults.first().snoozeMinutes).isEqualTo(60) + } + + @Test + fun `a corrupt stored snooze value clamps on read`(@TempDir tempDir: Path) = runTest { + val dataStore = newDataStore(tempDir) + val prefs = SettingsPrefs(PrefStore(dataStore)) + + dataStore.updateData { stored -> + stored.toMutablePreferences().apply { this[intPreferencesKey("default_snooze_minutes")] = -5 } + } + + assertThat(prefs.defaults.first().snoozeMinutes).isEqualTo(1) + } + + @Test + fun `a corrupt dismiss challenge degrades to none`(@TempDir tempDir: Path) = runTest { + val dataStore = newDataStore(tempDir) + val prefs = SettingsPrefs(PrefStore(dataStore)) + + dataStore.updateData { stored -> + stored.toMutablePreferences() + .apply { this[stringPreferencesKey("default_dismiss_challenge")] = "TELEPORT" } + } + + assertThat(prefs.defaults.first().dismissChallenge).isEqualTo(DismissChallenge.NONE) + } + + @Test + fun `a home zone the device no longer knows reads back as absent`(@TempDir tempDir: Path) = runTest { + val dataStore = newDataStore(tempDir) + val prefs = SettingsPrefs(PrefStore(dataStore)) + + dataStore.updateData { stored -> + stored.toMutablePreferences().apply { this[stringPreferencesKey("home_zone_id")] = "Mars/Phobos" } + } + assertThat(prefs.defaults.first().homeZoneId).isNull() + + prefs.setHomeZoneId(" Europe/Berlin ") + assertThat(prefs.defaults.first().homeZoneId).isEqualTo("Europe/Berlin") + + prefs.setHomeZoneId(null) + assertThat(prefs.defaults.first().homeZoneId).isNull() + } + + @Test + fun `writing a clock default does not disturb the appearance flows`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.themeMode.test { + assertThat(awaitItem()).isEqualTo(ThemeMode.SYSTEM) + + prefs.setSnoozeMinutes(5) + + expectNoEvents() + cancel() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/StopwatchPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/StopwatchPrefsTest.kt new file mode 100644 index 0000000..0db9c70 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/StopwatchPrefsTest.kt @@ -0,0 +1,137 @@ +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 com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchStateStore +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.floret.prefs.PrefStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +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 +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * The stopwatch's run record. It is monotonic time and nothing else — no key + * here may hold a wall-clock instant. + */ +class StopwatchPrefsTest { + + 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 `the stored run record holds no wall-clock value`(@TempDir tempDir: Path) = runTest { + val dataStore = newDataStore(tempDir) + val store = StopwatchStateStore(PrefStore(dataStore)) + + store.set( + StopwatchRun( + state = StopwatchState.RUNNING, + accumulated = Duration.ZERO, + startedAtElapsedRealtime = 1_000.seconds, + ), + ) + + val snapshot = dataStore.data.first().asMap() + assertThat(snapshot.values.filterIsInstance().filter { it >= 1_000_000_000_000L }).isEmpty() + assertThat(snapshot.keys.map { it.name }).containsExactly( + "stopwatch_state", + "stopwatch_started_elapsed_millis", + "stopwatch_accumulated_millis", + "stopwatch_last_lap_cumulative_millis", + ) + } + + @Test + fun `a fresh install is the default run`(@TempDir tempDir: Path) = runTest { + val store = StopwatchStateStore(PrefStore(newDataStore(tempDir))) + + assertThat(store.current()).isEqualTo(StopwatchRun()) + } + + @Test + fun `a full record round-trips`(@TempDir tempDir: Path) = runTest { + val store = StopwatchStateStore(PrefStore(newDataStore(tempDir))) + val run = StopwatchRun( + state = StopwatchState.PAUSED, + accumulated = 42.seconds, + startedAtElapsedRealtime = null, + lastLapCumulative = 30.seconds, + ) + + store.set(run) + + assertThat(store.current()).isEqualTo(run) + } + + @Test + fun `an unknown stored state degrades to idle`(@TempDir tempDir: Path) = runTest { + val dataStore = newDataStore(tempDir) + val store = StopwatchStateStore(PrefStore(dataStore)) + + dataStore.updateData { stored -> + stored.toMutablePreferences().apply { this[stringPreferencesKey("stopwatch_state")] = "SPINNING" } + } + + assertThat(store.current().state).isEqualTo(StopwatchState.IDLE) + } + + @Test + fun `a whole record lands in a single emission`(@TempDir tempDir: Path) = runTest { + val store = StopwatchStateStore(PrefStore(newDataStore(tempDir))) + val seen = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { store.run.toList(seen) } + + store.set( + StopwatchRun( + state = StopwatchState.RUNNING, + accumulated = 42.seconds, + startedAtElapsedRealtime = 1_000.seconds, + lastLapCumulative = 30.seconds, + ), + ) + + // Four keys, one transaction: a reader never sees RUNNING without its + // anchor, or the new state beside the old accumulated total. + assertThat(seen).containsExactly( + StopwatchRun(), + StopwatchRun( + state = StopwatchState.RUNNING, + accumulated = 42.seconds, + startedAtElapsedRealtime = 1_000.seconds, + lastLapCumulative = 30.seconds, + ), + ).inOrder() + } + + @Test + fun `clearing the start anchor removes its key`(@TempDir tempDir: Path) = runTest { + val dataStore = newDataStore(tempDir) + val store = StopwatchStateStore(PrefStore(dataStore)) + store.set( + StopwatchRun(state = StopwatchState.RUNNING, startedAtElapsedRealtime = 1_000.seconds), + ) + + store.set(StopwatchRun(state = StopwatchState.PAUSED, startedAtElapsedRealtime = null)) + + assertThat(store.current().startedAtElapsedRealtime).isNull() + assertThat(dataStore.data.first().asMap().keys.map { it.name }) + .doesNotContain("stopwatch_started_elapsed_millis") + } +}