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