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
This commit is contained in:
makiolaj committed 2026-10-21 19:43:35 +02:00
1 parent 9516dc1198
commit d2e73901c1
2 files changed
+34 -4

No files matched your search

@@ -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
@@ -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) {