From 405374de7e87feb6b0dcc584704e1fe840fd745b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 14:16:58 +0200 Subject: [PATCH] fix: close the alarm screen when a restore replaces the ringing alarm A backup restore stops the ring while its row still exists and deletes the row afterwards, so the screen's one check came too early and left a silent ring screen up for good. It now closes once the coordinator and storage both say nothing rings, which still keeps it up if the import fails and the ring resumes. --- .../clockula/alarm/AlarmEngine.kt | 10 +++++++++ .../clockula/ui/ring/AlarmRingViewModel.kt | 12 ++++++----- .../ui/ring/AlarmRingViewModelTest.kt | 21 +++++++++++++++++++ 3 files changed, 38 insertions(+), 5 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 7948eab..eb20589 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -23,8 +23,10 @@ import de.jeanlucmakiola.clockula.domain.time.ZoneProvider import de.jeanlucmakiola.clockula.system.RebootRepair import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.time.Duration.Companion.minutes @@ -142,6 +144,14 @@ class AlarmEngine @Inject constructor( /** Restarts a ring the process death or reboot interrupted. Returns the alarm id, or null. */ suspend fun resumeRingingIfAny(): Long? = lock.withLock { resumeRingingIfAnyLocked() } + /** + * Whether storage holds a ringing alarm, re-read on every write. The ring + * screen watches this as well as [RingCoordinator.ringing]: a backup restore + * stops the coordinator first and deletes the ringing row afterwards. + */ + fun ringStored(): Flow = + states.states().map { all -> all.any { it.ringingSince != null } }.distinctUntilChanged() + /** What the ringing service needs. Null when nothing is ringing. */ suspend fun ringSession(): RingSession? = lock.withLock { val state = states.ringing() ?: return@withLock null diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt index ea3a257..4079115 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt @@ -15,6 +15,7 @@ import de.jeanlucmakiola.clockula.domain.time.WallClock import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds import javax.inject.Inject @@ -53,12 +54,13 @@ class AlarmRingViewModel @Inject constructor( ) render() } - // The ring ending is what closes this screen, whoever ended it — but the - // coordinator is seeded null in a fresh process, so storage settles it. + // The ring ending is what closes this screen, whoever ended it. Both + // sources count: the coordinator is seeded null in a fresh process, and a + // backup restore stops it before the ringing row is deleted, so only + // the two together say the ring is over. viewModelScope.launch { - ring.ringing.collect { alarmId -> - if (alarmId == null && engine.ringSession() == null) _state.value = RingUiState.Finished - } + combine(ring.ringing, engine.ringStored()) { alarmId, stored -> alarmId == null && !stored } + .collect { over -> if (over) _state.value = RingUiState.Finished } } // The escape earns itself over time, so the gate needs the clock read // again on a cadence rather than only when the user touches something. diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt index 797c476..b455a06 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt @@ -227,6 +227,27 @@ class AlarmRingViewModelTest { .isEqualTo(null to RingUiState.Finished) } + @Test + fun `a backup restore that replaces the ringing alarm finishes the screen`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine) + val viewModel = viewModel(engine) + advanceUntilIdle() + + // The restore's order: the ring is stopped while its row still exists, + // so the screen must stay up in case the import fails and it resumes. + engine.suspendRingForImport() + advanceUntilIdle() + val whileSuspended = viewModel.state.value + // Replacing the rows cascades to the ring state. + stateDao.deleteAll() + advanceUntilIdle() + + assertThat(whileSuspended).isInstanceOf(RingUiState.Ringing::class.java) + assertThat(viewModel.state.value).isEqualTo(RingUiState.Finished) + } + @Test fun `dismiss does nothing while the maths gate is closed`(@TempDir tempDir: Path) = runTest(main.dispatcher) {