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:
timka123andClaude Opus 5 committed 2026-10-22 17:46:12 +03:00
1 parent 120c553537
commit a8c84c65da
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.blocker.BlockerEngine
import tech.kadence.android.data.KadenceRepository import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.net.KadenceApi import tech.kadence.android.data.net.KadenceApi
import tech.kadence.android.data.net.Reachability
import tech.kadence.android.data.store.KadencePrefs import tech.kadence.android.data.store.KadencePrefs
import tech.kadence.android.data.store.LocalStore import tech.kadence.android.data.store.LocalStore
import tech.kadence.android.glyph.GlyphDisplay import tech.kadence.android.glyph.GlyphDisplay
@@ -58,6 +59,10 @@ class KadenceApplication : Application() {
*/ */
lateinit var store: LocalStore lateinit var store: LocalStore
private set 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 lateinit var api: KadenceApi
private set private set
lateinit var repo: KadenceRepository lateinit var repo: KadenceRepository
@@ -83,6 +88,7 @@ class KadenceApplication : Application() {
prefs = KadencePrefs(this) prefs = KadencePrefs(this)
store = LocalStore(this) store = LocalStore(this)
reachability = Reachability(this)
// `config()` is read per request rather than captured, so changing the backend // `config()` is read per request rather than captured, so changing the backend
// in Settings takes effect on the next call instead of needing a restart. // in Settings takes effect on the next call instead of needing a restart.
api = KadenceApi(config = { prefs.current }) api = KadenceApi(config = { prefs.current })
@@ -92,6 +98,7 @@ class KadenceApplication : Application() {
scope = scope, scope = scope,
configured = { prefs.current.isConfigured && prefs.current.onboarded }, configured = { prefs.current.isConfigured && prefs.current.onboarded },
store = store, store = store,
reachable = reachability::online,
) )
// The focus guard, one per process like the timer — the accessibility service // 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 // 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 // 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. // 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 package tech.kadence.android.timer
import android.util.Log import android.util.Log
import java.io.IOException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay 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.net.ApiResult
import tech.kadence.android.data.store.Cached import tech.kadence.android.data.store.Cached
import tech.kadence.android.data.store.LocalStore 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.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.LocalBelief
import tech.kadence.core.PendingOps import tech.kadence.core.PendingOps
import tech.kadence.core.Reconciliation import tech.kadence.core.Reconciliation
@@ -55,6 +61,11 @@ import tech.kadence.core.ServerTimerState
import tech.kadence.core.Status import tech.kadence.core.Status
import tech.kadence.core.TickAnchor import tech.kadence.core.TickAnchor
import tech.kadence.core.TimerSessionFacts 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.progressOf
import tech.kadence.core.reconcile import tech.kadence.core.reconcile
import tech.kadence.core.remainingAt import tech.kadence.core.remainingAt
@@ -113,7 +124,12 @@ class TimerEngine(
private val configured: () -> Boolean, private val configured: () -> Boolean,
/** Where the server's last answer is kept, so a cold start has one. */ /** Where the server's last answer is kept, so a cold start has one. */
private val store: LocalStore? = null, 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()) private val _state = MutableStateFlow(TimerUi())
val state: StateFlow<TimerUi> = _state.asStateFlow() val state: StateFlow<TimerUi> = _state.asStateFlow()
@@ -262,7 +278,12 @@ class TimerEngine(
} }
when (val result = repo.timerState()) { 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 -> is ApiResult.Unauthorized ->
_state.value = _state.value.copy(connection = Connection.Unauthorized) _state.value = _state.value.copy(connection = Connection.Unauthorized)
is ApiResult.NetworkError -> { is ApiResult.NetworkError -> {
@@ -296,10 +317,17 @@ class TimerEngine(
// A command's reply *is* our own intent landing, so it is declared as pending // 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 // for the reconciler's benefit. Nothing is queued on disk yet: the offline
// outbox in `:core` is wired up by the service, not here. // 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) { val pending = if (fromCommand) {
PendingOps(hasAny = true, hasStart = true, hasTerminal = true) PendingOps(hasAny = true, hasStart = true, hasTerminal = true)
} else { } else {
PendingOps() outbox?.let { pendingOpsOf(it.entries.map { entry -> entry.toCore() }) } ?: PendingOps()
} }
val decision = reconcile(server, local, pending) val decision = reconcile(server, local, pending)
@@ -384,28 +412,43 @@ class TimerEngine(
// ---- commands ----------------------------------------------------------- // ---- commands -----------------------------------------------------------
fun startWork(minutes: Int? = null, taskId: Long? = null) = 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) = fun startKind(kind: String, minutes: Int? = null, taskId: Long? = null) = command(
command { repo.start(kind = kind, minutes = minutes) } 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() = fun interrupt() = command(
command(optimistic = { it.copy(interruptions = it.interruptions + 1) }) { optimistic = { it.copy(interruptions = it.interruptions + 1) },
repo.interrupt() queue = { box, ui -> box.add(OutboxOp.INTERRUPT, ui.sessionId) },
} ) { repo.interrupt() }
fun extend(minutes: Int = 5) = fun extend(minutes: Int = 5) = command(
command(optimistic = { it.extendedBy(minutes) }) { repo.extend(minutes) } 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. */ /** Returns the command's job, so a caller (or a test) can wait for the reply. */
fun toggle(): Job { fun toggle(): Job {
@@ -419,6 +462,14 @@ class TimerEngine(
/** `complete` is not in [command] because its reply carries the finish event. */ /** `complete` is not in [command] because its reply carries the finish event. */
fun complete(autoNext: Boolean = true) = scope.launch { 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 { mutex.withLock {
_state.value = _state.value.copy(busy = true) _state.value = _state.value.copy(busy = true)
when (val result = repo.complete(autoNext)) { 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) else -> handleFailure(result)
} }
_state.value = _state.value.copy(busy = false) _state.value = _state.value.copy(busy = false)
@@ -454,24 +510,62 @@ class TimerEngine(
*/ */
private fun command( private fun command(
optimistic: ((TimerUi) -> TimerUi)? = null, optimistic: ((TimerUi) -> TimerUi)? = null,
queue: ((OutboxStore, TimerUi) -> Unit)? = null,
call: suspend () -> ApiResult<TimerStateDto>, call: suspend () -> ApiResult<TimerStateDto>,
) = scope.launch { ) = scope.launch {
val before = _state.value
if (optimistic != null) { if (optimistic != null) {
// Outside the mutex and before the request: the point is that the dial and // 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. // 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) 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 { mutex.withLock {
_state.value = _state.value.copy(busy = true, notice = null) _state.value = _state.value.copy(busy = true, notice = null)
when (val result = call()) { when (val result = call()) {
is ApiResult.Ok -> applyServer(result.value, fromCommand = true) 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) else -> handleFailure(result)
} }
_state.value = _state.value.copy(busy = false) _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. */ /** Point the local tick at what [state] now says, after an optimistic change. */
private fun reanchor(ui: TimerUi) { private fun reanchor(ui: TimerUi) {
val id = ui.sessionId ?: return 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<*>) { private fun handleFailure(result: ApiResult<*>) {
val detail = when (result) { val detail = when (result) {
// A 400 here is the server refusing on the merits — "No interval is // 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 { } else {
this 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) { private fun ConnectionBanner(ui: TimerUi, onOpenSettings: () -> Unit) {
when (ui.connection) { when (ui.connection) {
Connection.Offline -> Banner( Connection.Offline -> Banner(
text = "No connection to the backend. The countdown keeps running from " + // It used to say only that the countdown keeps running, which left the
"where the server last left it.", // 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( Connection.Unauthorized -> Banner(
text = "The backend rejected our credentials.", 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")
}
}