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.
This commit is contained in:
2026-10-21 17:22:33 +02:00
parent d48c2e68fa
commit 4d29669222
2 changed files with 41 additions and 3 deletions
@@ -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)
@@ -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)