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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user