From c78bbb7ba58a112ea063b8be6f46451bfeaebddf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 5 Jun 2026 11:35:22 -0400 Subject: [PATCH 1/8] feat(android): pure ReachabilityMachine + 4-state ServerHealth enum Co-Authored-By: Claude Opus 4.8 (1M context) --- .../connectivity/ReachabilityMachine.kt | 88 +++++++++++++ .../minstrel/connectivity/ServerHealth.kt | 15 +++ .../connectivity/ReachabilityMachineTest.kt | 116 ++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityMachine.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealth.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/connectivity/ReachabilityMachineTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityMachine.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityMachine.kt new file mode 100644 index 00000000..e82ee376 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityMachine.kt @@ -0,0 +1,88 @@ +package com.fabledsword.minstrel.connectivity + +internal const val ESCALATE_AFTER_MS = 120_000L +internal const val CORROBORATION_WINDOW_MS = 30_000L +internal const val CORROBORATION_OP_THRESHOLD = 2 + +/** + * Pure reachability state machine. No Android, no coroutines, no real clock — + * every entry point takes `nowMs`, so it is fully deterministic and unit- + * testable. [NetworkStatusController] wires real time + signals around it. + * + * Reachability (independent of the device link): + * - Reachable last evidence says the server answered. + * - Unstable a probe failed; arbitration/escalation pending. + * - Unreachable corroborated or sustained failure. + * + * [health] folds the device link over that: no link → Offline; otherwise the + * reachability maps Reachable→Healthy, Unstable→Unstable, Unreachable→ServerDown. + * + * Principle: **success is self-proving, failure is ambiguous.** [onSuccess] + * (a real server byte-read or API 2xx, or a successful /healthz) snaps straight + * back to Reachable. A failure only escalates when a /healthz probe corroborates + * it ([onProbeFailure]) — either via fresh op-failure corroboration or the + * sustained-time backstop. + */ +class ReachabilityMachine { + + private enum class Reachability { Reachable, Unstable, Unreachable } + + private var linkUp = true + private var reachability = Reachability.Reachable + private var failureStreakStartMs: Long? = null + private val recentOpFailures = ArrayDeque() + + fun onLinkChange(up: Boolean, nowMs: Long) { + linkUp = up + // Link transitions don't reset reachability — a restored link keeps the + // last-known server reachability until a fresh probe/op result arrives. + if (!up) recentOpFailures.clear() + } + + /** A real successful server op (stream read, API 2xx) or a successful /healthz. */ + fun onSuccess(nowMs: Long) { + reachability = Reachability.Reachable + failureStreakStartMs = null + recentOpFailures.clear() + } + + /** A real network op failed. Ambiguous on its own — records corroboration. */ + fun onOpFailure(nowMs: Long) { + pruneOpFailures(nowMs) + recentOpFailures.addLast(nowMs) + } + + /** A /healthz probe failed — the arbiter. Escalates per corroboration/backstop. */ + fun onProbeFailure(nowMs: Long) { + pruneOpFailures(nowMs) + if (reachability == Reachability.Reachable) { + reachability = Reachability.Unstable + failureStreakStartMs = nowMs + } + if (reachability == Reachability.Unstable && shouldEscalate(nowMs)) { + reachability = Reachability.Unreachable + } + } + + fun health(): ServerHealth = when { + !linkUp -> ServerHealth.Offline + reachability == Reachability.Reachable -> ServerHealth.Healthy + reachability == Reachability.Unstable -> ServerHealth.Unstable + else -> ServerHealth.ServerDown + } + + private fun shouldEscalate(nowMs: Long): Boolean { + val corroborated = recentOpFailures.size >= CORROBORATION_OP_THRESHOLD + val sustained = + failureStreakStartMs?.let { nowMs - it >= ESCALATE_AFTER_MS } ?: false + return corroborated || sustained + } + + private fun pruneOpFailures(nowMs: Long) { + while (recentOpFailures.isNotEmpty() && + nowMs - recentOpFailures.first() > CORROBORATION_WINDOW_MS + ) { + recentOpFailures.removeFirst() + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealth.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealth.kt new file mode 100644 index 00000000..23e750b0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealth.kt @@ -0,0 +1,15 @@ +package com.fabledsword.minstrel.connectivity + +/** + * The single reachability signal every consumer branches on. + * + * - [Healthy] link up, /healthz ok — normal network behavior. + * - [Unstable] link up, a recent failure with arbitration pending — + * INFORMATIONAL ONLY. Does NOT gate playback; preserves the + * anti-flicker intent of commit 5c0db429 while still warning + * the user that something is flaky. + * - [ServerDown] link up but /healthz failing, corroborated or sustained — + * gate to cache-only. + * - [Offline] no device link at all — gate to cache-only. + */ +enum class ServerHealth { Healthy, Unstable, ServerDown, Offline } diff --git a/android/app/src/test/java/com/fabledsword/minstrel/connectivity/ReachabilityMachineTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/connectivity/ReachabilityMachineTest.kt new file mode 100644 index 00000000..abc104d4 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/connectivity/ReachabilityMachineTest.kt @@ -0,0 +1,116 @@ +package com.fabledsword.minstrel.connectivity + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ReachabilityMachineTest { + + private fun machine() = ReachabilityMachine() + + @Test + fun `starts healthy`() { + val m = machine() + m.onLinkChange(up = true, nowMs = 0) + assertEquals(ServerHealth.Healthy, m.health()) + } + + @Test + fun `no link is offline regardless of probes`() { + val m = machine() + m.onLinkChange(up = false, nowMs = 0) + m.onProbeFailure(nowMs = 1) + assertEquals(ServerHealth.Offline, m.health()) + } + + @Test + fun `single probe failure is unstable not down`() { + val m = machine() + m.onLinkChange(up = true, nowMs = 0) + m.onProbeFailure(nowMs = 1_000) + assertEquals(ServerHealth.Unstable, m.health()) + } + + @Test + fun `probe success from unstable recovers to healthy`() { + val m = machine() + m.onLinkChange(up = true, nowMs = 0) + m.onProbeFailure(nowMs = 1_000) + m.onSuccess(nowMs = 2_000) + assertEquals(ServerHealth.Healthy, m.health()) + } + + @Test + fun `op success from unstable recovers to healthy`() { + val m = machine() + m.onLinkChange(up = true, nowMs = 0) + m.onProbeFailure(nowMs = 1_000) + m.onSuccess(nowMs = 1_500) // a successful stream read / API 2xx is self-proving + assertEquals(ServerHealth.Healthy, m.health()) + } + + @Test + fun `two op failures plus a failed probe escalate immediately`() { + val m = machine() + m.onLinkChange(up = true, nowMs = 0) + m.onOpFailure(nowMs = 1_000) + m.onOpFailure(nowMs = 1_500) // corroboration reached + m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown + assertEquals(ServerHealth.ServerDown, m.health()) + } + + @Test + fun `op failures with a successful probe stay healthy (track-specific)`() { + val m = machine() + m.onLinkChange(up = true, nowMs = 0) + m.onOpFailure(nowMs = 1_000) + m.onOpFailure(nowMs = 1_500) + m.onSuccess(nowMs = 2_000) // arbiter says server is fine + assertEquals(ServerHealth.Healthy, m.health()) + } + + @Test + fun `stale op failures do not corroborate`() { + val m = machine() + m.onLinkChange(up = true, nowMs = 0) + m.onOpFailure(nowMs = 0) + m.onOpFailure(nowMs = 1_000) + // both op failures are now older than the corroboration window: + m.onProbeFailure(nowMs = 1_000 + CORROBORATION_WINDOW_MS + 1) + assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration + } + + @Test + fun `sustained failure backstop escalates after the window`() { + val m = machine() + m.onLinkChange(up = true, nowMs = 0) + m.onProbeFailure(nowMs = 1_000) // unstable, streak starts + m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // sustained ≥ backstop + assertEquals(ServerHealth.ServerDown, m.health()) + } + + @Test + fun `link restored keeps last-known down until a fresh result`() { + val m = machine() + m.onLinkChange(up = true, nowMs = 0) + m.onProbeFailure(nowMs = 1_000) + m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // ServerDown + m.onLinkChange(up = false, nowMs = 200_000) + assertEquals(ServerHealth.Offline, m.health()) + m.onLinkChange(up = true, nowMs = 201_000) + // link back but no fresh probe result yet — last known reachability was down: + assertEquals(ServerHealth.ServerDown, m.health()) + m.onSuccess(nowMs = 202_000) + assertEquals(ServerHealth.Healthy, m.health()) + } + + @Test + fun `unstable does not gate — it is not offline or serverdown`() { + val m = machine() + m.onLinkChange(up = true, nowMs = 0) + m.onProbeFailure(nowMs = 1_000) + val health = m.health() + assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown) + assertEquals(ServerHealth.Unstable, health) + } +} From b467cb7532243fd028e9d81e5d9677e2bbb39ec3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 5 Jun 2026 12:06:22 -0400 Subject: [PATCH 2/8] refactor(android): unify offline detection into NetworkStatusController Absorbs VersionCheckController (/healthz poll + version parse) and ServerHealthController (tri-state derive) into one signal-driven authority. Adds the non-gating Unstable state across all ServerHealth branch sites (OfflineGatedDataSource, SearchRepository, TrackRow, banner). Repoints MinstrelApplication, MainActivity, PlayerFactory, VersionTooOldViewModel. Drops the now-unused nowMs params the detekt UnusedParameter rule flagged. Co-Authored-By: Claude Opus 4.8 (1M context) --- android/.idea/caches/deviceStreaming.xml | 13 + android/.idea/misc.xml | 1 - .../6.json | 690 ++++++++++++++++++ .../com/fabledsword/minstrel/MainActivity.kt | 6 +- .../minstrel/MinstrelApplication.kt | 23 +- .../connectivity/ConnectivityObserver.kt | 2 +- .../connectivity/NetworkStatusController.kt | 173 +++++ .../connectivity/ReachabilityMachine.kt | 4 +- .../connectivity/ServerHealthController.kt | 75 -- .../connectivity/ui/ConnectionErrorBanner.kt | 7 +- .../minstrel/player/OfflineGatedDataSource.kt | 11 +- .../minstrel/player/PlayerFactory.kt | 2 +- .../minstrel/search/data/SearchRepository.kt | 11 +- .../minstrel/shared/widgets/TrackRow.kt | 7 +- .../minstrel/update/api/HealthzApi.kt | 4 +- .../update/data/VersionCheckController.kt | 112 --- .../minstrel/update/data/VersionResult.kt | 8 + .../update/ui/VersionTooOldViewModel.kt | 8 +- .../connectivity/ReachabilityMachineTest.kt | 34 +- 19 files changed, 942 insertions(+), 249 deletions(-) create mode 100644 android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/6.json create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/connectivity/NetworkStatusController.kt delete mode 100644 android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt delete mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionResult.kt diff --git a/android/.idea/caches/deviceStreaming.xml b/android/.idea/caches/deviceStreaming.xml index d469d46c..5dc2fb2b 100644 --- a/android/.idea/caches/deviceStreaming.xml +++ b/android/.idea/caches/deviceStreaming.xml @@ -1754,6 +1754,19 @@