diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepository.kt index e360364..2d85bc9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepository.kt @@ -19,4 +19,7 @@ interface StopwatchRepository { /** Marks a lap at the current elapsed value. Returns null unless the stopwatch is RUNNING. */ suspend fun lap(): Lap? + + /** PAUSED at the banked `accumulated`, discarding the segment the reboot lost. No-op otherwise. */ + suspend fun pauseAfterReboot() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryImpl.kt index 039fd95..dd0aa45 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryImpl.kt @@ -79,4 +79,17 @@ class StopwatchRepositoryImpl @Inject constructor( stateStore.set(run.copy(lastLapCumulative = cumulative)) return LapMapper.toDomain(entity) } + + /** + * The segment since [StopwatchRun.startedAtElapsedRealtime] ran on a clock the + * reboot reset, so it cannot be measured — and the stopwatch has no wall-clock + * fallback by design. Banking what was already accumulated loses the segment + * honestly; carrying the anchor over would invent one. + */ + override suspend fun pauseAfterReboot() { + val run = stateStore.current() + if (run.state != StopwatchState.RUNNING) return + + stateStore.set(run.copy(state = StopwatchState.PAUSED, startedAtElapsedRealtime = null)) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt index 0383aee..3e93e70 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt @@ -18,6 +18,9 @@ abstract class TimerDao { @Query("SELECT * FROM timers WHERE id = :id LIMIT 1") abstract suspend fun findById(id: Long): TimerEntity? + @Query("SELECT * FROM timers WHERE state = 'RUNNING'") + abstract suspend fun running(): List + @Query("SELECT MAX(sort_order) FROM timers") abstract suspend fun maxSortOrder(): Int? diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt index a394f26..cfa22b5 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt @@ -27,4 +27,13 @@ interface TimerRepository { suspend fun markExpired(id: Long) suspend fun reorder(idsInOrder: List) + + /** + * Rewrites every RUNNING timer's monotonic anchors from its wall-clock + * fallback. Call once at the top of a new boot, before the new uptime can + * climb past a stored anchor. A timer already past its wall-clock end + * becomes EXPIRED; one with no wall-clock fallback becomes PAUSED at its + * banked remaining. + */ + suspend fun repairAfterReboot() } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt index af99610..e83005e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt @@ -100,6 +100,32 @@ class TimerRepositoryImpl @Inject constructor( dao.reorder(idsInOrder.filter { it in known }) } + /** + * A reboot invalidates every monotonic anchor, and the new boot's uptime + * eventually climbs past the stored one — at which point a dead anchor looks + * live. The wall-clock end is the only thing a reboot leaves standing, so + * every RUNNING timer is re-anchored from it. + */ + override suspend fun repairAfterReboot() { + val now = elapsedRealtimeClock.elapsedRealtime() + val wall = wallClock.now() + for (entity in dao.running()) { + edit(entity.id, wall) { timer -> + val endsAt = timer.endsAtWallClock + // No fallback to re-anchor from: bank what it had rather than + // invent a remainder. + ?: return@edit timer.copy(state = TimerState.PAUSED).cleared() + + val left = endsAt - wall + if (left <= Duration.ZERO) { + timer.copy(state = TimerState.EXPIRED, remaining = Duration.ZERO).cleared() + } else { + timer.anchored(left, now, wall) + } + } + } + } + /** * The row is read, transformed and written back in one DAO transaction, so a * second writer racing this one cannot have its write swallowed whole. A diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/system/RebootRepair.kt b/app/src/main/java/de/jeanlucmakiola/clockula/system/RebootRepair.kt new file mode 100644 index 0000000..faeec54 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/system/RebootRepair.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.clockula.system + +import de.jeanlucmakiola.clockula.data.prefs.BootStateStore +import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository +import de.jeanlucmakiola.clockula.data.timers.TimerRepository +import de.jeanlucmakiola.clockula.domain.time.BootIdProvider +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The boot-id gate. Once per boot it re-anchors running timers and pauses the + * stopwatch, both of which hold monotonic anchors a reboot invalidated. The gate + * is persisted rather than held in memory, so a process death inside a boot does + * not make the repair run twice — and a `BOOT_COMPLETED` the system never + * delivered still gets repaired at the next launch. + */ +@Singleton +open class RebootRepair @Inject constructor( + private val bootIds: BootIdProvider, + private val bootState: BootStateStore, + private val timers: TimerRepository, + private val stopwatch: StopwatchRepository, +) { + /** + * True when this is the first call of a new boot (including the very first + * run ever, where there is no stored id). Idempotent within a boot. + */ + open suspend fun repairIfRebooted(): Boolean { + val current = bootIds.current() + val last = bootState.lastBootId() + if (last != null && last.isSameBootAs(current)) return false + + timers.repairAfterReboot() + stopwatch.pauseAfterReboot() + bootState.setLastBootId(current) + return true + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryTest.kt index 2af705a..6f88aaa 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryTest.kt @@ -222,4 +222,69 @@ class StopwatchRepositoryTest { ) assertThat(repository.laps().first().map { it.index }).containsExactly(1) } + + // --- M3: the post-reboot repair --- + + @Test + fun `pausing after a reboot banks what was accumulated and invents no segment`(@TempDir tempDir: Path) = + runTest { + val repository = repository(tempDir) + repository.start() + clock.reboot(20.seconds) + + repository.pauseAfterReboot() + + val run = repository.run().first() + assertThat(run.state).isEqualTo(StopwatchState.PAUSED) + assertThat(run.accumulated).isEqualTo(Duration.ZERO) + assertThat(run.startedAtElapsedRealtime).isNull() + } + + @Test + fun `pausing after a reboot leaves a paused or idle run untouched`(@TempDir tempDir: Path) = runTest { + val repository = repository(tempDir) + repository.start() + clock.value = 1_030.seconds + repository.pause() + val paused = repository.run().first() + + repository.pauseAfterReboot() + + assertThat(repository.run().first()).isEqualTo(paused) + } + + @Test + fun `an idle run is not written at all by the reboot repair`(@TempDir tempDir: Path) = runTest { + val dataStore = PreferenceDataStoreFactory.create( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + produceFile = { tempDir.resolve("stopwatch_idle_test.preferences_pb").toFile() }, + ) + val repository = StopwatchRepositoryImpl(dao, StopwatchStateStore(PrefStore(dataStore)), clock) + + repository.pauseAfterReboot() + + assertThat(dataStore.data.first().asMap()).isEmpty() + } + + @Test + fun `the record written by the reboot repair still holds no wall-clock value`(@TempDir tempDir: Path) = + runTest { + val dataStore = PreferenceDataStoreFactory.create( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + produceFile = { tempDir.resolve("stopwatch_keys_test.preferences_pb").toFile() }, + ) + val repository = StopwatchRepositoryImpl(dao, StopwatchStateStore(PrefStore(dataStore)), clock) + repository.start() + clock.reboot(20.seconds) + + repository.pauseAfterReboot() + + val snapshot = dataStore.data.first().asMap() + assertThat(snapshot.values.filterIsInstance().filter { it >= 1_000_000_000_000L }).isEmpty() + assertThat(snapshot.keys.map { it.name }).containsExactly( + "stopwatch_state", + "stopwatch_accumulated_millis", + "stopwatch_last_lap_cumulative_millis", + ) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt index c47348b..1b7f691 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt @@ -299,4 +299,92 @@ class TimerRepositoryTest { assertThat(repository.timers().first()).isEqualTo(before) assertThat(repository.find(999L)).isNull() } + + // --- M3: the post-reboot repair --- + + @Test + fun `the repair re-anchors a running timer from its wall-clock fallback`() = runTest { + val id = startedTimer() + elapsed.reboot(50.seconds) + wallClock.advance(2.minutes) + + repository.repairAfterReboot() + + val timer = repository.find(id)!! + assertThat(timer.state).isEqualTo(TimerState.RUNNING) + assertThat(timer.remaining).isEqualTo(3.minutes) + assertThat(timer.endsAtElapsedRealtime).isEqualTo(50.seconds + 3.minutes) + assertThat(timer.startedAtElapsedRealtime).isEqualTo(50.seconds) + assertThat(timer.endsAtWallClock).isEqualTo(T0 + 5.minutes) + } + + @Test + fun `a running timer whose wall-clock end has passed is expired by the repair`() = runTest { + val id = startedTimer() + elapsed.reboot(50.seconds) + wallClock.advance(10.minutes) + + repository.repairAfterReboot() + + val timer = repository.find(id)!! + assertThat(timer.state).isEqualTo(TimerState.EXPIRED) + assertThat(timer.remaining).isEqualTo(Duration.ZERO) + assertThat( + listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock), + ).containsExactly(null, null, null) + } + + @Test + fun `the repair leaves every timer that was not running exactly as it was`() = runTest { + val paused = repository.create(TimerDraft(5.minutes, "paused")) + repository.start(paused) + elapsed.value = 1_100.seconds + repository.pause(paused) + repository.create(TimerDraft(3.minutes, "idle")) + val expired = repository.create(TimerDraft(1.minutes, "expired")) + repository.markExpired(expired) + elapsed.reboot(50.seconds) + wallClock.advance(2.minutes) + val before = dao.stored + + repository.repairAfterReboot() + + assertThat(dao.stored).isEqualTo(before) + } + + @Test + fun `a running timer with no wall-clock fallback is paused at what it banked`() = runTest { + val id = startedTimer() + dao.update(dao.findById(id)!!.copy(endsAtWallClockMillis = null)) + elapsed.reboot(50.seconds) + + repository.repairAfterReboot() + + val timer = repository.find(id)!! + assertThat(timer.state).isEqualTo(TimerState.PAUSED) + assertThat(timer.remaining).isAtLeast(Duration.ZERO) + assertThat(timer.remaining).isEqualTo(5.minutes) + } + + @Test + fun `the repair closes the blind spot a reboot opens under a running timer`() = runTest { + val id = repository.create(TimerDraft(2.hours, "Roast")) + repository.start(id) + + // A new boot whose uptime has already climbed past the stored start anchor, + // and half an hour of wall-clock time gone with it. + elapsed.reboot(5_000.seconds) + wallClock.advance(30.minutes) + + val beforeRepair = repository.find(id)!!.snapshotAt(elapsed.elapsedRealtime(), wallClock.now()) + assertThat(beforeRepair.isRunning).isTrue() + assertThat(beforeRepair.anchorIsStale).isFalse() + assertThat(beforeRepair.remaining).isNotEqualTo(90.minutes) + + repository.repairAfterReboot() + + val repaired = repository.find(id)!!.snapshotAt(elapsed.elapsedRealtime(), wallClock.now()) + assertThat(repaired.remaining - 90.minutes).isLessThan(1.seconds) + assertThat(90.minutes - repaired.remaining).isLessThan(1.seconds) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/system/RebootRepairTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/system/RebootRepairTest.kt new file mode 100644 index 0000000..3a1be6c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/system/RebootRepairTest.kt @@ -0,0 +1,122 @@ +package de.jeanlucmakiola.clockula.system + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.data.prefs.BootStateStore +import de.jeanlucmakiola.clockula.data.prefs.SystemPrefs +import de.jeanlucmakiola.clockula.domain.time.BootId +import de.jeanlucmakiola.clockula.testing.FakeBootIdProvider +import de.jeanlucmakiola.clockula.testing.RecordingStopwatchRepository +import de.jeanlucmakiola.clockula.testing.RecordingTimerRepository +import de.jeanlucmakiola.clockula.testing.T0 +import de.jeanlucmakiola.floret.prefs.PrefStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import kotlin.time.Duration.Companion.hours + +/** + * The gate that runs the reboot repair exactly once per boot, over a real + * DataStore file so "survives process death" is a real assertion. + */ +class RebootRepairTest { + + private val bootIds = FakeBootIdProvider(BootId(bootCount = 7, approximateBootInstant = T0)) + private val timers = RecordingTimerRepository() + private val stopwatch = RecordingStopwatchRepository() + + private fun TestScope.bootStateStore(file: File, job: Job = Job()) = + BootStateStore(PrefStore(prefStore(file, job))) + + private fun TestScope.prefStore(file: File, job: Job) = PreferenceDataStoreFactory.create( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + job), + produceFile = { file }, + ) + + private fun repair(bootState: BootStateStore) = RebootRepair(bootIds, bootState, timers, stopwatch) + + private fun Path.prefsFile(): File = resolve("clockula_prefs_test.preferences_pb").toFile() + + @Test + fun `the very first run ever counts as a new boot and repairs`(@TempDir dir: Path) = runTest { + val bootState = bootStateStore(dir.prefsFile()) + + val repaired = repair(bootState).repairIfRebooted() + + assertThat(repaired).isTrue() + assertThat(timers.repairCalls).isEqualTo(1) + assertThat(stopwatch.pauseCalls).isEqualTo(1) + assertThat(bootState.lastBootId()).isEqualTo(bootIds.bootId) + } + + @Test + fun `the same boot repairs nothing`(@TempDir dir: Path) = runTest { + val bootState = bootStateStore(dir.prefsFile()) + bootState.setLastBootId(bootIds.bootId) + + val repaired = repair(bootState).repairIfRebooted() + + assertThat(repaired).isFalse() + assertThat(timers.repairCalls).isEqualTo(0) + assertThat(stopwatch.pauseCalls).isEqualTo(0) + } + + @Test + fun `a different boot repairs and stores the new id`(@TempDir dir: Path) = runTest { + val bootState = bootStateStore(dir.prefsFile()) + bootState.setLastBootId(BootId(bootCount = 6, approximateBootInstant = T0 - 5.hours)) + + val repaired = repair(bootState).repairIfRebooted() + + assertThat(repaired).isTrue() + assertThat(timers.repairCalls).isEqualTo(1) + assertThat(stopwatch.pauseCalls).isEqualTo(1) + assertThat(bootState.lastBootId()).isEqualTo(bootIds.bootId) + } + + @Test + fun `calling twice in one boot repairs once`(@TempDir dir: Path) = runTest { + val repair = repair(bootStateStore(dir.prefsFile())) + + val first = repair.repairIfRebooted() + val second = repair.repairIfRebooted() + + assertThat(listOf(first, second)).containsExactly(true, false).inOrder() + assertThat(timers.repairCalls).isEqualTo(1) + assertThat(stopwatch.pauseCalls).isEqualTo(1) + } + + @Test + fun `a malformed stored id reads as no known boot and is replaced`(@TempDir dir: Path) = runTest { + val file = dir.prefsFile() + val store = PrefStore(prefStore(file, Job())) + store.set(SystemPrefs.lastBootId, "not-a-boot-id") + val bootState = BootStateStore(store) + + val repaired = repair(bootState).repairIfRebooted() + + assertThat(repaired).isTrue() + assertThat(bootState.lastBootId()).isEqualTo(bootIds.bootId) + } + + @Test + fun `the gate survives process death, so the repair does not run twice per boot`(@TempDir dir: Path) = + runTest { + val file = dir.prefsFile() + val firstProcess = Job() + repair(bootStateStore(file, firstProcess)).repairIfRebooted() + firstProcess.cancel() + + val afterRestart = bootStateStore(file, Job()) + + assertThat(afterRestart.lastBootId()).isEqualTo(bootIds.bootId) + assertThat(repair(afterRestart).repairIfRebooted()).isFalse() + assertThat(timers.repairCalls).isEqualTo(1) + } +}