android: offline writes go to the outbox that was already written
`:core/Outbox.kt` is 245 lines and 29 tests of careful thinking — what coalesces with what, which post-condition proves an ambiguous send landed, which intervals cannot be filed honestly at all — and nothing in `app/` had ever called it. Three comments pointed at it as though it were wired up. A tap with no signal produced one banner and was gone, after a ten-second connect timeout taken *under the command mutex*, so a handful of taps was most of a minute of frozen UI and then a stack of identical banners. Now every command has a queued form. `Reachability` (ConnectivityManager, which the manifest has had permission for since the start and never used) is asked before the network is, so an offline tap is answered instantly; a request that fails as NetworkError anyway lands in the same place one timeout later. The optimistic state stays on screen, the local anchor keeps ticking, and the snapshot is cached — so an interval started with no signal survives the process being killed. `NET_CAPABILITY_VALIDATED` is deliberately not required: this backend is routinely a LAN address, and demanding validation would call the one setup the README recommends unreachable. Replay runs on the next successful poll and the moment a network appears. In order, stopping at the first thing that cannot go; at most once, with the state re-read and probed rather than resent, because none of these endpoints is idempotent; a 4xx dropped rather than retried eight times into a stuck queue. An interval that both began and ended offline is quarantined and *said out loud* — replaying it would stamp `started_at` as the reconnect moment and record a pomodoro of no duration, quietly corrupting the statistics the app exists to produce. The poll finally declares the queue as `PendingOps`, which is what that type was written for: a poll racing an unsent intent now keeps the dial still instead of bouncing it. The offline banner stops saying only that the countdown keeps running — the sentence that made the dead buttons look intentional. 7 tests: an offline pause that never touches the network, the queue going out on reconnect, two `+5`s coalescing into ten minutes, pause-then-resume annihilating, the unfilable interval being reported, and an offline interval surviving a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
6 files changed
+756
-19
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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<OutboxEntryDto> = 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<OutboxEntryDto> 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<Long>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<TimerUi> = _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<TimerStateDto>,
|
||||
) = 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<TimerStateDto> = 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<TimerStateDto> = 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,
|
||||
)
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<String>()
|
||||
|
||||
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<android.content.Context>()
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user