android: keep what the server said, on disk

Everything the backend owns lived in memory, which made every claim about
working offline true for exactly as long as the process. Android kills processes
constantly, and starts them again for the accessibility service with no Activity
to have fetched anything — so the common case was the unhandled one:

* the focus guard woke with empty matchers, enforced nothing, and reported
  "disabled", which is indistinguishable from the user switching it off;
* the dial read 00:00 until a reply landed, and with no signal, forever;
* Tasks and Today showed an error banner where yesterday's answer would do.

`LocalStore` is one small JSON file per subject under `filesDir` — not Room (no
queries, no relations, no migrations worth the dependency) and not
SharedPreferences (those are for what this *machine* owns; these are documents).
Every read degrades to "no cache" rather than throwing, and a file that fails to
parse is deleted on the way past, because a background service must not be
crash-looped by its own cache.

The timer state is stamped with the wall-clock moment it was received, which is
what lets a cold start subtract the time that passed while nothing was running —
wall clock deliberately, since `nanoTime` does not survive a reboot and the
question is how much of the *day* went by. A paused interval is left where it
was. None of it is marked Online: a cache is not a reply.

The guard keeps the rules and the last policy. The verdict is not adopted from
disk — `active` is a claim about this moment — but the expanded host list and
the masks do not go stale, so the guard can enforce before the first poll.

Read-only screens take a cache slot, so `Loader` paints the last good answer
before it asks. Changing the backend URL clears the lot: one server's report
under another's name is worse than an empty screen.

10 tests, including the two cold-start cases that failed before: a dial that
knows what is running, and a guard that still blocks with the server gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
timka123andClaude Opus 5 committed 2026-10-22 17:41:26 +03:00
1 parent 71a0389029
commit 120c553537
11 files changed
+611 -9

No files matched your search

