diff --git a/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt b/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt index f9e936b..636671f 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt @@ -31,6 +31,7 @@ import tech.kadence.android.blocker.BlockerEngine import tech.kadence.android.data.KadenceRepository import tech.kadence.android.data.net.KadenceApi 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.newGlyphDisplay import tech.kadence.android.notify.Notifications @@ -47,6 +48,16 @@ class KadenceApplication : Application() { lateinit var prefs: KadencePrefs 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 private set lateinit var repo: KadenceRepository @@ -71,6 +82,7 @@ class KadenceApplication : Application() { Notifications.ensureChannels(this) prefs = KadencePrefs(this) + store = LocalStore(this) // `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. api = KadenceApi(config = { prefs.current }) @@ -79,6 +91,7 @@ class KadenceApplication : Application() { repo = repo, scope = scope, configured = { prefs.current.isConfigured && prefs.current.onboarded }, + store = store, ) // The focus guard, one per process like the timer — the accessibility service @@ -94,6 +107,7 @@ class KadenceApplication : Application() { val ui = engine.state.value 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 @@ -142,7 +156,14 @@ class KadenceApplication : Application() { prefs.config .map { it.baseUrl } .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() + } } } } diff --git a/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerEngine.kt b/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerEngine.kt index b1e4baa..ed7b415 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerEngine.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerEngine.kt @@ -49,6 +49,8 @@ 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.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.timer.Connection 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. */ 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()) val state: StateFlow = _state.asStateFlow() @@ -186,6 +190,7 @@ class BlockerEngine( // ---- lifecycle ---------------------------------------------------------- fun start() { + seedFromCache() if (pollJob?.isActive != true) pollJob = scope.launch { pollLoop() } if (appJob?.isActive != true) appJob = scope.launch { appLoop() } if (watchJob?.isActive != true) watchJob = scope.launch { watchLoop() } @@ -200,6 +205,33 @@ class BlockerEngine( 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. * @@ -405,6 +437,7 @@ class BlockerEngine( is ApiResult.Ok -> { val blocker = result.value.settings.blocker rules = blocker.toCore() + store?.write(Cached.BLOCKER_RULES, BlockerSettingsDto.serializer(), blocker) serverZone = zoneOrDefault(result.value.settings.timezone) rebuildMatchers() if (_state.value.connection != Connection.Online) { @@ -440,6 +473,7 @@ class BlockerEngine( private fun applyPolicy(dto: BlockerPolicyDto) { policy = dto + store?.write(Cached.BLOCKER_POLICY, BlockerPolicyDto.serializer(), dto) rebuildMatchers() _state.value = _state.value.copy( connection = Connection.Online, diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt index a94884b..e6e3891 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt @@ -113,6 +113,21 @@ data class RefreshResultDto( val sources: List = 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 data class BlockerPolicyDto( val active: Boolean = false, diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/store/LocalStore.kt b/android/app/src/main/kotlin/tech/kadence/android/data/store/LocalStore.kt new file mode 100644 index 0000000..8fc13c7 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/data/store/LocalStore.kt @@ -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 read(of: Cached, serializer: KSerializer): 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 write(of: Cached, serializer: KSerializer, 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 slot(of: Cached, serializer: KSerializer): CacheSlot = + object : CacheSlot { + 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 { + fun read(): T? + fun write(value: T) +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt b/android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt index 38e3587..df343c6 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/timer/TimerEngine.kt @@ -41,9 +41,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock 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.TimerStateDto 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.LocalBelief import tech.kadence.core.PendingOps @@ -108,6 +111,8 @@ class TimerEngine( private val repo: KadenceRepository, private val scope: CoroutineScope, 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()) val state: StateFlow = _state.asStateFlow() @@ -135,6 +140,7 @@ class TimerEngine( // ---- lifecycle ---------------------------------------------------------- fun start() { + seedFromCache() if (pollJob?.isActive != true) { pollJob = scope.launch { pollLoop() } } @@ -153,6 +159,68 @@ class TimerEngine( /** Ask for a fresh poll now — on app foreground, or after a settings change. */ 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 ---------------------------------------------------------- private suspend fun pollLoop() { @@ -219,6 +287,7 @@ class TimerEngine( * and must not be narrated as somebody else's doing. */ private suspend fun applyServer(dto: TimerStateDto, fromCommand: Boolean) { + remember(dto) val server = dto.toCore() val local = LocalBelief( sessionId = _state.value.sessionId, diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt index 4be6f1f..8d356f2 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt @@ -38,7 +38,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import tech.kadence.android.KadenceApplication 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.store.Cached import tech.kadence.android.ui.blocker.BlockerScreen import tech.kadence.android.ui.components.HorizontalRule import tech.kadence.android.ui.onboarding.OnboardingScreen @@ -135,6 +138,7 @@ fun KadenceApp(app: KadenceApplication) { Destination.Tasks -> TasksScreen( repo = app.repo, + cache = app.store.slot(Cached.TASKS, TaskListDto.serializer()), runningTaskTitle = ui.taskTitle, intervalRunning = ui.isActive, onAttach = { taskId -> @@ -156,7 +160,10 @@ fun KadenceApp(app: KadenceApplication) { 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( config = config, diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/Loadable.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/Loadable.kt index e7d929e..81eaa5a 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/Loadable.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/Loadable.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.launch import tech.kadence.android.data.net.ApiResult +import tech.kadence.android.data.store.CacheSlot sealed interface Loadable { data object Idle : Loadable @@ -31,16 +32,33 @@ sealed interface Loadable { class Loader( private val state: MutableState>, private val scope: kotlinx.coroutines.CoroutineScope, + /** The last good answer on disk, if this endpoint keeps one. */ + private val cache: CacheSlot? = null, private val call: suspend () -> ApiResult, ) { val value: Loadable 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() { val existing = state.value state.value = if (existing is Loadable.Loaded) existing else Loadable.Loading scope.launch { 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.Unauthorized -> degrade(existing, "Not signed in.") is ApiResult.NetworkError -> degrade(existing, "No connection to the backend.") @@ -54,12 +72,20 @@ class Loader( } @Composable -fun rememberLoad(vararg keys: Any?, call: suspend () -> ApiResult): Loader { +fun rememberLoad( + vararg keys: Any?, + cache: CacheSlot? = null, + call: suspend () -> ApiResult, +): Loader { val state = remember { mutableStateOf>(Loadable.Idle) } 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) { - if (state.value == Loadable.Idle) loader.reload() + loader.reload() } return loader } diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/report/ReportScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/report/ReportScreen.kt index faec27e..1895512 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/report/ReportScreen.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/report/ReportScreen.kt @@ -42,14 +42,15 @@ import tech.kadence.android.ui.components.SectionTitle import tech.kadence.android.ui.components.Stat import tech.kadence.android.ui.components.StatSize 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.theme.KadenceTheme import tech.kadence.core.humanMinutes @Composable -fun ReportScreen(repo: KadenceRepository) { +fun ReportScreen(repo: KadenceRepository, cache: CacheSlot? = null) { val colors = KadenceTheme.colors - val loader = rememberLoad { repo.dailyReport() } + val loader = rememberLoad(cache = cache) { repo.dailyReport() } val state = loader.value LazyColumn( diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt index 356b066..9783b77 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/tasks/TasksScreen.kt @@ -56,6 +56,7 @@ import tech.kadence.android.ui.components.HorizontalRule import tech.kadence.android.ui.components.PageHeader import tech.kadence.android.ui.components.Panel 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.theme.KadenceTheme import tech.kadence.core.BUCKET_LABEL @@ -65,6 +66,8 @@ import tech.kadence.core.dueLabel @Composable fun TasksScreen( repo: KadenceRepository, + /** Yesterday's list, so a phone with no signal is not an empty screen. */ + cache: CacheSlot? = null, runningTaskTitle: String?, /** An interval is running — with or without a task attached to it. */ intervalRunning: Boolean, @@ -73,7 +76,7 @@ fun TasksScreen( val colors = KadenceTheme.colors val context = LocalContext.current val scope = rememberCoroutineScope() - val loader = rememberLoad { repo.tasks() } + val loader = rememberLoad(cache = cache) { repo.tasks() } var refreshing by remember { mutableStateOf(false) } var refreshNote by remember { mutableStateOf(null) } diff --git a/android/app/src/test/kotlin/tech/kadence/android/ColdStartOfflineTest.kt b/android/app/src/test/kotlin/tech/kadence/android/ColdStartOfflineTest.kt new file mode 100644 index 0000000..3890993 --- /dev/null +++ b/android/app/src/test/kotlin/tech/kadence/android/ColdStartOfflineTest.kt @@ -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() + 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") + } +} diff --git a/android/app/src/test/kotlin/tech/kadence/android/LocalStoreTest.kt b/android/app/src/test/kotlin/tech/kadence/android/LocalStoreTest.kt new file mode 100644 index 0000000..9c14a15 --- /dev/null +++ b/android/app/src/test/kotlin/tech/kadence/android/LocalStoreTest.kt @@ -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() + 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())) + } +}