feat(core-time): a persisted boot id, and a zone read afresh each time

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 16:04:53 +02:00
co-authored by Claude Opus 5
parent 0beb1749fc
commit 57605f22f6
8 changed files with 242 additions and 1 deletions
@@ -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
}
@@ -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())
}
@@ -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<String?> = nullableStringPref("last_boot_id")
}
@@ -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,
)
@@ -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()
}
@@ -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
}
/** `"<count>|<epochMillis>"`, 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
}
@@ -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
}
@@ -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()
}
}