refactor(android): unify offline detection into NetworkStatusController
android / Build + lint + test (push) Successful in 3m25s
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:
Generated
+13
@@ -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" />
|
||||||
|
|||||||
Generated
-1
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+173
@@ -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 }
|
||||||
+2
-2
@@ -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()
|
||||||
|
|||||||
-75
@@ -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 }
|
|
||||||
+4
-3
@@ -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")
|
||||||
|
|||||||
-112
@@ -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 }
|
||||||
+4
-4
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user