Merge pull request 'Unify offline detection + offline playlist UX (NetworkStatusController)' (#86) from dev into main
android / Build + lint + test (push) Successful in 3m56s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
release / Build + push container image (push) Successful in 13s

This commit was merged in pull request #86.
This commit is contained in:
2026-06-05 13:27:36 -04:00
34 changed files with 1602 additions and 323 deletions
+13
View File
@@ -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" />
-1
View File
@@ -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
@@ -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>)
@@ -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,
)
@@ -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
@@ -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 }
@@ -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()
}
}
}
@@ -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 }
@@ -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 }
@@ -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
}
/**
@@ -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() }
@@ -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,
@@ -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,
)
}
}
@@ -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)
}
@@ -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()
}
@@ -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")
@@ -1,112 +0,0 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.api.HealthzApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
// Hysteresis on the reachable signal. Flipping internalReachable to false on
// the very first /healthz failure produced two false positives:
// 1. App startup — AuthStore.baseUrl loads from Room asynchronously, so the
// first runOnce() can fire against AuthStore.DEFAULT_BASE_URL
// ("http://localhost:8080") before the real server URL has hydrated.
// 2. Deployments whose reverse proxy routes only /api/* to the Go server —
// /healthz never reaches the handler, so the user sees a permanent
// "Server unreachable" banner even though all real /api/* calls succeed.
// Requiring 3 consecutive failures (~15 min at the 5-min poll cadence) ensures
// the banner only fires on sustained, real unreachability — and a single
// success at any point resets the counter so transient hiccups self-clear.
private const val REACHABILITY_FAILURE_THRESHOLD = 3
/**
* Result of the most recent /healthz version-compatibility check.
* `Skipped` means the server didn't include `min_client_version`
* (partial deploy or older server) and the UI should not gate.
*/
enum class VersionResult { OK, TOO_OLD, SKIPPED }
/**
* Polls /healthz periodically and exposes the current
* [VersionResult] for the shell's VersionTooOld banner. Soft-fails
* on network errors — keeps the last-known result rather than
* showing a misleading "too old" on a connectivity blip.
*
* Constructed at app launch via the construct-the-singleton trick
* in [com.fabledsword.minstrel.MinstrelApplication].
*/
@Singleton
class VersionCheckController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) {
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
private val internal = MutableStateFlow(VersionResult.SKIPPED)
val result: StateFlow<VersionResult> = internal.asStateFlow()
// Whether the most recent /healthz poll reached the server. Separate from
// VersionResult because "unreachable" and "version mismatch" drive
// different UX (offline banner vs version-too-old banner). Optimistic
// initial value -- the first poll fires within seconds of app launch and
// we don't want a "server down" flash before we've actually tried.
// Consumed by ServerHealthController to compose with ConnectivityObserver
// for the tri-state offline / server-down / healthy signal.
private val internalReachable = MutableStateFlow(true)
val reachable: StateFlow<Boolean> = internalReachable.asStateFlow()
private var consecutiveFailures = 0
init {
scope.launch {
while (true) {
runOnce()
delay(POLL_INTERVAL_MS)
}
}
}
/** One-shot recheck, bypassing the poll cadence. Used by the banner's "Check now" button. */
fun recheck() {
scope.launch { runOnce() }
}
private suspend fun runOnce() {
val outcome = runCatching { api.check() }
val response = outcome.getOrNull()
if (response == null) {
consecutiveFailures++
if (consecutiveFailures >= REACHABILITY_FAILURE_THRESHOLD &&
internalReachable.value
) {
Timber.w(
"/healthz unreachable for %d consecutive polls — flipping reachable=false",
consecutiveFailures,
)
internalReachable.value = false
}
return
}
if (!internalReachable.value) {
Timber.i("/healthz recovered after %d failures", consecutiveFailures)
}
consecutiveFailures = 0
internalReachable.value = true
val min = response.minClientVersion
internal.value = when {
min.isEmpty() -> VersionResult.SKIPPED
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
else -> VersionResult.OK
}
}
}
@@ -0,0 +1,8 @@
package com.fabledsword.minstrel.update.data
/**
* Result of the most recent /healthz version-compatibility check.
* `SKIPPED` means the server didn't include `min_client_version`
* (partial deploy or older server) and the UI should not gate.
*/
enum class VersionResult { OK, TOO_OLD, SKIPPED }
@@ -2,21 +2,21 @@ package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.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()
}
@@ -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 })
}
}
@@ -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)
}
}