refactor(android): unify offline detection into NetworkStatusController
android / Build + lint + test (push) Successful in 3m25s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 12:06:22 -04:00
parent c78bbb7ba5
commit b467cb7532
19 changed files with 942 additions and 249 deletions
+13
View File
@@ -1754,6 +1754,19 @@
<option name="screenX" value="1600" /> <option name="screenX" value="1600" />
<option name="screenY" value="2560" /> <option name="screenY" value="2560" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="36" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="formFactor" value="Tablet" />
<option name="id" value="tangorpro" />
<option name="labId" value="google" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="35" /> <option name="api" value="35" />
<option name="brand" value="google" /> <option name="brand" value="google" />
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
@@ -0,0 +1,690 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "fb73ed8674efb1d82a586551baba5ef0",
"entities": [
{
"tableName": "sync_metadata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cursor",
"columnName": "cursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncAt",
"columnName": "lastSyncAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortName",
"columnName": "sortName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "artistThumbPath",
"columnName": "artistThumbPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistFanartPath",
"columnName": "artistFanartPath",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortTitle",
"columnName": "sortTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "releaseDate",
"columnName": "releaseDate",
"affinity": "TEXT"
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "mbid",
"columnName": "mbid",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMs",
"columnName": "durationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trackNumber",
"columnName": "trackNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER"
},
{
"fieldPath": "filePath",
"columnName": "filePath",
"affinity": "TEXT"
},
{
"fieldPath": "fileFormat",
"columnName": "fileFormat",
"affinity": "TEXT"
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_likes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "likedAt",
"columnName": "likedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"entityType",
"entityId"
]
}
},
{
"tableName": "cached_playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isPublic",
"columnName": "isPublic",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverPath",
"columnName": "coverPath",
"affinity": "TEXT"
},
{
"fieldPath": "trackCount",
"columnName": "trackCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "durationSec",
"columnName": "durationSec",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "systemVariant",
"columnName": "systemVariant",
"affinity": "TEXT"
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_playlist_tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))",
"fields": [
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlistId",
"trackId"
]
}
},
{
"tableName": "cached_quarantine_mine",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "reason",
"columnName": "reason",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackTitle",
"columnName": "trackTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "trackDurationMs",
"columnName": "trackDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumTitle",
"columnName": "albumTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumCoverArtPath",
"columnName": "albumCoverArtPath",
"affinity": "TEXT"
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "artistName",
"columnName": "artistName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "audio_cache_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))",
"fields": [
{
"fieldPath": "trackId",
"columnName": "trackId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cachedAt",
"columnName": "cachedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastPlayedAt",
"columnName": "lastPlayedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"trackId"
]
}
},
{
"tableName": "cached_mutations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payload",
"columnName": "payload",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastAttemptAt",
"columnName": "lastAttemptAt",
"affinity": "INTEGER"
},
{
"fieldPath": "attempts",
"columnName": "attempts",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_resume_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "json",
"columnName": "json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "cached_home_index",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))",
"fields": [
{
"fieldPath": "section",
"columnName": "section",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityType",
"columnName": "entityType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fetchedAt",
"columnName": "fetchedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"section",
"position"
]
}
},
{
"tableName": "cached_history_snapshot",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "json",
"columnName": "json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "auth_session",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, `clientId` TEXT, `cacheSettingsJson` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sessionCookie",
"columnName": "sessionCookie",
"affinity": "TEXT"
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userJson",
"columnName": "userJson",
"affinity": "TEXT"
},
{
"fieldPath": "themeMode",
"columnName": "themeMode",
"affinity": "TEXT"
},
{
"fieldPath": "clientId",
"columnName": "clientId",
"affinity": "TEXT"
},
{
"fieldPath": "cacheSettingsJson",
"columnName": "cacheSettingsJson",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fb73ed8674efb1d82a586551baba5ef0')"
]
}
}
@@ -23,7 +23,7 @@ import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth import com.fabledsword.minstrel.connectivity.LocalServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealthController import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.nav.DetailSeedCache import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph import com.fabledsword.minstrel.nav.MinstrelNavGraph
@@ -41,7 +41,7 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache @Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds @Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: ServerHealthController @Inject lateinit var serverHealth: NetworkStatusController
// Flipped to true when the user taps the media notification (or // Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App // any other entry point that asks for the full player). The App
@@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() {
private fun App( private fun App(
seedCache: DetailSeedCache, seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds, cachedTrackIds: CachedTrackIds,
serverHealth: ServerHealthController, serverHealth: NetworkStatusController,
pendingOpenNowPlaying: StateFlow<Boolean>, pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit, onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(), themeVm: ThemePreferenceViewModel = hiltViewModel(),
@@ -19,8 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.connectivity.ServerHealthController import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.update.data.VersionCheckController
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -115,21 +114,13 @@ class MinstrelApplication :
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher @Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
/** /**
* Same construct-the-singleton trick — VersionCheckController's * Same construct-the-singleton trick — NetworkStatusController owns the
* init block starts a 5-min poll loop against /healthz so the * /healthz poll loop + the device-link collector + the reachability state
* shell-level VersionTooOldBanner can surface min_client_version * machine, and is the single authority on the tri-state ServerHealth
* mismatches without waiting for the next user-driven request. * signal (plus the VersionTooOld byproduct). It must exist from launch so
* the poll loop runs and the StateFlow stays warm for every consumer.
*/ */
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController @Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
/**
* Same construct-the-singleton trick — ServerHealthController combines
* ConnectivityObserver + VersionCheckController.reachable into the
* tri-state ServerHealth signal. Its stateIn is `SharingStarted.Eagerly`
* so the StateFlow needs an active subscriber from launch onward; the
* @Inject keeps the singleton alive and the flow collecting.
*/
@Suppress("unused") @Inject lateinit var serverHealthController: ServerHealthController
/** /**
* Same construct-the-singleton trick — UpdateBannerController polls * Same construct-the-singleton trick — UpdateBannerController polls
@@ -29,7 +29,7 @@ import javax.inject.Singleton
* the app to Offline with no debounce and fast-failed in-flight playback * the app to Offline with no debounce and fast-failed in-flight playback
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The * via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
* authority on whether *Minstrel* is reachable is the `/healthz` poll * authority on whether *Minstrel* is reachable is the `/healthz` poll
* ([com.fabledsword.minstrel.update.data.VersionCheckController], which * ([com.fabledsword.minstrel.connectivity.NetworkStatusController], which
* has its own failure hysteresis), not this coarse device-link signal. * has its own failure hysteresis), not this coarse device-link signal.
* *
* Used by the shell-level ConnectionErrorBanner; downstream * Used by the shell-level ConnectionErrorBanner; downstream
@@ -0,0 +1,173 @@
package com.fabledsword.minstrel.connectivity
import androidx.compose.runtime.staticCompositionLocalOf
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.api.HealthzApi
import com.fabledsword.minstrel.update.api.HealthzResponse
import com.fabledsword.minstrel.update.data.VersionResult
import com.fabledsword.minstrel.update.data.isVersionNewer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import timber.log.Timber
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_HEALTHY_MS = 5 * 60 * 1000L
private const val POLL_DEGRADED_MS = 20_000L
private const val ARBITRATE_MIN_GAP_MS = 2_000L
/**
* THE single authority on network/server reachability. Absorbs the former
* VersionCheckController (the /healthz poll + version parsing) and
* ServerHealthController (the tri-state derive) into one signal-driven unit so
* the app has one place that answers "can we reach Minstrel?" — not three
* half-systems reporting differently.
*
* Inputs (all funnel through a single-consumer intent channel for thread
* safety — [reportSuccess]/[reportFailure] are called from the audio read path
* and OkHttp threads):
* - device link transitions ([ConnectivityObserver]); link-return triggers an
* immediate probe so recovery is near-instant (fixes the sticky banner).
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
* - reportSuccess / reportFailure from the API interceptor, the audio data
* source, and the playback-error reporter.
* - recheck() from pull-to-refresh and the banner.
*
* Version compatibility is a byproduct of the same /healthz response.
*
* Constructed at launch via the construct-the-singleton trick in
* [com.fabledsword.minstrel.MinstrelApplication].
*/
@Singleton
class NetworkStatusController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
connectivity: ConnectivityObserver,
private val authStore: AuthStore,
retrofit: Retrofit,
) {
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
private val machine = ReachabilityMachine()
private val lastProbeAtMs = AtomicLong(0)
private val stateInternal = MutableStateFlow(ServerHealth.Healthy)
val state: StateFlow<ServerHealth> = stateInternal.asStateFlow()
private val versionInternal = MutableStateFlow(VersionResult.SKIPPED)
val versionResult: StateFlow<VersionResult> = versionInternal.asStateFlow()
private sealed interface Intent {
data class Link(val up: Boolean) : Intent
data class Probe(val resp: HealthzResponse?) : Intent
object OpSuccess : Intent
object OpFailure : Intent
}
private val intents = Channel<Intent>(Channel.UNLIMITED)
init {
scope.launch { reduceLoop() }
scope.launch {
connectivity.online.collect { up ->
intents.trySend(Intent.Link(up))
if (up) probeOnce()
}
}
scope.launch { pollLoop() }
}
/** A real server op verifiably succeeded — self-proving recovery. Cheap; safe on hot paths. */
fun reportSuccess() {
if (stateInternal.value != ServerHealth.Healthy) intents.trySend(Intent.OpSuccess)
}
/** A real network op failed — triggers /healthz arbitration. No-op when already Offline. */
fun reportFailure() {
if (stateInternal.value == ServerHealth.Offline) return
intents.trySend(Intent.OpFailure)
}
/** One-shot recheck for pull-to-refresh and the banner. */
fun recheck() {
scope.launch { probeOnce(force = true) }
}
private suspend fun reduceLoop() {
for (intent in intents) {
val now = System.currentTimeMillis()
when (intent) {
is Intent.Link -> machine.onLinkChange(intent.up)
is Intent.Probe -> applyProbe(intent.resp, now)
Intent.OpSuccess -> machine.onSuccess()
Intent.OpFailure -> {
machine.onOpFailure(now)
probeOnce() // arbitrate: let /healthz decide if this is real
}
}
emit(machine.health())
}
}
private fun applyProbe(resp: HealthzResponse?, now: Long) {
if (resp == null) {
machine.onProbeFailure(now)
} else {
machine.onSuccess()
versionInternal.value = versionResultFor(resp)
}
}
private fun emit(next: ServerHealth) {
if (stateInternal.value != next) {
Timber.w("NetworkStatus -> %s", next)
stateInternal.value = next
}
}
private suspend fun pollLoop() {
probeOnce()
while (true) {
val interval =
if (stateInternal.value == ServerHealth.Healthy) POLL_HEALTHY_MS
else POLL_DEGRADED_MS
delay(interval)
probeOnce()
}
}
private suspend fun probeOnce(force: Boolean = false) {
// Startup guard: don't poll the localhost placeholder before AuthStore
// hydrates the real base URL — that false failure used to flash the banner.
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
val now = System.currentTimeMillis()
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
lastProbeAtMs.set(now)
val resp = runCatching { api.check() }.getOrNull()
intents.trySend(Intent.Probe(resp))
}
private fun versionResultFor(resp: HealthzResponse): VersionResult {
val min = resp.minClientVersion
return when {
min.isEmpty() -> VersionResult.SKIPPED
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
else -> VersionResult.OK
}
}
}
/**
* Reactive [ServerHealth] snapshot provided once at the app root. Lets leaf
* composables (TrackRow gating, write-affordance disabling) branch on health
* without re-injecting the controller. Defaults to Healthy so previews/tests
* don't crash.
*/
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
@@ -32,7 +32,7 @@ class ReachabilityMachine {
private var failureStreakStartMs: Long? = null private var failureStreakStartMs: Long? = null
private val recentOpFailures = ArrayDeque<Long>() private val recentOpFailures = ArrayDeque<Long>()
fun onLinkChange(up: Boolean, nowMs: Long) { fun onLinkChange(up: Boolean) {
linkUp = up linkUp = up
// Link transitions don't reset reachability — a restored link keeps the // Link transitions don't reset reachability — a restored link keeps the
// last-known server reachability until a fresh probe/op result arrives. // last-known server reachability until a fresh probe/op result arrives.
@@ -40,7 +40,7 @@ class ReachabilityMachine {
} }
/** A real successful server op (stream read, API 2xx) or a successful /healthz. */ /** A real successful server op (stream read, API 2xx) or a successful /healthz. */
fun onSuccess(nowMs: Long) { fun onSuccess() {
reachability = Reachability.Reachable reachability = Reachability.Reachable
failureStreakStartMs = null failureStreakStartMs = null
recentOpFailures.clear() recentOpFailures.clear()
@@ -1,75 +0,0 @@
package com.fabledsword.minstrel.connectivity
import androidx.compose.runtime.staticCompositionLocalOf
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.data.VersionCheckController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Tri-state server-reachability signal that downstream consumers can branch
* on to decide whether to hit the network, gate writes, or fall back to
* cache-only behavior.
*
* Composed from two existing signals -- this controller doesn't poll its own
* endpoint:
*
* - [ConnectivityObserver.online] -- system-level NetworkCallback that
* answers only "is there an active INTERNET-capable network link" (NOT
* VALIDATED -- a WAN-validation flicker must not read as offline when
* the LAN server is reachable).
* - [VersionCheckController.reachable] -- did the last `/healthz` poll
* succeed. This is the authority on whether *Minstrel* is reachable;
* it has its own failure hysteresis. Distinguishes "device has a link
* but our server is down" from "no network at all."
*
* `version too old` is intentionally *not* folded in here -- it's a separate
* UX (the VersionTooOldBanner) and conflating it with offline would mask the
* real cause.
*/
enum class ServerHealth { Healthy, Offline, ServerDown }
@Singleton
class ServerHealthController @Inject constructor(
@ApplicationScope scope: CoroutineScope,
connectivity: ConnectivityObserver,
versionCheck: VersionCheckController,
) {
val state: StateFlow<ServerHealth> = combine(
connectivity.online,
versionCheck.reachable,
) { online, serverReachable ->
when {
!online -> ServerHealth.Offline
!serverReachable -> ServerHealth.ServerDown
else -> ServerHealth.Healthy
}
}
// Transition log -- the signal had no instrumentation, which is why a
// false-offline (WAN flicker flipping playback to "Source error") was
// hard to diagnose from logcat. WARN-tier so ReleaseTree surfaces it.
.distinctUntilChanged()
.onEach { Timber.w("ServerHealth -> %s", it) }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = ServerHealth.Healthy,
)
}
/**
* Reactive [ServerHealth] snapshot provided once at the app root from the
* controller's StateFlow. Lets leaf composables (TrackRow gating, write-
* affordance disabling) branch on health without each ViewModel re-
* injecting the controller. Defaults to Healthy so unwrapped previews
* and tests don't crash.
*/
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
@@ -25,8 +25,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.composables.icons.lucide.CloudOff import com.composables.icons.lucide.CloudOff
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealthController
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -36,12 +36,12 @@ import javax.inject.Inject
private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L
/** /**
* Lifts [ServerHealthController]'s tri-state into a StateFlow for the banner * Lifts [NetworkStatusController]'s tri-state into a StateFlow for the banner
* composable. Keeps the banner pure-presentation. * composable. Keeps the banner pure-presentation.
*/ */
@HiltViewModel @HiltViewModel
class ConnectivityBannerViewModel @Inject constructor( class ConnectivityBannerViewModel @Inject constructor(
health: ServerHealthController, health: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle, @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val health: StateFlow<ServerHealth> = health.state.stateIn( val health: StateFlow<ServerHealth> = health.state.stateIn(
@@ -86,6 +86,7 @@ fun ConnectionErrorBanner(
"No connection — check Wi-Fi or mobile data." "No connection — check Wi-Fi or mobile data."
ServerHealth.ServerDown -> ServerHealth.ServerDown ->
"Server unreachable — your cached content is still available." "Server unreachable — your cached content is still available."
ServerHealth.Unstable -> "Reconnecting…"
ServerHealth.Healthy -> "" ServerHealth.Healthy -> ""
}, },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.player
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener import androidx.media3.datasource.TransferListener
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealthController
import java.io.IOException import java.io.IOException
import java.io.InterruptedIOException import java.io.InterruptedIOException
/** /**
* DataSource wrapper that fails the network read immediately when * DataSource wrapper that fails the network read immediately when
* [ServerHealthController] reports a non-Healthy state. CacheDataSource only * [NetworkStatusController] reports a gating state. CacheDataSource only
* calls this upstream factory on cache misses, so playback of cached audio is * calls this upstream factory on cache misses, so playback of cached audio is
* unaffected -- only "tap a non-cached track while offline" hits this branch * unaffected -- only "tap a non-cached track while offline" hits this branch
* and gets a fast, meaningful error instead of a multi-second network timeout * and gets a fast, meaningful error instead of a multi-second network timeout
@@ -22,7 +22,7 @@ import java.io.InterruptedIOException
*/ */
class OfflineGatedDataSource( class OfflineGatedDataSource(
private val delegate: DataSource, private val delegate: DataSource,
private val health: ServerHealthController, private val health: NetworkStatusController,
) : DataSource { ) : DataSource {
override fun open(dataSpec: DataSpec): Long { override fun open(dataSpec: DataSpec): Long {
@@ -33,7 +33,8 @@ class OfflineGatedDataSource(
ServerHealth.ServerDown -> throw OfflineException( ServerHealth.ServerDown -> throw OfflineException(
"Track not in the on-device cache and the Minstrel server is unreachable.", "Track not in the on-device cache and the Minstrel server is unreachable.",
) )
ServerHealth.Healthy -> Unit // Unstable is non-gating: still try the network. Healthy too.
ServerHealth.Unstable, ServerHealth.Healthy -> Unit
} }
return delegate.open(dataSpec) return delegate.open(dataSpec)
} }
@@ -49,7 +50,7 @@ class OfflineGatedDataSource(
class OfflineGatedDataSourceFactory( class OfflineGatedDataSourceFactory(
private val upstream: DataSource.Factory, private val upstream: DataSource.Factory,
private val health: ServerHealthController, private val health: NetworkStatusController,
) : DataSource.Factory { ) : DataSource.Factory {
override fun createDataSource(): DataSource = override fun createDataSource(): DataSource =
OfflineGatedDataSource(upstream.createDataSource(), health) OfflineGatedDataSource(upstream.createDataSource(), health)
@@ -55,7 +55,7 @@ class PlayerFactory @Inject constructor(
private val cacheConfig: CacheConfig, private val cacheConfig: CacheConfig,
private val activeUpnpHolder: ActiveUpnpHolder, private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val serverHealth: com.fabledsword.minstrel.connectivity.ServerHealthController, private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
) { ) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -4,8 +4,8 @@ import com.fabledsword.minstrel.api.endpoints.SearchApi
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealthController
import com.fabledsword.minstrel.library.data.toDomain import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.SearchResponseRef import com.fabledsword.minstrel.models.SearchResponseRef
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -16,8 +16,8 @@ import javax.inject.Singleton
private const val LOCAL_SEARCH_LIMIT = 20 private const val LOCAL_SEARCH_LIMIT = 20
/** /**
* Cache-first when offline. When [ServerHealthController] is Healthy the * Cache-first when offline. When [NetworkStatusController] reports Healthy or
* repository hits `/api/search` and returns the server's three-facet * Unstable the repository hits `/api/search` and returns the server's three-facet
* paged response. When health is Offline or ServerDown it falls back to * paged response. When health is Offline or ServerDown it falls back to
* Room LIKE queries against `cached_*` so the user can still find * Room LIKE queries against `cached_*` so the user can still find
* something to play from what's already on the device; the outcome's * something to play from what's already on the device; the outcome's
@@ -27,7 +27,7 @@ private const val LOCAL_SEARCH_LIMIT = 20
@Singleton @Singleton
class SearchRepository @Inject constructor( class SearchRepository @Inject constructor(
retrofit: Retrofit, retrofit: Retrofit,
private val serverHealth: ServerHealthController, private val serverHealth: NetworkStatusController,
private val trackDao: CachedTrackDao, private val trackDao: CachedTrackDao,
private val albumDao: CachedAlbumDao, private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao, private val artistDao: CachedArtistDao,
@@ -35,7 +35,8 @@ class SearchRepository @Inject constructor(
private val api: SearchApi = retrofit.create() private val api: SearchApi = retrofit.create()
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) { suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
ServerHealth.Healthy -> SearchOutcome(remoteSearch(query), localOnly = false) ServerHealth.Healthy, ServerHealth.Unstable ->
SearchOutcome(remoteSearch(query), localOnly = false)
ServerHealth.Offline, ServerHealth.ServerDown -> ServerHealth.Offline, ServerHealth.ServerDown ->
SearchOutcome(localSearch(query), localOnly = true) SearchOutcome(localSearch(query), localOnly = true)
} }
@@ -61,8 +61,11 @@ fun TrackRow(
trailing: @Composable RowScope.() -> Unit = {}, trailing: @Composable RowScope.() -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy && val health = LocalServerHealth.current
trackId !in LocalCachedTrackIds.current // Unstable is non-gating — only a real gating state dims uncached rows.
val offlineUnavailable =
(health == ServerHealth.Offline || health == ServerHealth.ServerDown) &&
trackId !in LocalCachedTrackIds.current
val titleColor = if (nowPlaying) { val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
@@ -7,8 +7,8 @@ import retrofit2.http.GET
/** /**
* Retrofit interface for `GET /healthz` — unauthenticated health probe * Retrofit interface for `GET /healthz` — unauthenticated health probe
* returning the server's running version + the minimum client version * returning the server's running version + the minimum client version
* it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController] * it'll talk to. Polled by [com.fabledsword.minstrel.connectivity.NetworkStatusController]
* to surface the VersionTooOld banner. * for both reachability and the VersionTooOld banner.
*/ */
interface HealthzApi { interface HealthzApi {
@GET("healthz") @GET("healthz")
@@ -1,112 +0,0 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.api.HealthzApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
// Hysteresis on the reachable signal. Flipping internalReachable to false on
// the very first /healthz failure produced two false positives:
// 1. App startup — AuthStore.baseUrl loads from Room asynchronously, so the
// first runOnce() can fire against AuthStore.DEFAULT_BASE_URL
// ("http://localhost:8080") before the real server URL has hydrated.
// 2. Deployments whose reverse proxy routes only /api/* to the Go server —
// /healthz never reaches the handler, so the user sees a permanent
// "Server unreachable" banner even though all real /api/* calls succeed.
// Requiring 3 consecutive failures (~15 min at the 5-min poll cadence) ensures
// the banner only fires on sustained, real unreachability — and a single
// success at any point resets the counter so transient hiccups self-clear.
private const val REACHABILITY_FAILURE_THRESHOLD = 3
/**
* Result of the most recent /healthz version-compatibility check.
* `Skipped` means the server didn't include `min_client_version`
* (partial deploy or older server) and the UI should not gate.
*/
enum class VersionResult { OK, TOO_OLD, SKIPPED }
/**
* Polls /healthz periodically and exposes the current
* [VersionResult] for the shell's VersionTooOld banner. Soft-fails
* on network errors — keeps the last-known result rather than
* showing a misleading "too old" on a connectivity blip.
*
* Constructed at app launch via the construct-the-singleton trick
* in [com.fabledsword.minstrel.MinstrelApplication].
*/
@Singleton
class VersionCheckController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) {
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
private val internal = MutableStateFlow(VersionResult.SKIPPED)
val result: StateFlow<VersionResult> = internal.asStateFlow()
// Whether the most recent /healthz poll reached the server. Separate from
// VersionResult because "unreachable" and "version mismatch" drive
// different UX (offline banner vs version-too-old banner). Optimistic
// initial value -- the first poll fires within seconds of app launch and
// we don't want a "server down" flash before we've actually tried.
// Consumed by ServerHealthController to compose with ConnectivityObserver
// for the tri-state offline / server-down / healthy signal.
private val internalReachable = MutableStateFlow(true)
val reachable: StateFlow<Boolean> = internalReachable.asStateFlow()
private var consecutiveFailures = 0
init {
scope.launch {
while (true) {
runOnce()
delay(POLL_INTERVAL_MS)
}
}
}
/** One-shot recheck, bypassing the poll cadence. Used by the banner's "Check now" button. */
fun recheck() {
scope.launch { runOnce() }
}
private suspend fun runOnce() {
val outcome = runCatching { api.check() }
val response = outcome.getOrNull()
if (response == null) {
consecutiveFailures++
if (consecutiveFailures >= REACHABILITY_FAILURE_THRESHOLD &&
internalReachable.value
) {
Timber.w(
"/healthz unreachable for %d consecutive polls — flipping reachable=false",
consecutiveFailures,
)
internalReachable.value = false
}
return
}
if (!internalReachable.value) {
Timber.i("/healthz recovered after %d failures", consecutiveFailures)
}
consecutiveFailures = 0
internalReachable.value = true
val min = response.minClientVersion
internal.value = when {
min.isEmpty() -> VersionResult.SKIPPED
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
else -> VersionResult.OK
}
}
}
@@ -0,0 +1,8 @@
package com.fabledsword.minstrel.update.data
/**
* Result of the most recent /healthz version-compatibility check.
* `SKIPPED` means the server didn't include `min_client_version`
* (partial deploy or older server) and the UI should not gate.
*/
enum class VersionResult { OK, TOO_OLD, SKIPPED }
@@ -2,21 +2,21 @@ package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.fabledsword.minstrel.update.data.VersionCheckController import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.update.data.VersionResult import com.fabledsword.minstrel.update.data.VersionResult
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject import javax.inject.Inject
/** /**
* Tiny VM that exposes the [VersionCheckController]'s current * Tiny VM that exposes the [NetworkStatusController]'s current
* [VersionResult] plus its recheck trigger for the banner button. * [VersionResult] plus its recheck trigger for the banner button.
*/ */
@HiltViewModel @HiltViewModel
class VersionTooOldViewModel @Inject constructor( class VersionTooOldViewModel @Inject constructor(
private val controller: VersionCheckController, private val controller: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle, @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val result: StateFlow<VersionResult> = controller.result val result: StateFlow<VersionResult> = controller.versionResult
fun recheck() = controller.recheck() fun recheck() = controller.recheck()
} }
@@ -11,14 +11,14 @@ class ReachabilityMachineTest {
@Test @Test
fun `starts healthy`() { fun `starts healthy`() {
val m = machine() val m = machine()
m.onLinkChange(up = true, nowMs = 0) m.onLinkChange(up = true)
assertEquals(ServerHealth.Healthy, m.health()) assertEquals(ServerHealth.Healthy, m.health())
} }
@Test @Test
fun `no link is offline regardless of probes`() { fun `no link is offline regardless of probes`() {
val m = machine() val m = machine()
m.onLinkChange(up = false, nowMs = 0) m.onLinkChange(up = false)
m.onProbeFailure(nowMs = 1) m.onProbeFailure(nowMs = 1)
assertEquals(ServerHealth.Offline, m.health()) assertEquals(ServerHealth.Offline, m.health())
} }
@@ -26,7 +26,7 @@ class ReachabilityMachineTest {
@Test @Test
fun `single probe failure is unstable not down`() { fun `single probe failure is unstable not down`() {
val m = machine() val m = machine()
m.onLinkChange(up = true, nowMs = 0) m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000) m.onProbeFailure(nowMs = 1_000)
assertEquals(ServerHealth.Unstable, m.health()) assertEquals(ServerHealth.Unstable, m.health())
} }
@@ -34,25 +34,25 @@ class ReachabilityMachineTest {
@Test @Test
fun `probe success from unstable recovers to healthy`() { fun `probe success from unstable recovers to healthy`() {
val m = machine() val m = machine()
m.onLinkChange(up = true, nowMs = 0) m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000) m.onProbeFailure(nowMs = 1_000)
m.onSuccess(nowMs = 2_000) m.onSuccess()
assertEquals(ServerHealth.Healthy, m.health()) assertEquals(ServerHealth.Healthy, m.health())
} }
@Test @Test
fun `op success from unstable recovers to healthy`() { fun `op success from unstable recovers to healthy`() {
val m = machine() val m = machine()
m.onLinkChange(up = true, nowMs = 0) m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000) m.onProbeFailure(nowMs = 1_000)
m.onSuccess(nowMs = 1_500) // a successful stream read / API 2xx is self-proving m.onSuccess() // a successful stream read / API 2xx is self-proving
assertEquals(ServerHealth.Healthy, m.health()) assertEquals(ServerHealth.Healthy, m.health())
} }
@Test @Test
fun `two op failures plus a failed probe escalate immediately`() { fun `two op failures plus a failed probe escalate immediately`() {
val m = machine() val m = machine()
m.onLinkChange(up = true, nowMs = 0) m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000) m.onOpFailure(nowMs = 1_000)
m.onOpFailure(nowMs = 1_500) // corroboration reached m.onOpFailure(nowMs = 1_500) // corroboration reached
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
@@ -62,17 +62,17 @@ class ReachabilityMachineTest {
@Test @Test
fun `op failures with a successful probe stay healthy (track-specific)`() { fun `op failures with a successful probe stay healthy (track-specific)`() {
val m = machine() val m = machine()
m.onLinkChange(up = true, nowMs = 0) m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000) m.onOpFailure(nowMs = 1_000)
m.onOpFailure(nowMs = 1_500) m.onOpFailure(nowMs = 1_500)
m.onSuccess(nowMs = 2_000) // arbiter says server is fine m.onSuccess() // arbiter says server is fine
assertEquals(ServerHealth.Healthy, m.health()) assertEquals(ServerHealth.Healthy, m.health())
} }
@Test @Test
fun `stale op failures do not corroborate`() { fun `stale op failures do not corroborate`() {
val m = machine() val m = machine()
m.onLinkChange(up = true, nowMs = 0) m.onLinkChange(up = true)
m.onOpFailure(nowMs = 0) m.onOpFailure(nowMs = 0)
m.onOpFailure(nowMs = 1_000) m.onOpFailure(nowMs = 1_000)
// both op failures are now older than the corroboration window: // both op failures are now older than the corroboration window:
@@ -83,7 +83,7 @@ class ReachabilityMachineTest {
@Test @Test
fun `sustained failure backstop escalates after the window`() { fun `sustained failure backstop escalates after the window`() {
val m = machine() val m = machine()
m.onLinkChange(up = true, nowMs = 0) m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000) // unstable, streak starts m.onProbeFailure(nowMs = 1_000) // unstable, streak starts
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // sustained ≥ backstop m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // sustained ≥ backstop
assertEquals(ServerHealth.ServerDown, m.health()) assertEquals(ServerHealth.ServerDown, m.health())
@@ -92,22 +92,22 @@ class ReachabilityMachineTest {
@Test @Test
fun `link restored keeps last-known down until a fresh result`() { fun `link restored keeps last-known down until a fresh result`() {
val m = machine() val m = machine()
m.onLinkChange(up = true, nowMs = 0) m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000) m.onProbeFailure(nowMs = 1_000)
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // ServerDown m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // ServerDown
m.onLinkChange(up = false, nowMs = 200_000) m.onLinkChange(up = false)
assertEquals(ServerHealth.Offline, m.health()) assertEquals(ServerHealth.Offline, m.health())
m.onLinkChange(up = true, nowMs = 201_000) m.onLinkChange(up = true)
// link back but no fresh probe result yet — last known reachability was down: // link back but no fresh probe result yet — last known reachability was down:
assertEquals(ServerHealth.ServerDown, m.health()) assertEquals(ServerHealth.ServerDown, m.health())
m.onSuccess(nowMs = 202_000) m.onSuccess()
assertEquals(ServerHealth.Healthy, m.health()) assertEquals(ServerHealth.Healthy, m.health())
} }
@Test @Test
fun `unstable does not gate — it is not offline or serverdown`() { fun `unstable does not gate — it is not offline or serverdown`() {
val m = machine() val m = machine()
m.onLinkChange(up = true, nowMs = 0) m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000) m.onProbeFailure(nowMs = 1_000)
val health = m.health() val health = m.health()
assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown) assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown)