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
This commit is contained in:
makiolaj committed 2026-10-21 19:06:50 +02:00
1 parent 8910ab7fbf
commit 84d31fe7f8
16 files changed
+223 -236

No files matched your search

@@ -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)
}
@@ -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<Preferences> = 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<Preferences>): 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<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
migrations = listOf(TimerVibrateMigration(isFreshInstall)),
scope = scope,
produceFile = produceFile,
)
@@ -29,11 +29,15 @@ object ClockPrefs {
val timerRingtoneUri: Pref<String?> = 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<Boolean> = booleanPref("default_timer_vibrate", false)
val timerVibrate: Pref<Boolean?> = nullableStringPref("default_timer_vibrate").map(
decode = { it?.toBooleanStrictOrNull() },
encode = { it?.toString() },
)
val timerVolumeRampSeconds: Pref<Int> =
intPref("default_timer_volume_ramp_seconds", 0).clampedTo(ClockLimits.VOLUME_RAMP_SECONDS)
@@ -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) =
@@ -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<Preferences> {
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
}
@@ -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
}
@@ -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
@@ -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
@@ -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,
@@ -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) },
)
@@ -25,7 +25,6 @@ class DataModuleTest {
private fun TestScope.newStore(file: File) = createDataStore(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
produceFile = { file },
isFreshInstall = { false },
)
@Test
@@ -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())
@@ -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)))
@@ -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()
}
}
@@ -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 */
+14 -6
View File
@@ -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.