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:
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()))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user