fix(alarms): never announce an alarm that has since stopped ringing

A notice is now re-checked against the engine before a screen shows it,
so an alarm switched off or deleted by any path - the ring screen, a
notification, an intent, a restore - is never announced as set. A
retract takes the publish lock, so a publish already past its read
cannot put the notice back.

Settling is per alarm: switching one alarm off in the list no longer
cancels the repeat days still settling on another. The identical-words
dedupe is gone, since it could swallow a real confirmation.

The editor's writes that move a fire time, with their reschedule and
announcement, run on the application scope. Pick, OK, back could
otherwise cancel the reschedule half-way and leave a stored time the
system had not been given. upcomingFor() reads alarm and state under
the engine lock, so a fire cannot land between the two reads.

Refs #19
This commit is contained in:
2026-10-21 19:18:19 +02:00
parent fa5ce55503
commit d2bf4349c5
6 changed files with 100 additions and 53 deletions
@@ -197,13 +197,18 @@ class AlarmEngine @Inject constructor(
/**
* One alarm's [upcoming] entry, resolved against [now]. Read-only like
* [upcoming], and for the same reason it takes no lock: it writes nothing.
* Null for an id no alarm has. A caller that measures a countdown passes
* the `now` it measures from, so both ends of it are the same instant.
* [upcoming]; null for an id no alarm has. A caller that measures a
* countdown passes the `now` it measures from, so both ends of it are the
* same instant.
*
* It writes nothing, but it does take [lock]: the alarm and its ring state
* are two reads, and a fire landing between them would pair an alarm with
* a state that is not its own. `upcoming()` gets the same consistency from
* combining the two flows.
*/
suspend fun upcomingFor(alarmId: Long, now: Instant = wallClock.now()): UpcomingAlarm? {
val alarm = alarms.find(alarmId) ?: return null
return preview(alarm, states.state(alarmId), now, zones.current())
suspend fun upcomingFor(alarmId: Long, now: Instant = wallClock.now()): UpcomingAlarm? = lock.withLock {
val alarm = alarms.find(alarmId) ?: return@withLock null
preview(alarm, states.state(alarmId), now, zones.current())
}
/**
@@ -146,7 +146,9 @@ class AlarmEditorViewModel @Inject internal constructor(
}
fun onSkipNextChanged(skip: Boolean) {
viewModelScope.launch {
// On the application scope, like every write that moves a fire time:
// see [edit].
appScope.launch {
alarms.setSkipNextOccurrence(alarmId, skip)
engine.reschedule()
// Skipping moves the next ring a day or more out, which is exactly
@@ -265,9 +267,13 @@ class AlarmEditorViewModel @Inject internal constructor(
/**
* Every editor write. [reschedule] is on only for a change that can move a
* fire time — so fiddling with sound settings costs no AlarmManager traffic
* (M5 D12). [announces] is on for the same changes, after [settle] — on
* the announcer's application scope, so leaving the editor while the
* repeat days settle still has them said, for the list to show (#19).
* (M5 D12). [announces] is on for the same changes, after [settle] (#19).
*
* A change that moves a fire time runs on the application scope. Pick →
* OK → back is the usual way to set an alarm, and back clears this
* ViewModel: on its own scope the reschedule — and the "Alarm set for …"
* the list is meant to pick up — could be cancelled half-way, leaving a
* stored time the system had not been given yet.
*/
private fun edit(
reschedule: Boolean = false,
@@ -275,7 +281,7 @@ class AlarmEditorViewModel @Inject internal constructor(
settle: Duration = Duration.ZERO,
transform: (Alarm) -> Alarm,
) {
viewModelScope.launch {
(if (reschedule) appScope else viewModelScope).launch {
alarms.edit(alarmId, transform)
if (reschedule) engine.reschedule()
if (announces) announcer.announce(alarmId, settle)
@@ -74,17 +74,23 @@ internal class AlarmSetAnnouncer @Inject constructor(
*/
private val lock = Mutex()
/** The request still settling, and the alarm it is about. Guarded by `this`. */
private var pending: Job? = null
private var pendingFor: Long? = null
/**
* Requests still settling, one per alarm: a request about one alarm must
* not cancel another's — switching B off in the list would otherwise
* swallow the repeat days just set on A. Guarded by `this`.
*/
private val pending = mutableMapOf<Long, Job>()
/**
* The notice to show, or null. Past [FRESH_FOR] it is dropped unshown: a
* countdown that surfaced when the user came back to the app a while later
* would be wrong, and about something they did long ago.
* would be wrong, and about something they did long ago. It is checked
* against the engine again on the way out, so an alarm switched off or
* deleted by any path — the ring screen, a notification, an intent, a
* restore — is never announced as set.
*/
val notice: Flow<AlarmSetNotice?> = current.map { notice ->
notice?.takeIf { elapsed.elapsedRealtime() - it.announcedAt < FRESH_FOR }
notice?.takeIf { isFresh(it) && stillRings(it) }
}
/**
@@ -96,25 +102,26 @@ internal class AlarmSetAnnouncer @Inject constructor(
*/
fun announce(alarmId: Long, settle: Duration = Duration.ZERO) {
synchronized(this) {
pending?.cancel()
pendingFor = alarmId
pending = appScope.launch {
pending.remove(alarmId)?.cancel()
val job = appScope.launch {
delay(settle)
publish(alarmId)
}
pending[alarmId] = job
job.invokeOnCompletion { synchronized(this) { pending.remove(alarmId, job) } }
}
}
/** Takes back anything said or about to be said about [alarmId]: it was deleted or dismissed. */
fun retract(alarmId: Long) {
synchronized(this) {
if (pendingFor == alarmId) {
pending?.cancel()
pending = null
pendingFor = null
}
/**
* Takes back anything said or about to be said about [alarmId]: it was
* deleted or dismissed. Under the same lock as [publish], so a publish
* already past its read cannot put the notice back afterwards.
*/
suspend fun retract(alarmId: Long) {
synchronized(this) { pending.remove(alarmId)?.cancel() }
lock.withLock {
current.update { notice -> notice?.takeUnless { it.alarmId == alarmId } }
}
current.update { notice -> notice?.takeUnless { it.alarmId == alarmId } }
}
/** [notice] was shown until it timed out or was swiped away. */
@@ -123,15 +130,9 @@ internal class AlarmSetAnnouncer @Inject constructor(
}
private suspend fun publish(alarmId: Long) = lock.withLock {
// One `now` for both ends of the countdown, or a resolve that lands a
// hair past the occurrence would read as a day and an hour.
val now = wall.now()
val countdown = AlarmSetCountdown.of(engine.upcomingFor(alarmId, now), now)
val countdown = countdownOf(alarmId)
current.update { standing ->
when {
// The same words about the same alarm stay as they are, rather
// than taking the snackbar down and putting it straight back.
countdown != null && standing?.alarmId == alarmId && standing.countdown == countdown -> standing
countdown != null -> AlarmSetNotice(alarmId, countdown, elapsed.elapsedRealtime())
standing?.alarmId == alarmId -> null
else -> standing
@@ -139,6 +140,18 @@ internal class AlarmSetAnnouncer @Inject constructor(
}
}
private fun isFresh(notice: AlarmSetNotice): Boolean =
elapsed.elapsedRealtime() - notice.announcedAt < FRESH_FOR
private suspend fun stillRings(notice: AlarmSetNotice): Boolean = countdownOf(notice.alarmId) != null
private suspend fun countdownOf(alarmId: Long): NextFireLabel? {
// One `now` for both ends of the countdown, or a resolve that lands a
// hair past the occurrence would read as a day and an hour.
val now = wall.now()
return AlarmSetCountdown.of(engine.upcomingFor(alarmId, now), now)
}
private companion object {
/** Comfortably longer than a short snackbar, so a hand-over between screens fits. */
val FRESH_FOR = 10.seconds
@@ -169,7 +182,9 @@ internal fun AlarmSetSnackbarEffect(
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
notice.collectLatest { current ->
if (current == null) return@collectLatest
host.showSnackbar(alarmSetMessage(resources, current.countdown) ?: return@collectLatest)
// A notice with nothing to say is used up all the same, so it
// cannot sit pending for ever.
alarmSetMessage(resources, current.countdown)?.let { host.showSnackbar(it) }
shown(current)
}
}
@@ -39,7 +39,6 @@ import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.time.DayOfWeek
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
/**
* The editor over the **real** alarm engine: every control writes at once
@@ -475,7 +474,7 @@ class AlarmEditorViewModelTest {
}
@Test
fun `the same countdown again leaves the standing notice alone`(@TempDir tempDir: Path) =
fun `an alarm switched off by another path is not announced as set`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.givenAlarmWith(hour = 7)
@@ -483,14 +482,11 @@ class AlarmEditorViewModelTest {
advanceUntilIdle()
viewModel.onTimePicked(6, 45)
advanceUntilIdle()
val first = viewModel.alarmSetNotice.first()
// A snackbar taken down and put straight back would flicker.
elapsed.advance(1.seconds)
viewModel.onTimePicked(6, 45)
advanceUntilIdle()
// The ring screen, a notification or an intent: none of them retracts.
harness.alarms.setEnabled(1L, enabled = false)
assertThat(viewModel.alarmSetNotice.first()).isSameInstanceAs(first)
assertThat(viewModel.alarmSetNotice.first()).isNull()
}
// --- label ---
@@ -26,6 +26,7 @@ import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* The list over the **real** alarm engine: what the rows say, that the readout
@@ -48,13 +49,16 @@ class AlarmsViewModelTest {
* `collectAsStateWithLifecycle`; without one there is no subscriber and
* nothing upstream would run.
*/
private fun TestScope.viewModel(harness: AlarmEngineHarness) = AlarmsViewModel(
private fun TestScope.viewModel(
harness: AlarmEngineHarness,
announcer: AlarmSetAnnouncer = AlarmSetAnnouncer(harness.engine, harness.wallClock, FakeElapsedRealtimeClock(), this),
) = AlarmsViewModel(
alarms = harness.alarms,
engine = harness.engine,
wall = harness.wallClock,
zones = harness.zones,
source = AlarmListSource(harness.engine, harness.wallClock, harness.ticker),
announcer = AlarmSetAnnouncer(harness.engine, harness.wallClock, FakeElapsedRealtimeClock(), this),
announcer = announcer,
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
private fun AlarmsUiState.rows(): List<AlarmRowState> = (this as AlarmsUiState.Loaded).rows
@@ -258,6 +262,25 @@ class AlarmsViewModelTest {
assertThat(viewModel.alarmSetNotice.first()).isNull()
}
@Test
fun `switching another alarm off keeps one still settling`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7)
harness.givenAlarm(id = 2L, hour = 9)
val announcer = AlarmSetAnnouncer(harness.engine, harness.wallClock, FakeElapsedRealtimeClock(), this)
val viewModel = viewModel(harness, announcer)
advanceUntilIdle()
// The editor's repeat days on alarm 1, still settling...
announcer.announce(1L, settle = 1.seconds)
// ...when alarm 2 is switched off in the list.
viewModel.onToggleEnabled(2L, enabled = false)
advanceUntilIdle()
assertThat(viewModel.alarmSetNotice.first()?.alarmId).isEqualTo(1L)
}
@Test
fun `adding an alarm leaves the announcement to the editor`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
+9 -7
View File
@@ -1126,13 +1126,15 @@ at once, but waits for repeat-day taps to settle for a second, so Mon–Fri is
one snackbar rather than five. `AlarmSetAnnouncer` is an app-wide singleton
holding the notice as *state* until a screen has shown it to the end: pick →
OK → back disposes the editor's snackbar a moment after it appears, and the
list underneath picks the same notice up. The settle runs on the application
scope, so leaving the editor mid-settle still announces; a screen shows the
notice only while resumed, so a cross-fade never shows it twice; the editor
shows only notices about its own alarm; deleting an alarm, or the `AlarmClock`
chooser dismissing it, takes its notice back. A notice older than ten seconds
on the monotonic clock is dropped unshown rather than shown with a stale
countdown.
list underneath picks the same notice up. The editor's fire-time-moving
writes, their reschedule and the per-alarm settle all run on the application
scope, so leaving the editor at once neither loses the announcement nor leaves
a stored time unscheduled. A screen shows the notice only while resumed, so a
cross-fade never shows it twice; the editor shows only notices about its own
alarm; a notice is re-checked against the engine before it is shown, so an
alarm switched off or deleted by any path is never announced as set. A notice
older than ten seconds on the monotonic clock is dropped unshown rather than
shown with a stale countdown.
`upcoming(refresh: Flow<Unit>)` takes its **re-read cadence as a parameter**.
The resolver needs a `now`, and `upcoming` reads one inside a `combine` of the