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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml
index 74dd639e..b2c751a3 100644
--- a/android/.idea/misc.xml
+++ b/android/.idea/misc.xml
@@ -1,4 +1,3 @@
-
diff --git a/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/6.json b/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/6.json
new file mode 100644
index 00000000..f0864eb0
--- /dev/null
+++ b/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/6.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt
index cfbe9396..c92f4dbd 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt
@@ -23,7 +23,7 @@ import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth
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.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph
@@ -41,7 +41,7 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache
@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
// any other entry point that asks for the full player). The App
@@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() {
private fun App(
seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds,
- serverHealth: ServerHealthController,
+ serverHealth: NetworkStatusController,
pendingOpenNowPlaying: StateFlow,
onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(),
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt
index 1b6f8e91..18db4513 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt
@@ -19,8 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController
-import com.fabledsword.minstrel.connectivity.ServerHealthController
-import com.fabledsword.minstrel.update.data.VersionCheckController
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -115,21 +114,13 @@ class MinstrelApplication :
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
/**
- * Same construct-the-singleton trick — VersionCheckController's
- * init block starts a 5-min poll loop against /healthz so the
- * shell-level VersionTooOldBanner can surface min_client_version
- * mismatches without waiting for the next user-driven request.
+ * Same construct-the-singleton trick — NetworkStatusController owns the
+ * /healthz poll loop + the device-link collector + the reachability state
+ * machine, and is the single authority on the tri-state ServerHealth
+ * 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
-
- /**
- * 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
+ @Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
/**
* Same construct-the-singleton trick — UpdateBannerController polls
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt
index 09db0a07..ee4ccc4e 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt
@@ -29,7 +29,7 @@ import javax.inject.Singleton
* the app to Offline with no debounce and fast-failed in-flight playback
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
* 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.
*
* Used by the shell-level ConnectionErrorBanner; downstream
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/NetworkStatusController.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/NetworkStatusController.kt
new file mode 100644
index 00000000..54eed5c4
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/NetworkStatusController.kt
@@ -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 = stateInternal.asStateFlow()
+
+ private val versionInternal = MutableStateFlow(VersionResult.SKIPPED)
+ val versionResult: StateFlow = 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(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 }
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
index e82ee376..11d54f15 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityMachine.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityMachine.kt
@@ -32,7 +32,7 @@ class ReachabilityMachine {
private var failureStreakStartMs: Long? = null
private val recentOpFailures = ArrayDeque()
- fun onLinkChange(up: Boolean, nowMs: Long) {
+ fun onLinkChange(up: Boolean) {
linkUp = up
// Link transitions don't reset reachability — a restored link keeps the
// 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. */
- fun onSuccess(nowMs: Long) {
+ fun onSuccess() {
reachability = Reachability.Reachable
failureStreakStartMs = null
recentOpFailures.clear()
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt
deleted file mode 100644
index a2b83f8d..00000000
--- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt
+++ /dev/null
@@ -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 = 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 }
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt
index c736610e..c360ab84 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt
@@ -25,8 +25,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.composables.icons.lucide.CloudOff
import com.composables.icons.lucide.Lucide
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
-import com.fabledsword.minstrel.connectivity.ServerHealthController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -36,12 +36,12 @@ import javax.inject.Inject
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.
*/
@HiltViewModel
class ConnectivityBannerViewModel @Inject constructor(
- health: ServerHealthController,
+ health: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
val health: StateFlow = health.state.stateIn(
@@ -86,6 +86,7 @@ fun ConnectionErrorBanner(
"No connection — check Wi-Fi or mobile data."
ServerHealth.ServerDown ->
"Server unreachable — your cached content is still available."
+ ServerHealth.Unstable -> "Reconnecting…"
ServerHealth.Healthy -> ""
},
style = MaterialTheme.typography.bodyMedium,
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt
index 677a9033..31b21971 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.player
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
-import com.fabledsword.minstrel.connectivity.ServerHealthController
import java.io.IOException
import java.io.InterruptedIOException
/**
* 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
* 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
@@ -22,7 +22,7 @@ import java.io.InterruptedIOException
*/
class OfflineGatedDataSource(
private val delegate: DataSource,
- private val health: ServerHealthController,
+ private val health: NetworkStatusController,
) : DataSource {
override fun open(dataSpec: DataSpec): Long {
@@ -33,7 +33,8 @@ class OfflineGatedDataSource(
ServerHealth.ServerDown -> throw OfflineException(
"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)
}
@@ -49,7 +50,7 @@ class OfflineGatedDataSource(
class OfflineGatedDataSourceFactory(
private val upstream: DataSource.Factory,
- private val health: ServerHealthController,
+ private val health: NetworkStatusController,
) : DataSource.Factory {
override fun createDataSource(): DataSource =
OfflineGatedDataSource(upstream.createDataSource(), health)
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt
index f3af0185..3420c498 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt
@@ -55,7 +55,7 @@ class PlayerFactory @Inject constructor(
private val cacheConfig: CacheConfig,
private val activeUpnpHolder: ActiveUpnpHolder,
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() }
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt
index 5c6b5527..7785bbb3 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt
@@ -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.CachedArtistDao
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.ServerHealthController
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.SearchResponseRef
import retrofit2.Retrofit
@@ -16,8 +16,8 @@ import javax.inject.Singleton
private const val LOCAL_SEARCH_LIMIT = 20
/**
- * Cache-first when offline. When [ServerHealthController] is Healthy the
- * repository hits `/api/search` and returns the server's three-facet
+ * Cache-first when offline. When [NetworkStatusController] reports Healthy or
+ * 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
* Room LIKE queries against `cached_*` so the user can still find
* 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
class SearchRepository @Inject constructor(
retrofit: Retrofit,
- private val serverHealth: ServerHealthController,
+ private val serverHealth: NetworkStatusController,
private val trackDao: CachedTrackDao,
private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao,
@@ -35,7 +35,8 @@ class SearchRepository @Inject constructor(
private val api: SearchApi = retrofit.create()
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 ->
SearchOutcome(localSearch(query), localOnly = true)
}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt
index a80c343b..4c708811 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt
@@ -61,8 +61,11 @@ fun TrackRow(
trailing: @Composable RowScope.() -> Unit = {},
) {
val context = LocalContext.current
- val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy &&
- trackId !in LocalCachedTrackIds.current
+ val health = LocalServerHealth.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) {
MaterialTheme.colorScheme.primary
} else {
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt
index 50e68480..52c0c482 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt
@@ -7,8 +7,8 @@ import retrofit2.http.GET
/**
* Retrofit interface for `GET /healthz` — unauthenticated health probe
* returning the server's running version + the minimum client version
- * it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController]
- * to surface the VersionTooOld banner.
+ * it'll talk to. Polled by [com.fabledsword.minstrel.connectivity.NetworkStatusController]
+ * for both reachability and the VersionTooOld banner.
*/
interface HealthzApi {
@GET("healthz")
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt
deleted file mode 100644
index c8749d95..00000000
--- a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt
+++ /dev/null
@@ -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 = 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 = 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
- }
- }
-}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionResult.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionResult.kt
new file mode 100644
index 00000000..58118a3f
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionResult.kt
@@ -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 }
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldViewModel.kt
index 41f842e0..52874561 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldViewModel.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldViewModel.kt
@@ -2,21 +2,21 @@ package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.SavedStateHandle
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
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.
*/
@HiltViewModel
class VersionTooOldViewModel @Inject constructor(
- private val controller: VersionCheckController,
+ private val controller: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
- val result: StateFlow = controller.result
+ val result: StateFlow = controller.versionResult
fun recheck() = controller.recheck()
}
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
index abc104d4..430fd08c 100644
--- a/android/app/src/test/java/com/fabledsword/minstrel/connectivity/ReachabilityMachineTest.kt
+++ b/android/app/src/test/java/com/fabledsword/minstrel/connectivity/ReachabilityMachineTest.kt
@@ -11,14 +11,14 @@ class ReachabilityMachineTest {
@Test
fun `starts healthy`() {
val m = machine()
- m.onLinkChange(up = true, nowMs = 0)
+ m.onLinkChange(up = true)
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `no link is offline regardless of probes`() {
val m = machine()
- m.onLinkChange(up = false, nowMs = 0)
+ m.onLinkChange(up = false)
m.onProbeFailure(nowMs = 1)
assertEquals(ServerHealth.Offline, m.health())
}
@@ -26,7 +26,7 @@ class ReachabilityMachineTest {
@Test
fun `single probe failure is unstable not down`() {
val m = machine()
- m.onLinkChange(up = true, nowMs = 0)
+ m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
assertEquals(ServerHealth.Unstable, m.health())
}
@@ -34,25 +34,25 @@ class ReachabilityMachineTest {
@Test
fun `probe success from unstable recovers to healthy`() {
val m = machine()
- m.onLinkChange(up = true, nowMs = 0)
+ m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
- m.onSuccess(nowMs = 2_000)
+ m.onSuccess()
assertEquals(ServerHealth.Healthy, m.health())
}
@Test
fun `op success from unstable recovers to healthy`() {
val m = machine()
- m.onLinkChange(up = true, nowMs = 0)
+ m.onLinkChange(up = true)
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())
}
@Test
fun `two op failures plus a failed probe escalate immediately`() {
val m = machine()
- m.onLinkChange(up = true, nowMs = 0)
+ m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000)
m.onOpFailure(nowMs = 1_500) // corroboration reached
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
@@ -62,17 +62,17 @@ class ReachabilityMachineTest {
@Test
fun `op failures with a successful probe stay healthy (track-specific)`() {
val m = machine()
- m.onLinkChange(up = true, nowMs = 0)
+ m.onLinkChange(up = true)
m.onOpFailure(nowMs = 1_000)
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())
}
@Test
fun `stale op failures do not corroborate`() {
val m = machine()
- m.onLinkChange(up = true, nowMs = 0)
+ m.onLinkChange(up = true)
m.onOpFailure(nowMs = 0)
m.onOpFailure(nowMs = 1_000)
// both op failures are now older than the corroboration window:
@@ -83,7 +83,7 @@ class ReachabilityMachineTest {
@Test
fun `sustained failure backstop escalates after the window`() {
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 + ESCALATE_AFTER_MS) // sustained ≥ backstop
assertEquals(ServerHealth.ServerDown, m.health())
@@ -92,22 +92,22 @@ class ReachabilityMachineTest {
@Test
fun `link restored keeps last-known down until a fresh result`() {
val m = machine()
- m.onLinkChange(up = true, nowMs = 0)
+ m.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
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())
- m.onLinkChange(up = true, nowMs = 201_000)
+ m.onLinkChange(up = true)
// link back but no fresh probe result yet — last known reachability was down:
assertEquals(ServerHealth.ServerDown, m.health())
- m.onSuccess(nowMs = 202_000)
+ m.onSuccess()
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.onLinkChange(up = true)
m.onProbeFailure(nowMs = 1_000)
val health = m.health()
assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown)