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