Merge pull request 'Unify offline detection + offline playlist UX (NetworkStatusController)' (#86) from dev into main
This commit was merged in pull request #86.
This commit is contained in:
Generated
+13
@@ -1754,6 +1754,19 @@
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</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>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
|
||||
Generated
-1
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<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.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<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -45,9 +46,15 @@ object NetworkModule {
|
||||
fun provideOkHttp(
|
||||
baseUrl: BaseUrlInterceptor,
|
||||
auth: AuthCookieInterceptor,
|
||||
reachability: ReachabilityReportingInterceptor,
|
||||
logging: HttpLoggingInterceptor,
|
||||
): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
// ReachabilityReportingInterceptor MUST run first: it identifies
|
||||
// Minstrel-bound requests by the still-unrewritten PLACEHOLDER_HOST
|
||||
// (so external artwork fetches don't read as server reachability)
|
||||
// and observes the final transport outcome by wrapping the chain.
|
||||
.addInterceptor(reachability)
|
||||
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
||||
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
||||
// Minstrel-server requests from external image fetches
|
||||
|
||||
+14
@@ -31,6 +31,20 @@ interface CachedPlaylistDao {
|
||||
@Query("SELECT * FROM cached_playlists WHERE id = :id")
|
||||
suspend fun getById(id: String): CachedPlaylistEntity?
|
||||
|
||||
/**
|
||||
* Per-playlist count of member tracks resident in the audio cache index.
|
||||
* LEFT JOINs so playlists with zero cached tracks still appear
|
||||
* (cachedCount = 0). Drives the offline "fully cached" greying.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " +
|
||||
"FROM cached_playlists p " +
|
||||
"LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " +
|
||||
"LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " +
|
||||
"GROUP BY p.id",
|
||||
)
|
||||
fun observeCachedCounts(): Flow<List<PlaylistCachedCount>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
|
||||
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.fabledsword.minstrel.cache.db.dao
|
||||
|
||||
/**
|
||||
* Projection: how many of a playlist's member tracks are resident in the audio
|
||||
* cache index. Backs the offline "fully cached" greying — a playlist is fully
|
||||
* available offline when [cachedCount] reaches its `trackCount`.
|
||||
*/
|
||||
data class PlaylistCachedCount(
|
||||
val playlistId: String,
|
||||
val cachedCount: Int,
|
||||
)
|
||||
+1
-1
@@ -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
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
// Arbitrate off the reducer thread: awaiting a stalled
|
||||
// /healthz here would block a concurrent self-proving
|
||||
// success from snapping us straight back to Healthy.
|
||||
scope.launch { probeOnce() }
|
||||
}
|
||||
}
|
||||
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 }
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
internal const val ESCALATE_AFTER_MS = 120_000L
|
||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||
|
||||
/**
|
||||
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
||||
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
||||
* testable. [NetworkStatusController] wires real time + signals around it.
|
||||
*
|
||||
* Reachability (independent of the device link):
|
||||
* - Reachable last evidence says the server answered.
|
||||
* - Unstable a probe failed; arbitration/escalation pending.
|
||||
* - Unreachable corroborated or sustained failure.
|
||||
*
|
||||
* [health] folds the device link over that: no link → Offline; otherwise the
|
||||
* reachability maps Reachable→Healthy, Unstable→Unstable, Unreachable→ServerDown.
|
||||
*
|
||||
* Principle: **success is self-proving, failure is ambiguous.** [onSuccess]
|
||||
* (a real server byte-read or API 2xx, or a successful /healthz) snaps straight
|
||||
* back to Reachable. A failure only escalates when a /healthz probe corroborates
|
||||
* it ([onProbeFailure]) — either via fresh op-failure corroboration or the
|
||||
* sustained-time backstop.
|
||||
*/
|
||||
class ReachabilityMachine {
|
||||
|
||||
private enum class Reachability { Reachable, Unstable, Unreachable }
|
||||
|
||||
private var linkUp = true
|
||||
private var reachability = Reachability.Reachable
|
||||
private var failureStreakStartMs: Long? = null
|
||||
private val recentOpFailures = ArrayDeque<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.
|
||||
if (!up) recentOpFailures.clear()
|
||||
}
|
||||
|
||||
/** A real successful server op (stream read, API 2xx) or a successful /healthz. */
|
||||
fun onSuccess() {
|
||||
reachability = Reachability.Reachable
|
||||
failureStreakStartMs = null
|
||||
recentOpFailures.clear()
|
||||
}
|
||||
|
||||
/** A real network op failed. Ambiguous on its own — records corroboration. */
|
||||
fun onOpFailure(nowMs: Long) {
|
||||
pruneOpFailures(nowMs)
|
||||
recentOpFailures.addLast(nowMs)
|
||||
}
|
||||
|
||||
/** A /healthz probe failed — the arbiter. Escalates per corroboration/backstop. */
|
||||
fun onProbeFailure(nowMs: Long) {
|
||||
pruneOpFailures(nowMs)
|
||||
if (reachability == Reachability.Reachable) {
|
||||
reachability = Reachability.Unstable
|
||||
failureStreakStartMs = nowMs
|
||||
}
|
||||
if (reachability == Reachability.Unstable && shouldEscalate(nowMs)) {
|
||||
reachability = Reachability.Unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fun health(): ServerHealth = when {
|
||||
!linkUp -> ServerHealth.Offline
|
||||
reachability == Reachability.Reachable -> ServerHealth.Healthy
|
||||
reachability == Reachability.Unstable -> ServerHealth.Unstable
|
||||
else -> ServerHealth.ServerDown
|
||||
}
|
||||
|
||||
private fun shouldEscalate(nowMs: Long): Boolean {
|
||||
val corroborated = recentOpFailures.size >= CORROBORATION_OP_THRESHOLD
|
||||
val sustained =
|
||||
failureStreakStartMs?.let { nowMs - it >= ESCALATE_AFTER_MS } ?: false
|
||||
return corroborated || sustained
|
||||
}
|
||||
|
||||
private fun pruneOpFailures(nowMs: Long) {
|
||||
while (recentOpFailures.isNotEmpty() &&
|
||||
nowMs - recentOpFailures.first() > CORROBORATION_WINDOW_MS
|
||||
) {
|
||||
recentOpFailures.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import com.fabledsword.minstrel.api.BaseUrlInterceptor.Companion.PLACEHOLDER_HOST
|
||||
import dagger.Lazy
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val HEALTHZ_PATH = "/healthz"
|
||||
|
||||
/**
|
||||
* Feeds real Minstrel API outcomes into [NetworkStatusController]. A 2xx is
|
||||
* self-proving proof the server is reachable → reportSuccess(); a transport
|
||||
* [IOException] (no response at all) → reportFailure(), which triggers /healthz
|
||||
* arbitration.
|
||||
*
|
||||
* MUST run first in the OkHttp chain (before [BaseUrlInterceptor]) so the host
|
||||
* is still the [PLACEHOLDER_HOST] sentinel: this shared client also fetches
|
||||
* EXTERNAL artwork (musicbrainz / coverartarchive), and an external image
|
||||
* loading must NOT be read as "our server is reachable" — only sentinel-host
|
||||
* requests are Minstrel-bound. 5xx is deliberately NOT a failure (the server
|
||||
* answered), and /healthz is skipped to avoid a feedback loop with the poll.
|
||||
*
|
||||
* [NetworkStatusController] is injected as a [Lazy] to break the Hilt cycle:
|
||||
* the controller needs `Retrofit`, which needs `OkHttpClient`, which needs this
|
||||
* interceptor. By the time a request flows through, the controller singleton is
|
||||
* already constructed (construct-the-singleton trick in MinstrelApplication).
|
||||
*/
|
||||
@Singleton
|
||||
class ReachabilityReportingInterceptor @Inject constructor(
|
||||
private val networkStatus: Lazy<NetworkStatusController>,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val isMinstrel = request.url.host == PLACEHOLDER_HOST
|
||||
val isHealthz = request.url.encodedPath.endsWith(HEALTHZ_PATH)
|
||||
if (!isMinstrel || isHealthz) return chain.proceed(request)
|
||||
return try {
|
||||
val response = chain.proceed(request)
|
||||
if (response.isSuccessful) networkStatus.get().reportSuccess()
|
||||
response
|
||||
} catch (e: IOException) {
|
||||
networkStatus.get().reportFailure()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
/**
|
||||
* The single reachability signal every consumer branches on.
|
||||
*
|
||||
* - [Healthy] link up, /healthz ok — normal network behavior.
|
||||
* - [Unstable] link up, a recent failure with arbitration pending —
|
||||
* INFORMATIONAL ONLY. Does NOT gate playback; preserves the
|
||||
* anti-flicker intent of commit 5c0db429 while still warning
|
||||
* the user that something is flaky.
|
||||
* - [ServerDown] link up but /healthz failing, corroborated or sustained —
|
||||
* gate to cache-only.
|
||||
* - [Offline] no device link at all — gate to cache-only.
|
||||
*/
|
||||
enum class ServerHealth { Healthy, Unstable, ServerDown, Offline }
|
||||
-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 }
|
||||
+81
-43
@@ -14,83 +14,121 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
private const val BACK_ONLINE_FLASH_MS = 2_000L
|
||||
|
||||
/**
|
||||
* Lifts [ServerHealthController]'s tri-state into a StateFlow for the banner
|
||||
* composable. Keeps the banner pure-presentation.
|
||||
* Exposes [NetworkStatusController]'s tri-state directly to the banner. No
|
||||
* re-wrapping StateFlow — the controller's is already app-scoped and warm.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ConnectivityBannerViewModel @Inject constructor(
|
||||
health: ServerHealthController,
|
||||
networkStatus: NetworkStatusController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val health: StateFlow<ServerHealth> = health.state.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = ServerHealth.Healthy,
|
||||
)
|
||||
val health: StateFlow<ServerHealth> = networkStatus.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner shown at the top of the shell when the user can't reach the server.
|
||||
* Tri-state so we tell the user *why*: no device network vs server-down.
|
||||
* Copy choices match the Flutter analogues. Auto-hides via slide+fade when
|
||||
* health returns to [ServerHealth.Healthy].
|
||||
* Shell banner. Tells the user *why* they're degraded — no link vs server-down
|
||||
* — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
|
||||
* window, and a brief "Back online" confirmation when health recovers so
|
||||
* recovery is unmistakable. Sentence case, understated voice (design system).
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionErrorBanner(
|
||||
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val health by viewModel.health.collectAsStateWithLifecycle()
|
||||
var showBackOnline by remember { mutableStateOf(false) }
|
||||
var wasDown by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(health) {
|
||||
val down = health == ServerHealth.Offline || health == ServerHealth.ServerDown
|
||||
if (health == ServerHealth.Healthy && wasDown) {
|
||||
// try/finally so a mid-delay cancellation (health flips again) can't
|
||||
// orphan the flag and leave "Back online" stuck on screen.
|
||||
try {
|
||||
showBackOnline = true
|
||||
delay(BACK_ONLINE_FLASH_MS)
|
||||
} finally {
|
||||
showBackOnline = false
|
||||
}
|
||||
}
|
||||
wasDown = down
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = health != ServerHealth.Healthy,
|
||||
visible = health != ServerHealth.Healthy || showBackOnline,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.CloudOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = when (health) {
|
||||
ServerHealth.Offline ->
|
||||
"No connection — check Wi-Fi or mobile data."
|
||||
ServerHealth.ServerDown ->
|
||||
"Server unreachable — your cached content is still available."
|
||||
ServerHealth.Healthy -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
BannerContent(health = health, backOnline = showBackOnline)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerContent(health: ServerHealth, backOnline: Boolean) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val background: Color
|
||||
val foreground: Color
|
||||
when {
|
||||
backOnline -> {
|
||||
background = scheme.secondaryContainer
|
||||
foreground = scheme.onSecondaryContainer
|
||||
}
|
||||
health == ServerHealth.Unstable -> {
|
||||
background = scheme.surfaceVariant
|
||||
foreground = scheme.onSurfaceVariant
|
||||
}
|
||||
else -> {
|
||||
background = scheme.errorContainer
|
||||
foreground = scheme.onErrorContainer
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(background)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(imageVector = Lucide.CloudOff, contentDescription = null, tint = foreground)
|
||||
Text(
|
||||
text = bannerText(health, backOnline),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = foreground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bannerText(health: ServerHealth, backOnline: Boolean): String = when {
|
||||
backOnline -> "Back online."
|
||||
health == ServerHealth.Offline -> "No connection — check Wi-Fi or mobile data."
|
||||
health == ServerHealth.ServerDown ->
|
||||
"Server unreachable — your cached content is still available."
|
||||
health == ServerHealth.Unstable -> "Reconnecting…"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import com.composables.icons.lucide.History
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
@@ -134,7 +135,7 @@ class HomeViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
|
||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
|
||||
@@ -142,9 +143,13 @@ class HomeViewModel @Inject constructor(
|
||||
/** System-playlist build status for the Home placeholder cards. */
|
||||
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
|
||||
|
||||
/** True when the device has no usable internet — gates the offline-pool cards. */
|
||||
val offline: StateFlow<Boolean> = connectivity.online
|
||||
.map { !it }
|
||||
/**
|
||||
* Cache-only when there's no link OR the server is unreachable. Reads the
|
||||
* unified [NetworkStatusController] (not the raw device link) so Home reacts
|
||||
* to ServerDown too; the transient Unstable state stays calm (not offline).
|
||||
*/
|
||||
val offline: StateFlow<Boolean> = networkStatus.state
|
||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
@@ -701,18 +706,19 @@ private fun PlaylistsRow(
|
||||
icon = iconForPool(item.kind),
|
||||
onClick = { onPlayPool(item.kind) },
|
||||
)
|
||||
is PlaylistRowItem.Real -> PlaylistCard(
|
||||
playlist = item.playlist,
|
||||
onClick = { onPlaylistClick(item.playlist.id) },
|
||||
onPlay = { onPlayPlaylist(item.playlist) },
|
||||
// Match Flutter: refreshable system playlists need
|
||||
// the live server endpoints, so disable their play
|
||||
// overlay when offline (user can still tap into the
|
||||
// detail and shuffle all from cache). User playlists
|
||||
// play from cache + survive offline.
|
||||
playEnabled = item.playlist.trackCount > 0 &&
|
||||
!(offline && item.playlist.refreshable),
|
||||
)
|
||||
is PlaylistRowItem.Real -> {
|
||||
// Greyed offline when the tile needs the live server or
|
||||
// isn't fully cached — dimmed but still tappable into the
|
||||
// detail to shuffle whatever subset is cached.
|
||||
val greyed = offline && item.playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = item.playlist,
|
||||
onClick = { onPlaylistClick(item.playlist.id) },
|
||||
onPlay = { onPlayPlaylist(item.playlist) },
|
||||
playEnabled = item.playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
|
||||
label = item.label,
|
||||
variant = item.variant,
|
||||
@@ -728,7 +734,7 @@ private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
|
||||
}
|
||||
|
||||
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
|
||||
private sealed interface PlaylistRowItem {
|
||||
internal sealed interface PlaylistRowItem {
|
||||
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
|
||||
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
|
||||
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
|
||||
@@ -741,55 +747,81 @@ enum class OfflinePoolKind(val label: String) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Home Playlists row. When [offline], the two cache-backed
|
||||
* pool cards (Recently played, Liked) lead the row. Then For You +
|
||||
* Discover + 3× Songs-like fixed slots (real card when generated,
|
||||
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||
* rediscover / new for you / on this day / first listens) when they
|
||||
* exist — no placeholders for these since they're conditional on
|
||||
* library shape, not guaranteed singletons. Finally user-owned
|
||||
* playlists.
|
||||
* Builds the Home Playlists row.
|
||||
*
|
||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
||||
* no placeholders for these since they're conditional on library shape — then
|
||||
* user-owned playlists.
|
||||
*
|
||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||
* same real playlists in curated order but stably partitioned fully-cached
|
||||
* first / greyed after, and the "building/pending" placeholders are dropped
|
||||
* (they need the server to generate, so they're meaningless offline).
|
||||
*
|
||||
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
|
||||
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
||||
* surfaces the secondary kinds on Home. Operator authorized the
|
||||
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
||||
*/
|
||||
private fun buildPlaylistsRow(
|
||||
internal fun buildPlaylistsRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
): List<PlaylistRowItem> {
|
||||
if (!offline) return buildOnlineRow(owned, status)
|
||||
val out = mutableListOf<PlaylistRowItem>(
|
||||
PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED),
|
||||
PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED),
|
||||
)
|
||||
val (available, greyed) = orderedRealPlaylists(owned).partition { !it.unavailableOffline }
|
||||
(available + greyed).forEach { out += PlaylistRowItem.Real(it) }
|
||||
return out
|
||||
}
|
||||
|
||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
||||
private fun buildOnlineRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
): List<PlaylistRowItem> {
|
||||
val out = mutableListOf<PlaylistRowItem>()
|
||||
if (offline) {
|
||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
|
||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
|
||||
}
|
||||
out += owned.firstOrNull { it.systemVariant == "for_you" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
|
||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(3)
|
||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
||||
out += songsLike.getOrNull(i)
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
// Secondary system kinds in server-registry order. Only included
|
||||
// when actually generated — these depend on library shape (Deep
|
||||
// cuts needs deep albums, On this day needs prior history, etc.)
|
||||
// so a missing one means "not enough data" rather than "still
|
||||
// building".
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }
|
||||
?.let { out += PlaylistRowItem.Real(it) }
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||
}
|
||||
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||
* only differs by dropping placeholders + partitioning available-first.
|
||||
*/
|
||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||
val out = mutableListOf<PlaylistRef>()
|
||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
||||
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||
}
|
||||
owned.filter { it.systemVariant == null }.forEach { out += it }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
||||
s.inFlight -> "building"
|
||||
s.lastError != null -> "failed"
|
||||
|
||||
@@ -20,6 +20,8 @@ data class PlaylistRef(
|
||||
val trackCount: Int = 0,
|
||||
val coverUrl: String = "",
|
||||
val ownerUsername: String = "",
|
||||
/** All member tracks resident in the audio cache — playable fully offline. */
|
||||
val fullyCached: Boolean = false,
|
||||
) {
|
||||
val isSystem: Boolean get() = systemVariant != null
|
||||
|
||||
@@ -30,6 +32,13 @@ data class PlaylistRef(
|
||||
* expose a refresh trigger; rebuilds are scheduler-driven).
|
||||
*/
|
||||
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
|
||||
|
||||
/**
|
||||
* Can't be relied on while offline — either it needs the live server to
|
||||
* (re)generate (a refreshable system mix) or not all its tracks are cached.
|
||||
* Callers combine this with the current offline state to grey the tile.
|
||||
*/
|
||||
val unavailableOffline: Boolean get() = refreshable || !fullyCached
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+19
-6
@@ -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,10 +22,23 @@ 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 {
|
||||
gateOnHealth()
|
||||
return try {
|
||||
val opened = delegate.open(dataSpec)
|
||||
health.reportSuccess() // bytes flowing from the server == reachable
|
||||
opened
|
||||
} catch (e: IOException) {
|
||||
health.reportFailure() // real network read failed → arbitrate via /healthz
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Fast-fail before touching the network when the server can't be reached. */
|
||||
private fun gateOnHealth() {
|
||||
when (health.state.value) {
|
||||
ServerHealth.Offline -> throw OfflineException(
|
||||
"Track not in the on-device cache and the device is offline.",
|
||||
@@ -33,9 +46,9 @@ 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)
|
||||
}
|
||||
|
||||
override fun close() = delegate.close()
|
||||
@@ -49,7 +62,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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -40,6 +41,7 @@ private const val DEBOUNCE_MS = 2_000L
|
||||
class PlaybackErrorReporter @Inject constructor(
|
||||
private val playerController: PlayerController,
|
||||
private val repository: PlaybackErrorRepository,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val outChannel = Channel<String>(Channel.BUFFERED)
|
||||
@@ -52,6 +54,10 @@ class PlaybackErrorReporter @Inject constructor(
|
||||
val buffer = mutableListOf<String>()
|
||||
var debounceJob: kotlinx.coroutines.Job? = null
|
||||
playerController.playbackErrorEvents.collect { event ->
|
||||
// A track failing to play is ambiguous (dead server vs. one bad
|
||||
// file) — let the controller arbitrate via /healthz. No-op when
|
||||
// already Offline; cheap otherwise.
|
||||
networkStatus.reportFailure()
|
||||
// Fire-and-forget the server report — repository handles
|
||||
// success/queue branching so callers don't see throws.
|
||||
scope.launch { repository.report(event) }
|
||||
|
||||
@@ -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() }
|
||||
|
||||
|
||||
+28
-2
@@ -7,6 +7,7 @@ import retrofit2.HttpException
|
||||
import java.net.HttpURLConnection
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
@@ -18,6 +19,7 @@ import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistWire
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
@@ -51,9 +53,16 @@ class PlaylistsRepository @Inject constructor(
|
||||
|
||||
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
|
||||
|
||||
/** Owned + public; UI splits by isSystem / userId comparison. */
|
||||
/**
|
||||
* Owned + public; UI splits by isSystem / userId comparison. Joined with
|
||||
* per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline
|
||||
* greying without each consumer re-querying the cache index.
|
||||
*/
|
||||
fun observeAll(): Flow<List<PlaylistRef>> =
|
||||
playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
combine(
|
||||
playlistDao.observeAll(),
|
||||
playlistDao.observeCachedCounts(),
|
||||
) { rows, counts -> mergePlaylistsWithCache(rows, counts) }
|
||||
|
||||
/** User-owned playlists only (systemVariant IS NULL). */
|
||||
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
|
||||
@@ -233,6 +242,23 @@ fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
|
||||
|
||||
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
|
||||
|
||||
/**
|
||||
* Maps cached playlist rows to domain refs, stamping [PlaylistRef.fullyCached]
|
||||
* from the cache-index counts. A playlist is fully cached when it has tracks
|
||||
* and every member track is resident (`cachedCount >= trackCount`). Pulled out
|
||||
* of the Flow so the predicate is unit-testable without a Room database.
|
||||
*/
|
||||
internal fun mergePlaylistsWithCache(
|
||||
rows: List<CachedPlaylistEntity>,
|
||||
counts: List<PlaylistCachedCount>,
|
||||
): List<PlaylistRef> {
|
||||
val cachedById = counts.associate { it.playlistId to it.cachedCount }
|
||||
return rows.map { row ->
|
||||
val cached = cachedById[row.id] ?: 0
|
||||
row.toDomain().copy(fullyCached = row.trackCount > 0 && cached >= row.trackCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
||||
PlaylistRef(
|
||||
id = id,
|
||||
|
||||
+14
-10
@@ -27,7 +27,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||
@@ -66,7 +67,7 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
private val repository: PlaylistsRepository,
|
||||
private val player: PlayerController,
|
||||
private val eventsStream: EventsStream,
|
||||
connectivity: ConnectivityObserver,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
||||
@@ -74,9 +75,9 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
/** Transient snackbar messages from playlist tile play taps. */
|
||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||
|
||||
/** True when the device has no usable internet -- gates refreshable system tiles. */
|
||||
val offline: StateFlow<Boolean> = connectivity.online
|
||||
.map { !it }
|
||||
/** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
|
||||
val offline: StateFlow<Boolean> = networkStatus.state
|
||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
@@ -191,14 +192,15 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("System playlists")
|
||||
}
|
||||
items(items = systemPlaylists, key = { it.id }) { playlist ->
|
||||
// Greyed offline when it needs the live server or isn't fully
|
||||
// cached — dimmed but still tappable into the detail.
|
||||
val greyed = offline && playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
// Refreshable system tiles need server endpoints (systemShuffle);
|
||||
// disable when offline. Empty mixes show a snackbar after tap.
|
||||
playEnabled = playlist.trackCount > 0 &&
|
||||
!(offline && playlist.refreshable),
|
||||
playEnabled = playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -207,11 +209,13 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("Your playlists")
|
||||
}
|
||||
items(items = userPlaylists, key = { it.id }) { playlist ->
|
||||
val greyed = offline && playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
playEnabled = playlist.trackCount > 0,
|
||||
playEnabled = playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -40,6 +41,10 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
|
||||
* uses it for system playlists in offline mode (their server-side
|
||||
* shuffle endpoint is unreachable) and for empty playlists.
|
||||
*
|
||||
* [greyed] dims the whole tile (it can't be reliably played offline) while
|
||||
* keeping it tappable — the detail screen is the escape hatch for shuffling
|
||||
* whatever subset is cached. Greyed always disables the play overlay too.
|
||||
*/
|
||||
@Composable
|
||||
fun PlaylistCard(
|
||||
@@ -48,11 +53,13 @@ fun PlaylistCard(
|
||||
modifier: Modifier = Modifier,
|
||||
onPlay: (suspend () -> Unit)? = null,
|
||||
playEnabled: Boolean = true,
|
||||
greyed: Boolean = false,
|
||||
) {
|
||||
val seedCache = LocalDetailSeedCache.current
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(176.dp)
|
||||
.alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
|
||||
.clickable {
|
||||
seedCache.stashPlaylist(playlist)
|
||||
onClick()
|
||||
@@ -63,7 +70,7 @@ fun PlaylistCard(
|
||||
PlaylistCardCover(
|
||||
playlist = playlist,
|
||||
onPlay = onPlay,
|
||||
playEnabled = playEnabled,
|
||||
playEnabled = playEnabled && !greyed,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
private const val PILL_BG_ALPHA = 0.85f
|
||||
private const val GREYED_ALPHA = 0.45f
|
||||
|
||||
private fun subtitleFor(playlist: PlaylistRef): String = when {
|
||||
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Routes a deliberate pull-to-refresh into a /healthz recheck so the connection
|
||||
* banner clears within seconds rather than waiting for the next poll — even on
|
||||
* cache-only screens whose own refresh never touches the network. Backs
|
||||
* [PullToRefreshScaffold].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PullRefreshNetworkViewModel @Inject constructor(
|
||||
private val networkStatus: NetworkStatusController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
fun recheck() = networkStatus.recheck()
|
||||
}
|
||||
+6
-1
@@ -10,12 +10,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Wraps [content] in a Material3 PullToRefreshBox so the user can
|
||||
* swipe-down to trigger [onRefresh]. The wrapper manages the
|
||||
* `isRefreshing` indicator while [onRefresh] is in flight.
|
||||
* `isRefreshing` indicator while [onRefresh] is in flight. Every pull also
|
||||
* fires a /healthz recheck (via [PullRefreshNetworkViewModel]) so a stale
|
||||
* connection banner clears promptly on a deliberate user refresh.
|
||||
*
|
||||
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
|
||||
* indicator hides exactly when the underlying coroutine completes,
|
||||
@@ -32,6 +35,7 @@ import kotlinx.coroutines.launch
|
||||
fun PullToRefreshScaffold(
|
||||
onRefresh: suspend () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
netVm: PullRefreshNetworkViewModel = hiltViewModel(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
@@ -42,6 +46,7 @@ fun PullToRefreshScaffold(
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
try {
|
||||
netVm.recheck() // deliberate pull → re-probe the server now
|
||||
onRefresh()
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
-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.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<VersionResult> = controller.result
|
||||
val result: StateFlow<VersionResult> = controller.versionResult
|
||||
fun recheck() = controller.recheck()
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReachabilityMachineTest {
|
||||
|
||||
private fun machine() = ReachabilityMachine()
|
||||
|
||||
@Test
|
||||
fun `starts healthy`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no link is offline regardless of probes`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = false)
|
||||
m.onProbeFailure(nowMs = 1)
|
||||
assertEquals(ServerHealth.Offline, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single probe failure is unstable not down`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
assertEquals(ServerHealth.Unstable, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `probe success from unstable recovers to healthy`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
m.onSuccess()
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `op success from unstable recovers to healthy`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
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)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
m.onOpFailure(nowMs = 1_500) // corroboration reached
|
||||
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
|
||||
assertEquals(ServerHealth.ServerDown, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `op failures with a successful probe stay healthy (track-specific)`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
m.onOpFailure(nowMs = 1_500)
|
||||
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)
|
||||
m.onOpFailure(nowMs = 0)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
// both op failures are now older than the corroboration window:
|
||||
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_WINDOW_MS + 1)
|
||||
assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sustained failure backstop escalates after the window`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000) // unstable, streak starts
|
||||
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // sustained ≥ backstop
|
||||
assertEquals(ServerHealth.ServerDown, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `link restored keeps last-known down until a fresh result`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // ServerDown
|
||||
m.onLinkChange(up = false)
|
||||
assertEquals(ServerHealth.Offline, m.health())
|
||||
m.onLinkChange(up = true)
|
||||
// link back but no fresh probe result yet — last known reachability was down:
|
||||
assertEquals(ServerHealth.ServerDown, m.health())
|
||||
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)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
val health = m.health()
|
||||
assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown)
|
||||
assertEquals(ServerHealth.Unstable, health)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.fabledsword.minstrel.home.ui
|
||||
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuildPlaylistsRowTest {
|
||||
|
||||
private fun user(id: String, cached: Boolean) =
|
||||
PlaylistRef(id = id, userId = "u", name = id, trackCount = 1, fullyCached = cached)
|
||||
|
||||
@Test
|
||||
fun `offline row leads with pools then available before greyed`() {
|
||||
val owned = listOf(user("partial", cached = false), user("full", cached = true))
|
||||
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = true)
|
||||
|
||||
assertTrue(row[0] is PlaylistRowItem.OfflinePool)
|
||||
assertTrue(row[1] is PlaylistRowItem.OfflinePool)
|
||||
// Fully-cached "full" comes before the greyed "partial".
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertEquals(listOf("full", "partial"), reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline greys a refreshable system playlist even when fully cached`() {
|
||||
val forYou = PlaylistRef(
|
||||
id = "fy",
|
||||
userId = "u",
|
||||
name = "For You",
|
||||
systemVariant = "for_you",
|
||||
trackCount = 1,
|
||||
fullyCached = true,
|
||||
)
|
||||
val row = buildPlaylistsRow(
|
||||
listOf(forYou, user("u1", cached = true)),
|
||||
SystemPlaylistsStatus(),
|
||||
offline = true,
|
||||
)
|
||||
// The fully-cached user playlist is available; the refreshable system
|
||||
// mix needs the server, so it greys out and sorts after.
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertEquals(listOf("u1", "fy"), reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline row drops building-pending placeholders`() {
|
||||
val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = true)
|
||||
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online row has no offline pools and keeps system-slot placeholders`() {
|
||||
val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = false)
|
||||
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
||||
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.fabledsword.minstrel.playlists.data
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PlaylistsRepositoryCacheMergeTest {
|
||||
|
||||
private fun playlist(id: String, trackCount: Int) =
|
||||
CachedPlaylistEntity(id = id, userId = "u", name = id, trackCount = trackCount)
|
||||
|
||||
@Test
|
||||
fun `fully cached when every member track is resident`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 3)),
|
||||
counts = listOf(PlaylistCachedCount("p", cachedCount = 3)),
|
||||
)
|
||||
assertTrue(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not fully cached when only some tracks are resident`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 3)),
|
||||
counts = listOf(PlaylistCachedCount("p", cachedCount = 2)),
|
||||
)
|
||||
assertFalse(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not fully cached when no tracks are resident (missing count row)`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 3)),
|
||||
counts = emptyList(),
|
||||
)
|
||||
assertFalse(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty playlist is never fully cached`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 0)),
|
||||
counts = listOf(PlaylistCachedCount("p", cachedCount = 0)),
|
||||
)
|
||||
assertFalse(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each playlist gets its own cache verdict`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("full", 2), playlist("partial", 2)),
|
||||
counts = listOf(
|
||||
PlaylistCachedCount("full", cachedCount = 2),
|
||||
PlaylistCachedCount("partial", cachedCount = 1),
|
||||
),
|
||||
)
|
||||
val byId = out.associateBy { it.id }
|
||||
assertEquals(true, byId.getValue("full").fullyCached)
|
||||
assertEquals(false, byId.getValue("partial").fullyCached)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user