From d2e73901c17d497852a16ff1a5bbdb01e050f58b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:43:35 +0200 Subject: [PATCH] fix(alarms): let a silent alarm-set announcement go for good An announcement whose alarm has nothing to say, such as one switched on and straight back off, is now dropped when a screen reads it. Before, it stood, and something else switching the alarm back on, such as an AlarmClock intent, would have announced it after all. A settle job that was cancelled while waiting for the monitor no longer writes. Refs #19 --- .../clockula/ui/alarms/AlarmSetSnackbar.kt | 19 +++++++++++++++---- .../clockula/ui/alarms/AlarmsViewModelTest.kt | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmSetSnackbar.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmSetSnackbar.kt index 1020152..26ef529 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmSetSnackbar.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmSetSnackbar.kt @@ -24,11 +24,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -100,8 +101,12 @@ internal class AlarmSetAnnouncer @Inject constructor( } else { engine.upcoming().map { upcoming -> val entry = upcoming.firstOrNull { it.alarm.id == announcement.alarmId } - AlarmSetCountdown.of(entry, wall.now()) - ?.let { AlarmSetNotice(announcement.alarmId, it, announcement.sequence) } + val countdown = AlarmSetCountdown.of(entry, wall.now()) + // Nothing to say is the answer for good: an announcement + // left standing would speak up later, long after the + // action, should something else switch the alarm back on. + if (countdown == null) dropIfCurrent(announcement) + countdown?.let { AlarmSetNotice(announcement.alarmId, it, announcement.sequence) } } } } @@ -121,7 +126,9 @@ internal class AlarmSetAnnouncer @Inject constructor( pending = appScope.launch { delay(settle) synchronized(this@AlarmSetAnnouncer) { - current.value = Announcement(alarmId, elapsed.elapsedRealtime(), ++sequence) + // A retract or a newer request may have cancelled this one + // after its delay, while it waited for the monitor. + if (isActive) current.value = Announcement(alarmId, elapsed.elapsedRealtime(), ++sequence) } } } @@ -147,6 +154,10 @@ internal class AlarmSetAnnouncer @Inject constructor( current.update { standing -> standing?.takeUnless { it.sequence == notice.sequence } } } + private fun dropIfCurrent(announcement: Announcement) { + current.update { standing -> standing?.takeUnless { it.sequence == announcement.sequence } } + } + private companion object { /** Comfortably longer than a short snackbar, so a hand-over between screens fits. */ val FRESH_FOR = 10.seconds diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt index b3465e2..17a990f 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt @@ -281,6 +281,25 @@ class AlarmsViewModelTest { assertThat(viewModel.alarmSetNotice.first()?.alarmId).isEqualTo(1L) } + @Test + fun `an announcement that fell silent does not speak up when something else re-enables the alarm`( + @TempDir tempDir: Path, + ) = runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 7, enabled = false) + val viewModel = viewModel(harness) + advanceUntilIdle() + viewModel.onToggleEnabled(1L, enabled = true) + viewModel.onToggleEnabled(1L, enabled = false) + advanceUntilIdle() + check(viewModel.alarmSetNotice.first() == null) + + // An AlarmClock intent switching it back on: intents never announce. + harness.alarms.setEnabled(1L, enabled = true) + + assertThat(viewModel.alarmSetNotice.first()).isNull() + } + @Test fun `adding an alarm leaves the announcement to the editor`(@TempDir tempDir: Path) = runTest(main.dispatcher) {