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

@@ -31,6 +31,7 @@ import tech.kadence.android.blocker.BlockerEngine
import tech.kadence.android.data.KadenceRepository import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.net.KadenceApi import tech.kadence.android.data.net.KadenceApi
import tech.kadence.android.data.store.KadencePrefs import tech.kadence.android.data.store.KadencePrefs
import tech.kadence.android.data.store.LocalStore
import tech.kadence.android.glyph.GlyphDisplay import tech.kadence.android.glyph.GlyphDisplay
import tech.kadence.android.glyph.newGlyphDisplay import tech.kadence.android.glyph.newGlyphDisplay
import tech.kadence.android.notify.Notifications import tech.kadence.android.notify.Notifications
@@ -47,6 +48,16 @@ class KadenceApplication : Application() {
lateinit var prefs: KadencePrefs lateinit var prefs: KadencePrefs
private set private set
/**
* The server's last answers, on disk.
*
* Read by the engines at startup and by the read-only screens, so a process that
* Android started for the accessibility service — or one launched with no signal —
* begins from what it was last told rather than from nothing.
*/
lateinit var store: LocalStore
private set
lateinit var api: KadenceApi lateinit var api: KadenceApi
private set private set
lateinit var repo: KadenceRepository lateinit var repo: KadenceRepository
@@ -71,6 +82,7 @@ class KadenceApplication : Application() {
Notifications.ensureChannels(this) Notifications.ensureChannels(this)
prefs = KadencePrefs(this) prefs = KadencePrefs(this)
store = LocalStore(this)
// `config()` is read per request rather than captured, so changing the backend // `config()` is read per request rather than captured, so changing the backend
// in Settings takes effect on the next call instead of needing a restart. // in Settings takes effect on the next call instead of needing a restart.
api = KadenceApi(config = { prefs.current }) api = KadenceApi(config = { prefs.current })
@@ -79,6 +91,7 @@ class KadenceApplication : Application() {
repo = repo, repo = repo,
scope = scope, scope = scope,
configured = { prefs.current.isConfigured && prefs.current.onboarded }, configured = { prefs.current.isConfigured && prefs.current.onboarded },
store = store,
) )
// The focus guard, one per process like the timer — the accessibility service // The focus guard, one per process like the timer — the accessibility service
@@ -94,6 +107,7 @@ class KadenceApplication : Application() {
val ui = engine.state.value val ui = engine.state.value
if (ui.isActive) IntervalFacts(kind = ui.kind, status = ui.status) else null if (ui.isActive) IntervalFacts(kind = ui.kind, status = ui.status) else null
}, },
store = store,
) )
// A 401 on anything but the login path means the session died mid-use. The // A 401 on anything but the login path means the session died mid-use. The
@@ -142,7 +156,14 @@ class KadenceApplication : Application() {
prefs.config prefs.config
.map { it.baseUrl } .map { it.baseUrl }
.distinctUntilChanged() .distinctUntilChanged()
.collect { api.cookieJar.clear() } .drop(1)
.collect {
api.cookieJar.clear()
// And the cached documents with it: they describe a *different*
// backend's day, and showing one server's report under another's
// name is worse than showing nothing.
store.clearAll()
}
} }
} }
} }
@@ -49,6 +49,8 @@ import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.dto.BlockerPolicyDto import tech.kadence.android.data.dto.BlockerPolicyDto
import tech.kadence.android.data.dto.BlockerSettingsDto import tech.kadence.android.data.dto.BlockerSettingsDto
import tech.kadence.android.data.net.ApiResult import tech.kadence.android.data.net.ApiResult
import tech.kadence.android.data.store.Cached
import tech.kadence.android.data.store.LocalStore
import tech.kadence.android.notify.Notifications import tech.kadence.android.notify.Notifications
import tech.kadence.android.timer.Connection import tech.kadence.android.timer.Connection
import tech.kadence.core.AppMatcher import tech.kadence.core.AppMatcher
@@ -118,6 +120,8 @@ class BlockerEngine(
* is not the thing being measured — "has twenty seconds of my day passed" is. * is not the thing being measured — "has twenty seconds of my day passed" is.
*/ */
private val now: () -> Long = System::currentTimeMillis, private val now: () -> Long = System::currentTimeMillis,
/** Where the rules and the last verdict are kept between processes. */
private val store: LocalStore? = null,
) { ) {
private val _state = MutableStateFlow(BlockerUi()) private val _state = MutableStateFlow(BlockerUi())
val state: StateFlow<BlockerUi> = _state.asStateFlow() val state: StateFlow<BlockerUi> = _state.asStateFlow()
@@ -186,6 +190,7 @@ class BlockerEngine(
// ---- lifecycle ---------------------------------------------------------- // ---- lifecycle ----------------------------------------------------------
fun start() { fun start() {
seedFromCache()
if (pollJob?.isActive != true) pollJob = scope.launch { pollLoop() } if (pollJob?.isActive != true) pollJob = scope.launch { pollLoop() }
if (appJob?.isActive != true) appJob = scope.launch { appLoop() } if (appJob?.isActive != true) appJob = scope.launch { appLoop() }
if (watchJob?.isActive != true) watchJob = scope.launch { watchLoop() } if (watchJob?.isActive != true) watchJob = scope.launch { watchLoop() }
@@ -200,6 +205,33 @@ class BlockerEngine(
watchJob = null watchJob = null
} }
/**
* Enforce from what we were last told, before anything is asked of the network.
*
* This is what makes "offline, the server's last answer keeps working" true. The
* rules used to live in memory only, so the guard was armed exactly as long as the
* process that had fetched them — and the process Android starts to *bind the
* accessibility service* has fetched nothing. It would sit there with empty
* matchers, enforcing nothing, and report `disabled` as though that were the
* policy.
*/
private fun seedFromCache() {
val keeper = store ?: return
keeper.read(Cached.BLOCKER_RULES, BlockerSettingsDto.serializer())?.let { dto ->
rules = dto.toCore()
}
keeper.read(Cached.BLOCKER_POLICY, BlockerPolicyDto.serializer())?.let { dto ->
// The verdict is *not* adopted: `active` is a statement about this moment
// and the cached one may be hours old. What the policy is good for is its
// expanded host list and its masks, which do not go stale.
policy = dto
}
if (rules != null || policy != null) {
rebuildMatchers()
applyOfflineVerdict(Connection.Connecting)
}
}
/** /**
* The timer moved, so the verdict may have — decide again at once. * The timer moved, so the verdict may have — decide again at once.
* *
@@ -405,6 +437,7 @@ class BlockerEngine(
is ApiResult.Ok -> { is ApiResult.Ok -> {
val blocker = result.value.settings.blocker val blocker = result.value.settings.blocker
rules = blocker.toCore() rules = blocker.toCore()
store?.write(Cached.BLOCKER_RULES, BlockerSettingsDto.serializer(), blocker)
serverZone = zoneOrDefault(result.value.settings.timezone) serverZone = zoneOrDefault(result.value.settings.timezone)
rebuildMatchers() rebuildMatchers()
if (_state.value.connection != Connection.Online) { if (_state.value.connection != Connection.Online) {
@@ -440,6 +473,7 @@ class BlockerEngine(
private fun applyPolicy(dto: BlockerPolicyDto) { private fun applyPolicy(dto: BlockerPolicyDto) {
policy = dto policy = dto
store?.write(Cached.BLOCKER_POLICY, BlockerPolicyDto.serializer(), dto)
rebuildMatchers() rebuildMatchers()
_state.value = _state.value.copy( _state.value = _state.value.copy(
connection = Connection.Online, connection = Connection.Online,
@@ -113,6 +113,21 @@ data class RefreshResultDto(
val sources: List<RefreshSourceDto> = emptyList(), val sources: List<RefreshSourceDto> = emptyList(),
) )
/**
* A timer state, with the wall-clock moment we were told it.
*
* The countdown cannot be restored from the state alone: `remaining_seconds` was true
* when the server said it, and a cold start may be hours later. The stamp is what lets
* the engine subtract the time that passed while nothing was running — using the wall
* clock deliberately, because `nanoTime` does not survive a reboot and the question
* here is "how much of the day went by", not "how long was this process up".
*/
@Serializable
data class CachedTimerDto(
val state: TimerStateDto,
@SerialName("saved_at_millis") val savedAtMillis: Long = 0,
)
@Serializable @Serializable
data class BlockerPolicyDto( data class BlockerPolicyDto(
val active: Boolean = false, val active: Boolean = false,
@@ -0,0 +1,135 @@
/**
* The server's last answers, on disk.
*
* Everything the backend owns was held in memory only, which made every claim about
* working offline true for exactly as long as the process lived. Android kills
* processes constantly — and starts them again for the accessibility service, with no
* Activity to have ever fetched anything. The consequences were all silent:
*
* * the focus guard woke up with no rules and enforced nothing, while reporting
* "disabled" as though that were the policy;
* * Tasks, Report and Guard showed an error banner rather than yesterday's answer;
* * the countdown had nothing to count from until the first poll replied.
*
* So: one small JSON file per subject under `filesDir`, written when the server
* answers and read once at startup. Not Room — there are no queries here, no relations
* and no migrations worth the dependency; the whole need is "hand me back the last
* document you were given". Not `SharedPreferences` either: these are documents, and
* [KadencePrefs] is deliberately only for what this *machine* owns.
*
* Every read is defensive. A half-written or stale-format file must degrade to "no
* cache" and never to a crash loop on a background service — which is why the type
* comes back as null rather than throwing, and why a file that fails to parse is
* deleted on the way past.
*/
package tech.kadence.android.data.store
import android.content.Context
import android.util.Log
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import tech.kadence.android.data.net.KadenceJson
import java.io.File
import java.io.IOException
private const val TAG = "Kadence"
/** The documents worth keeping. One file each, named here and nowhere else. */
enum class Cached(val fileName: String) {
/** `GET /api/timer/state` — so the dial has something to show at launch. */
TIMER("timer-state.json"),
/** The `blocker` subtree of the settings document — the guard's rules. */
BLOCKER_RULES("blocker-rules.json"),
/** `GET /api/blocker/policy` — the server's verdict, as last heard. */
BLOCKER_POLICY("blocker-policy.json"),
/** `GET /api/tasks` — the list, so the screen is not empty offline. */
TASKS("tasks.json"),
/** `GET /api/reports/daily`. */
REPORT("report.json"),
/** `GET /api/stats/summary`. */
STATS("stats.json"),
/** The offline outbox: what the user asked for while there was no signal. */
OUTBOX("outbox.json"),
}
class LocalStore(context: Context) {
private val dir: File = File(context.filesDir, "cache").apply { mkdirs() }
private fun file(of: Cached) = File(dir, of.fileName)
/**
* The document, or null if there is not a usable one.
*
* Synchronous on purpose: `TimerService.onCreate` and the accessibility service
* both want their state before any coroutine has run, for the same reason
* [KadencePrefs] is synchronous. These files are a few kilobytes at most.
*/
fun <T> read(of: Cached, serializer: KSerializer<T>): T? {
val path = file(of)
if (!path.isFile) return null
return try {
KadenceJson.decodeFromString(serializer, path.readText())
} catch (e: SerializationException) {
// A format that no longer parses is worse than nothing: it would fail
// identically on every start. Drop it and carry on with no cache.
Log.w(TAG, "discarding unreadable cache ${of.fileName}: ${e.message}")
path.delete()
null
} catch (e: IOException) {
Log.w(TAG, "could not read ${of.fileName}: ${e.message}")
null
}
}
/** Replace the document. Written to a sibling first, so a kill cannot truncate it. */
fun <T> write(of: Cached, serializer: KSerializer<T>, value: T) {
val path = file(of)
try {
val temp = File(dir, "${of.fileName}.tmp")
temp.writeText(KadenceJson.encodeToString(serializer, value))
if (!temp.renameTo(path)) {
path.writeText(temp.readText())
temp.delete()
}
} catch (e: IOException) {
// A cache that cannot be written is a slower app, not a broken one.
Log.w(TAG, "could not write ${of.fileName}: ${e.message}")
} catch (e: SerializationException) {
Log.w(TAG, "could not encode ${of.fileName}: ${e.message}")
}
}
fun clear(of: Cached) {
file(of).delete()
}
/** Everything the server owns, dropped — for a change of backend. */
fun clearAll() {
Cached.entries.forEach { clear(it) }
}
/**
* This store, narrowed to one document, for whoever only needs that one.
*
* `Loader` takes one of these rather than the whole store: a screen caching its
* own endpoint has no business being able to clear the outbox.
*/
fun <T> slot(of: Cached, serializer: KSerializer<T>): CacheSlot<T> =
object : CacheSlot<T> {
override fun read(): T? = read(of, serializer)
override fun write(value: T) = write(of, serializer, value)
}
}
/** One cached document, read and written by whoever owns that endpoint. */
interface CacheSlot<T> {
fun read(): T?
fun write(value: T)
}
@@ -41,9 +41,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import tech.kadence.android.data.KadenceRepository import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.dto.CachedTimerDto
import tech.kadence.android.data.dto.TimerSessionDto import tech.kadence.android.data.dto.TimerSessionDto
import tech.kadence.android.data.dto.TimerStateDto import tech.kadence.android.data.dto.TimerStateDto
import tech.kadence.android.data.net.ApiResult import tech.kadence.android.data.net.ApiResult
import tech.kadence.android.data.store.Cached
import tech.kadence.android.data.store.LocalStore
import tech.kadence.core.Kind import tech.kadence.core.Kind
import tech.kadence.core.LocalBelief import tech.kadence.core.LocalBelief
import tech.kadence.core.PendingOps import tech.kadence.core.PendingOps
@@ -108,6 +111,8 @@ class TimerEngine(
private val repo: KadenceRepository, private val repo: KadenceRepository,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val configured: () -> Boolean, private val configured: () -> Boolean,
/** Where the server's last answer is kept, so a cold start has one. */
private val store: LocalStore? = null,
) { ) {
private val _state = MutableStateFlow(TimerUi()) private val _state = MutableStateFlow(TimerUi())
val state: StateFlow<TimerUi> = _state.asStateFlow() val state: StateFlow<TimerUi> = _state.asStateFlow()
@@ -135,6 +140,7 @@ class TimerEngine(
// ---- lifecycle ---------------------------------------------------------- // ---- lifecycle ----------------------------------------------------------
fun start() { fun start() {
seedFromCache()
if (pollJob?.isActive != true) { if (pollJob?.isActive != true) {
pollJob = scope.launch { pollLoop() } pollJob = scope.launch { pollLoop() }
} }
@@ -153,6 +159,68 @@ class TimerEngine(
/** Ask for a fresh poll now — on app foreground, or after a settings change. */ /** Ask for a fresh poll now — on app foreground, or after a settings change. */
fun refreshNow() = scope.launch { poll() } fun refreshNow() = scope.launch { poll() }
/**
* Show the interval the server last told us about, before asking again.
*
* Without this the dial reads 00:00 until the first reply lands — and with no
* signal, forever, even though the phone knows perfectly well what was running.
* The elapsed wall time since the answer was stored is subtracted, because the
* interval kept going while the process was dead; a paused one is left where it
* was, which is the whole point of pausing.
*
* Deliberately *not* marked Online: nothing has been confirmed. The screen shows
* its connecting state until a real reply arrives.
*/
private fun seedFromCache() {
if (_state.value.sessionId != null) return
val cached = store?.read(Cached.TIMER, CachedTimerDto.serializer()) ?: return
val dto = cached.state
val current = dto.current
val elapsed = if (cached.savedAtMillis > 0) {
((System.currentTimeMillis() - cached.savedAtMillis) / 1000L).coerceAtLeast(0L).toInt()
} else {
0
}
val remaining = when {
current == null -> 0
current.status == Status.PAUSED -> current.remainingSeconds
else -> (current.remainingSeconds - elapsed).coerceAtLeast(0)
}
val server = dto.toCore()
if (current != null) {
anchor = TickAnchor(
sessionId = current.id,
remainingAtAnchor = remaining,
elapsedNanos = System.nanoTime(),
paused = current.status == Status.PAUSED,
)
}
_state.value = _state.value.copy(
sessionId = current?.id,
kind = current?.kind ?: server.nextKind,
status = current?.status ?: Status.COMPLETED,
remaining = remaining,
plannedSeconds = current?.plannedSeconds ?: 0,
interruptions = current?.interruptions ?: 0,
taskTitle = current?.task?.title,
completedToday = server.completedToday,
goal = server.goal,
cyclePosition = server.cyclePosition,
cyclesBeforeLongBreak = server.cyclesBeforeLongBreak,
nextKind = server.nextKind,
durationsMinutes = server.durationsMinutes,
timerEnabled = server.timerEnabled,
)
}
private fun remember(dto: TimerStateDto) {
store?.write(
Cached.TIMER,
CachedTimerDto.serializer(),
CachedTimerDto(state = dto, savedAtMillis = System.currentTimeMillis()),
)
}
// ---- the loops ---------------------------------------------------------- // ---- the loops ----------------------------------------------------------
private suspend fun pollLoop() { private suspend fun pollLoop() {
@@ -219,6 +287,7 @@ class TimerEngine(
* and must not be narrated as somebody else's doing. * and must not be narrated as somebody else's doing.
*/ */
private suspend fun applyServer(dto: TimerStateDto, fromCommand: Boolean) { private suspend fun applyServer(dto: TimerStateDto, fromCommand: Boolean) {
remember(dto)
val server = dto.toCore() val server = dto.toCore()
val local = LocalBelief( val local = LocalBelief(
sessionId = _state.value.sessionId, sessionId = _state.value.sessionId,
@@ -38,7 +38,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tech.kadence.android.KadenceApplication import tech.kadence.android.KadenceApplication
import tech.kadence.android.data.dto.AppSettingsDto import tech.kadence.android.data.dto.AppSettingsDto
import tech.kadence.android.data.dto.DailyReportEnvelopeDto
import tech.kadence.android.data.dto.TaskListDto
import tech.kadence.android.data.net.ApiResult import tech.kadence.android.data.net.ApiResult
import tech.kadence.android.data.store.Cached
import tech.kadence.android.ui.blocker.BlockerScreen import tech.kadence.android.ui.blocker.BlockerScreen
import tech.kadence.android.ui.components.HorizontalRule import tech.kadence.android.ui.components.HorizontalRule
import tech.kadence.android.ui.onboarding.OnboardingScreen import tech.kadence.android.ui.onboarding.OnboardingScreen
@@ -135,6 +138,7 @@ fun KadenceApp(app: KadenceApplication) {
Destination.Tasks -> TasksScreen( Destination.Tasks -> TasksScreen(
repo = app.repo, repo = app.repo,
cache = app.store.slot(Cached.TASKS, TaskListDto.serializer()),
runningTaskTitle = ui.taskTitle, runningTaskTitle = ui.taskTitle,
intervalRunning = ui.isActive, intervalRunning = ui.isActive,
onAttach = { taskId -> onAttach = { taskId ->
@@ -156,7 +160,10 @@ fun KadenceApp(app: KadenceApplication) {
engine = app.blocker, engine = app.blocker,
) )
Destination.Report -> ReportScreen(repo = app.repo) Destination.Report -> ReportScreen(
repo = app.repo,
cache = app.store.slot(Cached.REPORT, DailyReportEnvelopeDto.serializer()),
)
Destination.Settings -> SettingsScreen( Destination.Settings -> SettingsScreen(
config = config, config = config,
@@ -19,6 +19,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tech.kadence.android.data.net.ApiResult import tech.kadence.android.data.net.ApiResult
import tech.kadence.android.data.store.CacheSlot
sealed interface Loadable<out T> { sealed interface Loadable<out T> {
data object Idle : Loadable<Nothing> data object Idle : Loadable<Nothing>
@@ -31,16 +32,33 @@ sealed interface Loadable<out T> {
class Loader<T>( class Loader<T>(
private val state: MutableState<Loadable<T>>, private val state: MutableState<Loadable<T>>,
private val scope: kotlinx.coroutines.CoroutineScope, private val scope: kotlinx.coroutines.CoroutineScope,
/** The last good answer on disk, if this endpoint keeps one. */
private val cache: CacheSlot<T>? = null,
private val call: suspend () -> ApiResult<T>, private val call: suspend () -> ApiResult<T>,
) { ) {
val value: Loadable<T> get() = state.value val value: Loadable<T> get() = state.value
/**
* Show what we were last told, immediately.
*
* `degrade` already kept a loaded value through a failed *reload*, but only within
* one composition — after a process death there was nothing to keep, so a phone
* with no signal showed an error banner where yesterday's list would do.
*/
fun seedFromCache() {
if (state.value != Loadable.Idle) return
cache?.read()?.let { state.value = Loadable.Loaded(it) }
}
fun reload() { fun reload() {
val existing = state.value val existing = state.value
state.value = if (existing is Loadable.Loaded) existing else Loadable.Loading state.value = if (existing is Loadable.Loaded) existing else Loadable.Loading
scope.launch { scope.launch {
state.value = when (val result = call()) { state.value = when (val result = call()) {
is ApiResult.Ok -> Loadable.Loaded(result.value) is ApiResult.Ok -> {
cache?.write(result.value)
Loadable.Loaded(result.value)
}
is ApiResult.HttpError -> degrade(existing, result.detail) is ApiResult.HttpError -> degrade(existing, result.detail)
is ApiResult.Unauthorized -> degrade(existing, "Not signed in.") is ApiResult.Unauthorized -> degrade(existing, "Not signed in.")
is ApiResult.NetworkError -> degrade(existing, "No connection to the backend.") is ApiResult.NetworkError -> degrade(existing, "No connection to the backend.")
@@ -54,12 +72,20 @@ class Loader<T>(
} }
@Composable @Composable
fun <T> rememberLoad(vararg keys: Any?, call: suspend () -> ApiResult<T>): Loader<T> { fun <T> rememberLoad(
vararg keys: Any?,
cache: CacheSlot<T>? = null,
call: suspend () -> ApiResult<T>,
): Loader<T> {
val state = remember { mutableStateOf<Loadable<T>>(Loadable.Idle) } val state = remember { mutableStateOf<Loadable<T>>(Loadable.Idle) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val loader = remember(*keys) { Loader(state, scope, call) } val loader = remember(*keys) { Loader(state, scope, cache, call) }
// Before the first frame, not in a LaunchedEffect: the cached value is read from
// disk synchronously and the point is that the screen never flashes a spinner (or,
// offline, an error) over data we already have.
remember(loader) { loader.seedFromCache(); loader }
androidx.compose.runtime.LaunchedEffect(loader) { androidx.compose.runtime.LaunchedEffect(loader) {
if (state.value == Loadable.Idle) loader.reload() loader.reload()
} }
return loader return loader
} }
@@ -42,14 +42,15 @@ import tech.kadence.android.ui.components.SectionTitle
import tech.kadence.android.ui.components.Stat import tech.kadence.android.ui.components.Stat
import tech.kadence.android.ui.components.StatSize import tech.kadence.android.ui.components.StatSize
import tech.kadence.android.ui.components.VerticalRule import tech.kadence.android.ui.components.VerticalRule
import tech.kadence.android.data.store.CacheSlot
import tech.kadence.android.ui.rememberLoad import tech.kadence.android.ui.rememberLoad
import tech.kadence.android.ui.theme.KadenceTheme import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.humanMinutes import tech.kadence.core.humanMinutes
@Composable @Composable
fun ReportScreen(repo: KadenceRepository) { fun ReportScreen(repo: KadenceRepository, cache: CacheSlot<DailyReportEnvelopeDto>? = null) {
val colors = KadenceTheme.colors val colors = KadenceTheme.colors
val loader = rememberLoad<DailyReportEnvelopeDto> { repo.dailyReport() } val loader = rememberLoad(cache = cache) { repo.dailyReport() }
val state = loader.value val state = loader.value
LazyColumn( LazyColumn(
@@ -56,6 +56,7 @@ import tech.kadence.android.ui.components.HorizontalRule
import tech.kadence.android.ui.components.PageHeader import tech.kadence.android.ui.components.PageHeader
import tech.kadence.android.ui.components.Panel import tech.kadence.android.ui.components.Panel
import tech.kadence.android.ui.components.SectionTitle import tech.kadence.android.ui.components.SectionTitle
import tech.kadence.android.data.store.CacheSlot
import tech.kadence.android.ui.rememberLoad import tech.kadence.android.ui.rememberLoad
import tech.kadence.android.ui.theme.KadenceTheme import tech.kadence.android.ui.theme.KadenceTheme
import tech.kadence.core.BUCKET_LABEL import tech.kadence.core.BUCKET_LABEL
@@ -65,6 +66,8 @@ import tech.kadence.core.dueLabel
@Composable @Composable
fun TasksScreen( fun TasksScreen(
repo: KadenceRepository, repo: KadenceRepository,
/** Yesterday's list, so a phone with no signal is not an empty screen. */
cache: CacheSlot<tech.kadence.android.data.dto.TaskListDto>? = null,
runningTaskTitle: String?, runningTaskTitle: String?,
/** An interval is running — with or without a task attached to it. */ /** An interval is running — with or without a task attached to it. */
intervalRunning: Boolean, intervalRunning: Boolean,
@@ -73,7 +76,7 @@ fun TasksScreen(
val colors = KadenceTheme.colors val colors = KadenceTheme.colors
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val loader = rememberLoad<tech.kadence.android.data.dto.TaskListDto> { repo.tasks() } val loader = rememberLoad(cache = cache) { repo.tasks() }
var refreshing by remember { mutableStateOf(false) } var refreshing by remember { mutableStateOf(false) }
var refreshNote by remember { mutableStateOf<String?>(null) } var refreshNote by remember { mutableStateOf<String?>(null) }
@@ -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()))
}
}