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

@@ -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")
}
}