From 57605f22f670d9cc551d3a98c8767e5fd3f251f1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 11 Sep 2026 16:04:53 +0200 Subject: [PATCH] feat(core-time): a persisted boot id, and a zone read afresh each time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2 left a hole it wrote down: once a new boot's uptime climbs past a stored elapsed-realtime anchor, the reboot goes unnoticed and a running timer counts down from an anchor that died with the last boot. Detecting it needs an identity for the boot, which is what this is — Settings.Global.BOOT_COUNT, with a derived-instant fallback for the devices that will not give it up. The fallback is best-effort and is documented as such rather than dressed up. Co-Authored-By: Claude Opus 5 --- .../clockula/data/di/TimeModule.kt | 14 ++- .../clockula/data/prefs/BootStateStore.kt | 16 ++++ .../clockula/data/prefs/SystemPrefs.kt | 13 +++ .../data/time/AndroidBootIdProvider.kt | 45 +++++++++ .../clockula/data/time/AndroidClocks.kt | 8 ++ .../clockula/domain/time/BootId.kt | 47 ++++++++++ .../clockula/domain/time/Clocks.kt | 8 ++ .../clockula/domain/time/BootIdTest.kt | 92 +++++++++++++++++++ 8 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BootStateStore.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SystemPrefs.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidBootIdProvider.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/time/BootId.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/time/BootIdTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt index 08effc3..03b57bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt @@ -4,14 +4,20 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock import de.jeanlucmakiola.clockula.data.time.SystemWallClock +import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider +import de.jeanlucmakiola.clockula.domain.time.BootIdProvider import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.clockula.domain.time.ZoneProvider /** * Both clocks are injected, never called statically, so the wall-clock / - * elapsed-realtime distinction can be tested from both sides. + * elapsed-realtime distinction can be tested from both sides. The zone and the + * boot id are injected for the same reason: a zone change and a reboot are then + * values a test hands over rather than states of the machine it runs on. */ @Module @InstallIn(SingletonComponent::class) @@ -22,4 +28,10 @@ abstract class TimeModule { @Binds abstract fun bindElapsedRealtimeClock(impl: SystemElapsedRealtimeClock): ElapsedRealtimeClock + + @Binds + abstract fun bindZoneProvider(impl: SystemZoneProvider): ZoneProvider + + @Binds + abstract fun bindBootIdProvider(impl: AndroidBootIdProvider): BootIdProvider } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BootStateStore.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BootStateStore.kt new file mode 100644 index 0000000..f0f3edb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/BootStateStore.kt @@ -0,0 +1,16 @@ +package de.jeanlucmakiola.clockula.data.prefs + +import de.jeanlucmakiola.clockula.domain.time.BootId +import de.jeanlucmakiola.floret.prefs.PrefStore +import javax.inject.Inject +import javax.inject.Singleton + +/** The DataStore half of the boot gate. Nothing else touches [SystemPrefs]. */ +@Singleton +open class BootStateStore @Inject constructor(private val store: PrefStore) { + + /** Null when there is no known previous boot — i.e. "repair". */ + open suspend fun lastBootId(): BootId? = BootId.decode(store.get(SystemPrefs.lastBootId)) + + open suspend fun setLastBootId(id: BootId) = store.set(SystemPrefs.lastBootId, id.encode()) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SystemPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SystemPrefs.kt new file mode 100644 index 0000000..d498477 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SystemPrefs.kt @@ -0,0 +1,13 @@ +package de.jeanlucmakiola.clockula.data.prefs + +import de.jeanlucmakiola.floret.prefs.Pref +import de.jeanlucmakiola.floret.prefs.nullableStringPref + +/** + * System-level facts the app remembers across processes. Only [BootStateStore] + * touches these keys. + */ +object SystemPrefs { + /** The encoded [de.jeanlucmakiola.clockula.domain.time.BootId] of the last boot we repaired. */ + val lastBootId: Pref = nullableStringPref("last_boot_id") +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidBootIdProvider.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidBootIdProvider.kt new file mode 100644 index 0000000..b56434d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidBootIdProvider.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.clockula.data.time + +import android.content.Context +import android.provider.Settings +import de.jeanlucmakiola.clockula.domain.time.BootId +import de.jeanlucmakiola.clockula.domain.time.BootIdProvider +import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock +import de.jeanlucmakiola.clockula.domain.time.WallClock +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlin.time.Duration +import kotlin.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +/** + * `Settings.Global.BOOT_COUNT` (API 24+, no permission), with a derived-instant + * fallback for a device that will not give it up. + */ +@Singleton +class AndroidBootIdProvider @Inject constructor( + @param:ApplicationContext private val context: Context, + private val wallClock: WallClock, + private val elapsedRealtimeClock: ElapsedRealtimeClock, +) : BootIdProvider { + override fun current(): BootId = deriveBootId( + bootCount = readBootCount(), + wallClock = wallClock.now(), + elapsedRealtime = elapsedRealtimeClock.elapsedRealtime(), + ) + + private fun readBootCount(): Int? = runCatching { + Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT) + }.getOrNull() +} + +/** The pure half, so the JVM test does not need a `Context`. */ +internal fun deriveBootId( + bootCount: Int?, + wallClock: Instant, + elapsedRealtime: Duration, +): BootId = BootId( + // 0 is BOOT_COUNT's documented "unset": a count nobody wrote is no count. + bootCount = bootCount?.takeIf { it > 0 }, + approximateBootInstant = wallClock - elapsedRealtime, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClocks.kt index 1cb442e..bd8c880 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClocks.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClocks.kt @@ -3,10 +3,12 @@ package de.jeanlucmakiola.clockula.data.time import android.os.SystemClock import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.clockula.domain.time.ZoneProvider import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant import javax.inject.Inject +import javax.inject.Singleton /** The device's calendar time. Moves with the user, the network and DST. */ class SystemWallClock @Inject constructor() : WallClock { @@ -21,3 +23,9 @@ class SystemWallClock @Inject constructor() : WallClock { class SystemElapsedRealtimeClock @Inject constructor() : ElapsedRealtimeClock { override fun elapsedRealtime(): Duration = SystemClock.elapsedRealtime().milliseconds } + +/** The device's zone, read afresh on every call so a zone change is picked up. */ +@Singleton +class SystemZoneProvider @Inject constructor() : ZoneProvider { + override fun current(): java.time.ZoneId = java.time.ZoneId.systemDefault() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/BootId.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/BootId.kt new file mode 100644 index 0000000..6cd61a2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/BootId.kt @@ -0,0 +1,47 @@ +package de.jeanlucmakiola.clockula.domain.time + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** + * Identifies one boot of the device. [bootCount] is authoritative when both + * sides have it; the derived instant — the wall clock minus the uptime — is the + * best-effort fallback for a device whose `BOOT_COUNT` is unreadable, and it + * moves a little under NTP correction, which is what [TOLERANCE] absorbs. + */ +data class BootId(val bootCount: Int?, val approximateBootInstant: Instant) { + + fun isSameBootAs(other: BootId, tolerance: Duration = TOLERANCE): Boolean { + val count = bootCount + val otherCount = other.bootCount + if (count != null && otherCount != null) return count == otherCount + + val drift = approximateBootInstant - other.approximateBootInstant + return (if (drift.isNegative()) -drift else drift) <= tolerance + } + + /** `"|"`, with an empty count field when absent. */ + fun encode(): String = "${bootCount ?: ""}|${approximateBootInstant.toEpochMilliseconds()}" + + companion object { + val TOLERANCE: Duration = 1.minutes + + /** + * Strict: blank, malformed or wrong-arity input returns null — which + * reads as "no known previous boot", i.e. "repair". Guessing here would + * mean leaving a dead anchor in place. + */ + fun decode(raw: String?): BootId? { + val parts = raw?.split('|') ?: return null + if (parts.size != 2) return null + val millis = parts[1].toLongOrNull() ?: return null + val count = parts[0].takeIf { it.isNotEmpty() }?.let { it.toIntOrNull() ?: return null } + return BootId(count, Instant.fromEpochMilliseconds(millis)) + } + } +} + +interface BootIdProvider { + fun current(): BootId +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt index dc596aa..bb9532e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt @@ -20,3 +20,11 @@ interface WallClock { interface ElapsedRealtimeClock { fun elapsedRealtime(): Duration } + +/** + * The device's current zone. Injected, never `ZoneId.systemDefault()` at a call + * site, so a zone change is a value a test can hand over. + */ +interface ZoneProvider { + fun current(): java.time.ZoneId +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/time/BootIdTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/time/BootIdTest.kt new file mode 100644 index 0000000..0b411d9 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/time/BootIdTest.kt @@ -0,0 +1,92 @@ +package de.jeanlucmakiola.clockula.domain.time + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.data.time.deriveBootId +import de.jeanlucmakiola.clockula.testing.T0 +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * The gate that decides whether a monotonic anchor written before the last + * reboot can still be believed. A wrong answer here means a running timer + * counts down from a dead anchor. + */ +class BootIdTest { + + @Test + fun `the boot count decides when both sides have one`() { + val first = BootId(bootCount = 12, approximateBootInstant = T0) + + val same = first.isSameBootAs(BootId(bootCount = 12, approximateBootInstant = T0 + 10.hours)) + + assertThat(same).isTrue() + } + + @Test + fun `different boot counts are different boots however close the instants are`() { + val first = BootId(bootCount = 12, approximateBootInstant = T0) + + val same = first.isSameBootAs(BootId(bootCount = 13, approximateBootInstant = T0)) + + assertThat(same).isFalse() + } + + @Test + fun `without boot counts, instants inside the tolerance are the same boot`() { + val first = BootId(bootCount = null, approximateBootInstant = T0) + + val same = first.isSameBootAs(BootId(bootCount = null, approximateBootInstant = T0 + 30.seconds)) + + assertThat(same).isTrue() + } + + @Test + fun `without boot counts, instants beyond the tolerance are different boots`() { + val first = BootId(bootCount = null, approximateBootInstant = T0) + + val same = first.isSameBootAs(BootId(bootCount = null, approximateBootInstant = T0 + 5.minutes)) + + assertThat(same).isFalse() + } + + @Test + fun `one missing boot count falls back to comparing the derived instants`() { + val counted = BootId(bootCount = 12, approximateBootInstant = T0) + + assertThat(counted.isSameBootAs(BootId(null, T0 + 30.seconds))).isTrue() + assertThat(counted.isSameBootAs(BootId(null, T0 + 5.minutes))).isFalse() + } + + @Test + fun `an id with a boot count round-trips through encode and decode`() { + val id = BootId(bootCount = 12, approximateBootInstant = T0) + + assertThat(BootId.decode(id.encode())).isEqualTo(id) + } + + @Test + fun `an id without a boot count round-trips through encode and decode`() { + val id = BootId(bootCount = null, approximateBootInstant = T0) + + assertThat(BootId.decode(id.encode())).isEqualTo(id) + } + + @Test + fun `anything malformed decodes as no known previous boot`() { + val decoded = listOf(null, "", " ", "garbage", "1", "1|2|3", "x|y").map(BootId::decode) + + assertThat(decoded.filterNotNull()).isEmpty() + } + + @Test + fun `the derived id subtracts uptime from the wall clock and reads zero as absent`() { + assertThat(deriveBootId(bootCount = 7, wallClock = T0, elapsedRealtime = 2.hours)) + .isEqualTo(BootId(bootCount = 7, approximateBootInstant = T0 - 2.hours)) + assertThat(deriveBootId(bootCount = null, wallClock = T0, elapsedRealtime = 2.hours)) + .isEqualTo(BootId(bootCount = null, approximateBootInstant = T0 - 2.hours)) + assertThat(deriveBootId(bootCount = 0, wallClock = T0, elapsedRealtime = 2.hours).bootCount) + .isNull() + } +}