From fbc3a192bfe4c2cd2f4bf99fc75bd8d456fe2ba1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 18:49:45 +0200 Subject: [PATCH 1/8] feat(timers): give timers their own vibration and gradual volume Timers vibrated by the alarm vibration default and never ramped. Both are now the timers' own app-wide settings, next to the default timer sound in Settings > Timers: a Vibrate switch and the same Gradual volume picker alarms use. Timer.resolveSettings reads them instead of the alarm default and the old TimerRing.RAMP_SECONDS constant. A fresh install starts with timer vibration off, as Google Clock does, and no ramp. An existing install keeps what its timers did: a one-shot DataStore migration writes the new key from the alarm vibration default before the first read, so nobody's kitchen timer silently stops buzzing on update. A fresh install is told apart by an empty store (anything that has run once has written its boot id); the migration writes the default there too, so it never runs twice. Backups carry both new fields. A file without them restores the alarm vibration default as the timers' (what that install's timers did) and a flat ring. Silent plus vibration off still signals: Silent gives the player no sources, and its onSilenced callback starts the vibrator whatever the setting says. The settings row says so. Fixes #22 --- CHANGELOG.md | 3 + .../clockula/data/backup/BackupCodec.kt | 4 + .../clockula/data/backup/BackupDto.kt | 6 + .../clockula/data/di/DataModule.kt | 4 + .../clockula/data/prefs/ClockPrefs.kt | 10 ++ .../clockula/data/prefs/SettingsPrefs.kt | 18 ++- .../data/prefs/TimerVibrateMigration.kt | 36 ++++++ .../clockula/domain/ClockDefaults.kt | 7 ++ .../clockula/domain/timer/TimerRing.kt | 6 +- .../clockula/domain/timer/TimerSettings.kt | 8 +- .../clockula/ui/settings/SettingsScreen.kt | 2 +- .../clockula/ui/settings/SettingsViewModel.kt | 4 + .../ui/settings/TimerDefaultsScreen.kt | 54 ++++++++- app/src/main/res/values/strings.xml | 5 +- .../clockula/data/backup/BackupCodecTest.kt | 36 +++++- .../clockula/data/prefs/SettingsPrefsTest.kt | 37 +++++- .../data/prefs/TimerVibrateMigrationTest.kt | 110 ++++++++++++++++++ .../domain/timer/TimerRingPolicyTest.kt | 20 ++-- docs/ARCHITECTURE.md | 34 ++++-- docs/BACKUP.md | 6 + docs/ROADMAP.md | 3 +- 21 files changed, 375 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..c887e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. timer turns the screen on and opens over the lock screen with Stop and +1 min. Timers keep counting through a reboot and aren't affected by changes to the clock. +- Timers have their own vibration switch, off by default, and an optional + gradual volume, both apart from the alarms'. A kitchen timer can just beep + while alarms still vibrate; a silent timer still vibrates. - **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause in the notification. It keeps counting while the app is closed. - **World clock.** An analog face for your own time, plus cities from the diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index c6daf96..8aad668 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -125,6 +125,8 @@ object BackupCodec { volumeRampSeconds = defaults.volumeRampSeconds, alarmRingtoneUri = defaults.alarmRingtoneUri, timerRingtoneUri = defaults.timerRingtoneUri, + timerVibrate = defaults.timerVibrate, + timerVolumeRampSeconds = defaults.timerVolumeRampSeconds, dismissChallenge = defaults.dismissChallenge.name, homeZoneId = defaults.homeZoneId, timerPresets = timerPresets.map { it.inWholeMilliseconds }, @@ -231,6 +233,8 @@ object BackupCodec { volumeRampSeconds = settingsDto.volumeRampSeconds, alarmRingtoneUri = settingsDto.alarmRingtoneUri?.takeIf { it.isNotBlank() }, timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() }, + timerVibrate = settingsDto.timerVibrate ?: settingsDto.vibrate, + timerVolumeRampSeconds = settingsDto.timerVolumeRampSeconds, dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE), homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise), ), diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 118e99e..4e1a91f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -69,6 +69,12 @@ data class SettingsDto( @SerialName("default_volume_ramp_seconds") val volumeRampSeconds: Int = 15, @SerialName("default_alarm_ringtone_uri") val alarmRingtoneUri: String? = null, @SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null, + /** + * Absent in a file from before timers had their own vibration; such a file + * restores [vibrate] here, which is what its timers vibrated by. + */ + @SerialName("default_timer_vibrate") val timerVibrate: Boolean? = null, + @SerialName("default_timer_volume_ramp_seconds") val timerVolumeRampSeconds: Int = 0, @SerialName("default_dismiss_challenge") val dismissChallenge: String = "NONE", @SerialName("home_zone_id") val homeZoneId: String? = null, /** 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 index dcbd3c6..707addc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt @@ -12,6 +12,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.clockula.data.prefs.TimerVibrateMigration import de.jeanlucmakiola.floret.di.IoDispatcher import de.jeanlucmakiola.floret.prefs.PrefStore import kotlinx.coroutines.CoroutineDispatcher @@ -62,12 +63,15 @@ object DataModule { * 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. + * + * Migrations run before the first read, so nothing ever sees a key they fill. */ internal fun createDataStore( scope: CoroutineScope, produceFile: () -> File, ): DataStore = PreferenceDataStoreFactory.create( corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, + migrations = listOf(TimerVibrateMigration), scope = scope, produceFile = produceFile, ) 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 index 0cb43c1..8587aaf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -28,6 +28,16 @@ object ClockPrefs { val timerRingtoneUri: Pref = nullableStringPref("default_timer_ringtone_uri").blankAsAbsent() + /** + * Off on a fresh install. An install from before the setting existed has + * it written once by [TimerVibrateMigration], from [vibrate] — which is + * what its timers vibrated by until then. + */ + val timerVibrate: Pref = booleanPref("default_timer_vibrate", false) + + val timerVolumeRampSeconds: Pref = + intPref("default_timer_volume_ramp_seconds", 0).clampedTo(ClockLimits.VOLUME_RAMP_SECONDS) + val dismissChallenge: Pref = enumPref("default_dismiss_challenge", DismissChallenge.NONE) 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 b76785b..1d13e6e 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 @@ -62,13 +62,21 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { dismissChallenge = dismissChallenge, ) }, + combine( + store.flow(ClockPrefs.timerRingtoneUri), + store.flow(ClockPrefs.timerVibrate), + store.flow(ClockPrefs.timerVolumeRampSeconds), + ) { timerRingtoneUri, timerVibrate, timerVolumeRampSeconds -> + Triple(timerRingtoneUri, timerVibrate, timerVolumeRampSeconds) + }, store.flow(ClockPrefs.alarmRingtoneUri), - store.flow(ClockPrefs.timerRingtoneUri), store.flow(ClockPrefs.homeZoneId), - ) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId -> + ) { partial, (timerRingtoneUri, timerVibrate, timerVolumeRampSeconds), alarmRingtoneUri, homeZoneId -> partial.copy( alarmRingtoneUri = alarmRingtoneUri, timerRingtoneUri = timerRingtoneUri, + timerVibrate = timerVibrate, + timerVolumeRampSeconds = timerVolumeRampSeconds, homeZoneId = homeZoneId, ) }.distinctUntilChanged() @@ -90,6 +98,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { prefs[ClockPrefs.volumeRampSeconds] = backup.defaults.volumeRampSeconds prefs[ClockPrefs.alarmRingtoneUri] = backup.defaults.alarmRingtoneUri prefs[ClockPrefs.timerRingtoneUri] = backup.defaults.timerRingtoneUri + prefs[ClockPrefs.timerVibrate] = backup.defaults.timerVibrate + prefs[ClockPrefs.timerVolumeRampSeconds] = backup.defaults.timerVolumeRampSeconds prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId prefs[TimerPrefs.presets] = backup.timerPresets @@ -108,6 +118,10 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setTimerRingtoneUri(uri: String?) = store.set(ClockPrefs.timerRingtoneUri, uri) + suspend fun setTimerVibrate(enabled: Boolean) = store.set(ClockPrefs.timerVibrate, enabled) + + suspend fun setTimerVolumeRampSeconds(seconds: Int) = store.set(ClockPrefs.timerVolumeRampSeconds, seconds) + suspend fun setDismissChallenge(challenge: DismissChallenge) = store.set(ClockPrefs.dismissChallenge, challenge) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt new file mode 100644 index 0000000..b31291c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt @@ -0,0 +1,36 @@ +package de.jeanlucmakiola.clockula.data.prefs + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import de.jeanlucmakiola.floret.prefs.set + +/** + * Gives an install from before #22 a timer-vibrate setting that keeps its + * timers doing what they did: until then a timer vibrated exactly when the + * alarm default did, so that is the value written. A fresh install — an empty + * store, since anything that has run once has at least written its boot id — + * gets the setting's own default, off. + * + * The value is always written, the default included, so this runs once: left + * absent on a fresh install, the next launch would see a non-empty store and + * copy the alarm default after all. + */ +internal object TimerVibrateMigration : DataMigration { + + private val timerVibrateKey = booleanPreferencesKey(ClockPrefs.timerVibrate.name) + private val alarmVibrateKey = booleanPreferencesKey(ClockPrefs.vibrate.name) + + override suspend fun shouldMigrate(currentData: Preferences): Boolean = timerVibrateKey !in currentData + + override suspend fun migrate(currentData: Preferences): Preferences { + val inherited = if (currentData.asMap().isEmpty()) { + ClockPrefs.timerVibrate.default + } else { + currentData[alarmVibrateKey] ?: ClockPrefs.vibrate.default + } + return currentData.toMutablePreferences().apply { this[ClockPrefs.timerVibrate] = inherited } + } + + override suspend fun cleanUp() = Unit +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt index 73499b3..ef8fc73 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt @@ -3,6 +3,11 @@ package de.jeanlucmakiola.clockula.domain /** * The app-wide defaults an alarm or timer inherits when it has no override. * Backed by DataStore, edited in M10's settings screen. + * + * [vibrate] and [volumeRampSeconds] are the alarms'; timers have their own + * [timerVibrate] and [timerVolumeRampSeconds], because a kitchen timer that + * should just beep is not an alarm that should wake someone. Their defaults + * are a fresh install's: no vibration, no ramp. */ data class ClockDefaults( val snoozeMinutes: Int = 10, @@ -11,6 +16,8 @@ data class ClockDefaults( val volumeRampSeconds: Int = 15, val alarmRingtoneUri: String? = null, val timerRingtoneUri: String? = null, + val timerVibrate: Boolean = false, + val timerVolumeRampSeconds: Int = 0, val dismissChallenge: DismissChallenge = DismissChallenge.NONE, val homeZoneId: String? = null, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt index 7d0d82e..e0c905d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt @@ -6,7 +6,8 @@ import kotlin.time.Duration.Companion.minutes /** * The timer ring's constants. Values rather than settings: v1 does not offer - * them to the user (docs/PLAN.md §5, slice plan D11). + * them to the user (docs/PLAN.md §5, slice plan D11). The ramp was one of them + * until it became `ClockDefaults.timerVolumeRampSeconds` (#22). */ object TimerRing { /** @@ -19,9 +20,6 @@ object TimerRing { /** What "+1 min" grants (D12). */ val ADD_TIME: Duration = 1.minutes - /** A timer's ring does not ramp: it is set by someone awake (D11). */ - const val RAMP_SECONDS: Int = 0 - /** The floor under every `delay` the service takes, so a rounding error cannot spin it. */ val WAKE_FLOOR: Duration = 100.milliseconds } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt index 6c3ff0c..91833b2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt @@ -12,11 +12,11 @@ data class TimerSettings( /** * The ringtone is the timer's own when it has one, the app default otherwise; - * vibration is app-wide, and the ramp is always zero — a timer's ring does not - * ramp, so the ramp is not a setting to inherit (D11, D27). + * vibration and the ramp are the timers' own app-wide settings, never the + * alarms' (D27, #22). Neither has a per-timer override. */ fun Timer.resolveSettings(defaults: ClockDefaults): TimerSettings = TimerSettings( ringtoneUri = ringtoneUri ?: defaults.timerRingtoneUri, - vibrate = defaults.vibrate, - rampSeconds = TimerRing.RAMP_SECONDS, + vibrate = defaults.timerVibrate, + rampSeconds = defaults.timerVolumeRampSeconds, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt index eaeda0d..654054e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt @@ -222,7 +222,7 @@ private fun SettingsHub( ) GroupedRow( title = stringResource(R.string.settings_section_timers), - summary = stringResource(R.string.settings_timers_sound_subtitle), + summary = stringResource(R.string.settings_timers_subtitle), position = Position.Middle, leading = { CategoryIcon(Icons.Default.Timer, ChipAccent.Tertiary) }, onClick = { onOpenSection(SettingsSection.Timers) }, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt index 7d90968..1475ac6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt @@ -142,6 +142,10 @@ class SettingsViewModel @Inject internal constructor( fun onDismissChallengeChanged(value: DismissChallenge) = viewModelScope.launch { settings.setDismissChallenge(value) } + fun onTimerVibrateChanged(value: Boolean) = viewModelScope.launch { settings.setTimerVibrate(value) } + + fun onTimerVolumeRampChanged(value: Int) = viewModelScope.launch { settings.setTimerVolumeRampSeconds(value) } + // --- the default ringtone pickers --- internal fun onOpenRingtonePicker(kind: DefaultRingtoneKind) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt index cc68b9a..b3f25d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt @@ -2,31 +2,77 @@ package de.jeanlucmakiola.clockula.ui.settings import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.material3.Switch import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.ui.alarms.VolumeRampPicker +import de.jeanlucmakiola.clockula.ui.alarms.volumeRampText import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position -/** What a new timer starts with: its sound. */ +/** + * How timers ring: the sound a new timer starts with, and the vibration and + * gradual volume every timer rings with. The latter two are the timers' own, + * not the alarms' (#22). The ringtone picker itself is ViewModel state, drawn + * by the hub. + */ @Composable internal fun TimerDefaultsScreen( state: SettingsUiState.Loaded, viewModel: SettingsViewModel, onBack: () -> Unit, ) { + val defaults = state.defaults + var showRampPicker by rememberSaveable { mutableStateOf(false) } + CollapsingScaffold(title = stringResource(R.string.settings_section_timers), onBack = onBack) { - SettingsHint(stringResource(R.string.settings_timer_sound_hint)) + SettingsHint(stringResource(R.string.settings_timer_defaults_hint)) Spacer(Modifier.height(8.dp)) GroupedRow( title = stringResource(R.string.editor_ringtone), - summary = ringtoneSummary(state.defaults.timerRingtoneUri), - position = Position.Alone, + summary = ringtoneSummary(defaults.timerRingtoneUri), + position = Position.Top, onClick = { viewModel.onOpenRingtonePicker(DefaultRingtoneKind.TIMER) }, ) + // A switch, not the alarm screen's picker: there is no per-timer + // override for it to pick "app default" against. The summary says what + // the ring does with Silent and vibration both off — vibrates anyway. + GroupedRow( + title = stringResource(R.string.editor_vibrate), + summary = stringResource(R.string.settings_timer_vibrate_hint), + position = Position.Middle, + trailing = { + Switch( + checked = defaults.timerVibrate, + onCheckedChange = viewModel::onTimerVibrateChanged, + ) + }, + onClick = { viewModel.onTimerVibrateChanged(!defaults.timerVibrate) }, + ) + GroupedRow( + title = stringResource(R.string.editor_volume_ramp), + summary = volumeRampText(defaults.timerVolumeRampSeconds), + position = Position.Bottom, + onClick = { showRampPicker = true }, + ) + } + + if (showRampPicker) { + VolumeRampPicker( + stored = defaults.timerVolumeRampSeconds, + default = defaults.timerVolumeRampSeconds, + includeAppDefault = false, + onSelect = { viewModel.onTimerVolumeRampChanged(it ?: defaults.timerVolumeRampSeconds) }, + onDismiss = { showRampPicker = false }, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..c9117ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -298,8 +298,9 @@ Sound, snooze and dismiss for new alarms New alarms start with these. Each alarm can still change them in its editor. Timers - Sound for new timers - New timers ring with this sound. Each timer can still change it in its editor. + Sound, vibration and gradual volume + How timers ring. A timer can still pick its own sound in its editor. + A timer set to silent vibrates either way Clock Home time zone World clocks show how far each city is from your home time zone. diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index db57734..04f7cc2 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -58,6 +58,9 @@ class BackupCodecTest { volumeRampSeconds = 20, alarmRingtoneUri = "content://media/1", timerRingtoneUri = "content://media/2", + // Unlike the alarms' own, so a codec that crossed them is caught. + timerVibrate = true, + timerVolumeRampSeconds = 10, dismissChallenge = DismissChallenge.HOLD, homeZoneId = "America/New_York", ), @@ -131,9 +134,40 @@ class BackupCodecTest { assertThat(decoded).isInstanceOf(BackupResult.Success::class.java) val defaults = (decoded as BackupResult.Success).content.settings.defaults - assertThat(defaults).isEqualTo(ClockDefaults(snoozeMinutes = 7)) + // Timers vibrated by the alarm default then, which defaults to on. + assertThat(defaults).isEqualTo(ClockDefaults(snoozeMinutes = 7, timerVibrate = true)) } + @Test + fun `a file from before timers had their own vibration restores the alarm default for them`() { + val on = BackupCodec.decode(settingsOnly(""""default_vibrate": true""")) + val off = BackupCodec.decode(settingsOnly(""""default_vibrate": false""")) + + assertThat(on.defaults().timerVibrate).isTrue() + assertThat(off.defaults().timerVibrate).isFalse() + } + + @Test + fun `a file from before timers could ramp restores an unramped timer ring`() { + val decoded = BackupCodec.decode(settingsOnly(""""default_volume_ramp_seconds": 30""")) + + assertThat(decoded.defaults().volumeRampSeconds).isEqualTo(30) + assertThat(decoded.defaults().timerVolumeRampSeconds).isEqualTo(0) + } + + @Test + fun `the timers' own vibration wins over the alarm default when the file has both`() { + val decoded = BackupCodec.decode(settingsOnly(""""default_vibrate": true, "default_timer_vibrate": false""")) + + assertThat(decoded.defaults().vibrate).isTrue() + assertThat(decoded.defaults().timerVibrate).isFalse() + } + + private fun settingsOnly(settings: String): String = + """{ "format": "clockula-backup", "version": 1, "exportedAt": 0, "settings": { $settings } }""" + + private fun BackupResult.defaults(): ClockDefaults = (this as BackupResult.Success).content.settings.defaults + @Test fun `the retired default timer duration is no longer written`() { assertThat(BackupCodec.encode(sampleContent())).doesNotContain("default_timer_duration_millis") 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 index 71ead3e..1e0a018 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -74,7 +74,13 @@ class SettingsPrefsTest { BackupSettings( themeMode = ThemeMode.DARK, dynamicColor = false, - defaults = ClockDefaults(snoozeMinutes = 99, volumeRampSeconds = 99, alarmRingtoneUri = " "), + defaults = ClockDefaults( + snoozeMinutes = 99, + volumeRampSeconds = 99, + alarmRingtoneUri = " ", + timerVibrate = true, + timerVolumeRampSeconds = 99, + ), timerPresets = listOf(5.minutes, 1.minutes, 5.minutes), ), ) @@ -84,6 +90,8 @@ class SettingsPrefsTest { assertThat(prefs.currentDefaults().snoozeMinutes).isEqualTo(60) assertThat(prefs.currentDefaults().volumeRampSeconds).isEqualTo(60) assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull() + assertThat(prefs.currentDefaults().timerVibrate).isTrue() + assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(60) assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder() } @@ -143,6 +151,33 @@ class SettingsPrefsTest { } } + // --- #22: the timers' own vibration and ramp --- + + @Test + fun `the timer settings are their own, apart from the alarm ones`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.setVibrate(true) + prefs.setVolumeRampSeconds(30) + prefs.setTimerVibrate(false) + prefs.setTimerVolumeRampSeconds(10) + + val defaults = prefs.currentDefaults() + assertThat(defaults.vibrate).isTrue() + assertThat(defaults.volumeRampSeconds).isEqualTo(30) + assertThat(defaults.timerVibrate).isFalse() + assertThat(defaults.timerVolumeRampSeconds).isEqualTo(10) + } + + @Test + fun `the timer ramp is clamped like the alarm ramp`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.setTimerVolumeRampSeconds(-5) + + assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(0) + } + // --- M4: the one-shot notification permission request --- @Test diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt new file mode 100644 index 0000000..ba2832e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt @@ -0,0 +1,110 @@ +package de.jeanlucmakiola.clockula.data.prefs + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.data.di.createDataStore +import de.jeanlucmakiola.floret.prefs.PrefStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.job +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 + +/** + * #22's upgrade path, through the store `DataModule` builds: an install from + * before timers had their own vibration keeps vibrating its timers exactly + * when it did, and a fresh one starts with it off. + */ +class TimerVibrateMigrationTest { + + private fun TestScope.newScope(): CoroutineScope = + CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()) + + private fun file(tempDir: Path): File = tempDir.resolve("clockula_prefs.preferences_pb").toFile() + + /** The file as a version before #22 left it: no migration, only the keys it knew. */ + private suspend fun TestScope.writeOldInstall(tempDir: Path, alarmVibrate: Boolean?) { + val scope = newScope() + PreferenceDataStoreFactory.create(scope = scope, produceFile = { file(tempDir) }).edit { prefs -> + prefs[stringPreferencesKey("last_boot_id")] = "boot" + if (alarmVibrate != null) prefs[booleanPreferencesKey("default_vibrate")] = alarmVibrate + } + // DataStore refuses a second store over a file a live one holds. + scope.coroutineContext.job.cancelAndJoin() + } + + private suspend fun TestScope.openPrefs(tempDir: Path, scope: CoroutineScope = newScope()): SettingsPrefs = + SettingsPrefs(PrefStore(createDataStore(scope = scope, produceFile = { file(tempDir) }))) + + @Test + fun `a fresh install does not vibrate its timers`(@TempDir tempDir: Path) = runTest { + val prefs = openPrefs(tempDir) + + assertThat(prefs.currentDefaults().timerVibrate).isFalse() + assertThat(prefs.currentDefaults().vibrate).isTrue() + } + + @Test + fun `a fresh install stays off on the next launch, once the store is no longer empty`( + @TempDir tempDir: Path, + ) = runTest { + val firstLaunch = newScope() + openPrefs(tempDir, firstLaunch).setSnoozeMinutes(7) + firstLaunch.coroutineContext.job.cancelAndJoin() + + val prefs = openPrefs(tempDir) + + assertThat(prefs.currentDefaults().timerVibrate).isFalse() + } + + @Test + fun `an older install with alarm vibration on keeps vibrating its timers`(@TempDir tempDir: Path) = runTest { + writeOldInstall(tempDir, alarmVibrate = true) + + assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isTrue() + } + + @Test + fun `an older install that never touched alarm vibration keeps vibrating its timers`( + @TempDir tempDir: Path, + ) = runTest { + writeOldInstall(tempDir, alarmVibrate = null) + + assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isTrue() + } + + @Test + fun `an older install with alarm vibration off keeps its timers still`(@TempDir tempDir: Path) = runTest { + writeOldInstall(tempDir, alarmVibrate = false) + + assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isFalse() + } + + @Test + fun `once migrated, the two settings move independently`(@TempDir tempDir: Path) = runTest { + writeOldInstall(tempDir, alarmVibrate = true) + val prefs = openPrefs(tempDir) + + prefs.setVibrate(false) + + assertThat(prefs.currentDefaults().timerVibrate).isTrue() + } + + @Test + fun `a choice the user made is never overwritten`(@TempDir tempDir: Path) = runTest { + val firstLaunch = newScope() + openPrefs(tempDir, firstLaunch).setTimerVibrate(true) + firstLaunch.coroutineContext.job.cancelAndJoin() + + assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isTrue() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt index d95ecc1..43ae2ab 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt @@ -82,20 +82,24 @@ class TimerRingPolicyTest { assertThat(outcome.sound().settings.ringtoneUri).isEqualTo(Ringtones.SILENT_URI) } - /** §5.4 #6 */ + /** §5.4 #6, #22: the timers' own vibration setting, never the alarms'. */ @Test - fun `vibration comes from the app default`() { - val outcome = decide(defaults = defaults.copy(vibrate = false)) + fun `vibration comes from the timer default, not the alarm default`() { + val off = decide(defaults = defaults.copy(vibrate = true, timerVibrate = false)) + val on = decide(defaults = defaults.copy(vibrate = false, timerVibrate = true)) - assertThat(outcome.sound().settings.vibrate).isFalse() + assertThat(off.sound().settings.vibrate).isFalse() + assertThat(on.sound().settings.vibrate).isTrue() } - /** §5.4 #7 */ + /** §5.4 #7, #22: the timers' own ramp, never the alarms'. */ @Test - fun `a timer's ring does not ramp`() { - val outcome = decide(defaults = defaults.copy(volumeRampSeconds = 30)) + fun `the ramp comes from the timer default, not the alarm default`() { + val flat = decide(defaults = defaults.copy(volumeRampSeconds = 30, timerVolumeRampSeconds = 0)) + val ramped = decide(defaults = defaults.copy(volumeRampSeconds = 0, timerVolumeRampSeconds = 20)) - assertThat(outcome.sound().settings.rampSeconds).isEqualTo(TimerRing.RAMP_SECONDS) + assertThat(flat.sound().settings.rampSeconds).isEqualTo(0) + assertThat(ramped.sound().settings.rampSeconds).isEqualTo(20) } /** §5.4 #8 */ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..0366fa3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -595,7 +595,7 @@ Four slices: | Slice | Keys | Owner | |---|---|---| | Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs`, the family's shared key names | -| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow` | +| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_timer_vibrate`, `default_timer_volume_ramp_seconds` (#22), `default_dismiss_challenge`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow` | | Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` | | System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate | | Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` | @@ -1062,7 +1062,7 @@ M10 adds no ``. | Per-timer vibrate, volume-ramp or dismiss-challenge overrides. `timers` has exactly one nullable settings column, its ringtone; four more would be a schema change for settings the roadmap does not ask for | not planned for v1 | | A "stop all" for several expired timers, and undo after a timer delete. Each expired timer is acknowledged on its own, and the delete confirmation asks before rather than after | not planned for v1 | | A presets management screen (add/remove the quick-start durations themselves). M10's settings screen edits `default_timer_ringtone_uri`, but the preset list is still only editable from the two-gesture surface on the setup panel | not planned for v1 | -| A timer that ramps its volume, a dismiss challenge or a snooze for a timer | not planned for v1 | +| A dismiss challenge or a snooze for a timer. (A timer that ramps its volume landed with #22, as an app-wide timer setting.) | not planned for v1 | | Surfacing `TimerSnapshot.anchorIsStale` in the UI. The boot repair makes it vanishingly rare and the value is already honest; a row does not say "estimated" in v1 | not planned for v1 | | Lap export, sharing, deletion or editing, and any "previous run" history. A run's laps belong to the run that produced them; keeping old runs is a different feature with its own table | not planned for v1 | | A Lap button on the live pill. The pill has two controls by design, and a third would rewrite a contract M4 asserted | not planned for v1 | @@ -1282,8 +1282,8 @@ loop, the ramp stepper, the looped waveform, `AudioSourcePolicy`, and are used verbatim by both, so "silent" still means "vibration only" for a timer too. What the alarm **keeps to itself** is the full-screen intent, the ring activity, the dismiss challenge and the snooze; what the timer -parameterises is its own ringtone, a ramp of **zero seconds** (a timer is set by -someone awake, and a gentle start is just a quiet start) and its own +parameterises is its own ringtone, its own vibration and ramp settings (#22; +the ramp is **zero seconds** unless the user asks for one) and its own auto-silence constant. §14 has the rest, including which of the two wins the one audio stream. @@ -1898,11 +1898,24 @@ finished, and the user coming back must be able to see that and add a minute to it. Auto-silence is decided **before** suppression, so an alarm ringing over a window that has run out cannot hand it a fresh one. -`TimerRing.RAMP_SECONDS` is **0**. The volume ramp exists so an alarm does not +A timer's ramp is `ClockDefaults.timerVolumeRampSeconds`, **0** unless the +user picks one in Settings > Timers (#22; it was the constant +`TimerRing.RAMP_SECONDS` before). The volume ramp exists so an alarm does not jolt a sleeping person awake; a timer is set by someone awake who wants to know -*now*, and for them a gentle start is a quiet start. `VolumeRamp.levelAt(_, 0s)` -already returns `1f`, so "parameterised" means one different argument to the -same player. +*now*, and for most of them a gentle start is a quiet start, so the default +stays flat. `VolumeRamp.levelAt(_, 0s)` already returns `1f`, so +"parameterised" means one different argument to the same player. + +Vibration is `ClockDefaults.timerVibrate`, never the alarms' `vibrate`: an +alarm that should buzz the bed and a kitchen timer that should only beep are +different questions. A fresh install has it **off**. An install from before +the setting existed had its timers vibrate exactly when the alarm default did, +so `TimerVibrateMigration` (a DataStore migration, run before the first read) +writes that value once rather than silently changing what its timers do; it +tells a fresh install by an empty store, since anything that has run has +written its boot id. Silent with vibration off is still not silence: +`AudioSourcePolicy` gives Silent no sources, and the player's `onSilenced` +starts the vibrator regardless of the setting. ### The notification: one channel, one id, alert once @@ -2060,8 +2073,9 @@ ever read it, so it was removed. A backup file that still carries the key imports with it ignored.) A timer has exactly **one** per-setting override, its ringtone, because `timers` -has exactly one nullable settings column. Vibrate and the (zero) ramp come from -`ClockDefaults`. The picker is M5's, moved to `ui/common/`, so the row inherits +has exactly one nullable settings column. Vibrate and the ramp come from +`ClockDefaults`' timer-only `timerVibrate` and `timerVolumeRampSeconds` (#22), +not the alarm defaults. The picker is M5's, moved to `ui/common/`, so the row inherits all of its behaviour for free: "App default" naming what it resolves to, Silent as an explicit sentinel that forces vibration, a SAF-picked file that is still stored when its grant cannot be made persistent, and an unreadable sound diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e8536de..8126f1e 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -42,6 +42,10 @@ written here. are clamped at parse time to the same ranges as the defaults they override, and a timer's `duration_millis` to the setup range (1 second to 24 hours). Room has no clamp of its own, so this is where it happens. +- A missing `default_timer_vibrate` (a file from before timers had their own + vibration setting) restores `default_vibrate` in its place: that is what + the exporting install's timers vibrated by. A missing + `default_timer_volume_ramp_seconds` restores 0, the ring timers had then. - A missing `timer_presets` keeps the built-in presets. An empty list is a user who removed them all, and is imported as such. - An enum field with a name this version does not recognise (a `dismiss_ @@ -134,6 +138,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands "default_volume_ramp_seconds": 15, "default_alarm_ringtone_uri": null, "default_timer_ringtone_uri": null, + "default_timer_vibrate": false, + "default_timer_volume_ramp_seconds": 0, "default_dismiss_challenge": "NONE", "home_zone_id": null, "timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000] diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb7731b..33a8bc7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -316,7 +316,8 @@ Elapsed-realtime anchored. per-timer vibrate, volume-ramp or dismiss-challenge overrides (`timers` has one nullable settings column and four more would be a schema change), no "stop all" when several timers are expired, no undo after a delete (traded for - a confirmation, as with alarms), no ramp on a timer's ring, no presets + a confirmation, as with alarms), no ramp on a timer's ring (since added as + an app-wide timer setting, #22), no presets management screen and no editing of `default_timer_duration_millis` or `default_timer_ringtone_uri` (M10's settings screen). `TimerSnapshot.anchorIsStale` is still not surfaced in the UI. Of `AlarmClock`'s intent contract only the From 8910ab7fbf12f89683cb99e409d3825de40cd447 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 18:56:07 +0200 Subject: [PATCH 2/8] fix(timers): keep a ramp from restarting mid-ring and a migration from guessing A timer ring that hands over from one expired timer to another now starts at full volume instead of ramping again from near silence. The timer-vibrate migration only treats an install as fresh when the package was never updated and the store is empty. An updated install whose store was replaced after corruption, or an Auto Backup restore onto a new device, keeps the alarm default as before. The Vibrate row now says whether it is on, and only when it is off adds that a silent timer still vibrates. Refs #22 --- .../clockula/data/di/DataModule.kt | 14 +++++++- .../data/prefs/TimerVibrateMigration.kt | 22 +++++++----- .../clockula/domain/timer/TimerSettings.kt | 9 +++++ .../clockula/timer/service/TimerService.kt | 4 ++- .../ui/settings/TimerDefaultsScreen.kt | 11 ++++-- app/src/main/res/values/strings.xml | 2 +- .../clockula/data/di/DataModuleTest.kt | 1 + .../data/prefs/TimerVibrateMigrationTest.kt | 34 +++++++++++++++---- .../domain/timer/TimerRingPolicyTest.kt | 8 +++++ docs/ARCHITECTURE.md | 8 +++-- 10 files changed, 90 insertions(+), 23 deletions(-) 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 index 707addc..2fb730b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt @@ -46,8 +46,18 @@ object DataModule { ): DataStore = createDataStore( scope = CoroutineScope(io + SupervisorJob()), produceFile = { context.preferencesDataStoreFile(PREFS_NAME) }, + isFreshInstall = { isFreshInstall(context) }, ) + /** + * Never updated since it was installed. Unknown reads as not fresh, which + * is the migrations' safe side: it keeps what an install already did. + */ + private fun isFreshInstall(context: Context): Boolean = runCatching { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + info.firstInstallTime == info.lastUpdateTime + }.getOrDefault(false) + @Provides @Singleton fun providePrefStore(dataStore: DataStore): PrefStore = PrefStore(dataStore) @@ -65,13 +75,15 @@ object DataModule { * the alternative is an app that can only be fixed by clearing its data. * * Migrations run before the first read, so nothing ever sees a key they fill. + * [isFreshInstall] is asked only by them, and only once. */ internal fun createDataStore( scope: CoroutineScope, produceFile: () -> File, + isFreshInstall: () -> Boolean, ): DataStore = PreferenceDataStoreFactory.create( corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, - migrations = listOf(TimerVibrateMigration), + migrations = listOf(TimerVibrateMigration(isFreshInstall)), scope = scope, produceFile = produceFile, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt index b31291c..0c82ba8 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt @@ -8,15 +8,21 @@ import de.jeanlucmakiola.floret.prefs.set /** * Gives an install from before #22 a timer-vibrate setting that keeps its * timers doing what they did: until then a timer vibrated exactly when the - * alarm default did, so that is the value written. A fresh install — an empty - * store, since anything that has run once has at least written its boot id — - * gets the setting's own default, off. + * alarm default did, so that is the value written. Only a fresh install gets + * the setting's own default, off. * - * The value is always written, the default included, so this runs once: left - * absent on a fresh install, the next launch would see a non-empty store and - * copy the alarm default after all. + * Fresh means both: the package was never updated ([isFreshInstall], from the + * package manager) **and** the store is empty. The first alone would take an + * Auto Backup restore onto a new device for fresh; the second alone would take + * an updated install whose store was replaced after corruption, or never + * written, for fresh. Either mistake silently stops someone's timers buzzing, + * so when in doubt this keeps the old behaviour. + * + * The value is always written, the default included, so this runs once. */ -internal object TimerVibrateMigration : DataMigration { +internal class TimerVibrateMigration( + private val isFreshInstall: () -> Boolean, +) : DataMigration { private val timerVibrateKey = booleanPreferencesKey(ClockPrefs.timerVibrate.name) private val alarmVibrateKey = booleanPreferencesKey(ClockPrefs.vibrate.name) @@ -24,7 +30,7 @@ internal object TimerVibrateMigration : DataMigration { override suspend fun shouldMigrate(currentData: Preferences): Boolean = timerVibrateKey !in currentData override suspend fun migrate(currentData: Preferences): Preferences { - val inherited = if (currentData.asMap().isEmpty()) { + val inherited = if (currentData.asMap().isEmpty() && isFreshInstall()) { ClockPrefs.timerVibrate.default } else { currentData[alarmVibrateKey] ?: ClockPrefs.vibrate.default diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt index 91833b2..43cd22d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt @@ -20,3 +20,12 @@ fun Timer.resolveSettings(defaults: ClockDefaults): TimerSettings = TimerSetting vibrate = defaults.timerVibrate, rampSeconds = defaults.timerVolumeRampSeconds, ) + +/** + * The ramp a ring actually starts with. A [handover] — the sounding timer + * changes while the ring goes on, because a second one expired and sorts first + * or the first was stopped with another still expired — starts at full volume: + * the user has already heard the ring, and starting the ramp over would drop it + * to a whisper partway through. + */ +fun TimerSettings.rampSecondsFor(handover: Boolean): Int = if (handover) 0 else rampSeconds diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt index 5010fbf..ea103e6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt @@ -17,6 +17,7 @@ import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState import de.jeanlucmakiola.clockula.domain.timer.TimerMode import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision +import de.jeanlucmakiola.clockula.domain.timer.rampSecondsFor import de.jeanlucmakiola.clockula.ring.RingAudioPlayer import de.jeanlucmakiola.clockula.ring.RingVibrator import de.jeanlucmakiola.clockula.timer.AlarmRingStatus @@ -228,6 +229,7 @@ class TimerService : Service() { // An unchanged decision does nothing: restarting the player on // every emission would stutter the ring. if (sounding == decision.subjectId) return + val handover = sounding != null releaseRing() sounding = decision.subjectId acquireWakeLock() @@ -240,7 +242,7 @@ class TimerService : Service() { audio.start( scope = scope, ringtoneUri = decision.settings.ringtoneUri, - rampSeconds = decision.settings.rampSeconds, + rampSeconds = decision.settings.rampSecondsFor(handover), // Silence is not one of the outcomes: with no audio source at // all the timer vibrates even when vibration is switched off. onSilenced = vibrator::start, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt index b3f25d9..9130302 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.ui.alarms.VolumeRampPicker +import de.jeanlucmakiola.clockula.ui.alarms.vibrateText import de.jeanlucmakiola.clockula.ui.alarms.volumeRampText import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedRow @@ -44,11 +45,15 @@ internal fun TimerDefaultsScreen( onClick = { viewModel.onOpenRingtonePicker(DefaultRingtoneKind.TIMER) }, ) // A switch, not the alarm screen's picker: there is no per-timer - // override for it to pick "app default" against. The summary says what - // the ring does with Silent and vibration both off — vibrates anyway. + // override for it to pick "app default" against. Off, the summary says + // what the ring does with Silent and vibration both off: vibrates anyway. GroupedRow( title = stringResource(R.string.editor_vibrate), - summary = stringResource(R.string.settings_timer_vibrate_hint), + summary = if (defaults.timerVibrate) { + vibrateText(on = true) + } else { + stringResource(R.string.settings_timer_vibrate_off) + }, position = Position.Middle, trailing = { Switch( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9117ae..f1c70a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -300,7 +300,7 @@ Timers Sound, vibration and gradual volume How timers ring. A timer can still pick its own sound in its editor. - A timer set to silent vibrates either way + Off. A timer set to Silent still vibrates Clock Home time zone World clocks show how far each city is from your home time zone. 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 index 46a4cdc..df5d4a9 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/di/DataModuleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/di/DataModuleTest.kt @@ -25,6 +25,7 @@ class DataModuleTest { private fun TestScope.newStore(file: File) = createDataStore( scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), produceFile = { file }, + isFreshInstall = { false }, ) @Test diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt index ba2832e..dd55e95 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt @@ -42,12 +42,18 @@ class TimerVibrateMigrationTest { scope.coroutineContext.job.cancelAndJoin() } - private suspend fun TestScope.openPrefs(tempDir: Path, scope: CoroutineScope = newScope()): SettingsPrefs = - SettingsPrefs(PrefStore(createDataStore(scope = scope, produceFile = { file(tempDir) }))) + /** [freshInstall] is the package manager's half: never updated since installed. */ + private fun TestScope.openPrefs( + tempDir: Path, + freshInstall: Boolean = false, + scope: CoroutineScope = newScope(), + ): SettingsPrefs = SettingsPrefs( + PrefStore(createDataStore(scope = scope, produceFile = { file(tempDir) }, isFreshInstall = { freshInstall })), + ) @Test fun `a fresh install does not vibrate its timers`(@TempDir tempDir: Path) = runTest { - val prefs = openPrefs(tempDir) + val prefs = openPrefs(tempDir, freshInstall = true) assertThat(prefs.currentDefaults().timerVibrate).isFalse() assertThat(prefs.currentDefaults().vibrate).isTrue() @@ -58,10 +64,11 @@ class TimerVibrateMigrationTest { @TempDir tempDir: Path, ) = runTest { val firstLaunch = newScope() - openPrefs(tempDir, firstLaunch).setSnoozeMinutes(7) + openPrefs(tempDir, freshInstall = true, scope = firstLaunch).setSnoozeMinutes(7) firstLaunch.coroutineContext.job.cancelAndJoin() - val prefs = openPrefs(tempDir) + // Updated since, too: by now the key is there, and nothing asks again. + val prefs = openPrefs(tempDir, freshInstall = false) assertThat(prefs.currentDefaults().timerVibrate).isFalse() } @@ -89,6 +96,21 @@ class TimerVibrateMigrationTest { assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isFalse() } + @Test + fun `an updated install with an empty store keeps vibrating its timers`(@TempDir tempDir: Path) = runTest { + // A store replaced after corruption, or never written: empty, but not new. + val prefs = openPrefs(tempDir, freshInstall = false) + + assertThat(prefs.currentDefaults().timerVibrate).isTrue() + } + + @Test + fun `a new install restored from Auto Backup keeps what the old device did`(@TempDir tempDir: Path) = runTest { + writeOldInstall(tempDir, alarmVibrate = true) + + assertThat(openPrefs(tempDir, freshInstall = true).currentDefaults().timerVibrate).isTrue() + } + @Test fun `once migrated, the two settings move independently`(@TempDir tempDir: Path) = runTest { writeOldInstall(tempDir, alarmVibrate = true) @@ -102,7 +124,7 @@ class TimerVibrateMigrationTest { @Test fun `a choice the user made is never overwritten`(@TempDir tempDir: Path) = runTest { val firstLaunch = newScope() - openPrefs(tempDir, firstLaunch).setTimerVibrate(true) + openPrefs(tempDir, freshInstall = true, scope = firstLaunch).setTimerVibrate(true) firstLaunch.coroutineContext.job.cancelAndJoin() assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isTrue() diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt index 43ae2ab..d033b1e 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt @@ -102,6 +102,14 @@ class TimerRingPolicyTest { assertThat(ramped.sound().settings.rampSeconds).isEqualTo(20) } + @Test + fun `a new ring ramps, a handover between timers mid-ring does not`() { + val settings = decide(defaults = defaults.copy(timerVolumeRampSeconds = 20)).sound().settings + + assertThat(settings.rampSecondsFor(handover = false)).isEqualTo(20) + assertThat(settings.rampSecondsFor(handover = true)).isEqualTo(0) + } + /** §5.4 #8 */ @Test fun `a session already sounding is not restarted`() { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0366fa3..c2ec184 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1911,9 +1911,11 @@ alarm that should buzz the bed and a kitchen timer that should only beep are different questions. A fresh install has it **off**. An install from before the setting existed had its timers vibrate exactly when the alarm default did, so `TimerVibrateMigration` (a DataStore migration, run before the first read) -writes that value once rather than silently changing what its timers do; it -tells a fresh install by an empty store, since anything that has run has -written its boot id. Silent with vibration off is still not silence: +writes that value once rather than silently changing what its timers do. Only +a package never updated since install *and* an empty store count as fresh; +anything else (an update, an Auto Backup restore, a store replaced after +corruption) keeps the old behaviour. A ring that hands over from one expired +timer to another mid-ring does not start the ramp again. Silent with vibration off is still not silence: `AudioSourcePolicy` gives Silent no sources, and the player's `onSilenced` starts the vibrator regardless of the setting. From 84d31fe7f846fc0ee3a962fdd6579d0a980483a6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:06:50 +0200 Subject: [PATCH 3/8] fix(timers): settle timer vibration outside the store's init and resume a ramp The DataStore migration ran its write inside the store's initialisation, so a failed write (a full disk) would have failed every preference read, the alarm path's included. The timer-vibrate key is now nullable instead: unsettled reads as the alarm default, which is what timers did before, and ClockulaApp settles it once at the head of its startup pass, off only for a never-updated package with an empty store. A failed settle leaves the fallback in place. A timer ring restarted mid-session (handed to a second expired timer, back after an alarm held it, a recreated service) now picks its ramp up from the session's elapsed time instead of either ramping again from a whisper or jumping straight to full volume. RingAudioPlayer takes the offset; alarms pass none. The Vibrate row's switch is display-only, so a screen reader stops on the row once. Refs #22 --- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 28 ++++ .../clockula/data/di/DataModule.kt | 16 --- .../clockula/data/prefs/ClockPrefs.kt | 12 +- .../clockula/data/prefs/SettingsPrefs.kt | 24 +++- .../data/prefs/TimerVibrateMigration.kt | 42 ------ .../clockula/domain/timer/TimerRing.kt | 10 ++ .../clockula/domain/timer/TimerSettings.kt | 8 -- .../clockula/ring/RingAudioPlayer.kt | 39 ++++-- .../clockula/timer/service/TimerService.kt | 11 +- .../ui/settings/TimerDefaultsScreen.kt | 7 +- .../clockula/data/di/DataModuleTest.kt | 1 - .../clockula/data/prefs/ClockPrefsTest.kt | 7 + .../clockula/data/prefs/SettingsPrefsTest.kt | 89 ++++++++++++ .../data/prefs/TimerVibrateMigrationTest.kt | 132 ------------------ .../domain/timer/TimerRingPolicyTest.kt | 13 +- docs/ARCHITECTURE.md | 20 ++- 16 files changed, 223 insertions(+), 236 deletions(-) delete mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt delete mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 3a6c66e..9953f26 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -4,6 +4,7 @@ import android.app.Application import dagger.hilt.android.HiltAndroidApp import de.jeanlucmakiola.clockula.alarm.AlarmEngine import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications import de.jeanlucmakiola.clockula.timer.TimerEngine @@ -12,6 +13,7 @@ import de.jeanlucmakiola.clockula.widget.WidgetSync import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.di.ApplicationScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -41,6 +43,9 @@ class ClockulaApp : Application() { @Inject lateinit var widgetSync: WidgetSync + @Inject + lateinit var settings: SettingsPrefs + @Inject @ApplicationScope lateinit var scope: CoroutineScope @@ -67,6 +72,7 @@ class ClockulaApp : Application() { // a cold start caused by the fire broadcast must not interleave its // read-modify-write over `alarm_states` with the fire's own. scope.launch { + settleTimerVibrate() engine.onBootCompleted() // After the alarm engine's pass, and idempotent within a boot: the // boot-id gate makes the second repair a no-op, so neither engine @@ -86,4 +92,26 @@ class ClockulaApp : Application() { // for as long as the process lives (M10, docs/PLAN.md §9). scope.launch { widgetSync.run() } } + + /** + * Ahead of the engines' pass, because the boot gate writes the store and + * "nothing written yet" is half of how a fresh install is told apart. Never + * in the way of that pass: a failure here leaves the setting unsettled, + * which reads as the old behaviour (#22). + */ + private suspend fun settleTimerVibrate() { + try { + settings.settleTimerVibrate(freshInstall = isFreshInstall()) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // Unsettled is safe; the next launch tries again. + } + } + + /** Never updated since it was installed. Unknown reads as not fresh: the safe side. */ + private fun isFreshInstall(): Boolean = runCatching { + val info = packageManager.getPackageInfo(packageName, 0) + info.firstInstallTime == info.lastUpdateTime + }.getOrDefault(false) } 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 index 2fb730b..dcbd3c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt @@ -12,7 +12,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import de.jeanlucmakiola.clockula.data.prefs.TimerVibrateMigration import de.jeanlucmakiola.floret.di.IoDispatcher import de.jeanlucmakiola.floret.prefs.PrefStore import kotlinx.coroutines.CoroutineDispatcher @@ -46,18 +45,8 @@ object DataModule { ): DataStore = createDataStore( scope = CoroutineScope(io + SupervisorJob()), produceFile = { context.preferencesDataStoreFile(PREFS_NAME) }, - isFreshInstall = { isFreshInstall(context) }, ) - /** - * Never updated since it was installed. Unknown reads as not fresh, which - * is the migrations' safe side: it keeps what an install already did. - */ - private fun isFreshInstall(context: Context): Boolean = runCatching { - val info = context.packageManager.getPackageInfo(context.packageName, 0) - info.firstInstallTime == info.lastUpdateTime - }.getOrDefault(false) - @Provides @Singleton fun providePrefStore(dataStore: DataStore): PrefStore = PrefStore(dataStore) @@ -73,17 +62,12 @@ object DataModule { * 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. - * - * Migrations run before the first read, so nothing ever sees a key they fill. - * [isFreshInstall] is asked only by them, and only once. */ internal fun createDataStore( scope: CoroutineScope, produceFile: () -> File, - isFreshInstall: () -> Boolean, ): DataStore = PreferenceDataStoreFactory.create( corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, - migrations = listOf(TimerVibrateMigration(isFreshInstall)), scope = scope, produceFile = produceFile, ) 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 index 8587aaf..118a782 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -29,11 +29,15 @@ object ClockPrefs { val timerRingtoneUri: Pref = nullableStringPref("default_timer_ringtone_uri").blankAsAbsent() /** - * Off on a fresh install. An install from before the setting existed has - * it written once by [TimerVibrateMigration], from [vibrate] — which is - * what its timers vibrated by until then. + * Null — absent, or not a boolean — means "never settled", and reads as + * [vibrate]: what every timer vibrated by before the setting existed (#22). + * `SettingsPrefs.settleTimerVibrate` writes it once at startup, off on a + * fresh install. Stored as text because the kit has no nullable boolean. */ - val timerVibrate: Pref = booleanPref("default_timer_vibrate", false) + val timerVibrate: Pref = nullableStringPref("default_timer_vibrate").map( + decode = { it?.toBooleanStrictOrNull() }, + encode = { it?.toString() }, + ) val timerVolumeRampSeconds: Pref = intPref("default_timer_volume_ramp_seconds", 0).clampedTo(ClockLimits.VOLUME_RAMP_SECONDS) 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 1d13e6e..977db31 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 @@ -75,7 +75,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { partial.copy( alarmRingtoneUri = alarmRingtoneUri, timerRingtoneUri = timerRingtoneUri, - timerVibrate = timerVibrate, + // Unsettled follows the alarm default, which is what it did before #22. + timerVibrate = timerVibrate ?: partial.vibrate, timerVolumeRampSeconds = timerVolumeRampSeconds, homeZoneId = homeZoneId, ) @@ -120,6 +121,27 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setTimerVibrate(enabled: Boolean) = store.set(ClockPrefs.timerVibrate, enabled) + /** + * Gives the timers' vibration a value of its own, once (#22). Until then a + * timer vibrated exactly when the alarm default said so, and an install + * from before keeps that: the alarm default is copied, so changing it + * later no longer drags the timers along. Only a fresh install — a + * [freshInstall] package *and* a store nothing has written yet — starts + * with it off. Either signal alone mistakes something for fresh (an Auto + * Backup restore; a store replaced after corruption), and that mistake + * would silently stop someone's timers buzzing. + * + * Best-effort by design: until it lands, and if it never does, the read + * side's fallback keeps the old behaviour, so a failed write costs nothing. + */ + suspend fun settleTimerVibrate(freshInstall: Boolean) { + val untouched = store.data.first().asMap().isEmpty() + val alarmVibrate = store.get(ClockPrefs.vibrate) + store.update(ClockPrefs.timerVibrate) { stored -> + stored ?: if (untouched && freshInstall) false else alarmVibrate + } + } + suspend fun setTimerVolumeRampSeconds(seconds: Int) = store.set(ClockPrefs.timerVolumeRampSeconds, seconds) suspend fun setDismissChallenge(challenge: DismissChallenge) = diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt deleted file mode 100644 index 0c82ba8..0000000 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt +++ /dev/null @@ -1,42 +0,0 @@ -package de.jeanlucmakiola.clockula.data.prefs - -import androidx.datastore.core.DataMigration -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import de.jeanlucmakiola.floret.prefs.set - -/** - * Gives an install from before #22 a timer-vibrate setting that keeps its - * timers doing what they did: until then a timer vibrated exactly when the - * alarm default did, so that is the value written. Only a fresh install gets - * the setting's own default, off. - * - * Fresh means both: the package was never updated ([isFreshInstall], from the - * package manager) **and** the store is empty. The first alone would take an - * Auto Backup restore onto a new device for fresh; the second alone would take - * an updated install whose store was replaced after corruption, or never - * written, for fresh. Either mistake silently stops someone's timers buzzing, - * so when in doubt this keeps the old behaviour. - * - * The value is always written, the default included, so this runs once. - */ -internal class TimerVibrateMigration( - private val isFreshInstall: () -> Boolean, -) : DataMigration { - - private val timerVibrateKey = booleanPreferencesKey(ClockPrefs.timerVibrate.name) - private val alarmVibrateKey = booleanPreferencesKey(ClockPrefs.vibrate.name) - - override suspend fun shouldMigrate(currentData: Preferences): Boolean = timerVibrateKey !in currentData - - override suspend fun migrate(currentData: Preferences): Preferences { - val inherited = if (currentData.asMap().isEmpty() && isFreshInstall()) { - ClockPrefs.timerVibrate.default - } else { - currentData[alarmVibrateKey] ?: ClockPrefs.vibrate.default - } - return currentData.toMutablePreferences().apply { this[ClockPrefs.timerVibrate] = inherited } - } - - override suspend fun cleanUp() = Unit -} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt index e0c905d..f0402cd 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt @@ -22,4 +22,14 @@ object TimerRing { /** The floor under every `delay` the service takes, so a rounding error cannot spin it. */ val WAKE_FLOOR: Duration = 100.milliseconds + + /** + * How far into its ramp a ring is, from what is left of its window + * ([AUTO_SILENCE_AFTER] long, opened when the session first sounded). A ring + * restarted mid-session — handed to another expired timer, back after an + * alarm held it, or a recreated service — carries on from there rather than + * ramping again from a whisper. Zero for a session that has not sounded. + */ + fun rampElapsed(silenceIn: Duration?): Duration = + silenceIn?.let { (AUTO_SILENCE_AFTER - it).coerceAtLeast(Duration.ZERO) } ?: Duration.ZERO } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt index 43cd22d..3c63b17 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt @@ -21,11 +21,3 @@ fun Timer.resolveSettings(defaults: ClockDefaults): TimerSettings = TimerSetting rampSeconds = defaults.timerVolumeRampSeconds, ) -/** - * The ramp a ring actually starts with. A [handover] — the sounding timer - * changes while the ring goes on, because a second one expired and sorts first - * or the first was stopped with another still expired — starts at full volume: - * the user has already heard the ring, and starting the ramp over would drop it - * to a whisper partway through. - */ -fun TimerSettings.rampSecondsFor(handover: Boolean): Int = if (handover) 0 else rampSeconds diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ring/RingAudioPlayer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ring/RingAudioPlayer.kt index a631089..a096520 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ring/RingAudioPlayer.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ring/RingAudioPlayer.kt @@ -72,19 +72,26 @@ class RingAudioPlayer(private val context: Context) { * * [onSilenced] is called, at most once, when that chain found no sound at * all; never after [stop]. + * + * [rampFrom] picks the ramp up partway, for a ring that is restarted rather + * than new — a timer ring handed from one timer to another, or back after an + * alarm held it — so the volume carries on rather than dropping back to a + * whisper. Past the ramp's end it starts at full volume. */ fun start( scope: CoroutineScope, ringtoneUri: String?, rampSeconds: Int, onSilenced: () -> Unit, + rampFrom: Duration = Duration.ZERO, ) { stop() val ring = generation val rampFor = rampSeconds.coerceAtLeast(0).seconds + val rampStart = rampFrom.coerceAtLeast(Duration.ZERO) scope.launch { - val audible = startMedia(ring, scope, ringtoneUri, rampFor, onSilenced) || - startPlayer(ring, scope, ringtoneUri, rampFor, onSilenced) + val audible = startMedia(ring, scope, ringtoneUri, rampFor, rampStart, onSilenced) || + startPlayer(ring, scope, ringtoneUri, rampFor, rampStart, onSilenced) if (!audible && ring == generation) onSilenced() } } @@ -108,6 +115,7 @@ class RingAudioPlayer(private val context: Context) { scope: CoroutineScope, ringtoneUri: String?, rampFor: Duration, + rampStart: Duration, onSilenced: () -> Unit, ): Boolean { val ref = MediaSources.decode(ringtoneUri) ?: return false @@ -126,7 +134,7 @@ class RingAudioPlayer(private val context: Context) { false } if (started && ring == generation) { - watchMedia(ring, scope, media, ringtoneUri, rampFor, onSilenced) + watchMedia(ring, scope, media, ringtoneUri, rampFor, rampStart, onSilenced) return true } media.stop() @@ -148,6 +156,7 @@ class RingAudioPlayer(private val context: Context) { scope: CoroutineScope, ringtoneUri: String?, rampFor: Duration, + rampStart: Duration, onSilenced: () -> Unit, from: Int = 0, ): Boolean { @@ -161,7 +170,7 @@ class RingAudioPlayer(private val context: Context) { return false } requestFocus() - val initial = VolumeRamp.levelAt(Duration.ZERO, rampFor) + val initial = VolumeRamp.levelAt(rampStart, rampFor) val started = runCatching { prepared.setVolume(initial, initial) prepared.start() @@ -180,13 +189,13 @@ class RingAudioPlayer(private val context: Context) { release(failed) abandonFocus() scope.launch { - val next = startPlayer(ring, scope, ringtoneUri, Duration.ZERO, onSilenced, index + 1) + val next = startPlayer(ring, scope, ringtoneUri, Duration.ZERO, Duration.ZERO, onSilenced, index + 1) if (!next && ring == generation) onSilenced() } } true } - ramp = launchRamp(scope, rampFor) { level -> runCatching { player?.setVolume(level, level) } } + ramp = launchRamp(scope, rampFor, rampStart) { level -> runCatching { player?.setVolume(level, level) } } return true } return false @@ -205,10 +214,11 @@ class RingAudioPlayer(private val context: Context) { media: MediaAppRing, ringtoneUri: String?, rampFor: Duration, + rampStart: Duration, onSilenced: () -> Unit, ) { - media.setLevel(VolumeRamp.levelAt(Duration.ZERO, rampFor)) - ramp = launchRamp(scope, rampFor, media::setLevel) + media.setLevel(VolumeRamp.levelAt(rampStart, rampFor)) + ramp = launchRamp(scope, rampFor, rampStart, media::setLevel) watchdog = scope.launch { var stall = MediaRingPolicy.Stall() while (isActive && ring == generation) { @@ -221,14 +231,19 @@ class RingAudioPlayer(private val context: Context) { ramp = null media.stop() mediaRing = null - if (!startPlayer(ring, scope, ringtoneUri, Duration.ZERO, onSilenced) && ring == generation) onSilenced() + if (!startPlayer(ring, scope, ringtoneUri, Duration.ZERO, Duration.ZERO, onSilenced) && ring == generation) onSilenced() } } - /** Steps [setLevel] along [VolumeRamp] every [RingAudio.RAMP_TICK] until [rampFor] has passed. */ - private fun launchRamp(scope: CoroutineScope, rampFor: Duration, setLevel: (Float) -> Unit): Job = + /** Steps [setLevel] along [VolumeRamp] every [RingAudio.RAMP_TICK] from [from] until [rampFor] has passed. */ + private fun launchRamp( + scope: CoroutineScope, + rampFor: Duration, + from: Duration, + setLevel: (Float) -> Unit, + ): Job = scope.launch { - var elapsed = Duration.ZERO + var elapsed = from while (isActive && elapsed < rampFor) { delay(RingAudio.RAMP_TICK) elapsed += RingAudio.RAMP_TICK diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt index ea103e6..e4226bf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt @@ -16,8 +16,8 @@ import de.jeanlucmakiola.clockula.data.timers.TimerRepository import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState import de.jeanlucmakiola.clockula.domain.timer.TimerMode +import de.jeanlucmakiola.clockula.domain.timer.TimerRing import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision -import de.jeanlucmakiola.clockula.domain.timer.rampSecondsFor import de.jeanlucmakiola.clockula.ring.RingAudioPlayer import de.jeanlucmakiola.clockula.ring.RingVibrator import de.jeanlucmakiola.clockula.timer.AlarmRingStatus @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch +import kotlin.time.Duration import kotlin.time.Instant import javax.inject.Inject @@ -190,7 +191,7 @@ class TimerService : Service() { return } - applyRing(state.ring) + applyRing(state.ring, state.silenceIn) post(notification, sounding = state.ring is TimerRingDecision.Sound) scheduleDelays(state) progressRefresh?.cancel() @@ -223,13 +224,12 @@ class TimerService : Service() { return engine.serviceState(alreadyAlerted) } - private fun applyRing(decision: TimerRingDecision) { + private fun applyRing(decision: TimerRingDecision, silenceIn: Duration?) { when (decision) { is TimerRingDecision.Sound -> { // An unchanged decision does nothing: restarting the player on // every emission would stutter the ring. if (sounding == decision.subjectId) return - val handover = sounding != null releaseRing() sounding = decision.subjectId acquireWakeLock() @@ -242,7 +242,8 @@ class TimerService : Service() { audio.start( scope = scope, ringtoneUri = decision.settings.ringtoneUri, - rampSeconds = decision.settings.rampSecondsFor(handover), + rampSeconds = decision.settings.rampSeconds, + rampFrom = TimerRing.rampElapsed(silenceIn), // Silence is not one of the outcomes: with no audio source at // all the timer vibrates even when vibration is switched off. onSilenced = vibrator::start, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt index 9130302..be6aca0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt @@ -56,10 +56,9 @@ internal fun TimerDefaultsScreen( }, position = Position.Middle, trailing = { - Switch( - checked = defaults.timerVibrate, - onCheckedChange = viewModel::onTimerVibrateChanged, - ) + // Display only: the row is the one control, so a screen reader + // stops on it once rather than on the row and then the switch. + Switch(checked = defaults.timerVibrate, onCheckedChange = null) }, onClick = { viewModel.onTimerVibrateChanged(!defaults.timerVibrate) }, ) 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 index df5d4a9..46a4cdc 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/di/DataModuleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/di/DataModuleTest.kt @@ -25,7 +25,6 @@ class DataModuleTest { private fun TestScope.newStore(file: File) = createDataStore( scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), produceFile = { file }, - isFreshInstall = { false }, ) @Test 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 index 869cca3..8401734 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt @@ -37,6 +37,8 @@ class ClockPrefsTest { @Test fun `a fresh install reads the documented defaults`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + // As ClockulaApp's startup pass does on a fresh install. + prefs.settleTimerVibrate(freshInstall = true) assertThat(prefs.defaults.first()).isEqualTo(ClockDefaults()) } @@ -51,6 +53,8 @@ class ClockPrefsTest { prefs.setVolumeRampSeconds(0) prefs.setAlarmRingtoneUri("content://a") prefs.setTimerRingtoneUri("content://t") + prefs.setTimerVibrate(true) + prefs.setTimerVolumeRampSeconds(25) prefs.setDismissChallenge(DismissChallenge.HOLD) prefs.setHomeZoneId("Europe/Berlin") @@ -62,6 +66,8 @@ class ClockPrefsTest { volumeRampSeconds = 0, alarmRingtoneUri = "content://a", timerRingtoneUri = "content://t", + timerVibrate = true, + timerVolumeRampSeconds = 25, dismissChallenge = DismissChallenge.HOLD, homeZoneId = "Europe/Berlin", ), @@ -71,6 +77,7 @@ class ClockPrefsTest { @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.settleTimerVibrate(freshInstall = true) prefs.defaults.test { assertThat(awaitItem()).isEqualTo(ClockDefaults()) 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 index 1e0a018..bdffca6 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -169,6 +169,95 @@ class SettingsPrefsTest { assertThat(defaults.timerVolumeRampSeconds).isEqualTo(10) } + @Test + fun `an unsettled timer vibration follows the alarm default, as timers did before`( + @TempDir tempDir: Path, + ) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.setVibrate(false) + assertThat(prefs.currentDefaults().timerVibrate).isFalse() + prefs.setVibrate(true) + assertThat(prefs.currentDefaults().timerVibrate).isTrue() + } + + @Test + fun `a fresh install settles timer vibration off`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.settleTimerVibrate(freshInstall = true) + + assertThat(prefs.currentDefaults().timerVibrate).isFalse() + assertThat(prefs.currentDefaults().vibrate).isTrue() + } + + @Test + fun `an older install settles timer vibration from the alarm default`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + prefs.setVibrate(false) + + prefs.settleTimerVibrate(freshInstall = false) + // Settled, so the two now move apart. + prefs.setVibrate(true) + + assertThat(prefs.currentDefaults().timerVibrate).isFalse() + } + + @Test + fun `an older install that never touched alarm vibration keeps vibrating its timers`( + @TempDir tempDir: Path, + ) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + prefs.setSnoozeMinutes(7) + + prefs.settleTimerVibrate(freshInstall = false) + prefs.setVibrate(false) + + assertThat(prefs.currentDefaults().timerVibrate).isTrue() + } + + @Test + fun `an updated install with an empty store is not taken for fresh`(@TempDir tempDir: Path) = runTest { + // A store replaced after corruption, or never written: empty, but not new. + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.settleTimerVibrate(freshInstall = false) + + assertThat(prefs.currentDefaults().timerVibrate).isTrue() + } + + @Test + fun `a new package with a restored store is not taken for fresh`(@TempDir tempDir: Path) = runTest { + // Auto Backup restored the old device's store before the first launch. + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + prefs.setSnoozeMinutes(7) + + prefs.settleTimerVibrate(freshInstall = true) + + assertThat(prefs.currentDefaults().timerVibrate).isTrue() + } + + @Test + fun `settling never overwrites a choice`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + prefs.setTimerVibrate(true) + + prefs.settleTimerVibrate(freshInstall = true) + + assertThat(prefs.currentDefaults().timerVibrate).isTrue() + } + + @Test + fun `a garbled timer vibration reads as unsettled`(@TempDir tempDir: Path) = runTest { + val dataStore = newDataStore(tempDir) + val prefs = SettingsPrefs(PrefStore(dataStore)) + dataStore.updateData { stored -> + stored.toMutablePreferences().apply { this[stringPreferencesKey("default_timer_vibrate")] = "maybe" } + } + + assertThat(prefs.currentDefaults().timerVibrate).isTrue() + } + @Test fun `the timer ramp is clamped like the alarm ramp`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt deleted file mode 100644 index dd55e95..0000000 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -package de.jeanlucmakiola.clockula.data.prefs - -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import com.google.common.truth.Truth.assertThat -import de.jeanlucmakiola.clockula.data.di.createDataStore -import de.jeanlucmakiola.floret.prefs.PrefStore -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.job -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 - -/** - * #22's upgrade path, through the store `DataModule` builds: an install from - * before timers had their own vibration keeps vibrating its timers exactly - * when it did, and a fresh one starts with it off. - */ -class TimerVibrateMigrationTest { - - private fun TestScope.newScope(): CoroutineScope = - CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()) - - private fun file(tempDir: Path): File = tempDir.resolve("clockula_prefs.preferences_pb").toFile() - - /** The file as a version before #22 left it: no migration, only the keys it knew. */ - private suspend fun TestScope.writeOldInstall(tempDir: Path, alarmVibrate: Boolean?) { - val scope = newScope() - PreferenceDataStoreFactory.create(scope = scope, produceFile = { file(tempDir) }).edit { prefs -> - prefs[stringPreferencesKey("last_boot_id")] = "boot" - if (alarmVibrate != null) prefs[booleanPreferencesKey("default_vibrate")] = alarmVibrate - } - // DataStore refuses a second store over a file a live one holds. - scope.coroutineContext.job.cancelAndJoin() - } - - /** [freshInstall] is the package manager's half: never updated since installed. */ - private fun TestScope.openPrefs( - tempDir: Path, - freshInstall: Boolean = false, - scope: CoroutineScope = newScope(), - ): SettingsPrefs = SettingsPrefs( - PrefStore(createDataStore(scope = scope, produceFile = { file(tempDir) }, isFreshInstall = { freshInstall })), - ) - - @Test - fun `a fresh install does not vibrate its timers`(@TempDir tempDir: Path) = runTest { - val prefs = openPrefs(tempDir, freshInstall = true) - - assertThat(prefs.currentDefaults().timerVibrate).isFalse() - assertThat(prefs.currentDefaults().vibrate).isTrue() - } - - @Test - fun `a fresh install stays off on the next launch, once the store is no longer empty`( - @TempDir tempDir: Path, - ) = runTest { - val firstLaunch = newScope() - openPrefs(tempDir, freshInstall = true, scope = firstLaunch).setSnoozeMinutes(7) - firstLaunch.coroutineContext.job.cancelAndJoin() - - // Updated since, too: by now the key is there, and nothing asks again. - val prefs = openPrefs(tempDir, freshInstall = false) - - assertThat(prefs.currentDefaults().timerVibrate).isFalse() - } - - @Test - fun `an older install with alarm vibration on keeps vibrating its timers`(@TempDir tempDir: Path) = runTest { - writeOldInstall(tempDir, alarmVibrate = true) - - assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isTrue() - } - - @Test - fun `an older install that never touched alarm vibration keeps vibrating its timers`( - @TempDir tempDir: Path, - ) = runTest { - writeOldInstall(tempDir, alarmVibrate = null) - - assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isTrue() - } - - @Test - fun `an older install with alarm vibration off keeps its timers still`(@TempDir tempDir: Path) = runTest { - writeOldInstall(tempDir, alarmVibrate = false) - - assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isFalse() - } - - @Test - fun `an updated install with an empty store keeps vibrating its timers`(@TempDir tempDir: Path) = runTest { - // A store replaced after corruption, or never written: empty, but not new. - val prefs = openPrefs(tempDir, freshInstall = false) - - assertThat(prefs.currentDefaults().timerVibrate).isTrue() - } - - @Test - fun `a new install restored from Auto Backup keeps what the old device did`(@TempDir tempDir: Path) = runTest { - writeOldInstall(tempDir, alarmVibrate = true) - - assertThat(openPrefs(tempDir, freshInstall = true).currentDefaults().timerVibrate).isTrue() - } - - @Test - fun `once migrated, the two settings move independently`(@TempDir tempDir: Path) = runTest { - writeOldInstall(tempDir, alarmVibrate = true) - val prefs = openPrefs(tempDir) - - prefs.setVibrate(false) - - assertThat(prefs.currentDefaults().timerVibrate).isTrue() - } - - @Test - fun `a choice the user made is never overwritten`(@TempDir tempDir: Path) = runTest { - val firstLaunch = newScope() - openPrefs(tempDir, freshInstall = true, scope = firstLaunch).setTimerVibrate(true) - firstLaunch.coroutineContext.job.cancelAndJoin() - - assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isTrue() - } -} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt index d033b1e..76a579d 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt @@ -103,11 +103,14 @@ class TimerRingPolicyTest { } @Test - fun `a new ring ramps, a handover between timers mid-ring does not`() { - val settings = decide(defaults = defaults.copy(timerVolumeRampSeconds = 20)).sound().settings - - assertThat(settings.rampSecondsFor(handover = false)).isEqualTo(20) - assertThat(settings.rampSecondsFor(handover = true)).isEqualTo(0) + fun `a ring restarted mid-session picks its ramp up where the window says it is`() { + assertThat(TimerRing.rampElapsed(silenceIn = null)).isEqualTo(Duration.ZERO) + assertThat(TimerRing.rampElapsed(silenceIn = TimerRing.AUTO_SILENCE_AFTER)).isEqualTo(Duration.ZERO) + assertThat(TimerRing.rampElapsed(silenceIn = TimerRing.AUTO_SILENCE_AFTER - 12.seconds)) + .isEqualTo(12.seconds) + // A window longer than the constant (a clock change) never ramps backwards. + assertThat(TimerRing.rampElapsed(silenceIn = TimerRing.AUTO_SILENCE_AFTER + 5.seconds)) + .isEqualTo(Duration.ZERO) } /** §5.4 #8 */ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c2ec184..f87ee1d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1910,12 +1910,20 @@ Vibration is `ClockDefaults.timerVibrate`, never the alarms' `vibrate`: an alarm that should buzz the bed and a kitchen timer that should only beep are different questions. A fresh install has it **off**. An install from before the setting existed had its timers vibrate exactly when the alarm default did, -so `TimerVibrateMigration` (a DataStore migration, run before the first read) -writes that value once rather than silently changing what its timers do. Only -a package never updated since install *and* an empty store count as fresh; -anything else (an update, an Auto Backup restore, a store replaced after -corruption) keeps the old behaviour. A ring that hands over from one expired -timer to another mid-ring does not start the ramp again. Silent with vibration off is still not silence: +and keeps that. The key is nullable: absent reads as the alarm default, and +`SettingsPrefs.settleTimerVibrate` writes a value of its own once, at the head +of `ClockulaApp`'s startup pass (before the boot gate writes the store). Only a +package never updated since install *and* an empty store count as fresh and +get `false`; anything else (an update, an Auto Backup restore, a store replaced +after corruption) gets the alarm default copied. It is deliberately not a +DataStore migration: a migration's write runs inside the store's +initialisation, and a failed one would fail every read, the alarm path's +included. A failed settle only leaves the fallback in place. + +A ring restarted mid-session (handed to a second expired timer, back after an +alarm held it, a recreated service) picks its ramp up where the session is, +`TimerRing.rampElapsed` from the window's remaining time, rather than ramping +again from a whisper. Silent with vibration off is still not silence: `AudioSourcePolicy` gives Silent no sources, and the player's `onSilenced` starts the vibrator regardless of the setting. From cc2099c40a45ebee3f10e8d814bf841c0358304e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:15:39 +0200 Subject: [PATCH 4/8] fix(timers): settle timer vibration in one transaction and announce its row as a switch The settle read the store, then wrote it in a second step; a write in between could leave it deciding on a stale snapshot. It is one edit now, which also skips the write once the value exists. The Vibrate row carries the switch role and its state, since the switch itself is display-only. The changelog says what an updated install gets, not just a fresh one. Refs #22 --- CHANGELOG.md | 8 +++++--- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 4 +++- .../clockula/data/prefs/SettingsPrefs.kt | 16 ++++++++++++---- .../clockula/ui/settings/TimerDefaultsScreen.kt | 12 +++++++++++- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c887e30..e278004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,9 +38,11 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. timer turns the screen on and opens over the lock screen with Stop and +1 min. Timers keep counting through a reboot and aren't affected by changes to the clock. -- Timers have their own vibration switch, off by default, and an optional - gradual volume, both apart from the alarms'. A kitchen timer can just beep - while alarms still vibrate; a silent timer still vibrates. +- Timers have their own vibration switch and an optional gradual volume, both + apart from the alarms'. A kitchen timer can just beep while alarms still + vibrate; a silent timer still vibrates. New installs start with timer + vibration off; an updated install keeps vibrating its timers if it did + before. - **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause in the notification. It keeps counting while the app is closed. - **World clock.** An analog face for your own time, plus cities from the diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 9953f26..5945f12 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -105,7 +105,9 @@ class ClockulaApp : Application() { } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { - // Unsettled is safe; the next launch tries again. + // Unsettled reads as the old behaviour. The next launch tries again, + // and by then the store is no longer empty, so even a fresh install + // settles on the alarm default: vibrating, the safe side to err on. } } 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 977db31..bed61f8 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,5 +1,7 @@ package de.jeanlucmakiola.clockula.data.prefs +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.DismissChallenge import de.jeanlucmakiola.clockula.domain.backup.BackupSettings @@ -135,10 +137,16 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { * side's fallback keeps the old behaviour, so a failed write costs nothing. */ suspend fun settleTimerVibrate(freshInstall: Boolean) { - val untouched = store.data.first().asMap().isEmpty() - val alarmVibrate = store.get(ClockPrefs.vibrate) - store.update(ClockPrefs.timerVibrate) { stored -> - stored ?: if (untouched && freshInstall) false else alarmVibrate + // One transaction, so nothing written meanwhile can make the snapshot + // it decides on stale. Raw keys for the reads: the kit reads a Pref + // only from a store, not from a transaction's preferences. + store.edit { prefs -> + if (stringPreferencesKey(ClockPrefs.timerVibrate.name) in prefs) return@edit + prefs[ClockPrefs.timerVibrate] = if (prefs.asMap().isEmpty() && freshInstall) { + false + } else { + prefs[booleanPreferencesKey(ClockPrefs.vibrate.name)] ?: ClockPrefs.vibrate.default + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt index be6aca0..2d7c659 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt @@ -10,6 +10,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.dp import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.ui.alarms.VolumeRampPicker @@ -49,6 +54,11 @@ internal fun TimerDefaultsScreen( // what the ring does with Silent and vibration both off: vibrates anyway. GroupedRow( title = stringResource(R.string.editor_vibrate), + // The switch below is display-only, so the row carries its role and state. + modifier = Modifier.semantics { + role = Role.Switch + toggleableState = ToggleableState(defaults.timerVibrate) + }, summary = if (defaults.timerVibrate) { vibrateText(on = true) } else { @@ -57,7 +67,7 @@ internal fun TimerDefaultsScreen( position = Position.Middle, trailing = { // Display only: the row is the one control, so a screen reader - // stops on it once rather than on the row and then the switch. + // stops once, on a row announced as a switch. Switch(checked = defaults.timerVibrate, onCheckedChange = null) }, onClick = { viewModel.onTimerVibrateChanged(!defaults.timerVibrate) }, From 820317f9d90aa7300239934b5e03f0ba049a36d9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:24:52 +0200 Subject: [PATCH 5/8] fix(timers): tell a fresh install by its boot id and repair a garbled timer vibration An empty store was a racy sign of a fresh install: the UI can write the store (the notification-permission flag) before the startup settle runs, and a fresh install would then vibrate its timers. The boot id is written only by the engines' pass, which runs after the settle in the same coroutine, and an Auto Backup restore brings the old device's. Once settled, the startup pass costs one read and no package-manager call. A garbled stored value is replaced rather than left following the alarm default. Refs #22 --- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 10 ++-- .../clockula/data/prefs/SettingsPrefs.kt | 23 +++++--- .../clockula/data/prefs/ClockPrefsTest.kt | 4 +- .../clockula/data/prefs/SettingsPrefsTest.kt | 54 +++++++++++++++---- docs/ARCHITECTURE.md | 8 +-- 5 files changed, 72 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 5945f12..30c4d1f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -94,20 +94,20 @@ class ClockulaApp : Application() { } /** - * Ahead of the engines' pass, because the boot gate writes the store and - * "nothing written yet" is half of how a fresh install is told apart. Never + * Ahead of the engines' pass, because the boot gate writes the boot id, and + * its absence is half of how a fresh install is told apart. Never * in the way of that pass: a failure here leaves the setting unsettled, * which reads as the old behaviour (#22). */ private suspend fun settleTimerVibrate() { try { - settings.settleTimerVibrate(freshInstall = isFreshInstall()) + settings.settleTimerVibrate(freshInstall = ::isFreshInstall) } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { // Unsettled reads as the old behaviour. The next launch tries again, - // and by then the store is no longer empty, so even a fresh install - // settles on the alarm default: vibrating, the safe side to err on. + // and by then the boot id is there, so even a fresh install settles + // on the alarm default: vibrating, the safe side to err on. } } 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 bed61f8..c1f81da 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 @@ -127,22 +127,29 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { * Gives the timers' vibration a value of its own, once (#22). Until then a * timer vibrated exactly when the alarm default said so, and an install * from before keeps that: the alarm default is copied, so changing it - * later no longer drags the timers along. Only a fresh install — a - * [freshInstall] package *and* a store nothing has written yet — starts - * with it off. Either signal alone mistakes something for fresh (an Auto - * Backup restore; a store replaced after corruption), and that mistake - * would silently stop someone's timers buzzing. + * later no longer drags the timers along. Only a fresh install starts with + * it off: a [freshInstall] package *and* a store without a boot id, which + * the engines write on every launch after this runs. Either signal alone + * mistakes something for fresh (an Auto Backup restore brings the old + * device's boot id; a store replaced after corruption is not a fresh + * package), and that mistake would silently stop someone's timers buzzing. + * The boot id rather than "empty" because the UI may write the store first. * * Best-effort by design: until it lands, and if it never does, the read * side's fallback keeps the old behaviour, so a failed write costs nothing. + * Once settled it is one read; [freshInstall] is asked only when needed. */ - suspend fun settleTimerVibrate(freshInstall: Boolean) { + suspend fun settleTimerVibrate(freshInstall: () -> Boolean) { + if (store.get(ClockPrefs.timerVibrate) != null) return // One transaction, so nothing written meanwhile can make the snapshot // it decides on stale. Raw keys for the reads: the kit reads a Pref // only from a store, not from a transaction's preferences. store.edit { prefs -> - if (stringPreferencesKey(ClockPrefs.timerVibrate.name) in prefs) return@edit - prefs[ClockPrefs.timerVibrate] = if (prefs.asMap().isEmpty() && freshInstall) { + val stored = prefs[stringPreferencesKey(ClockPrefs.timerVibrate.name)] + // Settled meanwhile; a garbled value falls through and is replaced. + if (stored?.toBooleanStrictOrNull() != null) return@edit + val neverRan = stringPreferencesKey(SystemPrefs.lastBootId.name) !in prefs + prefs[ClockPrefs.timerVibrate] = if (neverRan && freshInstall()) { false } else { prefs[booleanPreferencesKey(ClockPrefs.vibrate.name)] ?: ClockPrefs.vibrate.default 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 index 8401734..fc833b0 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt @@ -38,7 +38,7 @@ class ClockPrefsTest { fun `a fresh install reads the documented defaults`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) // As ClockulaApp's startup pass does on a fresh install. - prefs.settleTimerVibrate(freshInstall = true) + prefs.settleTimerVibrate(freshInstall = { true }) assertThat(prefs.defaults.first()).isEqualTo(ClockDefaults()) } @@ -77,7 +77,7 @@ class ClockPrefsTest { @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.settleTimerVibrate(freshInstall = true) + prefs.settleTimerVibrate(freshInstall = { true }) prefs.defaults.test { assertThat(awaitItem()).isEqualTo(ClockDefaults()) 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 index bdffca6..c44f501 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -185,7 +185,7 @@ class SettingsPrefsTest { fun `a fresh install settles timer vibration off`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) - prefs.settleTimerVibrate(freshInstall = true) + prefs.settleTimerVibrate(freshInstall = { true }) assertThat(prefs.currentDefaults().timerVibrate).isFalse() assertThat(prefs.currentDefaults().vibrate).isTrue() @@ -196,7 +196,7 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) prefs.setVibrate(false) - prefs.settleTimerVibrate(freshInstall = false) + prefs.settleTimerVibrate(freshInstall = { false }) // Settled, so the two now move apart. prefs.setVibrate(true) @@ -210,7 +210,7 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) prefs.setSnoozeMinutes(7) - prefs.settleTimerVibrate(freshInstall = false) + prefs.settleTimerVibrate(freshInstall = { false }) prefs.setVibrate(false) assertThat(prefs.currentDefaults().timerVibrate).isTrue() @@ -221,28 +221,49 @@ class SettingsPrefsTest { // A store replaced after corruption, or never written: empty, but not new. val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) - prefs.settleTimerVibrate(freshInstall = false) + prefs.settleTimerVibrate(freshInstall = { false }) assertThat(prefs.currentDefaults().timerVibrate).isTrue() } @Test fun `a new package with a restored store is not taken for fresh`(@TempDir tempDir: Path) = runTest { - // Auto Backup restored the old device's store before the first launch. - val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) - prefs.setSnoozeMinutes(7) + // Auto Backup restored the old device's store, boot id and all. + val store = PrefStore(newDataStore(tempDir)) + val prefs = SettingsPrefs(store) + store.set(SystemPrefs.lastBootId, "old-device-boot") - prefs.settleTimerVibrate(freshInstall = true) + prefs.settleTimerVibrate(freshInstall = { true }) assertThat(prefs.currentDefaults().timerVibrate).isTrue() } + @Test + fun `a fresh install is still fresh when the UI wrote the store first`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + prefs.markNotificationPermissionAsked() + + prefs.settleTimerVibrate(freshInstall = { true }) + + assertThat(prefs.currentDefaults().timerVibrate).isFalse() + } + + @Test + fun `once settled, the package is not asked again`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + prefs.settleTimerVibrate(freshInstall = { true }) + + prefs.settleTimerVibrate(freshInstall = { error("asked again") }) + + assertThat(prefs.currentDefaults().timerVibrate).isFalse() + } + @Test fun `settling never overwrites a choice`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) prefs.setTimerVibrate(true) - prefs.settleTimerVibrate(freshInstall = true) + prefs.settleTimerVibrate(freshInstall = { true }) assertThat(prefs.currentDefaults().timerVibrate).isTrue() } @@ -258,6 +279,21 @@ class SettingsPrefsTest { assertThat(prefs.currentDefaults().timerVibrate).isTrue() } + @Test + fun `a garbled timer vibration is settled like an absent one`(@TempDir tempDir: Path) = runTest { + val dataStore = newDataStore(tempDir) + val prefs = SettingsPrefs(PrefStore(dataStore)) + prefs.setVibrate(false) + dataStore.updateData { stored -> + stored.toMutablePreferences().apply { this[stringPreferencesKey("default_timer_vibrate")] = "maybe" } + } + + prefs.settleTimerVibrate(freshInstall = { false }) + prefs.setVibrate(true) + + assertThat(prefs.currentDefaults().timerVibrate).isFalse() + } + @Test fun `the timer ramp is clamped like the alarm ramp`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f87ee1d..7f5db0a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1913,9 +1913,11 @@ the setting existed had its timers vibrate exactly when the alarm default did, and keeps that. The key is nullable: absent reads as the alarm default, and `SettingsPrefs.settleTimerVibrate` writes a value of its own once, at the head of `ClockulaApp`'s startup pass (before the boot gate writes the store). Only a -package never updated since install *and* an empty store count as fresh and -get `false`; anything else (an update, an Auto Backup restore, a store replaced -after corruption) gets the alarm default copied. It is deliberately not a +package never updated since install *and* a store without `last_boot_id` +(the engines write it on every launch, after the settle) count as fresh and +get `false`; anything else (an update, an Auto Backup restore, which brings the +old boot id, a store replaced after corruption) gets the alarm default copied. +The boot id rather than an empty store, because the UI may write first. It is deliberately not a DataStore migration: a migration's write runs inside the store's initialisation, and a failed one would fail every read, the alarm path's included. A failed settle only leaves the fallback in place. From 058fc27749e014c77127d81dcfd9e9b83f0b658f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:33:26 +0200 Subject: [PATCH 6/8] fix(timers): apply a timer setting changed mid-ring The service ignored a decision for the timer already sounding, so switching timer vibration off in Settings while a timer rang kept it buzzing until the ring ended. A decision whose settings changed now restarts the ring, its ramp picking up where it was. The package-manager check for a fresh install also moved out of the settle's DataStore transaction. Refs #22 --- .../clockula/data/prefs/SettingsPrefs.kt | 5 ++++- .../clockula/timer/service/TimerService.kt | 12 ++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) 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 c1f81da..b4eea11 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 @@ -141,6 +141,9 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { */ suspend fun settleTimerVibrate(freshInstall: () -> Boolean) { if (store.get(ClockPrefs.timerVibrate) != null) return + // Asked outside the transaction: a binder call has no business holding + // the store's write lock. + val freshPackage = freshInstall() // One transaction, so nothing written meanwhile can make the snapshot // it decides on stale. Raw keys for the reads: the kit reads a Pref // only from a store, not from a transaction's preferences. @@ -149,7 +152,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { // Settled meanwhile; a garbled value falls through and is replaced. if (stored?.toBooleanStrictOrNull() != null) return@edit val neverRan = stringPreferencesKey(SystemPrefs.lastBootId.name) !in prefs - prefs[ClockPrefs.timerVibrate] = if (neverRan && freshInstall()) { + prefs[ClockPrefs.timerVibrate] = if (neverRan && freshPackage) { false } else { prefs[booleanPreferencesKey(ClockPrefs.vibrate.name)] ?: ClockPrefs.vibrate.default diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt index e4226bf..8722726 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt @@ -18,6 +18,7 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState import de.jeanlucmakiola.clockula.domain.timer.TimerMode import de.jeanlucmakiola.clockula.domain.timer.TimerRing import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision +import de.jeanlucmakiola.clockula.domain.timer.TimerSettings import de.jeanlucmakiola.clockula.ring.RingAudioPlayer import de.jeanlucmakiola.clockula.ring.RingVibrator import de.jeanlucmakiola.clockula.timer.AlarmRingStatus @@ -93,6 +94,9 @@ class TimerService : Service() { /** Which timer the audio is currently sounding for, so an unchanged decision does nothing. */ private var sounding: Long? = null + /** What [sounding] rings with, so a settings change mid-ring is applied rather than ignored. */ + private var soundingSettings: TimerSettings? = null + /** Owned here, scoped to the ring session it was spent in: the rule itself is pure (D17). */ private var alreadyAlerted: Boolean = false @@ -228,10 +232,13 @@ class TimerService : Service() { when (decision) { is TimerRingDecision.Sound -> { // An unchanged decision does nothing: restarting the player on - // every emission would stutter the ring. - if (sounding == decision.subjectId) return + // every emission would stutter the ring. A changed setting (the + // user switched timer vibration off mid-ring, say) restarts it, + // the ramp picking up where it was. + if (sounding == decision.subjectId && soundingSettings == decision.settings) return releaseRing() sounding = decision.subjectId + soundingSettings = decision.settings acquireWakeLock() // Vibration first, and the sound without waiting for it: a sound // another app plays can take seconds to open, and this runs @@ -334,6 +341,7 @@ class TimerService : Service() { /** The whole ring released, not just the audio: an orphaned looping player cannot be stopped. */ private fun releaseRing() { sounding = null + soundingSettings = null audio.stop() vibrator.stop() wakeLock?.let { if (it.isHeld) runCatching { it.release() } } From 17a44369b6b2e03465b12902940e128079537388 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:41:46 +0200 Subject: [PATCH 7/8] refactor(timers): settle timer vibration by copying the alarm default Telling a fresh install from an updated one (package install times, the boot id, a string-encoded tri-state) was more machinery than the default is worth. Timer vibration is a plain boolean again, defaulting to on like the alarm default, and a startup copy writes the alarm default into it once, so no install's timers change on update. The copy runs in its own coroutine, off the engines' boot pass. Fresh installs start with timer vibration on, matching alarms; the switch is one tap away in Settings > Timers. Refs #22 --- CHANGELOG.md | 5 +- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 22 +--- .../clockula/data/prefs/ClockPrefs.kt | 13 +-- .../clockula/data/prefs/SettingsPrefs.kt | 42 ++----- .../clockula/domain/ClockDefaults.kt | 6 +- .../clockula/data/prefs/ClockPrefsTest.kt | 7 +- .../clockula/data/prefs/SettingsPrefsTest.kt | 107 ++---------------- docs/ARCHITECTURE.md | 22 ++-- 8 files changed, 43 insertions(+), 181 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e278004..c974f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,9 +40,8 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. to the clock. - Timers have their own vibration switch and an optional gradual volume, both apart from the alarms'. A kitchen timer can just beep while alarms still - vibrate; a silent timer still vibrates. New installs start with timer - vibration off; an updated install keeps vibrating its timers if it did - before. + vibrate; a silent timer still vibrates. Timer vibration starts out matching + the alarm vibration setting, so nothing changes until you change it. - **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause in the notification. It keeps counting while the app is closed. - **World clock.** An analog face for your own time, plus cities from the diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 30c4d1f..4b949c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -72,7 +72,6 @@ class ClockulaApp : Application() { // a cold start caused by the fire broadcast must not interleave its // read-modify-write over `alarm_states` with the fire's own. scope.launch { - settleTimerVibrate() engine.onBootCompleted() // After the alarm engine's pass, and idempotent within a boot: the // boot-id gate makes the second repair a no-op, so neither engine @@ -91,29 +90,18 @@ class ClockulaApp : Application() { // Never completes on its own — it is the widgets' one refresh trigger // for as long as the process lives (M10, docs/PLAN.md §9). scope.launch { widgetSync.run() } + + scope.launch { settleTimerVibrate() } } - /** - * Ahead of the engines' pass, because the boot gate writes the boot id, and - * its absence is half of how a fresh install is told apart. Never - * in the way of that pass: a failure here leaves the setting unsettled, - * which reads as the old behaviour (#22). - */ + /** Off the engines' pass: a preference copy has no business delaying an alarm. */ private suspend fun settleTimerVibrate() { try { - settings.settleTimerVibrate(freshInstall = ::isFreshInstall) + settings.settleTimerVibrate() } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { - // Unsettled reads as the old behaviour. The next launch tries again, - // and by then the boot id is there, so even a fresh install settles - // on the alarm default: vibrating, the safe side to err on. + // Until it lands timers vibrate, the default; the next launch tries again. } } - - /** Never updated since it was installed. Unknown reads as not fresh: the safe side. */ - private fun isFreshInstall(): Boolean = runCatching { - val info = packageManager.getPackageInfo(packageName, 0) - info.firstInstallTime == info.lastUpdateTime - }.getOrDefault(false) } 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 index 118a782..070d2d1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -29,15 +29,12 @@ object ClockPrefs { val timerRingtoneUri: Pref = nullableStringPref("default_timer_ringtone_uri").blankAsAbsent() /** - * Null — absent, or not a boolean — means "never settled", and reads as - * [vibrate]: what every timer vibrated by before the setting existed (#22). - * `SettingsPrefs.settleTimerVibrate` writes it once at startup, off on a - * fresh install. Stored as text because the kit has no nullable boolean. + * The timers' own vibration (#22). Until it existed a timer vibrated + * exactly when [vibrate] said so; `SettingsPrefs.settleTimerVibrate` copies + * that over once, so nobody's timers change on update. The default is + * [vibrate]'s, for the moment before that lands. */ - val timerVibrate: Pref = nullableStringPref("default_timer_vibrate").map( - decode = { it?.toBooleanStrictOrNull() }, - encode = { it?.toString() }, - ) + val timerVibrate: Pref = booleanPref("default_timer_vibrate", true) val timerVolumeRampSeconds: Pref = intPref("default_timer_volume_ramp_seconds", 0).clampedTo(ClockLimits.VOLUME_RAMP_SECONDS) 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 b4eea11..f5bb996 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,7 +1,6 @@ package de.jeanlucmakiola.clockula.data.prefs import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.DismissChallenge import de.jeanlucmakiola.clockula.domain.backup.BackupSettings @@ -77,8 +76,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { partial.copy( alarmRingtoneUri = alarmRingtoneUri, timerRingtoneUri = timerRingtoneUri, - // Unsettled follows the alarm default, which is what it did before #22. - timerVibrate = timerVibrate ?: partial.vibrate, + timerVibrate = timerVibrate, timerVolumeRampSeconds = timerVolumeRampSeconds, homeZoneId = homeZoneId, ) @@ -124,39 +122,17 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setTimerVibrate(enabled: Boolean) = store.set(ClockPrefs.timerVibrate, enabled) /** - * Gives the timers' vibration a value of its own, once (#22). Until then a - * timer vibrated exactly when the alarm default said so, and an install - * from before keeps that: the alarm default is copied, so changing it - * later no longer drags the timers along. Only a fresh install starts with - * it off: a [freshInstall] package *and* a store without a boot id, which - * the engines write on every launch after this runs. Either signal alone - * mistakes something for fresh (an Auto Backup restore brings the old - * device's boot id; a store replaced after corruption is not a fresh - * package), and that mistake would silently stop someone's timers buzzing. - * The boot id rather than "empty" because the UI may write the store first. - * - * Best-effort by design: until it lands, and if it never does, the read - * side's fallback keeps the old behaviour, so a failed write costs nothing. - * Once settled it is one read; [freshInstall] is asked only when needed. + * Gives the timers' vibration a value of its own, once (#22): the alarm + * default, which is what every timer vibrated by until then. From here on + * the two move apart. A no-op once the key exists. */ - suspend fun settleTimerVibrate(freshInstall: () -> Boolean) { - if (store.get(ClockPrefs.timerVibrate) != null) return - // Asked outside the transaction: a binder call has no business holding - // the store's write lock. - val freshPackage = freshInstall() - // One transaction, so nothing written meanwhile can make the snapshot - // it decides on stale. Raw keys for the reads: the kit reads a Pref - // only from a store, not from a transaction's preferences. + suspend fun settleTimerVibrate() { store.edit { prefs -> - val stored = prefs[stringPreferencesKey(ClockPrefs.timerVibrate.name)] - // Settled meanwhile; a garbled value falls through and is replaced. - if (stored?.toBooleanStrictOrNull() != null) return@edit - val neverRan = stringPreferencesKey(SystemPrefs.lastBootId.name) !in prefs - prefs[ClockPrefs.timerVibrate] = if (neverRan && freshPackage) { - false - } else { + // Raw keys for the reads: the kit reads a Pref only from a store, not + // from a transaction's preferences. Both are plain boolean prefs. + if (booleanPreferencesKey(ClockPrefs.timerVibrate.name) in prefs) return@edit + prefs[ClockPrefs.timerVibrate] = prefs[booleanPreferencesKey(ClockPrefs.vibrate.name)] ?: ClockPrefs.vibrate.default - } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt index ef8fc73..f2152d5 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt @@ -6,8 +6,8 @@ package de.jeanlucmakiola.clockula.domain * * [vibrate] and [volumeRampSeconds] are the alarms'; timers have their own * [timerVibrate] and [timerVolumeRampSeconds], because a kitchen timer that - * should just beep is not an alarm that should wake someone. Their defaults - * are a fresh install's: no vibration, no ramp. + * should just beep is not an alarm that should wake someone. They start as + * timers have always rung: vibrating like the alarms' default, unramped. */ data class ClockDefaults( val snoozeMinutes: Int = 10, @@ -16,7 +16,7 @@ data class ClockDefaults( val volumeRampSeconds: Int = 15, val alarmRingtoneUri: String? = null, val timerRingtoneUri: String? = null, - val timerVibrate: Boolean = false, + val timerVibrate: Boolean = true, val timerVolumeRampSeconds: Int = 0, val dismissChallenge: DismissChallenge = DismissChallenge.NONE, val homeZoneId: String? = null, 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 index fc833b0..aa495dd 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt @@ -37,8 +37,6 @@ class ClockPrefsTest { @Test fun `a fresh install reads the documented defaults`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) - // As ClockulaApp's startup pass does on a fresh install. - prefs.settleTimerVibrate(freshInstall = { true }) assertThat(prefs.defaults.first()).isEqualTo(ClockDefaults()) } @@ -53,7 +51,7 @@ class ClockPrefsTest { prefs.setVolumeRampSeconds(0) prefs.setAlarmRingtoneUri("content://a") prefs.setTimerRingtoneUri("content://t") - prefs.setTimerVibrate(true) + prefs.setTimerVibrate(false) prefs.setTimerVolumeRampSeconds(25) prefs.setDismissChallenge(DismissChallenge.HOLD) prefs.setHomeZoneId("Europe/Berlin") @@ -66,7 +64,7 @@ class ClockPrefsTest { volumeRampSeconds = 0, alarmRingtoneUri = "content://a", timerRingtoneUri = "content://t", - timerVibrate = true, + timerVibrate = false, timerVolumeRampSeconds = 25, dismissChallenge = DismissChallenge.HOLD, homeZoneId = "Europe/Berlin", @@ -77,7 +75,6 @@ class ClockPrefsTest { @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.settleTimerVibrate(freshInstall = { true }) prefs.defaults.test { assertThat(awaitItem()).isEqualTo(ClockDefaults()) 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 index c44f501..948b50b 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -78,7 +78,7 @@ class SettingsPrefsTest { snoozeMinutes = 99, volumeRampSeconds = 99, alarmRingtoneUri = " ", - timerVibrate = true, + timerVibrate = false, timerVolumeRampSeconds = 99, ), timerPresets = listOf(5.minutes, 1.minutes, 5.minutes), @@ -90,7 +90,7 @@ class SettingsPrefsTest { assertThat(prefs.currentDefaults().snoozeMinutes).isEqualTo(60) assertThat(prefs.currentDefaults().volumeRampSeconds).isEqualTo(60) assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull() - assertThat(prefs.currentDefaults().timerVibrate).isTrue() + assertThat(prefs.currentDefaults().timerVibrate).isFalse() assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(60) assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder() } @@ -170,33 +170,11 @@ class SettingsPrefsTest { } @Test - fun `an unsettled timer vibration follows the alarm default, as timers did before`( - @TempDir tempDir: Path, - ) = runTest { - val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) - - prefs.setVibrate(false) - assertThat(prefs.currentDefaults().timerVibrate).isFalse() - prefs.setVibrate(true) - assertThat(prefs.currentDefaults().timerVibrate).isTrue() - } - - @Test - fun `a fresh install settles timer vibration off`(@TempDir tempDir: Path) = runTest { - val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) - - prefs.settleTimerVibrate(freshInstall = { true }) - - assertThat(prefs.currentDefaults().timerVibrate).isFalse() - assertThat(prefs.currentDefaults().vibrate).isTrue() - } - - @Test - fun `an older install settles timer vibration from the alarm default`(@TempDir tempDir: Path) = runTest { + fun `settling copies the alarm default, as timers did before`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) prefs.setVibrate(false) - prefs.settleTimerVibrate(freshInstall = { false }) + prefs.settleTimerVibrate() // Settled, so the two now move apart. prefs.setVibrate(true) @@ -204,92 +182,23 @@ class SettingsPrefsTest { } @Test - fun `an older install that never touched alarm vibration keeps vibrating its timers`( + fun `an install that never touched alarm vibration keeps vibrating its timers`( @TempDir tempDir: Path, ) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) - prefs.setSnoozeMinutes(7) - prefs.settleTimerVibrate(freshInstall = { false }) + prefs.settleTimerVibrate() prefs.setVibrate(false) assertThat(prefs.currentDefaults().timerVibrate).isTrue() } - @Test - fun `an updated install with an empty store is not taken for fresh`(@TempDir tempDir: Path) = runTest { - // A store replaced after corruption, or never written: empty, but not new. - val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) - - prefs.settleTimerVibrate(freshInstall = { false }) - - assertThat(prefs.currentDefaults().timerVibrate).isTrue() - } - - @Test - fun `a new package with a restored store is not taken for fresh`(@TempDir tempDir: Path) = runTest { - // Auto Backup restored the old device's store, boot id and all. - val store = PrefStore(newDataStore(tempDir)) - val prefs = SettingsPrefs(store) - store.set(SystemPrefs.lastBootId, "old-device-boot") - - prefs.settleTimerVibrate(freshInstall = { true }) - - assertThat(prefs.currentDefaults().timerVibrate).isTrue() - } - - @Test - fun `a fresh install is still fresh when the UI wrote the store first`(@TempDir tempDir: Path) = runTest { - val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) - prefs.markNotificationPermissionAsked() - - prefs.settleTimerVibrate(freshInstall = { true }) - - assertThat(prefs.currentDefaults().timerVibrate).isFalse() - } - - @Test - fun `once settled, the package is not asked again`(@TempDir tempDir: Path) = runTest { - val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) - prefs.settleTimerVibrate(freshInstall = { true }) - - prefs.settleTimerVibrate(freshInstall = { error("asked again") }) - - assertThat(prefs.currentDefaults().timerVibrate).isFalse() - } - @Test fun `settling never overwrites a choice`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) - prefs.setTimerVibrate(true) + prefs.setTimerVibrate(false) - prefs.settleTimerVibrate(freshInstall = { true }) - - assertThat(prefs.currentDefaults().timerVibrate).isTrue() - } - - @Test - fun `a garbled timer vibration reads as unsettled`(@TempDir tempDir: Path) = runTest { - val dataStore = newDataStore(tempDir) - val prefs = SettingsPrefs(PrefStore(dataStore)) - dataStore.updateData { stored -> - stored.toMutablePreferences().apply { this[stringPreferencesKey("default_timer_vibrate")] = "maybe" } - } - - assertThat(prefs.currentDefaults().timerVibrate).isTrue() - } - - @Test - fun `a garbled timer vibration is settled like an absent one`(@TempDir tempDir: Path) = runTest { - val dataStore = newDataStore(tempDir) - val prefs = SettingsPrefs(PrefStore(dataStore)) - prefs.setVibrate(false) - dataStore.updateData { stored -> - stored.toMutablePreferences().apply { this[stringPreferencesKey("default_timer_vibrate")] = "maybe" } - } - - prefs.settleTimerVibrate(freshInstall = { false }) - prefs.setVibrate(true) + prefs.settleTimerVibrate() assertThat(prefs.currentDefaults().timerVibrate).isFalse() } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f5db0a..b28a77e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1908,19 +1908,15 @@ stays flat. `VolumeRamp.levelAt(_, 0s)` already returns `1f`, so Vibration is `ClockDefaults.timerVibrate`, never the alarms' `vibrate`: an alarm that should buzz the bed and a kitchen timer that should only beep are -different questions. A fresh install has it **off**. An install from before -the setting existed had its timers vibrate exactly when the alarm default did, -and keeps that. The key is nullable: absent reads as the alarm default, and -`SettingsPrefs.settleTimerVibrate` writes a value of its own once, at the head -of `ClockulaApp`'s startup pass (before the boot gate writes the store). Only a -package never updated since install *and* a store without `last_boot_id` -(the engines write it on every launch, after the settle) count as fresh and -get `false`; anything else (an update, an Auto Backup restore, which brings the -old boot id, a store replaced after corruption) gets the alarm default copied. -The boot id rather than an empty store, because the UI may write first. It is deliberately not a -DataStore migration: a migration's write runs inside the store's -initialisation, and a failed one would fail every read, the alarm path's -included. A failed settle only leaves the fallback in place. +different questions. Until #22 a timer vibrated exactly when the alarm +default did, so nobody's timers change on update: `SettingsPrefs.settleTimerVibrate` +copies the alarm default into the new key once, from its own coroutine at +startup (never ahead of the engines' pass), and from then on the two move +apart. The key's own default is `true`, the alarm default's, for the moment +before that lands; a failed copy is tried again on the next launch. Fresh +installs therefore start with timer vibration on, like alarms; telling a fresh +install from an updated one reliably was more machinery than the default is +worth, and the switch is one tap away. A ring restarted mid-session (handed to a second expired timer, back after an alarm held it, a recreated service) picks its ramp up where the session is, From 5c6ec88bb641f89f309debc6676a2c3281ae62a0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 11:05:48 +0200 Subject: [PATCH 8/8] docs(architecture): say the timer-vibrate copy runs alongside the boot pass It runs in its own coroutine with no ordering against the engines, not ahead of them. Refs #22 --- docs/ARCHITECTURE.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b28a77e..4876d70 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1911,9 +1911,11 @@ alarm that should buzz the bed and a kitchen timer that should only beep are different questions. Until #22 a timer vibrated exactly when the alarm default did, so nobody's timers change on update: `SettingsPrefs.settleTimerVibrate` copies the alarm default into the new key once, from its own coroutine at -startup (never ahead of the engines' pass), and from then on the two move -apart. The key's own default is `true`, the alarm default's, for the moment -before that lands; a failed copy is tried again on the next launch. Fresh +startup, concurrent with the engines' pass rather than ahead of it, and from +then on the two move apart. The key's own default is `true`, the alarm +default's, for the moment before that lands (a timer that rings in that moment +on the first launch after the update may vibrate once); a failed copy is tried +again on the next launch. Fresh installs therefore start with timer vibration on, like alarms; telling a fresh install from an updated one reliably was more machinery than the default is worth, and the switch is one tap away.