From 4d29669222aee4f1f1f607648093120cec8a1b85 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 17:22:33 +0200 Subject: [PATCH] fix: ring a snooze that is delivered late A snooze stays pending for the ring window after it comes due, but the fire was rejected after the two-minute grace and re-registered at the same past instant, so the alarm looped on wakeups and never rang after a reboot or a late delivery. --- .../clockula/alarm/AlarmEngine.kt | 10 ++++-- .../clockula/alarm/AlarmEngineTest.kt | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) 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 eb20589..d9ff0a9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -234,9 +234,15 @@ class AlarmEngine @Inject constructor( private suspend fun onFireLocked(alarmId: Long, scheduledFor: Instant): FireOutcome { val now = wallClock.now() val alarm = alarms.find(alarmId) + val state = states.state(alarmId) + val isSnoozeFire = state.snoozedUntil != null && state.snoozedUntil == scheduledFor + // 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 // 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 > AlarmRing.FIRE_GRACE) { + if (alarm == null || !alarm.enabled || now - scheduledFor > lateBy) { rescheduleLocked() return FireOutcome.Ignored } @@ -249,8 +255,6 @@ class AlarmEngine @Inject constructor( states.save(other.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0)) } - val state = states.state(alarmId) - val isSnoozeFire = state.snoozedUntil != null && state.snoozedUntil == scheduledFor val opened = if (isSnoozeFire) { // The cycle is the same one: the count and the watermark carry over. state.copy(snoozedUntil = null) 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 2a75b31..0a0c53c 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineTest.kt @@ -363,6 +363,40 @@ class AlarmEngineTest { assertThat(states.state(1L).snoozeCount).isEqualTo(1) } + @Test + fun `a snooze delivered late after a reboot still rings`(@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 + 4.minutes + val engine = engine(dir) + + val outcome = engine.onFire(alarmId = 1L, scheduledFor = snoozeAt) + + assertThat(outcome).isEqualTo(FireOutcome.Ringing(1L)) + assertThat(states.state(1L).snoozeCount).isEqualTo(1) + assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow) + } + + @Test + fun `a snooze delivered after its ring window is ignored`(@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 + 11.minutes + val engine = engine(dir) + + val outcome = engine.onFire(alarmId = 1L, scheduledFor = snoozeAt) + + assertThat(outcome).isEqualTo(FireOutcome.Ignored) + assertThat(ring.events).isEmpty() + assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow) + } + @Test fun `the ringing alarm never takes the next-alarm slot from another one`(@TempDir dir: Path) = runTest { givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)