@@ -0,0 +1,181 @@
package tech.kadence.android
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import tech.kadence.android.blocker.BlockerEngine
import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.dto.BlockerPolicyDto
import tech.kadence.android.data.dto.BlockerSettingsDto
import tech.kadence.android.data.dto.CachedTimerDto
import tech.kadence.android.data.dto.SettingsEnvelopeDto
import tech.kadence.android.data.dto.TimerStateDto
import tech.kadence.android.data.net.AuthConfig
import tech.kadence.android.data.net.KadenceApi
import tech.kadence.android.data.net.KadenceJson
import tech.kadence.android.data.store.Cached
import tech.kadence.android.data.store.LocalStore
import tech.kadence.android.timer.Connection
import tech.kadence.android.timer.TimerEngine
import tech.kadence.core.Status
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Starting up with no backend at all.
*
* This is the state the app spends more time in than anyone designs for: a process
* Android started for the accessibility service, or a launch on a phone with no signal.
* Everything the server owns was in memory only, so both engines woke up knowing
* nothing — the dial read 00:00 and the focus guard enforced nothing while reporting
* "disabled", which is indistinguishable from the user having turned it off.
*
* Nothing here can reach a server: the base URL points at a port that refuses, which
* is also what makes these tests fast.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ColdStartOfflineTest {
private lateinit var store: LocalStore
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
/** Nothing is listening here, and nothing is meant to be. */
private val nowhere = object : AuthConfig {
override val baseUrl = "http://127.0.0.1:1"
override val apiToken: String? = null
}
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name"))
.bufferedReader().readText()
private fun repo() = KadenceRepository(KadenceApi(config = { nowhere }))
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
File(context.filesDir, "cache").deleteRecursively()
store = LocalStore(context)
}
@Test
fun `the dial shows the interval the server last told us about`() {
val state = KadenceJson.decodeFromString(
TimerStateDto.serializer(),
fixture("timer_state_running.json"),
)
// Told to us two minutes ago, so two minutes of it have gone.
store.write(
Cached.TIMER,
CachedTimerDto.serializer(),
CachedTimerDto(state, savedAtMillis = System.currentTimeMillis() - 120_000),
)
val engine = TimerEngine(repo(), scope, configured = { true }, store = store)
engine.start()
engine.stop()
val ui = engine.state.value
assertEquals(412L, ui.sessionId)
assertEquals(Status.RUNNING, ui.status)
assertEquals(1500, ui.plannedSeconds)
assertEquals("Fix the flaky importer", ui.taskTitle)
assertTrue(ui.remaining in 975..990, "1104 less the two minutes, got ${ui.remaining}")
assertTrue(
ui.connection != Connection.Online,
"nothing has been confirmed — the cache is not a reply",
)
}
@Test
fun `a paused interval is left exactly where it was`() {
val paused = fixture("timer_state_running.json")
.replace("\"status\": \"running\"", "\"status\": \"paused\"")
store.write(
Cached.TIMER,
CachedTimerDto.serializer(),
CachedTimerDto(
KadenceJson.decodeFromString(TimerStateDto.serializer(), paused),
savedAtMillis = System.currentTimeMillis() - 600_000,
),
)
val engine = TimerEngine(repo(), scope, configured = { true }, store = store)
engine.start()
engine.stop()
assertEquals(1104, engine.state.value.remaining, "ten minutes paused is not ten minutes gone")
}
@Test
fun `the focus guard enforces from the rules it was last given`() {
val envelope = KadenceJson.decodeFromString(
SettingsEnvelopeDto.serializer(),
fixture("settings.json"),
)
store.write(
Cached.BLOCKER_RULES,
BlockerSettingsDto.serializer(),
// `always` so the verdict does not depend on an interval the timer cannot
// confirm offline; the mode itself is the server's to choose.
envelope.settings.blocker.copy(mode = "always"),
)
store.write(
Cached.BLOCKER_POLICY,
BlockerPolicyDto.serializer(),
KadenceJson.decodeFromString(
BlockerPolicyDto.serializer(),
fixture("blocker_policy_active.json"),
),
)
var bounces = 0
val engine = BlockerEngine(
context = ApplicationProvider.getApplicationContext(),
repo = repo(),
scope = scope,
configured = { true },
timerFacts = { null },
store = store,
)
engine.attachCloser { bounces++ }
engine.start()
engine.stop()
assertTrue(engine.state.value.active, "the kept rules say the guard is on")
engine.onUrlSeen("youtube.com", "com.android.chrome")
assertEquals(1, bounces, "a blocked site is still blocked with the server gone")
// And the masks the backend could never expand are the phone's own job.
engine.onUrlSeen("telegram.org", "com.android.chrome")
assertEquals(2, bounces)
}
@Test
fun `with nothing cached the guard says so rather than guessing`() {
val engine = BlockerEngine(
context = ApplicationProvider.getApplicationContext(),
repo = repo(),
scope = scope,
configured = { true },
timerFacts = { null },
store = store,
)
var bounces = 0
engine.attachCloser { bounces++ }
engine.start()
engine.stop()
assertTrue(!engine.state.value.active)
engine.onUrlSeen("youtube.com", "com.android.chrome")
assertEquals(0, bounces, "an empty block list blocks nothing, and says nothing is on")
}
}
@@ -0,0 +1,110 @@
package tech.kadence.android
import androidx.test.core.app.ApplicationProvider
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import tech.kadence.android.data.dto.BlockerPolicyDto
import tech.kadence.android.data.dto.CachedTimerDto
import tech.kadence.android.data.dto.TimerStateDto
import tech.kadence.android.data.net.KadenceJson
import tech.kadence.android.data.store.Cached
import tech.kadence.android.data.store.LocalStore
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The documents the server owns, kept across a process death.
*
* The rules that matter are the defensive ones: a background service must never be
* crash-looped by its own cache, so an unreadable file has to read back as "no cache"
* and be cleared on the way past.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class LocalStoreTest {
private lateinit var store: LocalStore
private lateinit var dir: File
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name"))
.bufferedReader().readText()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
dir = File(context.filesDir, "cache")
dir.deleteRecursively()
store = LocalStore(context)
}
@Test
fun `a document survives a round trip`() {
val state = KadenceJson.decodeFromString(
TimerStateDto.serializer(),
fixture("timer_state_running.json"),
)
store.write(
Cached.TIMER,
CachedTimerDto.serializer(),
CachedTimerDto(state = state, savedAtMillis = 1_700_000_000_000),
)
val back = store.read(Cached.TIMER, CachedTimerDto.serializer())
assertEquals(412L, back?.state?.current?.id)
assertEquals(1104, back?.state?.current?.remainingSeconds)
assertEquals(1_700_000_000_000, back?.savedAtMillis)
}
@Test
fun `nothing written reads back as nothing`() {
assertNull(store.read(Cached.REPORT, BlockerPolicyDto.serializer()))
}
@Test
fun `an unreadable document is discarded rather than thrown`() {
val path = File(dir, Cached.BLOCKER_POLICY.fileName)
path.parentFile?.mkdirs()
path.writeText("{ this is not json")
assertNull(store.read(Cached.BLOCKER_POLICY, BlockerPolicyDto.serializer()))
assertTrue(!path.exists(), "a file that cannot parse would fail identically forever")
}
@Test
fun `a document with fields the client does not know still loads`() {
// The backend has no response models, so a new key can appear at any time —
// and a cache that rejected one would blank the screen it was meant to fill.
File(dir, Cached.BLOCKER_POLICY.fileName).writeText(
"""{"active": true, "reason": "always on", "something_new": 7}""",
)
val back = store.read(Cached.BLOCKER_POLICY, BlockerPolicyDto.serializer())
assertEquals(true, back?.active)
assertEquals("always on", back?.reason)
}
@Test
fun `changing the backend clears everything the old one told us`() {
store.write(Cached.BLOCKER_POLICY, BlockerPolicyDto.serializer(), BlockerPolicyDto(active = true))
store.clearAll()
assertNull(store.read(Cached.BLOCKER_POLICY, BlockerPolicyDto.serializer()))
}
@Test
fun `a slot can only see its own document`() {
val slot = store.slot(Cached.BLOCKER_POLICY, BlockerPolicyDto.serializer())
slot.write(BlockerPolicyDto(active = true, reason = "always on"))
assertEquals("always on", slot.read()?.reason)
assertNull(store.read(Cached.TIMER, CachedTimerDto.serializer()))
}
}