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 636671f..8fcb6b5 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch import tech.kadence.android.blocker.BlockerEngine import tech.kadence.android.data.KadenceRepository import tech.kadence.android.data.net.KadenceApi +import tech.kadence.android.data.net.Reachability import tech.kadence.android.data.store.KadencePrefs import tech.kadence.android.data.store.LocalStore import tech.kadence.android.glyph.GlyphDisplay @@ -58,6 +59,10 @@ class KadenceApplication : Application() { */ lateinit var store: LocalStore private set + + /** Whether there is a network, so an offline tap can be answered instantly. */ + lateinit var reachability: Reachability + private set lateinit var api: KadenceApi private set lateinit var repo: KadenceRepository @@ -83,6 +88,7 @@ class KadenceApplication : Application() { prefs = KadencePrefs(this) store = LocalStore(this) + reachability = Reachability(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 }) @@ -92,6 +98,7 @@ class KadenceApplication : Application() { scope = scope, configured = { prefs.current.isConfigured && prefs.current.onboarded }, store = store, + reachable = reachability::online, ) // The focus guard, one per process like the timer — the accessibility service @@ -138,6 +145,14 @@ class KadenceApplication : Application() { } } + // The moment Wi-Fi or data comes back, send what was queued and re-read the + // policy. This beats any poll interval: the phone knows before we would ask. + reachability.whenNetworkReturns { + Log.i(TAG, "a network appeared — syncing") + engine.refreshNow() + blocker.refreshNow() + } + // Starting or ending an interval arms or releases the guard immediately. Without // this the blocker only noticed on its own poll — 45s apart while idle, which is // exactly the window between "Start focus" and opening the thing you blocked. diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/net/Reachability.kt b/android/app/src/main/kotlin/tech/kadence/android/data/net/Reachability.kt new file mode 100644 index 0000000..c2fc8c9 --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/data/net/Reachability.kt @@ -0,0 +1,45 @@ +/** + * Whether there is any point trying. + * + * `ACCESS_NETWORK_STATE` was declared in the manifest from the start and never used, + * which is why an offline tap cost a ten-second connect timeout *under the command + * mutex*: five taps queued behind each other was most of a minute of apparently frozen + * UI, and then five identical banners. Asking Android first turns that into an instant, + * honest answer. + * + * `NET_CAPABILITY_VALIDATED` is deliberately **not** required. Kadence's backend is + * routinely a LAN address, and a Wi-Fi network with no route to the internet is still + * perfectly able to reach it; insisting on validation would declare the one case the + * README tells people to use unreachable. + */ +package tech.kadence.android.data.net + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities + +class Reachability(context: Context) { + + private val manager = context.getSystemService(ConnectivityManager::class.java) + + /** Whether a network exists at all. Not a promise that the backend answers. */ + fun online(): Boolean { + val active = manager?.activeNetwork ?: return false + val caps = manager.getNetworkCapabilities(active) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + /** + * Call [onAvailable] when a network appears. + * + * This is the moment the outbox has been waiting for, and it beats any polling + * interval: the phone knows the instant Wi-Fi comes back. + */ + fun whenNetworkReturns(onAvailable: () -> Unit) { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = onAvailable() + } + runCatching { manager?.registerDefaultNetworkCallback(callback) } + } +} diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/store/OutboxStore.kt b/android/app/src/main/kotlin/tech/kadence/android/data/store/OutboxStore.kt new file mode 100644 index 0000000..6ed809d --- /dev/null +++ b/android/app/src/main/kotlin/tech/kadence/android/data/store/OutboxStore.kt @@ -0,0 +1,153 @@ +/** + * The offline queue, on disk. + * + * `:core`'s `Outbox.kt` has all the thinking — what coalesces with what, which + * post-condition proves an ambiguous send landed, which intervals cannot be filed + * honestly at all — and 29 tests to go with it. What it never had was a caller: there + * was no offline write path in the app, so a tap with no signal produced one banner and + * was gone. + * + * This is the storage half, kept deliberately dumb. `:core` stays free of every + * dependency (which is why its suite runs on a laptop with nothing but a JDK), so the + * serializable mirror of [OutboxEntry] lives here instead, with `attempt` and the + * wall-clock moment the user asked — neither of which `:core` needs to reason, and both + * of which this side needs to replay with backoff and to describe what was lost. + */ +package tech.kadence.android.data.store + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import tech.kadence.core.OutboxEntry +import tech.kadence.core.OutboxOp +import tech.kadence.core.coalesce + +/** One queued intent, as it is written down. */ +@Serializable +data class OutboxEntryDto( + val seq: Long, + val op: String, + @SerialName("anchor_session_id") val anchorSessionId: Long? = null, + val kind: String? = null, + val minutes: Int = 0, + @SerialName("task_id") val taskId: Long? = null, + val reason: String? = null, + val repeat: Int = 1, + /** How many times replay has been attempted, for the backoff. */ + val attempt: Int = 0, + /** When the user asked, so an unfilable interval can still be described. */ + @SerialName("asked_at_millis") val askedAtMillis: Long = 0, +) + +@Serializable +data class OutboxFileDto( + val entries: List = emptyList(), + @SerialName("next_seq") val nextSeq: Long = 1, +) + +fun OutboxEntryDto.toCore(): OutboxEntry = OutboxEntry( + seq = seq, + op = OutboxOp.valueOf(op), + anchorSessionId = anchorSessionId, + kind = kind, + minutes = minutes, + taskId = taskId, + reason = reason, + repeat = repeat, +) + +/** + * The queue as a whole, read and written whole. + * + * Whole because it is tiny — a handful of rows at the very most — and because + * `coalesce` is defined over the entire queue. A row-at-a-time store would buy + * nothing and would make "pause then resume annihilate" into a transaction. + */ +class OutboxStore(private val store: LocalStore) { + + private var file: OutboxFileDto = + store.read(Cached.OUTBOX, OutboxFileDto.serializer()) ?: OutboxFileDto() + + val entries: List get() = file.entries + + val isEmpty: Boolean get() = file.entries.isEmpty() + + /** + * Add one intent, folded into what is already queued. + * + * Coalescing at enqueue rather than at replay is `:core`'s decision and a good + * one: the queue stays a faithful, minimal description of what the user asked for, + * which is far easier to reason about — and to show them — than a pile of intents + * collapsed at the last moment. + */ + fun add( + op: OutboxOp, + anchorSessionId: Long? = null, + kind: String? = null, + minutes: Int = 0, + taskId: Long? = null, + reason: String? = null, + ) { + val seq = file.nextSeq + val incoming = OutboxEntry( + seq = seq, + op = op, + anchorSessionId = anchorSessionId, + kind = kind, + minutes = minutes, + taskId = taskId, + reason = reason, + ) + val asked = System.currentTimeMillis() + val folded = coalesce(file.entries.map { it.toCore() }, incoming) + // `coalesce` works in `:core`'s vocabulary, so the attempt counts and the + // timestamps are carried back across by seq — a row it rewrote (a doubled + // `+5`, a counted interruption) keeps the metadata it had. + val previous = file.entries.associateBy { it.seq } + file = file.copy( + entries = folded.map { core -> + val old = previous[core.seq] + OutboxEntryDto( + seq = core.seq, + op = core.op.name, + anchorSessionId = core.anchorSessionId, + kind = core.kind, + minutes = core.minutes, + taskId = core.taskId, + reason = core.reason, + repeat = core.repeat, + attempt = old?.attempt ?: 0, + askedAtMillis = old?.askedAtMillis ?: asked, + ) + }, + nextSeq = seq + 1, + ) + persist() + } + + fun remove(seq: Long) { + file = file.copy(entries = file.entries.filterNot { it.seq == seq }) + persist() + } + + fun removeAll(seqs: Set) { + if (seqs.isEmpty()) return + file = file.copy(entries = file.entries.filterNot { it.seq in seqs }) + persist() + } + + fun noteAttempt(seq: Long, attempt: Int) { + file = file.copy( + entries = file.entries.map { if (it.seq == seq) it.copy(attempt = attempt) else it }, + ) + persist() + } + + fun clear() { + file = OutboxFileDto(nextSeq = file.nextSeq) + persist() + } + + private fun persist() { + store.write(Cached.OUTBOX, OutboxFileDto.serializer(), file) + } +} 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 df343c6..3eb2788 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 @@ -27,6 +27,7 @@ package tech.kadence.android.timer import android.util.Log +import java.io.IOException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -47,7 +48,12 @@ 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.android.data.store.OutboxStore +import tech.kadence.android.data.store.toCore import tech.kadence.core.Kind +import tech.kadence.core.MAX_REPLAY_ATTEMPTS +import tech.kadence.core.OutboxEntry +import tech.kadence.core.OutboxOp import tech.kadence.core.LocalBelief import tech.kadence.core.PendingOps import tech.kadence.core.Reconciliation @@ -55,6 +61,11 @@ import tech.kadence.core.ServerTimerState import tech.kadence.core.Status import tech.kadence.core.TickAnchor import tech.kadence.core.TimerSessionFacts +import tech.kadence.core.partitionForReplay +import tech.kadence.core.pendingOpsOf +import tech.kadence.core.postconditionHolds +import tech.kadence.core.replayOutcomeFor +import tech.kadence.core.ReplayOutcome import tech.kadence.core.progressOf import tech.kadence.core.reconcile import tech.kadence.core.remainingAt @@ -113,7 +124,12 @@ class TimerEngine( private val configured: () -> Boolean, /** Where the server's last answer is kept, so a cold start has one. */ private val store: LocalStore? = null, + /** Whether a network exists; `{ true }` means "just try it", as tests do. */ + private val reachable: () -> Boolean = { true }, ) { + /** The offline queue. Absent when there is nowhere to persist it. */ + private val outbox: OutboxStore? = store?.let { OutboxStore(it) } + private val _state = MutableStateFlow(TimerUi()) val state: StateFlow = _state.asStateFlow() @@ -262,7 +278,12 @@ class TimerEngine( } when (val result = repo.timerState()) { - is ApiResult.Ok -> mutex.withLock { applyServer(result.value, fromCommand = false) } + is ApiResult.Ok -> { + mutex.withLock { applyServer(result.value, fromCommand = false) } + // The server is answering, so anything the user asked for while it was + // not can go now. + replay() + } is ApiResult.Unauthorized -> _state.value = _state.value.copy(connection = Connection.Unauthorized) is ApiResult.NetworkError -> { @@ -296,10 +317,17 @@ class TimerEngine( // A command's reply *is* our own intent landing, so it is declared as pending // for the reconciler's benefit. Nothing is queued on disk yet: the offline // outbox in `:core` is wired up by the service, not here. + // For a poll, what is pending is literally what is in the outbox — this is the + // vocabulary `PendingOps` was written in, and the reason `KeepOptimistic` exists: + // a poll that races our own unsent intent must not bounce the dial back. + // + // For a command's reply nothing is in dispute: it *is* the answer. (It used to + // claim a full set of pending ops here, which is what made `reconcile` discard + // every reply for the interval already running.) val pending = if (fromCommand) { PendingOps(hasAny = true, hasStart = true, hasTerminal = true) } else { - PendingOps() + outbox?.let { pendingOpsOf(it.entries.map { entry -> entry.toCore() }) } ?: PendingOps() } val decision = reconcile(server, local, pending) @@ -384,28 +412,43 @@ class TimerEngine( // ---- commands ----------------------------------------------------------- fun startWork(minutes: Int? = null, taskId: Long? = null) = - command { repo.start(kind = Kind.WORK, minutes = minutes, taskId = taskId) } + startKind(Kind.WORK, minutes, taskId) - fun startKind(kind: String, minutes: Int? = null) = - command { repo.start(kind = kind, minutes = minutes) } + fun startKind(kind: String, minutes: Int? = null, taskId: Long? = null) = command( + optimistic = { it.startedLocally(kind, minutes) }, + queue = { box, _ -> box.add(OutboxOp.START, kind = kind, minutes = minutes ?: 0, taskId = taskId) }, + ) { repo.start(kind = kind, minutes = minutes, taskId = taskId) } - fun startNext() = command { repo.start(kind = _state.value.nextKind) } + fun startNext() = startKind(_state.value.nextKind) - fun pause() = command(optimistic = { it.paused() }) { repo.pause() } + fun pause() = command( + optimistic = { it.paused() }, + queue = { box, ui -> box.add(OutboxOp.PAUSE, ui.sessionId) }, + ) { repo.pause() } - fun resume() = command(optimistic = { it.resumed() }) { repo.resume() } + fun resume() = command( + optimistic = { it.resumed() }, + queue = { box, ui -> box.add(OutboxOp.RESUME, ui.sessionId) }, + ) { repo.resume() } - fun interrupt() = - command(optimistic = { it.copy(interruptions = it.interruptions + 1) }) { - repo.interrupt() - } + fun interrupt() = command( + optimistic = { it.copy(interruptions = it.interruptions + 1) }, + queue = { box, ui -> box.add(OutboxOp.INTERRUPT, ui.sessionId) }, + ) { repo.interrupt() } - fun extend(minutes: Int = 5) = - command(optimistic = { it.extendedBy(minutes) }) { repo.extend(minutes) } + fun extend(minutes: Int = 5) = command( + optimistic = { it.extendedBy(minutes) }, + queue = { box, ui -> box.add(OutboxOp.EXTEND, ui.sessionId, minutes = minutes) }, + ) { repo.extend(minutes) } - fun attachTask(taskId: Long?) = command { repo.attachTask(taskId) } + fun attachTask(taskId: Long?) = command( + queue = { box, ui -> box.add(OutboxOp.ATTACH_TASK, ui.sessionId, taskId = taskId) }, + ) { repo.attachTask(taskId) } - fun abandon(reason: String? = null) = command { repo.abandon(reason) } + fun abandon(reason: String? = null) = command( + optimistic = { it.ended() }, + queue = { box, ui -> box.add(OutboxOp.ABANDON, ui.sessionId, reason = reason) }, + ) { repo.abandon(reason) } /** Returns the command's job, so a caller (or a test) can wait for the reply. */ fun toggle(): Job { @@ -419,6 +462,14 @@ class TimerEngine( /** `complete` is not in [command] because its reply carries the finish event. */ fun complete(autoNext: Boolean = true) = scope.launch { + val before = _state.value + if (!reachable() && outbox != null && before.isActive) { + _state.value = before.ended() + anchor = null + enqueue({ box, ui -> box.add(OutboxOp.COMPLETE, ui.sessionId) }, before) + completing = false + return@launch + } mutex.withLock { _state.value = _state.value.copy(busy = true) when (val result = repo.complete(autoNext)) { @@ -437,6 +488,11 @@ class TimerEngine( ) } } + is ApiResult.NetworkError if outbox != null && before.isActive -> { + _state.value = before.ended() + anchor = null + enqueue({ box, ui -> box.add(OutboxOp.COMPLETE, ui.sessionId) }, before) + } else -> handleFailure(result) } _state.value = _state.value.copy(busy = false) @@ -454,24 +510,62 @@ class TimerEngine( */ private fun command( optimistic: ((TimerUi) -> TimerUi)? = null, + queue: ((OutboxStore, TimerUi) -> Unit)? = null, call: suspend () -> ApiResult, ) = scope.launch { + val before = _state.value if (optimistic != null) { // Outside the mutex and before the request: the point is that the dial and // the button move on the tap, not when the lock is free. - _state.value = optimistic(_state.value).copy(notice = null) + _state.value = optimistic(before).copy(notice = null) reanchor(_state.value) } + + // Ask Android before asking the network. Otherwise an offline tap costs the + // full connect timeout *while holding the mutex*, so a handful of taps is most + // of a minute of frozen UI followed by a stack of identical banners. + if (queue != null && !reachable()) { + enqueue(queue, before) + return@launch + } + mutex.withLock { _state.value = _state.value.copy(busy = true, notice = null) when (val result = call()) { is ApiResult.Ok -> applyServer(result.value, fromCommand = true) + is ApiResult.NetworkError if queue != null -> { + // It looked reachable and was not. Same answer, one timeout later. + enqueue(queue, before) + } else -> handleFailure(result) } _state.value = _state.value.copy(busy = false) } } + /** + * Keep what the user asked for, and say so. + * + * The optimistic state is already on screen and stays there; the countdown keeps + * running from the local anchor. What this adds is the promise that the intent is + * not lost — which is the difference between a timer that works on the underground + * and a button that does nothing. + */ + private fun enqueue(queue: (OutboxStore, TimerUi) -> Unit, before: TimerUi) { + val box = outbox + if (box == null) { + handleFailure(ApiResult.NetworkError(IOException("offline"))) + return + } + queue(box, before) + remember(_state.value.asDto()) + _state.value = _state.value.copy( + connection = Connection.Offline, + busy = false, + notice = "No connection — kept, and sent when the backend is back.", + ) + } + /** Point the local tick at what [state] now says, after an optimistic change. */ private fun reanchor(ui: TimerUi) { val id = ui.sessionId ?: return @@ -483,6 +577,150 @@ class TimerEngine( ) } + /** + * Send what was queued while there was no signal. + * + * Three rules, all of them `:core`'s and all of them pinned by `OutboxTest`: + * + * * **at most once.** None of these endpoints is idempotent — `start` creates, + * `extend` adds, `interrupt` increments — so an ambiguous failure (the request + * went out, the socket died before the reply) is never retried blind. The state + * is re-read and asked "does the world look like my call landed?" instead. + * * **in order**, stopping at the first thing that cannot go yet. Replaying later + * intents past an earlier one would file them against the wrong interval. + * * **an interval that began *and* ended offline cannot be filed honestly.** + * `pomodoro.start()` stamps `started_at` as *now*, so replaying start-then- + * complete would record a pomodoro at the wrong hour with roughly no duration, + * corrupting the very statistics this app exists to produce. Those are + * quarantined and reported, never sent. + */ + private suspend fun replay() { + val box = outbox ?: return + if (box.isEmpty) return + + val (replayable, unfiled) = partitionForReplay(box.entries.map { it.toCore() }) + if (unfiled.isNotEmpty()) { + val starts = unfiled.count { it.op == OutboxOp.START } + box.removeAll(unfiled.map { it.seq }.toSet()) + _state.value = _state.value.copy( + notice = if (starts == 1) { + "One interval began and ended with no connection, so it could not be " + + "filed with the right times. It is not in today's count." + } else { + "$starts intervals began and ended with no connection, so they could " + + "not be filed with the right times. They are not in today's count." + }, + ) + } + + for (entry in replayable) { + val before = _state.value.let { + if (it.sessionId != null) { + TimerSessionFacts( + id = it.sessionId, + kind = it.kind, + status = it.status, + remainingSeconds = it.remaining, + plannedSeconds = it.plannedSeconds, + interruptions = it.interruptions, + ) + } else { + null + } + } + if (!send(entry, before, box)) return + } + + // Whatever the server made of it all is the final word. + if (box.isEmpty) poll() + } + + /** @return whether to carry on with the rest of the queue. */ + private suspend fun send( + entry: OutboxEntry, + before: TimerSessionFacts?, + box: OutboxStore, + ): Boolean { + // INTERRUPT is the one op that counts rather than coalesces: three + // interruptions have to become three increments on the server's counter. + val repeats = if (entry.op == OutboxOp.INTERRUPT) entry.repeat else 1 + var result: ApiResult = ApiResult.Ok(_state.value.asDto()) + repeat(repeats) { + if (result is ApiResult.Ok) result = perform(entry) + } + + return when (val outcome = result) { + is ApiResult.Ok -> { + box.remove(entry.seq) + mutex.withLock { applyServer(outcome.value, fromCommand = true) } + true + } + + // On its merits, and it will be refused identically forever: "No interval + // is running", or strict mode wanting a reason. Dropping it is kinder than + // eight more attempts and a stuck queue. + is ApiResult.HttpError -> { + box.remove(entry.seq) + _state.value = _state.value.copy(notice = outcome.detail) + true + } + + is ApiResult.Unauthorized -> { + _state.value = _state.value.copy(connection = Connection.Unauthorized) + false + } + + is ApiResult.NetworkError -> { + val landed = probe(entry, before) + if (landed) { + box.remove(entry.seq) + return true + } + when (replayOutcomeFor(entry.attemptOf(box))) { + is ReplayOutcome.GiveUp -> { + box.remove(entry.seq) + _state.value = _state.value.copy( + notice = "Gave up sending one change after " + + "$MAX_REPLAY_ATTEMPTS tries.", + ) + } + is ReplayOutcome.Retry -> box.noteAttempt(entry.seq, entry.attemptOf(box) + 1) + else -> Unit + } + _state.value = _state.value.copy(connection = Connection.Offline) + false + } + } + } + + private fun OutboxEntry.attemptOf(box: OutboxStore): Int = + box.entries.firstOrNull { it.seq == seq }?.attempt ?: 0 + + private suspend fun perform(entry: OutboxEntry): ApiResult = when (entry.op) { + OutboxOp.START -> repo.start( + kind = entry.kind, + minutes = entry.minutes.takeIf { it > 0 }, + taskId = entry.taskId, + ) + OutboxOp.PAUSE -> repo.pause() + OutboxOp.RESUME -> repo.resume() + OutboxOp.COMPLETE -> repo.complete(autoNext = false) + OutboxOp.ABANDON -> repo.abandon(entry.reason) + OutboxOp.EXTEND -> repo.extend(entry.minutes) + OutboxOp.INTERRUPT -> repo.interrupt() + OutboxOp.ATTACH_TASK -> repo.attachTask(entry.taskId) + } + + /** Did the ambiguous send land after all? Read the world back and see. */ + private suspend fun probe(entry: OutboxEntry, before: TimerSessionFacts?): Boolean { + val after = repo.timerState() + if (after !is ApiResult.Ok) return false + val state = after.value.toCore() + val holds = postconditionHolds(entry, before, state) + if (holds) mutex.withLock { applyServer(after.value, fromCommand = true) } + return holds + } + private fun handleFailure(result: ApiResult<*>) { val detail = when (result) { // A 400 here is the server refusing on the merits — "No interval is @@ -560,3 +798,63 @@ internal fun TimerUi.extendedBy(minutes: Int): TimerUi = } else { this } + +/** + * An interval begun with no signal. + * + * The id is negative, which is how everything downstream can tell: the server's ids + * are positive, so a negative one is a local placeholder that no `anchorSessionId` + * will ever collide with and that `reconcile` reads as "a different interval" the + * moment a real one arrives. + */ +internal const val LOCAL_SESSION_ID: Long = -1L + +internal fun TimerUi.startedLocally(kind: String, minutes: Int?): TimerUi { + val planned = (minutes ?: durationsMinutes[kind] ?: 25) * 60 + return copy( + sessionId = LOCAL_SESSION_ID, + kind = kind, + status = Status.RUNNING, + remaining = planned, + plannedSeconds = planned, + interruptions = 0, + taskTitle = null, + ) +} + +/** No interval any more. The counters are the server's and are left alone. */ +internal fun TimerUi.ended(): TimerUi = copy( + sessionId = null, + status = Status.COMPLETED, + remaining = 0, + plannedSeconds = 0, + interruptions = 0, + taskTitle = null, +) + +/** + * The snapshot, in the shape the cache keeps. + * + * So an interval started or changed offline survives the process being killed — the + * same restore path as a server answer, which is why it is the same type. + */ +internal fun TimerUi.asDto(): TimerStateDto = TimerStateDto( + current = sessionId?.let { + TimerSessionDto( + id = it, + kind = kind, + status = status, + plannedSeconds = plannedSeconds, + remainingSeconds = remaining, + elapsedSeconds = (plannedSeconds - remaining).coerceAtLeast(0), + interruptions = interruptions, + ) + }, + nextKind = nextKind, + completedToday = completedToday, + goal = goal, + cyclePosition = cyclePosition, + cyclesBeforeLongBreak = cyclesBeforeLongBreak, + durations = durationsMinutes, + timerEnabled = timerEnabled, +) diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/timer/TimerScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/timer/TimerScreen.kt index bcee1d9..f28e095 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/timer/TimerScreen.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/timer/TimerScreen.kt @@ -367,8 +367,10 @@ fun TimerScreen( private fun ConnectionBanner(ui: TimerUi, onOpenSettings: () -> Unit) { when (ui.connection) { Connection.Offline -> Banner( - text = "No connection to the backend. The countdown keeps running from " + - "where the server last left it.", + // It used to say only that the countdown keeps running, which left the + // buttons looking broken: they *were* broken, and now they are not. + text = "No connection to the backend. The timer still works — what you " + + "change here is kept and sent when it is back.", ) Connection.Unauthorized -> Banner( text = "The backend rejected our credentials.", diff --git a/android/app/src/test/kotlin/tech/kadence/android/OfflineWriteTest.kt b/android/app/src/test/kotlin/tech/kadence/android/OfflineWriteTest.kt new file mode 100644 index 0000000..645f9ef --- /dev/null +++ b/android/app/src/test/kotlin/tech/kadence/android/OfflineWriteTest.kt @@ -0,0 +1,224 @@ +package tech.kadence.android + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import mockwebserver3.Dispatcher +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.RecordedRequest +import org.junit.After +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.KadenceRepository +import tech.kadence.android.data.net.AuthConfig +import tech.kadence.android.data.net.KadenceApi +import tech.kadence.android.data.store.Cached +import tech.kadence.android.data.store.LocalStore +import tech.kadence.android.data.store.OutboxFileDto +import tech.kadence.android.data.store.OutboxStore +import tech.kadence.android.timer.Connection +import tech.kadence.android.timer.TimerEngine +import tech.kadence.core.OutboxOp +import tech.kadence.core.Status +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Taps with no signal. + * + * `:core`'s outbox — coalescing, post-condition probes, the quarantine for an interval + * that both began and ended offline — was complete and 29-tests deep, and nothing in + * the app had ever called it. A tap without a network produced one banner and was + * gone. These tests are about the queue being real: the intent is kept, the UI moves + * at once, and the server gets it when it comes back. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class OfflineWriteTest { + + private lateinit var server: MockWebServer + private lateinit var store: LocalStore + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + /** Flipped by the tests: this is "the phone has no network". */ + private var online = true + private val seen = mutableListOf() + + private fun fixture(name: String): String = + checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) + .bufferedReader().readText() + + private fun engine(): TimerEngine = TimerEngine( + repo = KadenceRepository( + KadenceApi(config = { + object : AuthConfig { + override val baseUrl = server.url("/").toString().trimEnd('/') + override val apiToken = null + } + }), + ), + scope = scope, + configured = { true }, + store = store, + reachable = { online }, + ) + + private fun outbox() = OutboxStore(store) + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + File(context.filesDir, "cache").deleteRecursively() + store = LocalStore(context) + online = true + seen.clear() + + server = MockWebServer() + server.start() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.url.encodedPath + seen += path + val body = when { + path.endsWith("/api/timer/state") -> fixture("timer_state_running.json") + path.endsWith("/api/timer/pause") -> + fixture("timer_state_running.json") + .replace("\"status\": \"running\"", "\"status\": \"paused\"") + else -> fixture("timer_state_running.json") + } + return MockResponse.Builder() + .code(200) + .addHeader("Content-Type", "application/json") + .body(body) + .build() + } + } + } + + @After + fun tearDown() { + server.close() + } + + @Test + fun `an offline pause moves the dial, is kept, and never touches the network`() { + val engine = engine() + runBlocking { engine.refreshNow().join() } + online = false + + runBlocking { engine.pause().join() } + + val ui = engine.state.value + assertEquals(Status.PAUSED, ui.status, "the user asked, so the user can see it") + assertEquals(Connection.Offline, ui.connection) + assertTrue(ui.notice?.contains("kept") == true, "say it will be sent, got: ${ui.notice}") + assertEquals(listOf(OutboxOp.PAUSE.name), outbox().entries.map { it.op }) + assertTrue(seen.none { it.endsWith("/api/timer/pause") }, "nothing was attempted") + } + + @Test + fun `the queue is sent when the server answers again`() { + val engine = engine() + runBlocking { engine.refreshNow().join() } + online = false + runBlocking { engine.pause().join() } + + online = true + runBlocking { engine.refreshNow().join() } + + assertTrue(seen.any { it.endsWith("/api/timer/pause") }, "the queued pause went out") + assertTrue(outbox().isEmpty, "and left the queue") + } + + @Test + fun `two taps on plus five are one ten-minute extend`() { + val engine = engine() + runBlocking { engine.refreshNow().join() } + online = false + + runBlocking { engine.extend(5).join() } + runBlocking { engine.extend(5).join() } + + val queued = outbox().entries + assertEquals(1, queued.size, "coalesced, as :core says it should be") + assertEquals(10, queued.single().minutes) + assertEquals(1104 + 600, engine.state.value.remaining, "and both were shown") + } + + @Test + fun `pause then resume with no signal cancels out`() { + val engine = engine() + runBlocking { engine.refreshNow().join() } + online = false + + runBlocking { engine.pause().join() } + runBlocking { engine.resume().join() } + + assertTrue(outbox().isEmpty, "the user changed their mind before we had signal") + assertEquals(Status.RUNNING, engine.state.value.status) + } + + @Test + fun `an interval that began and ended offline is not filed, and is said so`() { + val engine = engine() + runBlocking { engine.refreshNow().join() } + online = false + + // A start and a complete that the server never saw. Replaying them would stamp + // `started_at` as the reconnect moment and record a pomodoro of no duration, + // which is worse than losing it. + runBlocking { engine.complete().join() } + runBlocking { engine.startKind("work").join() } + runBlocking { engine.complete().join() } + + online = true + runBlocking { engine.refreshNow().join() } + + val ui = engine.state.value + assertTrue( + ui.notice?.contains("could not be filed") == true, + "the loss has to be visible, got: ${ui.notice}", + ) + assertTrue(seen.none { it.endsWith("/api/timer/start") }, "and never sent") + assertTrue(outbox().isEmpty) + } + + @Test + fun `an interval started offline survives the process being killed`() { + val engine = engine() + runBlocking { engine.refreshNow().join() } + online = false + runBlocking { engine.complete().join() } + runBlocking { engine.startKind("short_break", minutes = 5).join() } + + // A whole new engine, as after a process death: same disk, nothing in memory. + val reborn = engine() + reborn.start() + reborn.stop() + + val ui = reborn.state.value + assertEquals("short_break", ui.kind) + assertEquals(Status.RUNNING, ui.status) + assertTrue(ui.remaining in 290..300, "five minutes, less the moment that passed") + } + + @Test + fun `a queue that survives a restart is still readable`() { + val box = outbox() + box.add(OutboxOp.EXTEND, anchorSessionId = 412, minutes = 5) + box.add(OutboxOp.INTERRUPT, anchorSessionId = 412) + box.add(OutboxOp.INTERRUPT, anchorSessionId = 412) + + val back = store.read(Cached.OUTBOX, OutboxFileDto.serializer()) + + assertEquals(2, back?.entries?.size) + assertEquals(2, back?.entries?.last()?.repeat, "three interruptions are three increments") + } +}