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.
This commit is contained in:
2026-10-21 14:16:58 +02:00
parent c83c12cf64
commit 405374de7e
3 changed files with 38 additions and 5 deletions
@@ -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<Boolean> =
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
@@ -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.
@@ -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) {