From 412e54db09654b484ade6d4801a849cf95883f1b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 18:52:25 +0200 Subject: [PATCH] feat(settings): let alarms and timers choose when they silence themselves A ringing alarm and a finished timer used to stop after a fixed ten minutes. Settings > Alarms and Settings > Timers now each have a "Silence after" row with Google Clock's choices: 1, 5, 10, 15, 20 or 25 minutes, or Never. Ten minutes stays the default, so nothing changes on upgrade. The value is app-wide; a per-alarm override would need a new column, a migration and an editor row, and is left out. "Never" rings for an hour, then stops. The backstop is also what keeps a phone left at home from ringing its battery flat, what bounds the ring services' wake locks, and what stops a reboot hours later from resuming a ring nobody was there for. A truly unbounded ring would give all of that up, so Never maps to a one-hour hard stop, longer than any finite choice, and the settings hint says so. Stored values clamp to 0..60 on read, on write and from a backup. The window is read everywhere the fixed constant was: the resolver's ringing and late-snooze checks (which now take it without a default), the backstop onFire arms, how late a snooze fire may arrive, whether an interrupted ring is resumed after a reboot, upcoming(), the timer ring policy, the lapsed-session check and the timer ring screen. Both ring services' wake-lock timeouts now derive from the hard stop instead of a hard-coded fifteen minutes. When the resolver closes a ring (the window was shortened mid-ring, the clock jumped, the alarm was switched off), the engine now also stops the ring service and cancels the backstop. Before, it only cleared ringingSince and left the sound to a backstop armed for the old window. Both values are backed up as default_alarm_silence_after_minutes and default_timer_silence_after_minutes; a file without them imports as ten minutes. Fixes #17 --- CHANGELOG.md | 4 + .../clockula/alarm/AlarmEngine.kt | 37 ++++-- .../clockula/alarm/ring/AlarmRingService.kt | 9 +- .../clockula/data/backup/BackupCodec.kt | 5 + .../clockula/data/backup/BackupDto.kt | 7 + .../clockula/data/prefs/ClockPrefs.kt | 14 ++ .../clockula/data/prefs/SettingsPrefs.kt | 16 ++- .../clockula/domain/ClockDefaults.kt | 18 ++- .../clockula/domain/alarm/AlarmResolver.kt | 6 +- .../clockula/domain/alarm/AlarmRing.kt | 14 +- .../clockula/domain/ring/SilenceAfter.kt | 44 +++++++ .../clockula/domain/timer/TimerRing.kt | 14 +- .../clockula/domain/timer/TimerRingPolicy.kt | 9 +- .../clockula/timer/TimerEngine.kt | 3 +- .../clockula/timer/service/TimerService.kt | 9 +- .../clockula/ui/alarms/OverridePickers.kt | 30 +++++ .../clockula/ui/ring/TimerRingViewModel.kt | 13 +- .../ui/settings/AlarmDefaultsScreen.kt | 23 +++- .../clockula/ui/settings/SettingsViewModel.kt | 6 + .../ui/settings/TimerDefaultsScreen.kt | 27 +++- app/src/main/res/values/strings.xml | 10 ++ .../clockula/alarm/AlarmEngineInteropTest.kt | 4 +- .../clockula/alarm/AlarmEngineTest.kt | 123 +++++++++++++++++- .../clockula/data/backup/BackupCodecTest.kt | 17 +++ .../clockula/data/prefs/ClockPrefsTest.kt | 22 ++++ .../clockula/data/prefs/SettingsPrefsTest.kt | 10 +- .../domain/alarm/AlarmResolverTest.kt | 56 +++++++- .../clockula/domain/ring/SilenceAfterTest.kt | 50 +++++++ .../domain/timer/TimerRingPolicyTest.kt | 44 ++++++- .../clockula/timer/TimerEngineTest.kt | 23 +++- .../ui/ring/TimerRingViewModelTest.kt | 23 +++- docs/ARCHITECTURE.md | 57 ++++++-- docs/BACKUP.md | 13 +- 33 files changed, 680 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/SilenceAfter.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/SilenceAfterTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..bf9bb46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. with Clockula. A voice-set alarm cleans up after itself once dismissed. - Settings: theme, dynamic colour, language, and the defaults every new alarm and timer inherits. +- Choose how long a ringing alarm or a finished timer keeps going before it + stops on its own: 1, 5, 10, 15, 20 or 25 minutes, or Never, separately for + alarms and timers. Ten minutes stays the default, and Never still stops + after an hour so a phone left at home can't ring its battery flat. - Back up alarms, timers, world clocks and settings to a JSON file, and restore them on this device or another one. - "Why might my alarm not ring?", a self-check that reads your device's real diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt index d9ff0a9..9acaedc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -182,7 +182,12 @@ class AlarmEngine @Inject constructor( * emission; a live readout passes a `Ticker`'s flow (M5 D8). */ fun upcoming(refresh: Flow = flowOf(Unit)): Flow> = - combine(alarms.alarms(), states.states(), refresh) { alarms, states, _ -> + combine( + alarms.alarms(), + states.states(), + settings.defaults.map { it.alarmSilenceAfter }.distinctUntilChanged(), + refresh, + ) { alarms, states, silenceAfter, _ -> val now = wallClock.now() val zone = zones.current() val byId = states.associateBy { it.alarmId } @@ -195,6 +200,7 @@ class AlarmEngine @Inject constructor( byId[alarm.id] ?: AlarmRingState.initial(alarm.id), now, zone, + silenceAfter, ) UpcomingAlarm(alarm, resolution.nextFire, resolution.source) } @@ -207,11 +213,12 @@ class AlarmEngine @Inject constructor( private suspend fun rescheduleLocked(): ScheduledFire? { val now = wallClock.now() val zone = zones.current() + val silenceAfter = settings.currentDefaults().alarmSilenceAfter var earliest: Pair? = null for (alarm in alarms.alarms().first()) { val state = states.state(alarm.id) - val resolution = AlarmResolver.resolve(alarm, state, now, zone) + val resolution = AlarmResolver.resolve(alarm, state, now, zone, silenceAfter) persist(alarm, state, resolution) val fireAt = resolution.nextFire ?: continue @@ -236,10 +243,11 @@ class AlarmEngine @Inject constructor( val alarm = alarms.find(alarmId) val state = states.state(alarmId) val isSnoozeFire = state.snoozedUntil != null && state.snoozedUntil == scheduledFor + val silenceAfter = settings.currentDefaults().alarmSilenceAfter // A snooze is kept for as long as the resolver keeps it pending, so one // delivered late (after a reboot, or by Doze) still rings. Rejecting it // here would only re-register the same past instant and fire again. - val lateBy = if (isSnoozeFire) AlarmRing.AUTO_SILENCE_AFTER else AlarmRing.FIRE_GRACE + val lateBy = if (isSnoozeFire) silenceAfter else AlarmRing.FIRE_GRACE // Deleted, switched off, or a PendingIntent so stale the moment has gone: // re-resolve the slot and stay quiet. if (alarm == null || !alarm.enabled || now - scheduledFor > lateBy) { @@ -270,7 +278,7 @@ class AlarmEngine @Inject constructor( states.save(states.state(alarmId).copy(ringingSince = now)) ring.startRinging(alarmId) - scheduler.scheduleAutoSilence(now + AlarmRing.AUTO_SILENCE_AFTER, alarmId) + scheduler.scheduleAutoSilence(now + silenceAfter, alarmId) return FireOutcome.Ringing(alarmId) } @@ -307,20 +315,33 @@ class AlarmEngine @Inject constructor( private suspend fun resumeRingingIfAnyLocked(): Long? { val state = states.ringing() ?: return null val since = state.ringingSince ?: return null + val silenceAfter = settings.currentDefaults().alarmSilenceAfter // Slept through it: leave it to the next resolution to close the cycle. - if (wallClock.now() - since >= AlarmRing.AUTO_SILENCE_AFTER) return null + if (wallClock.now() - since >= silenceAfter) return null val alarm = alarms.find(state.alarmId)?.takeIf { it.enabled } ?: return null ring.startRinging(alarm.id) // The backstop is measured from when the ring started, not from now, so - // a reboot does not buy the alarm another ten minutes. - scheduler.scheduleAutoSilence(since + AlarmRing.AUTO_SILENCE_AFTER, alarm.id) + // a reboot does not buy the alarm a fresh window. + scheduler.scheduleAutoSilence(since + silenceAfter, alarm.id) return alarm.id } - /** Everything the resolver asked to be written, and nothing it did not. */ + /** + * Everything the resolver asked to be written, and nothing it did not — + * plus stopping the sound of a ring it closed, which storage alone cannot do. + */ private suspend fun persist(alarm: Alarm, before: AlarmRingState, resolution: AlarmResolution) { if (resolution.state != before) states.save(resolution.state) + // The resolver closed a ring: its window ran out before the backstop + // fired ("Silence after" was shortened mid-ring, or the clock jumped), + // or the alarm was switched off while ringing. Storage now says it is + // not ringing, so the sound stops with it rather than waiting for a + // backstop registered against the old window. + if (before.ringingSince != null && resolution.state.ringingSince == null) { + ring.stopRinging(alarm.id) + scheduler.cancelAutoSilence() + } if (resolution.clearSkipFlag) alarms.setSkipNextOccurrence(alarm.id, false) // A transient alarm is deleted wherever a permanent one would be // disabled: the cycle has closed either way (M9 D10). diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt index 828d27f..11d71a2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt @@ -17,6 +17,7 @@ import de.jeanlucmakiola.clockula.alarm.AlarmIntents import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy import de.jeanlucmakiola.clockula.domain.alarm.RingSession import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy +import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import de.jeanlucmakiola.clockula.ring.RingAudioPlayer import de.jeanlucmakiola.clockula.ring.RingVibrator import kotlinx.coroutines.CoroutineScope @@ -25,6 +26,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.minutes import javax.inject.Inject /** @@ -198,8 +200,11 @@ class AlarmRingService : Service() { private const val ACTION_REPOST = AlarmIntents.ACTION_DISMISS + ".SERVICE_REPOST" private const val WAKE_LOCK_TAG = "clockula:alarm-ring" - /** A backstop on the backstop: the ring window is ten minutes. */ - private const val WAKE_LOCK_TIMEOUT_MILLIS = 15L * 60L * 1_000L + /** + * A backstop on the backstop: past the longest ring window any "Silence + * after" choice allows, so a ring is never cut short by its own wake lock. + */ + private val WAKE_LOCK_TIMEOUT_MILLIS = (SilenceAfter.HARD_STOP + 5.minutes).inWholeMilliseconds fun start(context: Context) { context.startForegroundService(Intent(context, AlarmRingService::class.java)) 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..b6fc836 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 @@ -127,6 +127,8 @@ object BackupCodec { timerRingtoneUri = defaults.timerRingtoneUri, dismissChallenge = defaults.dismissChallenge.name, homeZoneId = defaults.homeZoneId, + alarmSilenceAfterMinutes = defaults.alarmSilenceAfterMinutes, + timerSilenceAfterMinutes = defaults.timerSilenceAfterMinutes, timerPresets = timerPresets.map { it.inWholeMilliseconds }, ) @@ -233,6 +235,9 @@ object BackupCodec { timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() }, dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE), homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise), + // Clamped on apply, by the preference, like every other default. + alarmSilenceAfterMinutes = settingsDto.alarmSilenceAfterMinutes, + timerSilenceAfterMinutes = settingsDto.timerSilenceAfterMinutes, ), timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN, ) 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..a45d071 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 @@ -1,5 +1,6 @@ package de.jeanlucmakiola.clockula.data.backup +import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -71,6 +72,12 @@ data class SettingsDto( @SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null, @SerialName("default_dismiss_challenge") val dismissChallenge: String = "NONE", @SerialName("home_zone_id") val homeZoneId: String? = null, + /** + * Minutes, or 0 for "Never". Absent in a file from before the setting + * existed, which is v1's fixed ten minutes. + */ + @SerialName("default_alarm_silence_after_minutes") val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES, + @SerialName("default_timer_silence_after_minutes") val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES, /** * Absent is not empty: an empty list is a user who removed every preset, * while a file without the field (an older or hand-written one) keeps the 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..a14e002 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 @@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.data.prefs import de.jeanlucmakiola.clockula.domain.ClockLimits import de.jeanlucmakiola.clockula.domain.DismissChallenge import de.jeanlucmakiola.clockula.domain.Zones +import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import de.jeanlucmakiola.floret.prefs.Pref import de.jeanlucmakiola.floret.prefs.booleanPref import de.jeanlucmakiola.floret.prefs.enumPref @@ -31,6 +32,19 @@ object ClockPrefs { val dismissChallenge: Pref = enumPref("default_dismiss_challenge", DismissChallenge.NONE) + /** + * How long a ringing alarm, and a finished timer, keep going before they + * stop on their own (#17): minutes, or [SilenceAfter.NEVER]. An absent key + * reads as v1's fixed ten minutes, so an upgrade changes nothing. + */ + val alarmSilenceAfterMinutes: Pref = + intPref("default_alarm_silence_after_minutes", SilenceAfter.DEFAULT_MINUTES) + .clampedTo(ClockLimits.SILENCE_AFTER_MINUTES) + + val timerSilenceAfterMinutes: Pref = + intPref("default_timer_silence_after_minutes", SilenceAfter.DEFAULT_MINUTES) + .clampedTo(ClockLimits.SILENCE_AFTER_MINUTES) + /** Normalised through [Zones] both ways, so a zone the device dropped reads as absent. */ val homeZoneId: Pref = nullableStringPref("home_zone_id") .map(decode = Zones::normalise, encode = Zones::normalise) 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..e342a1a 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,14 +62,20 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { dismissChallenge = dismissChallenge, ) }, + combine( + store.flow(ClockPrefs.alarmSilenceAfterMinutes), + store.flow(ClockPrefs.timerSilenceAfterMinutes), + ) { alarmMinutes, timerMinutes -> alarmMinutes to timerMinutes }, store.flow(ClockPrefs.alarmRingtoneUri), store.flow(ClockPrefs.timerRingtoneUri), store.flow(ClockPrefs.homeZoneId), - ) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId -> + ) { partial, silenceAfter, alarmRingtoneUri, timerRingtoneUri, homeZoneId -> partial.copy( alarmRingtoneUri = alarmRingtoneUri, timerRingtoneUri = timerRingtoneUri, homeZoneId = homeZoneId, + alarmSilenceAfterMinutes = silenceAfter.first, + timerSilenceAfterMinutes = silenceAfter.second, ) }.distinctUntilChanged() @@ -92,6 +98,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { prefs[ClockPrefs.timerRingtoneUri] = backup.defaults.timerRingtoneUri prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId + prefs[ClockPrefs.alarmSilenceAfterMinutes] = backup.defaults.alarmSilenceAfterMinutes + prefs[ClockPrefs.timerSilenceAfterMinutes] = backup.defaults.timerSilenceAfterMinutes prefs[TimerPrefs.presets] = backup.timerPresets } } @@ -111,6 +119,12 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setDismissChallenge(challenge: DismissChallenge) = store.set(ClockPrefs.dismissChallenge, challenge) + suspend fun setAlarmSilenceAfterMinutes(minutes: Int) = + store.set(ClockPrefs.alarmSilenceAfterMinutes, minutes) + + suspend fun setTimerSilenceAfterMinutes(minutes: Int) = + store.set(ClockPrefs.timerSilenceAfterMinutes, minutes) + suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId) // --- timer presets (M6) --- 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..20b2dd2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt @@ -1,8 +1,15 @@ package de.jeanlucmakiola.clockula.domain +import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter +import kotlin.time.Duration + /** * The app-wide defaults an alarm or timer inherits when it has no override. * Backed by DataStore, edited in M10's settings screen. + * + * The two silence-after values are app-wide only: no alarm or timer overrides + * them (#17). They are stored as [SilenceAfter] minutes; read them as a window + * through [alarmSilenceAfter] and [timerSilenceAfter]. */ data class ClockDefaults( val snoozeMinutes: Int = 10, @@ -13,7 +20,15 @@ data class ClockDefaults( val timerRingtoneUri: String? = null, val dismissChallenge: DismissChallenge = DismissChallenge.NONE, val homeZoneId: String? = null, -) + val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES, + val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES, +) { + /** How long a ringing alarm rings before it is silenced as missed. */ + val alarmSilenceAfter: Duration get() = SilenceAfter.window(alarmSilenceAfterMinutes) + + /** How long a finished timer sounds before the noise stops. */ + val timerSilenceAfter: Duration get() = SilenceAfter.window(timerSilenceAfterMinutes) +} /** * The ranges a user can choose from, for the defaults and for an alarm's own @@ -24,4 +39,5 @@ object ClockLimits { val SNOOZE_MINUTES: IntRange = 1..60 val SNOOZE_LIMIT: IntRange = 0..10 val VOLUME_RAMP_SECONDS: IntRange = 0..60 + val SILENCE_AFTER_MINUTES: IntRange = SilenceAfter.MINUTES } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolver.kt index ba6d548..7109ba4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolver.kt @@ -27,6 +27,10 @@ data class AlarmResolution( * * The precedence is fixed and total: disabled, then ringing, then snoozed, then * the natural occurrence. + * + * `autoSilenceAfter` is the "Silence after" setting's window + * (`ClockDefaults.alarmSilenceAfter`). It has no default, so no caller can + * measure a ring against a window the user did not choose. */ object AlarmResolver { fun resolve( @@ -34,7 +38,7 @@ object AlarmResolver { state: AlarmRingState, now: Instant, zone: ZoneId, - autoSilenceAfter: Duration = AlarmRing.AUTO_SILENCE_AFTER, + autoSilenceAfter: Duration, fireGrace: Duration = AlarmRing.FIRE_GRACE, ): AlarmResolution { // 1. Disabled: every volatile field goes, except the dismissal watermark diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt index 96103c0..061b57c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmRing.kt @@ -4,17 +4,13 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes /** - * The alarm ring cycle's constants. They are values rather than settings: v1 - * does not offer them to the user (docs/PLAN.md §4). The audio chain's own - * constants live in `domain/ring/RingAudio`, shared with the timer's ring. + * The alarm ring cycle's constants. They are values rather than settings + * (docs/PLAN.md §4). How long an alarm rings before it is silenced as missed + * became a setting in #17: `ClockDefaults.alarmSilenceAfter`. The audio + * chain's own constants live in `domain/ring/RingAudio`, shared with the + * timer's ring. */ object AlarmRing { - /** - * How long an undismissed alarm rings before it is silenced as missed. - * AOSP DeskClock's default, and the window a resumed ring is measured against. - */ - val AUTO_SILENCE_AFTER: Duration = 10.minutes - /** * How late a fire may arrive and still count. A fire delayed by doze, a slow * boot or a `TIME_SET` nudge still rings; one missed by hours does not. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/SilenceAfter.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/SilenceAfter.kt new file mode 100644 index 0000000..5312037 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/SilenceAfter.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.clockula.domain.ring + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +/** + * How long a ring keeps going before it stops on its own: the "Silence after" + * setting, one for alarms and one for timers (#17). Stored as whole minutes, + * with [NEVER] for the option that has no user-facing limit. + * + * "Never" is not unbounded. The auto-silence backstop is also what stops a + * phone left at home from ringing until its battery is flat, and what keeps a + * reboot from resuming a ring that started hours ago, so "Never" rings until + * [HARD_STOP] — longer than any finite choice — and then stops as any other + * window would. + */ +object SilenceAfter { + /** The stored value for "Never". Zero, because no window can be zero minutes long. */ + const val NEVER: Int = 0 + + /** + * Everything a stored value is clamped into, on read, on write and from a + * backup. Any value in it means something: [NEVER], or that many minutes. + */ + val MINUTES: IntRange = NEVER..60 + + /** + * The longest a ring ever lasts, and what "Never" means in practice. The + * settings hints say "an hour" in words; change them with it. + */ + val HARD_STOP: Duration = MINUTES.last.minutes + + /** The default, for alarms and timers alike: AOSP DeskClock's, and v1's fixed value. */ + const val DEFAULT_MINUTES: Int = 10 + + /** The choices the pickers offer, in order: Google Clock's, so nothing here surprises. */ + val CHOICES: List = listOf(1, 5, 10, 15, 20, 25, NEVER) + + /** The ring window [minutes] stands for, clamped first so the engines never see an unofferable value. */ + fun window(minutes: Int): Duration { + val clamped = minutes.coerceIn(MINUTES) + return if (clamped == NEVER) HARD_STOP else clamped.minutes + } +} 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..b620acc 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 @@ -5,17 +5,13 @@ import kotlin.time.Duration.Companion.milliseconds 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). + * The timer ring's constants. Values rather than settings (docs/PLAN.md §5, + * slice plan D11). How long an unacknowledged timer sounds before the noise + * stops became a setting in #17, `ClockDefaults.timerSilenceAfter`: its own + * rather than the alarm's, because coupling the two would make one number + * answer two questions. */ object TimerRing { - /** - * How long an unacknowledged timer sounds before the noise stops (D11). - * The same value as the alarm's and its own constant, free to diverge: - * coupling the two would make one number answer two questions. - */ - val AUTO_SILENCE_AFTER: Duration = 10.minutes - /** What "+1 min" grants (D12). */ val ADD_TIME: Duration = 1.minutes diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicy.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicy.kt index 663c03e..478747d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicy.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicy.kt @@ -16,7 +16,7 @@ sealed interface TimerRingDecision { data class Sound( val subjectId: Long, val settings: TimerSettings, - /** When the window closes: the session anchor + [TimerRing.AUTO_SILENCE_AFTER]. */ + /** When the window closes: the session anchor + [ClockDefaults.timerSilenceAfter]. */ val silenceAt: Instant, ) : TimerRingDecision } @@ -49,7 +49,8 @@ object TimerRingPolicy { // Auto-silence before suppression: a window that has run out stays run // out, and an alarm ringing over it must not hand it a fresh one. - if (soundingSince != null && now - soundingSince >= TimerRing.AUTO_SILENCE_AFTER) { + val window = defaults.timerSilenceAfter + if (soundingSince != null && now - soundingSince >= window) { return TimerRingOutcome( TimerRingDecision.AutoSilenced(subject.timer.id), soundingSince = soundingSince, @@ -67,13 +68,13 @@ object TimerRingPolicy { // The anchor is the instant the audio *opened*, so a second timer // expiring mid-session inherits the window rather than buying a fresh - // ten minutes (D10). + // one (D10). val anchor = soundingSince ?: now return TimerRingOutcome( TimerRingDecision.Sound( subjectId = subject.timer.id, settings = subject.timer.resolveSettings(defaults), - silenceAt = anchor + TimerRing.AUTO_SILENCE_AFTER, + silenceAt = anchor + window, ), soundingSince = anchor, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt index 04b27cc..ea12615 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt @@ -266,7 +266,8 @@ class TimerEngine @Inject constructor( */ private suspend fun dropLapsedSessionLocked() { val anchor = ringState.current() ?: return - if (wall.now() - anchor >= TimerRing.AUTO_SILENCE_AFTER) ringState.set(null) + val window = settings.currentDefaults().timerSilenceAfter + if (wall.now() - anchor >= window) ringState.set(null) } private suspend fun serviceStateLocked(alreadyAlerted: Boolean): TimerServiceState { 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..0775b6a 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 @@ -14,6 +14,7 @@ import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.data.timers.TimerRepository import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy +import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState import de.jeanlucmakiola.clockula.domain.timer.TimerMode import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision @@ -34,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.Companion.minutes import kotlin.time.Instant import javax.inject.Inject @@ -363,8 +365,11 @@ class TimerService : Service() { companion object { private const val WAKE_LOCK_TAG = "clockula:timer-ring" - /** A backstop on the backstop: the ring window is ten minutes. */ - private const val WAKE_LOCK_TIMEOUT_MILLIS = 15L * 60L * 1_000L + /** + * A backstop on the backstop: past the longest ring window any "Silence + * after" choice allows, so a ring is never cut short by its own wake lock. + */ + private val WAKE_LOCK_TIMEOUT_MILLIS = (SilenceAfter.HARD_STOP + 5.minutes).inWholeMilliseconds fun start(context: Context) { context.startForegroundService(Intent(context, TimerService::class.java)) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/OverridePickers.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/OverridePickers.kt index 4ba9867..d93d58c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/OverridePickers.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/OverridePickers.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.DismissChallenge +import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import de.jeanlucmakiola.floret.components.OptionPicker /** @@ -67,6 +68,35 @@ internal fun challengeText(challenge: DismissChallenge): String = stringResource }, ) +/** [SilenceAfter.NEVER] is "Never" in words, never "0 minutes". */ +@Composable +internal fun silenceAfterText(minutes: Int): String = if (minutes == SilenceAfter.NEVER) { + stringResource(R.string.silence_after_never) +} else { + pluralStringResource(R.plurals.silence_after_minutes, minutes, minutes) +} + +/** + * "Silence after" is app-wide only (#17): no alarm or timer overrides it, so + * this picker never offers "App default". It shares the overrides' shape so + * the settings screens' pickers all look and behave alike. + */ +@Composable +internal fun SilenceAfterPicker( + selected: Int, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, +) = OverridePicker( + title = stringResource(R.string.settings_silence_after), + options = SilenceAfter.CHOICES, + stored = selected, + default = selected, + text = { silenceAfterText(it) }, + onSelect = { it?.let(onSelect) }, + onDismiss = onDismiss, + includeAppDefault = false, +) + @Composable internal fun VibratePicker( stored: Boolean?, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModel.kt index a5fd38d..d87d70a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModel.kt @@ -3,18 +3,19 @@ package de.jeanlucmakiola.clockula.ui.ring import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.data.timers.TimerRepository import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock import de.jeanlucmakiola.clockula.domain.time.Ticker import de.jeanlucmakiola.clockula.domain.time.WallClock import de.jeanlucmakiola.clockula.domain.timer.TimerReadings -import de.jeanlucmakiola.clockula.domain.timer.TimerRing import de.jeanlucmakiola.clockula.timer.TimerEngine import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds @@ -29,6 +30,7 @@ import javax.inject.Inject class TimerRingViewModel @Inject constructor( timers: TimerRepository, ringState: TimerRingStateStore, + settings: SettingsPrefs, private val engine: TimerEngine, elapsed: ElapsedRealtimeClock, wall: WallClock, @@ -41,7 +43,12 @@ class TimerRingViewModel @Inject constructor( * the auto-silence window ends without anything being written at all. */ val state: StateFlow = - combine(timers.timers(), ringState.soundingSince, ticker.ticks(TICK)) { rows, soundingSince, _ -> + combine( + timers.timers(), + ringState.soundingSince, + settings.defaults.map { it.timerSilenceAfter }.distinctUntilChanged(), + ticker.ticks(TICK), + ) { rows, soundingSince, silenceAfter, _ -> val now = wall.now() val expired = TimerReadings.expired( TimerReadings.active(rows, elapsed.elapsedRealtime(), now), @@ -49,7 +56,7 @@ class TimerRingViewModel @Inject constructor( val subject = expired.firstOrNull() // Auto-silence leaves the rows EXPIRED (D11) but ends the ring, and a // screen held on over a silent timer is only a battery drain. - val silenced = soundingSince != null && now - soundingSince >= TimerRing.AUTO_SILENCE_AFTER + val silenced = soundingSince != null && now - soundingSince >= silenceAfter if (subject == null || silenced) { TimerRingUiState.Finished } else { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt index 4e4075d..4942a8e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt @@ -12,11 +12,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.ui.alarms.DismissChallengePicker +import de.jeanlucmakiola.clockula.ui.alarms.SilenceAfterPicker import de.jeanlucmakiola.clockula.ui.alarms.SnoozeLengthPicker import de.jeanlucmakiola.clockula.ui.alarms.SnoozeLimitPicker import de.jeanlucmakiola.clockula.ui.alarms.VibratePicker import de.jeanlucmakiola.clockula.ui.alarms.VolumeRampPicker import de.jeanlucmakiola.clockula.ui.alarms.challengeText +import de.jeanlucmakiola.clockula.ui.alarms.silenceAfterText import de.jeanlucmakiola.clockula.ui.alarms.snoozeLimitText import de.jeanlucmakiola.clockula.ui.alarms.snoozeMinutesText import de.jeanlucmakiola.clockula.ui.alarms.vibrateText @@ -26,11 +28,12 @@ import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position /** Which of the alarm-default pickers is open. Mirrors [de.jeanlucmakiola.clockula.ui.alarms.AlarmEditorScreen]'s shape. */ -private enum class OpenAlarmPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE } +private enum class OpenAlarmPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE, SILENCE_AFTER } /** * What every new alarm starts with: how it sounds, how it snoozes, and how it - * is dismissed. The ringtone picker itself is ViewModel state, drawn by the hub. + * is dismissed; then how long any alarm rings, which no alarm overrides (#17). + * The ringtone picker itself is ViewModel state, drawn by the hub. */ @Composable internal fun AlarmDefaultsScreen( @@ -85,6 +88,17 @@ internal fun AlarmDefaultsScreen( position = Position.Alone, onClick = { openPicker = OpenAlarmPicker.CHALLENGE }, ) + + // Its own group with its own hint: unlike everything above, this one is + // not a default an alarm can change in its editor. + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_silence_after), + summary = silenceAfterText(defaults.alarmSilenceAfterMinutes), + position = Position.Alone, + onClick = { openPicker = OpenAlarmPicker.SILENCE_AFTER }, + ) + SettingsHint(stringResource(R.string.settings_alarm_silence_after_hint)) } when (openPicker) { @@ -124,5 +138,10 @@ internal fun AlarmDefaultsScreen( onSelect = { viewModel.onDismissChallengeChanged(it ?: defaults.dismissChallenge) }, onDismiss = { openPicker = null }, ) + OpenAlarmPicker.SILENCE_AFTER -> SilenceAfterPicker( + selected = defaults.alarmSilenceAfterMinutes, + onSelect = { viewModel.onAlarmSilenceAfterChanged(it) }, + onDismiss = { openPicker = null }, + ) } } 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..224d8b2 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,12 @@ class SettingsViewModel @Inject internal constructor( fun onDismissChallengeChanged(value: DismissChallenge) = viewModelScope.launch { settings.setDismissChallenge(value) } + fun onAlarmSilenceAfterChanged(minutes: Int) = + viewModelScope.launch { settings.setAlarmSilenceAfterMinutes(minutes) } + + fun onTimerSilenceAfterChanged(minutes: Int) = + viewModelScope.launch { settings.setTimerSilenceAfterMinutes(minutes) } + // --- 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..90ffbb2 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 @@ -3,21 +3,29 @@ package de.jeanlucmakiola.clockula.ui.settings import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height 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.SilenceAfterPicker +import de.jeanlucmakiola.clockula.ui.alarms.silenceAfterText 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. */ +/** What a new timer starts with, its sound; then how long any timer sounds (#17). */ @Composable internal fun TimerDefaultsScreen( state: SettingsUiState.Loaded, viewModel: SettingsViewModel, onBack: () -> Unit, ) { + var silenceAfterOpen by rememberSaveable { mutableStateOf(false) } + CollapsingScaffold(title = stringResource(R.string.settings_section_timers), onBack = onBack) { SettingsHint(stringResource(R.string.settings_timer_sound_hint)) Spacer(Modifier.height(8.dp)) @@ -28,5 +36,22 @@ internal fun TimerDefaultsScreen( position = Position.Alone, onClick = { viewModel.onOpenRingtonePicker(DefaultRingtoneKind.TIMER) }, ) + + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_silence_after), + summary = silenceAfterText(state.defaults.timerSilenceAfterMinutes), + position = Position.Alone, + onClick = { silenceAfterOpen = true }, + ) + SettingsHint(stringResource(R.string.settings_timer_silence_after_hint)) + } + + if (silenceAfterOpen) { + SilenceAfterPicker( + selected = state.defaults.timerSilenceAfterMinutes, + onSelect = { viewModel.onTimerSilenceAfterChanged(it) }, + onDismiss = { silenceAfterOpen = false }, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..66cac00 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,6 +164,16 @@ Solve a sum Press and hold + + Silence after + Never + + %1$d minute + %1$d minutes + + Applies to every alarm. An alarm nobody stops goes quiet after this long and counts as missed. Never still stops after an hour, so a phone left at home can\'t ring until its battery runs out. + Applies to every timer. A finished timer nobody stops goes quiet after this long but stays finished, so you can still add a minute. Never still stops after an hour. + Alarm sound Silent diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineInteropTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineInteropTest.kt index 9a5a2ab..2687d74 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineInteropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineInteropTest.kt @@ -2,8 +2,8 @@ package de.jeanlucmakiola.clockula.alarm import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.RepeatDays -import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing import de.jeanlucmakiola.clockula.domain.alarm.DismissOutcome import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome import de.jeanlucmakiola.clockula.testing.BERLIN @@ -246,7 +246,7 @@ class AlarmEngineInteropTest { harness.givenAlarm(id = 1L, hour = 7, deleteAfterUse = true) harness.engine.reschedule() harness.givenRinging(alarmId = 1L, at = sevenToday) - harness.wallClock.instant = sevenToday + AlarmRing.AUTO_SILENCE_AFTER + harness.wallClock.instant = sevenToday + ClockDefaults().alarmSilenceAfter harness.engine.onAutoSilence(1L) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt index 0a0c53c..bb87cb9 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt @@ -10,15 +10,16 @@ import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.domain.Alarm import de.jeanlucmakiola.clockula.domain.AlarmDraft +import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.RepeatDays import de.jeanlucmakiola.clockula.domain.TimeOfDay -import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState import de.jeanlucmakiola.clockula.domain.alarm.FireOutcome import de.jeanlucmakiola.clockula.domain.alarm.FireSource import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome import de.jeanlucmakiola.clockula.domain.resolveSettings +import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import de.jeanlucmakiola.clockula.testing.BERLIN import de.jeanlucmakiola.clockula.testing.FakeAlarmDao import de.jeanlucmakiola.clockula.testing.FakeAlarmNotifier @@ -274,7 +275,7 @@ class AlarmEngineTest { assertThat(states.state(1L).ringingSince).isEqualTo(sevenToday) assertThat(states.state(1L).handledOccurrence).isEqualTo(sevenToday) assertThat(scheduler.autoSilence) - .isEqualTo(Registration(sevenToday + AlarmRing.AUTO_SILENCE_AFTER, 1L)) + .isEqualTo(Registration(sevenToday + ClockDefaults().alarmSilenceAfter, 1L)) } @Test @@ -680,7 +681,7 @@ class AlarmEngineTest { assertThat(ring.ringing.value).isEqualTo(1L) assertThat(scheduler.autoSilence) - .isEqualTo(Registration(sevenToday + AlarmRing.AUTO_SILENCE_AFTER, 1L)) + .isEqualTo(Registration(sevenToday + ClockDefaults().alarmSilenceAfter, 1L)) } @Test @@ -692,7 +693,8 @@ class AlarmEngineTest { engine.onBootCompleted() - assertThat(ring.events).isEmpty() + // Closing the stale ring stops whatever might still sound; nothing starts. + assertThat(ring.events).containsExactly(RingEvent.Stopped(1L)) assertThat(states.state(1L).ringingSince).isNull() assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow) } @@ -798,6 +800,117 @@ class AlarmEngineTest { assertThat(scheduler.next).isEqualTo(Registration(sevenToday, 1L)) } + // --- #17: the ring window is the "Silence after" setting --- + + @Test + fun `the backstop is armed at the chosen silence-after, not ten minutes`(@TempDir dir: Path) = runTest { + givenAlarm(repeatDays = RepeatDays.EVERY_DAY) + wallClock.instant = sevenToday + val settings = settings(dir) + settings.setAlarmSilenceAfterMinutes(25) + val engine = engineOver(settings) + + engine.onFire(alarmId = 1L, scheduledFor = sevenToday) + + assertThat(scheduler.autoSilence).isEqualTo(Registration(sevenToday + 25.minutes, 1L)) + } + + @Test + fun `Never arms the backstop at the hard stop`(@TempDir dir: Path) = runTest { + givenAlarm(repeatDays = RepeatDays.EVERY_DAY) + wallClock.instant = sevenToday + val settings = settings(dir) + settings.setAlarmSilenceAfterMinutes(SilenceAfter.NEVER) + val engine = engineOver(settings) + + engine.onFire(alarmId = 1L, scheduledFor = sevenToday) + + assertThat(scheduler.autoSilence).isEqualTo(Registration(sevenToday + SilenceAfter.HARD_STOP, 1L)) + } + + @Test + fun `a reschedule inside a longer window keeps the ring going`(@TempDir dir: Path) = runTest { + givenAlarm(repeatDays = RepeatDays.EVERY_DAY) + wallClock.instant = sevenToday + val settings = settings(dir) + settings.setAlarmSilenceAfterMinutes(25) + val engine = engineOver(settings) + engine.onFire(alarmId = 1L, scheduledFor = sevenToday) + wallClock.instant = sevenToday + 20.minutes + + engine.reschedule() + + assertThat(ring.ringing.value).isEqualTo(1L) + assertThat(states.state(1L).ringingSince).isEqualTo(sevenToday) + assertThat(scheduler.autoSilence).isEqualTo(Registration(sevenToday + 25.minutes, 1L)) + } + + @Test + fun `shortening silence-after mid-ring stops a ring already past the new window`(@TempDir dir: Path) = + runTest { + givenAlarm(repeatDays = RepeatDays.EVERY_DAY) + wallClock.instant = sevenToday + val settings = settings(dir) + val engine = engineOver(settings) + engine.onFire(alarmId = 1L, scheduledFor = sevenToday) + wallClock.instant = sevenToday + 5.minutes + + settings.setAlarmSilenceAfterMinutes(1) + engine.reschedule() + + assertThat(ring.ringing.value).isNull() + assertThat(ring.events.last()).isEqualTo(RingEvent.Stopped(1L)) + assertThat(states.state(1L).ringingSince).isNull() + assertThat(scheduler.autoSilence).isNull() + assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow) + } + + @Test + fun `a ring interrupted by a reboot resumes inside a longer window`(@TempDir dir: Path) = runTest { + givenAlarm(repeatDays = RepeatDays.EVERY_DAY) + wallClock.instant = sevenToday + 20.minutes + givenRingingAt(alarmId = 1L, occurrence = sevenToday, since = sevenToday) + val settings = settings(dir) + settings.setAlarmSilenceAfterMinutes(25) + val engine = engineOver(settings) + + engine.onBootCompleted() + + assertThat(ring.ringing.value).isEqualTo(1L) + assertThat(scheduler.autoSilence).isEqualTo(Registration(sevenToday + 25.minutes, 1L)) + } + + @Test + fun `a late snooze is kept for as long as the chosen window`(@TempDir dir: Path) = runTest { + givenAlarm(repeatDays = RepeatDays.EVERY_DAY) + val snoozeAt = sevenToday + 9.minutes + states.save( + ringState(snoozedUntil = snoozeAt, snoozeCount = 1, handledOccurrence = sevenToday), + ) + wallClock.instant = snoozeAt + 20.minutes + val settings = settings(dir) + settings.setAlarmSilenceAfterMinutes(25) + val engine = engineOver(settings) + + val outcome = engine.onFire(alarmId = 1L, scheduledFor = snoozeAt) + + assertThat(outcome).isEqualTo(FireOutcome.Ringing(1L)) + } + + @Test + fun `upcoming measures a ring against the chosen window`(@TempDir dir: Path) = runTest { + givenAlarm(repeatDays = RepeatDays.EVERY_DAY) + wallClock.instant = sevenToday + 20.minutes + givenRingingAt(alarmId = 1L, occurrence = sevenToday, since = sevenToday) + val settings = settings(dir) + settings.setAlarmSilenceAfterMinutes(25) + val engine = engineOver(settings) + + val entry = engine.upcoming().first().single() + + assertThat(entry.source).isEqualTo(FireSource.RESUMED_RING) + } + // --- upcoming() --- @Test @@ -886,7 +999,7 @@ class AlarmEngineTest { assertThat(ring.ringing.value).isEqualTo(2L) assertThat(states.state(2L).ringingSince).isEqualTo(eightToday) assertThat(scheduler.autoSilence) - .isEqualTo(Registration(eightToday + AlarmRing.AUTO_SILENCE_AFTER, 2L)) + .isEqualTo(Registration(eightToday + ClockDefaults().alarmSilenceAfter, 2L)) assertThat(states.state(1L).snoozedUntil).isNull() assertThat(notifier.cleared).contains(1L) } 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..9b39d5d 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 @@ -10,6 +10,7 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent import de.jeanlucmakiola.clockula.domain.backup.BackupSettings import de.jeanlucmakiola.clockula.domain.backup.BackupTimer import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock +import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import de.jeanlucmakiola.clockula.domain.timer.TimerPresets import de.jeanlucmakiola.floret.prefs.ThemeMode import org.junit.jupiter.api.Test @@ -60,6 +61,8 @@ class BackupCodecTest { timerRingtoneUri = "content://media/2", dismissChallenge = DismissChallenge.HOLD, homeZoneId = "America/New_York", + alarmSilenceAfterMinutes = 25, + timerSilenceAfterMinutes = SilenceAfter.NEVER, ), timerPresets = listOf(1.minutes, 2.minutes, 10.minutes), ), @@ -263,6 +266,20 @@ class BackupCodecTest { assertThat(settings.timerPresets).isEqualTo(TimerPresets.BUILT_IN) } + @Test + fun `a file from before silence after keeps the ten minutes it was made with`() { + val defaults = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings.defaults + assertThat(defaults.alarmSilenceAfterMinutes).isEqualTo(10) + assertThat(defaults.timerSilenceAfterMinutes).isEqualTo(10) + } + + @Test + fun `silence after is written under its documented keys`() { + val json = BackupCodec.encode(sampleContent()) + assertThat(json).contains("\"default_alarm_silence_after_minutes\": 25") + assertThat(json).contains("\"default_timer_silence_after_minutes\": 0") + } + @Test fun `an empty preset list is honoured as all removed`() { val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}") 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..bdfe100 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 @@ -9,6 +9,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.DismissChallenge +import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import de.jeanlucmakiola.floret.prefs.PrefStore import de.jeanlucmakiola.floret.prefs.ThemeMode import kotlinx.coroutines.CoroutineScope @@ -53,6 +54,8 @@ class ClockPrefsTest { prefs.setTimerRingtoneUri("content://t") prefs.setDismissChallenge(DismissChallenge.HOLD) prefs.setHomeZoneId("Europe/Berlin") + prefs.setAlarmSilenceAfterMinutes(25) + prefs.setTimerSilenceAfterMinutes(SilenceAfter.NEVER) assertThat(prefs.defaults.first()).isEqualTo( ClockDefaults( @@ -64,6 +67,8 @@ class ClockPrefsTest { timerRingtoneUri = "content://t", dismissChallenge = DismissChallenge.HOLD, homeZoneId = "Europe/Berlin", + alarmSilenceAfterMinutes = 25, + timerSilenceAfterMinutes = SilenceAfter.NEVER, ), ) } @@ -148,4 +153,21 @@ class ClockPrefsTest { cancel() } } + + @Test + fun `silence after clamps on write and on read`(@TempDir tempDir: Path) = runTest { + val dataStore = newDataStore(tempDir) + val prefs = SettingsPrefs(PrefStore(dataStore)) + + prefs.setAlarmSilenceAfterMinutes(999) + prefs.setTimerSilenceAfterMinutes(-3) + assertThat(prefs.defaults.first().alarmSilenceAfterMinutes).isEqualTo(60) + assertThat(prefs.defaults.first().timerSilenceAfterMinutes).isEqualTo(SilenceAfter.NEVER) + + dataStore.updateData { stored -> + stored.toMutablePreferences() + .apply { this[intPreferencesKey("default_alarm_silence_after_minutes")] = 5_000 } + } + assertThat(prefs.defaults.first().alarmSilenceAfter).isEqualTo(SilenceAfter.HARD_STOP) + } } 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..2abb2d3 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 = " ", + alarmSilenceAfterMinutes = 25, + timerSilenceAfterMinutes = 999, + ), 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().alarmSilenceAfterMinutes).isEqualTo(25) + assertThat(prefs.currentDefaults().timerSilenceAfterMinutes).isEqualTo(60) assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder() } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolverTest.kt index 960cb2a..e963c99 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolverTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolverTest.kt @@ -2,7 +2,9 @@ package de.jeanlucmakiola.clockula.domain.alarm import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.clockula.domain.Alarm +import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.RepeatDays +import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import de.jeanlucmakiola.clockula.testing.BERLIN import de.jeanlucmakiola.clockula.testing.alarmAt import de.jeanlucmakiola.clockula.testing.instantAt @@ -28,8 +30,9 @@ class AlarmResolverTest { state: AlarmRingState = ringState(alarmId = alarm.id), now: Instant = morning, fireGrace: Duration = AlarmRing.FIRE_GRACE, + silenceAfter: Duration = ClockDefaults().alarmSilenceAfter, ): AlarmResolution = - AlarmResolver.resolve(alarm, state, now, BERLIN, AlarmRing.AUTO_SILENCE_AFTER, fireGrace) + AlarmResolver.resolve(alarm, state, now, BERLIN, silenceAfter, fireGrace) // --- T32–T34: the disabled and plain cases --- @@ -158,7 +161,7 @@ class AlarmResolverTest { @Test fun `a ring exactly as old as the auto-silence window has timed out`() { val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) - val state = ringState(ringingSince = morning - AlarmRing.AUTO_SILENCE_AFTER) + val state = ringState(ringingSince = morning - ClockDefaults().alarmSilenceAfter) val resolution = resolve(alarm, state) @@ -357,4 +360,53 @@ class AlarmResolverTest { assertThat(resolve(alarm, state)).isEqualTo(first) assertThat(state).isEqualTo(copy) } + + // --- #17: the window is the "Silence after" setting --- + + @Test + fun `a longer window keeps a ring going past the default's ten minutes`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val state = ringState(ringingSince = morning - 20.minutes) + + val resolution = resolve(alarm, state, silenceAfter = SilenceAfter.window(25)) + + assertThat(resolution.source).isEqualTo(FireSource.RESUMED_RING) + assertThat(resolution.state).isEqualTo(state) + } + + @Test + fun `a shorter window closes a ring the default would still keep`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val state = ringState(ringingSince = morning - 2.minutes) + + val resolution = resolve(alarm, state, silenceAfter = SilenceAfter.window(1)) + + assertThat(resolution.source).isEqualTo(FireSource.OCCURRENCE) + assertThat(resolution.state.ringingSince).isNull() + } + + @Test + fun `Never still closes a ring at the hard stop`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val never = SilenceAfter.window(SilenceAfter.NEVER) + + val ringing = resolve(alarm, ringState(ringingSince = morning - 59.minutes), silenceAfter = never) + val stopped = resolve(alarm, ringState(ringingSince = morning - 1.hours), silenceAfter = never) + + assertThat(ringing.source).isEqualTo(FireSource.RESUMED_RING) + assertThat(stopped.state.ringingSince).isNull() + } + + @Test + fun `a late snooze is kept for as long as the window the user chose`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val snoozedUntil = morning - 20.minutes + val state = ringState(snoozedUntil = snoozedUntil, snoozeCount = 1) + + val kept = resolve(alarm, state, silenceAfter = SilenceAfter.window(25)) + val dropped = resolve(alarm, state, silenceAfter = SilenceAfter.window(15)) + + assertThat(kept.nextFire to kept.source).isEqualTo(snoozedUntil to FireSource.SNOOZE) + assertThat(dropped.state.snoozedUntil).isNull() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/SilenceAfterTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/SilenceAfterTest.kt new file mode 100644 index 0000000..e977bbb --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/SilenceAfterTest.kt @@ -0,0 +1,50 @@ +package de.jeanlucmakiola.clockula.domain.ring + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.ClockDefaults +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +/** #17: what a stored "Silence after" value means to the two engines. */ +class SilenceAfterTest { + + @Test + fun `the default is v1's fixed ten minutes, for alarms and timers alike`() { + val defaults = ClockDefaults() + + assertThat(defaults.alarmSilenceAfter).isEqualTo(10.minutes) + assertThat(defaults.timerSilenceAfter).isEqualTo(10.minutes) + } + + @Test + fun `a stored value is that many minutes`() { + assertThat(SilenceAfter.window(1)).isEqualTo(1.minutes) + assertThat(SilenceAfter.window(25)).isEqualTo(25.minutes) + } + + @Test + fun `Never is the hard stop, an hour`() { + assertThat(SilenceAfter.window(SilenceAfter.NEVER)).isEqualTo(SilenceAfter.HARD_STOP) + assertThat(SilenceAfter.HARD_STOP).isEqualTo(1.hours) + } + + @Test + fun `no value reaches the engines as a window longer than the hard stop`() { + assertThat(SilenceAfter.window(24 * 60)).isEqualTo(SilenceAfter.HARD_STOP) + // Negative is clamped to Never rather than read as an instant silence. + assertThat(SilenceAfter.window(-5)).isEqualTo(SilenceAfter.HARD_STOP) + } + + @Test + fun `every choice is in range, and the hard stop outlasts every finite one`() { + assertThat(SilenceAfter.CHOICES).contains(SilenceAfter.DEFAULT_MINUTES) + assertThat(SilenceAfter.CHOICES).contains(SilenceAfter.NEVER) + SilenceAfter.CHOICES.forEach { minutes -> + assertThat(minutes).isIn(SilenceAfter.MINUTES) + assertThat(SilenceAfter.window(minutes)).isAtMost(SilenceAfter.HARD_STOP) + } + assertThat(SilenceAfter.CHOICES.filter { it != SilenceAfter.NEVER }.max().minutes) + .isLessThan(SilenceAfter.HARD_STOP) + } +} 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..70f4c8f 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 @@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.domain.timer import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.Ringtones +import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import de.jeanlucmakiola.clockula.testing.T0 import de.jeanlucmakiola.clockula.testing.expiredTimer import org.junit.jupiter.api.Test @@ -154,7 +155,7 @@ class TimerRingPolicyTest { TimerRingDecision.Sound( subjectId = 7L, settings = released.sound().settings, - silenceAt = now + TimerRing.AUTO_SILENCE_AFTER, + silenceAt = now + ClockDefaults().timerSilenceAfter, ), soundingSince = now, ), @@ -174,7 +175,7 @@ class TimerRingPolicyTest { /** §5.4 #14 */ @Test fun `a session that has run its window auto-silences`() { - val opened = now - TimerRing.AUTO_SILENCE_AFTER + val opened = now - ClockDefaults().timerSilenceAfter val outcome = decide(soundingSince = opened) @@ -185,7 +186,7 @@ class TimerRingPolicyTest { /** §5.4 #15 */ @Test fun `auto-silence is decided before suppression`() { - val opened = now - TimerRing.AUTO_SILENCE_AFTER + val opened = now - ClockDefaults().timerSilenceAfter val outcome = decide(alarmIsRinging = true, soundingSince = opened) @@ -195,7 +196,7 @@ class TimerRingPolicyTest { /** §5.4 #16 */ @Test fun `an auto-silenced session does not restart a minute later`() { - val opened = now - TimerRing.AUTO_SILENCE_AFTER + val opened = now - ClockDefaults().timerSilenceAfter val outcome = decide(soundingSince = opened, at = now + 1.minutes) @@ -207,7 +208,7 @@ class TimerRingPolicyTest { @Test fun `resetting the last expired timer closes an auto-silenced session`() { val outcome = - decide(expired = emptyList(), soundingSince = now - TimerRing.AUTO_SILENCE_AFTER) + decide(expired = emptyList(), soundingSince = now - ClockDefaults().timerSilenceAfter) assertThat(outcome).isEqualTo(TimerRingOutcome(TimerRingDecision.Silent, soundingSince = null)) } @@ -227,7 +228,7 @@ class TimerRingPolicyTest { fun `the window closes ten minutes after the audio opened`() { val outcome = decide() - assertThat(outcome.sound().silenceAt).isEqualTo(now + TimerRing.AUTO_SILENCE_AFTER) + assertThat(outcome.sound().silenceAt).isEqualTo(now + ClockDefaults().timerSilenceAfter) } /** §5.4 #20 */ @@ -242,4 +243,35 @@ class TimerRingPolicyTest { assertThat(outcome.sound().subjectId).isEqualTo(9L) } + + // --- #17: the window is the timer's own "Silence after" setting --- + + @Test + fun `the window is the timer setting, not the alarm's`() { + val custom = defaults.copy(timerSilenceAfterMinutes = 25, alarmSilenceAfterMinutes = 1) + + val outcome = decide(defaults = custom) + + assertThat(outcome.sound().silenceAt).isEqualTo(now + 25.minutes) + } + + @Test + fun `a shorter window auto-silences a session the default would keep`() { + val custom = defaults.copy(timerSilenceAfterMinutes = 1) + + val outcome = decide(defaults = custom, soundingSince = now - 1.minutes) + + assertThat(outcome.decision).isEqualTo(TimerRingDecision.AutoSilenced(7L)) + } + + @Test + fun `Never sounds up to the hard stop and no further`() { + val never = defaults.copy(timerSilenceAfterMinutes = SilenceAfter.NEVER) + + val sounding = decide(defaults = never, soundingSince = now - 59.minutes) + val silenced = decide(defaults = never, soundingSince = now - SilenceAfter.HARD_STOP) + + assertThat(sounding.sound().silenceAt).isEqualTo(now - 59.minutes + SilenceAfter.HARD_STOP) + assertThat(silenced.decision).isEqualTo(TimerRingDecision.AutoSilenced(7L)) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/timer/TimerEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/timer/TimerEngineTest.kt index 10df056..6f248c5 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/timer/TimerEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/timer/TimerEngineTest.kt @@ -1,10 +1,10 @@ package de.jeanlucmakiola.clockula.timer import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.TimerState import de.jeanlucmakiola.clockula.domain.time.BootId import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction -import de.jeanlucmakiola.clockula.domain.timer.TimerRing import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision import de.jeanlucmakiola.clockula.testing.T0 import de.jeanlucmakiola.clockula.testing.TimerEngineHarness @@ -497,7 +497,22 @@ class TimerEngineTest { val state = harness.engine.serviceState(alreadyAlerted = false) - assertThat(state.silenceIn).isEqualTo(TimerRing.AUTO_SILENCE_AFTER - 30.seconds) + assertThat(state.silenceIn).isEqualTo(ClockDefaults().timerSilenceAfter - 30.seconds) + } + + /** #17 */ + @Test + fun `the ring session's window is the timer's silence-after setting`( + @TempDir tempDir: Path, + ) = runTest { + val harness = timerEngineHarness(tempDir) + harness.settings.setTimerSilenceAfterMinutes(25) + harness.given(expiredTimer(id = 1L)) + harness.ringState.set(T0 - 30.seconds) + + val state = harness.engine.serviceState(alreadyAlerted = false) + + assertThat(state.silenceIn).isEqualTo(25.minutes - 30.seconds) } /** §5.8 #31 */ @@ -688,7 +703,7 @@ class TimerEngineTest { harness.engine.onExpiryDue() // The first timer sounds, and is then left alone until the window lapses. harness.engine.serviceState(alreadyAlerted = false) - harness.advance(TimerRing.AUTO_SILENCE_AFTER) + harness.advance(ClockDefaults().timerSilenceAfter) val lapsed = harness.engine.serviceState(alreadyAlerted = true) harness.engine.start(2L) @@ -699,7 +714,7 @@ class TimerEngineTest { assertThat(lapsed.ring).isInstanceOf(TimerRingDecision.AutoSilenced::class.java) assertThat(second.ring).isInstanceOf(TimerRingDecision.Sound::class.java) assertThat((second.ring as TimerRingDecision.Sound).silenceAt) - .isEqualTo(harness.wallClock.instant + TimerRing.AUTO_SILENCE_AFTER) + .isEqualTo(harness.wallClock.instant + ClockDefaults().timerSilenceAfter) } /** §5.8 #42 — added with the review fix for finding 2. */ diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModelTest.kt index 93a42ed..f86301b 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModelTest.kt @@ -1,8 +1,8 @@ package de.jeanlucmakiola.clockula.ui.ring import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.TimerState -import de.jeanlucmakiola.clockula.domain.timer.TimerRing import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension import de.jeanlucmakiola.clockula.testing.T0 import de.jeanlucmakiola.clockula.testing.TimerEngineHarness @@ -35,6 +35,7 @@ class TimerRingViewModelTest { private fun TestScope.viewModel(harness: TimerEngineHarness) = TimerRingViewModel( timers = harness.timers, ringState = harness.ringState, + settings = harness.settings, engine = harness.engine, elapsed = harness.elapsed, wall = harness.wallClock, @@ -124,11 +125,29 @@ class TimerRingViewModelTest { advanceUntilIdle() assertThat(viewModel.state.value).isInstanceOf(TimerRingUiState.Ringing::class.java) - harness.wallClock.advance(TimerRing.AUTO_SILENCE_AFTER + 1.seconds) + harness.wallClock.advance(ClockDefaults().timerSilenceAfter + 1.seconds) harness.ticker.tick() advanceUntilIdle() assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.EXPIRED) assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished) } + + @Test + fun `the screen follows the timer's own silence-after setting`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = timerEngineHarness(tempDir) + harness.settings.setTimerSilenceAfterMinutes(1) + harness.given(expiredTimer(id = 1L)) + harness.ringState.set(T0) + val viewModel = viewModel(harness) + advanceUntilIdle() + assertThat(viewModel.state.value).isInstanceOf(TimerRingUiState.Ringing::class.java) + + harness.wallClock.advance(1.minutes + 1.seconds) + harness.ticker.tick() + advanceUntilIdle() + + assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished) + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..31868fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1050,14 +1050,14 @@ M10 adds no ``. | Not here | Milestone | |---|---| | Reordering alarms by hand. The list is ordered by time of day (§13) and `alarms` has no `sort_order` column, so this would be a schema change for a preference the time order already answers | not planned for v1 | -| A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; this one is still global and still `AlarmRing.AUTO_SILENCE_AFTER` | not planned for v1 | +| A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; "Silence after" (#17) is an app-wide setting only, because an override would need a new `alarms` column, a migration and an editor row for a value almost nobody sets per alarm | not planned | | Voice-interaction follow-on flows: `Activity.isVoiceInteraction`, `VoiceInteractor.CompleteVoiceRequest` (publishing a deeplink to the alarm just created) and `PickOptionRequest` (disambiguating by voice). `android.intent.category.VOICE` is therefore not declared: advertising it while answering with a touch dialog would be a promise the app does not keep. Clockula *accepts* a `clockula://` deeplink and publishes none | not planned for v1 | | A "most closely matched" time search for `DISMISS_ALARM`. `ALARM_SEARCH_MODE_TIME` matches **exactly**: dismissing an alarm the caller did not name is a missed alarm, and there is no distance at which that becomes a good trade | not planned for v1 | | A toast or any other confirmation on a `SKIP_UI` write. `SKIP_UI` means "bypass any intermediate UI", a voice assistant speaks its own confirmation, and the status-bar alarm icon `setAlarmClock` lights is the platform's own receipt (§17) | not planned for v1 | | Surfacing `delete_after_use` in either editor. It is a property the contract sets, not a setting; an alarm the user edits keeps it, and that consequence is documented (§17) rather than papered over with a checkbox | not planned for v1 | | Carrying `EXTRA_MESSAGE` into the timer setup panel when no length was given. There is no row to carry it on and the panel has no label field | not planned for v1 | | Verifying a ringtone URI at the intent boundary. M5 locked "an unreadable sound is reported, never rewritten"; an unmounted card comes back, and the boundary opens no `ContentResolver` | not planned for v1 | -| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | not planned for v1 | +| An unlimited-snooze option, per-alarm auto-silence | not planned for v1 | | Reordering timers by hand. `sort_order` and `TimerRepository.reorder` exist and stay caller-less: the list is in storage order, M5 gave the same answer for alarms, and a drag surface would also mean abandoning the `LazyColumn` | not planned for v1 | | 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 | @@ -1142,7 +1142,7 @@ repair, resume and reschedule one by one, so its whole pass is inside the lock. 1. **Disabled** ⇒ clear the snooze, the ring and the skip watermark; keep `handled_occurrence`, because re-enabling must not un-suppress a dismissal. -2. **Ringing** within `AUTO_SILENCE_AFTER` ⇒ `RESUMED_RING`, nothing scheduled: +2. **Ringing** within the "Silence after" window ⇒ `RESUMED_RING`, nothing scheduled: it is not an alarm to come, it is one that is happening. A clock moved *backwards* under a ringing alarm reads as a negative age, which is inside the window, so it keeps ringing. Beyond the window it is auto-dismissed as @@ -1157,8 +1157,32 @@ repair, resume and reschedule one by one, so its whole pass is inside the lock. ### The ring cycle A cycle opens when the alarm fires and closes on dismiss, on the snooze limit -being reached, or on auto-silence after `AUTO_SILENCE_AFTER` = **10 minutes** -(AOSP DeskClock's default; not user-configurable in v1). Snoozing keeps it open. +being reached, or on auto-silence after the **"Silence after"** window. That is +a setting since #17 (Settings › Alarms: 1, 5, 10, 15, 20 or 25 minutes, or +Never), **10 minutes** by default, AOSP DeskClock's default and v1's fixed +value, so an upgrade changes nothing. Snoozing keeps it open. + +The window is read from `ClockDefaults.alarmSilenceAfter` everywhere a ring is +measured: the resolver's ringing and late-snooze checks, the backstop `onFire` +arms, how late a snooze fire may arrive, and whether a ring interrupted by a +reboot is resumed. `AlarmResolver.resolve` takes it without a default, so no +caller can measure against a window the user did not choose. + +**"Never" is an hour.** The backstop is also what stops a phone left at home +from ringing until its battery is flat, what bounds the ring services' wake +locks, and what keeps a reboot hours later from resuming a ring nobody was +there for. So "Never" maps to `SilenceAfter.HARD_STOP` (60 minutes), longer +than any finite choice, and the settings hint says so in words. Stored values +are clamped to 0..60 on read, on write and from a backup, so no value can make +a ring outlast it. + +A ring started under one window keeps the backstop it was armed with. When the +window is shortened mid-ring, the next re-resolution closes the ring once it is +past the new window, and `persist` then stops the ring service and cancels the +backstop with it: storage says nothing rings, so nothing may. The same applies +to any ring the resolver closes (a clock jumped forward, a ringing alarm +switched off), which earlier only cleared `ringingSince` and left the sound to +the backstop. A **non-repeating alarm is disabled when its cycle closes, never when it opens**. Disabling it at the start would clear its own snooze and the alarm would vanish @@ -1284,7 +1308,7 @@ 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 -auto-silence constant. §14 has the rest, including which of the two wins the +"Silence after" setting. §14 has the rest, including which of the two wins the one audio stream. --- @@ -1433,7 +1457,7 @@ whichever comes first, for every challenge kind, asserted as a parameterised invariant over `DismissChallenge`, not as three happy paths. Three more guarantees hold alongside it. Snooze is never gated: the gate protects -dismissal only. Auto-silence still ends the ring at ten minutes regardless of the +dismissal only. Auto-silence still ends the ring at its "Silence after" window regardless of the gate, because that is the engine's, and the screen only follows the session to `Finished`. And a wrong answer carries no lockout and no penalty timer; it replaces the problem and that is all. @@ -1693,6 +1717,12 @@ the engine's own mutex, so it cannot race the fire it is asking about, and it is deliberately **conditional**: dismissing unconditionally would also drop a merely *snoozed* alarm's pending snooze, which is wrong for a label edit. +Since #17 the engine's `persist` also stops the ring service and cancels the +backstop whenever the resolver closes a ring (§11), so the disabled branch no +longer leaves a ring sounding on its own. The dismiss-first rule stays: it is +what closes the cycle *as a dismissal* (a one-shot alarm disabled, the snooze +notification cleared), and a deleted row never reaches the resolver at all. + ### Immediate apply There is no Save button, no dirty state and no discard dialog: `PLAN.md` §11 @@ -1877,8 +1907,8 @@ sound. - **A session is one window.** It opens when the audio *actually starts sounding* and closes when the expired set returns to empty. A second timer expiring mid-session inherits the session's remaining window rather than - buying a fresh ten minutes. That is the conservative direction, and the same rule as - §11's "a reboot does not buy the alarm another ten minutes". The anchor is the + buying a fresh window. That is the conservative direction, and the same rule as + §11's "a reboot does not buy the alarm a fresh window". The anchor is the instant the audio *opened* rather than the instant of expiry, so a long alarm ring in front of it does not eat the timer's whole window. - **A lapsed window does not silence the next timer.** A window that has run out @@ -1889,9 +1919,12 @@ sound. opens a fresh window with its own heads-up. Inheriting stays the rule for a timer expiring *inside* a live window. -`TimerRing.AUTO_SILENCE_AFTER` is **ten minutes**: its own constant with the -same value as the alarm's, free to diverge, because coupling the two would make -one number answer two questions. Unlike an alarm's, a timer's +The window is the timer's own **"Silence after"** setting +(`ClockDefaults.timerSilenceAfter`, Settings › Timers, #17), ten minutes by +default: the same choices as the alarm's and the same one-hour "Never", but a +separate value, because coupling the two would make one number answer two +questions. `TimerRingPolicy` reads it from the defaults it already takes, so a +change applies on the service's next pass. Unlike an alarm's, a timer's **auto-silence stops the noise and leaves the row `EXPIRED`.** An auto-silenced alarm is a *missed* alarm and its cycle closes; an auto-silenced timer has still finished, and the user coming back must be able to see that and add a minute diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e8536de..670499b 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -136,6 +136,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands "default_timer_ringtone_uri": null, "default_dismiss_challenge": "NONE", "home_zone_id": null, + "default_alarm_silence_after_minutes": 10, + "default_timer_silence_after_minutes": 10, "timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000] } ``` @@ -143,8 +145,15 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older files may also carry `default_timer_duration_millis`, from a default timer length setting that has since been removed; it is ignored on import like any -other unknown field. Every -numeric/duration value is clamped on *apply* using the same ranges and encoding +other unknown field. + +`default_alarm_silence_after_minutes` and `default_timer_silence_after_minutes` +are how long a ringing alarm or a finished timer keeps going before it stops on +its own: whole minutes, with `0` meaning "Never" (which still stops after an +hour). A file without them, from before the setting existed, imports as `10`, +the value it was made with. Out-of-range values clamp to 0..60. + +Every numeric/duration value is clamped on *apply* using the same ranges and encoding as the existing preference setters, never on parse. ## Ringtone URIs