Merge pull request 'Android #618 offline-mode UX + Sonos polish + server DRY' (#81) from dev into main
android / Build + lint + test (push) Successful in 4m35s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 13s

This commit was merged in pull request #81.
This commit is contained in:
2026-06-04 12:53:59 -04:00
20 changed files with 515 additions and 82 deletions
@@ -21,6 +21,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealthController
import com.fabledsword.minstrel.nav.DetailSeedCache import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph import com.fabledsword.minstrel.nav.MinstrelNavGraph
@@ -38,6 +41,7 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache @Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds @Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: ServerHealthController
// Flipped to true when the user taps the media notification (or // Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App // any other entry point that asks for the full player). The App
@@ -54,6 +58,7 @@ class MainActivity : ComponentActivity() {
App( App(
seedCache = seedCache, seedCache = seedCache,
cachedTrackIds = cachedTrackIds, cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth,
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(), pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false }, onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
) )
@@ -86,6 +91,7 @@ class MainActivity : ComponentActivity() {
private fun App( private fun App(
seedCache: DetailSeedCache, seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds, cachedTrackIds: CachedTrackIds,
serverHealth: ServerHealthController,
pendingOpenNowPlaying: StateFlow<Boolean>, pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedNowPlaying: () -> Unit, onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(), themeVm: ThemePreferenceViewModel = hiltViewModel(),
@@ -93,11 +99,13 @@ private fun App(
) { ) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle() val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle() val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle() val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) { MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalDetailSeedCache provides seedCache, LocalDetailSeedCache provides seedCache,
LocalCachedTrackIds provides cached, LocalCachedTrackIds provides cached,
LocalServerHealth provides health,
) { ) {
val startDestination by gate.startDestination.collectAsStateWithLifecycle() val startDestination by gate.startDestination.collectAsStateWithLifecycle()
val resolved = startDestination val resolved = startDestination
@@ -19,6 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.connectivity.ServerHealthController
import com.fabledsword.minstrel.update.data.VersionCheckController import com.fabledsword.minstrel.update.data.VersionCheckController
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -121,6 +122,15 @@ class MinstrelApplication :
*/ */
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController @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
/** /**
* Same construct-the-singleton trick — UpdateBannerController polls * Same construct-the-singleton trick — UpdateBannerController polls
* /api/client/version at launch + every 24h and drives the shell's * /api/client/version at launch + every 24h and drives the shell's
@@ -24,6 +24,14 @@ interface CachedAlbumDao {
@Query("SELECT * FROM cached_albums WHERE id = :id") @Query("SELECT * FROM cached_albums WHERE id = :id")
fun observeById(id: String): Flow<CachedAlbumEntity?> fun observeById(id: String): Flow<CachedAlbumEntity?>
@Query(
"SELECT * FROM cached_albums " +
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY sortTitle COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByTitle(q: String, limit: Int): List<CachedAlbumEntity>
@Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit") @Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String> suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -18,6 +18,14 @@ interface CachedArtistDao {
@Query("SELECT * FROM cached_artists WHERE id = :id") @Query("SELECT * FROM cached_artists WHERE id = :id")
fun observeById(id: String): Flow<CachedArtistEntity?> fun observeById(id: String): Flow<CachedArtistEntity?>
@Query(
"SELECT * FROM cached_artists " +
"WHERE name LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY sortName COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByName(q: String, limit: Int): List<CachedArtistEntity>
@Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit") @Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String> suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -24,6 +24,14 @@ interface CachedTrackDao {
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)") @Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity> suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
@Query(
"SELECT * FROM cached_tracks " +
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
"ORDER BY title COLLATE NOCASE ASC " +
"LIMIT :limit",
)
suspend fun searchByTitle(q: String, limit: Int): List<CachedTrackEntity>
@Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit") @Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit")
suspend fun idsStaleBefore(before: Long, limit: Int): List<String> suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
@@ -2,12 +2,18 @@ package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val QUEUED_MESSAGE = "Saved — will sync when online"
/** /**
* Stable mutation kinds the queue knows how to replay. Strings are * Stable mutation kinds the queue knows how to replay. Strings are
* persisted in `cached_mutations.kind` so renaming a variant breaks * persisted in `cached_mutations.kind` so renaming a variant breaks
@@ -71,47 +77,59 @@ class MutationQueue @Inject constructor(
private val dao: CachedMutationDao, private val dao: CachedMutationDao,
private val json: Json, private val json: Json,
) { ) {
// capacity=1 DROP_OLDEST so a burst of user enqueues (e.g. liking N
// tracks while offline) surfaces as one snackbar rather than queueing
// N. replay=0 because a hint observed at enqueue time isn't useful
// to a screen that mounts later.
private val _userEnqueueHints = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/**
* Hint stream consumed by [com.fabledsword.minstrel.shared.widgets.ShellScaffold]
* to surface "Saved — will sync when online" as a snackbar whenever a
* user-driven write hits the offline-fallback path. Background-only
* enqueues (play-events, playback-error reports) do not emit — those
* fire from non-foreground paths where a snackbar would be either
* dropped (no shell mounted) or jarring (lock-screen toggle).
*/
val userEnqueueHints: SharedFlow<String> = _userEnqueueHints.asSharedFlow()
suspend fun enqueueLikeToggle( suspend fun enqueueLikeToggle(
entityType: String, entityType: String,
entityId: String, entityId: String,
desiredState: Boolean, desiredState: Boolean,
): Long = dao.insert( ): Long = insertUserDriven(
CachedMutationEntity( MutationKind.LIKE_TOGGLE,
kind = MutationKind.LIKE_TOGGLE, json.encodeToString(
payload = json.encodeToString( LikeTogglePayload.serializer(),
LikeTogglePayload.serializer(), LikeTogglePayload(entityType, entityId, desiredState),
LikeTogglePayload(entityType, entityId, desiredState),
),
), ),
) )
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert( suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = insertUserDriven(
CachedMutationEntity( MutationKind.REQUEST_CREATE,
kind = MutationKind.REQUEST_CREATE, json.encodeToString(RequestCreatePayload.serializer(), payload),
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
),
) )
suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert( suspend fun enqueueQuarantineUnflag(trackId: String): Long = insertUserDriven(
CachedMutationEntity( MutationKind.QUARANTINE_UNFLAG,
kind = MutationKind.QUARANTINE_UNFLAG, json.encodeToString(
payload = json.encodeToString( QuarantineUnflagPayload.serializer(),
QuarantineUnflagPayload.serializer(), QuarantineUnflagPayload(trackId),
QuarantineUnflagPayload(trackId),
),
), ),
) )
suspend fun enqueuePlaylistAppend( suspend fun enqueuePlaylistAppend(
playlistId: String, playlistId: String,
trackIds: List<String>, trackIds: List<String>,
): Long = dao.insert( ): Long = insertUserDriven(
CachedMutationEntity( MutationKind.PLAYLIST_APPEND,
kind = MutationKind.PLAYLIST_APPEND, json.encodeToString(
payload = json.encodeToString( PlaylistAppendPayload.serializer(),
PlaylistAppendPayload.serializer(), PlaylistAppendPayload(playlistId, trackIds),
PlaylistAppendPayload(playlistId, trackIds),
),
), ),
) )
@@ -119,13 +137,19 @@ class MutationQueue @Inject constructor(
trackId: String, trackId: String,
reason: String, reason: String,
notes: String, notes: String,
): Long = dao.insert( ): Long = insertUserDriven(
CachedMutationEntity( MutationKind.QUARANTINE_FLAG,
kind = MutationKind.QUARANTINE_FLAG, json.encodeToString(
payload = json.encodeToString( QuarantineFlagPayload.serializer(),
QuarantineFlagPayload.serializer(), QuarantineFlagPayload(trackId, reason, notes),
QuarantineFlagPayload(trackId, reason, notes), ),
), )
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL,
json.encodeToString(
RequestCancelPayload.serializer(),
RequestCancelPayload(requestId),
), ),
) )
@@ -136,22 +160,18 @@ class MutationQueue @Inject constructor(
), ),
) )
suspend fun enqueueRequestCancel(requestId: String): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.REQUEST_CANCEL,
payload = json.encodeToString(
RequestCancelPayload.serializer(),
RequestCancelPayload(requestId),
),
),
)
suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert( suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
CachedMutationEntity( CachedMutationEntity(
kind = MutationKind.PLAYBACK_ERROR_REPORT, kind = MutationKind.PLAYBACK_ERROR_REPORT,
payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload), payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload),
), ),
) )
private suspend fun insertUserDriven(kind: String, payload: String): Long {
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
return id
}
} }
/** /**
@@ -0,0 +1,20 @@
package com.fabledsword.minstrel.cache.mutations
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Hilt-injectable wrapper exposing [MutationQueue.userEnqueueHints] to
* ShellScaffold. The queue itself is an app-scoped singleton; this VM
* just bridges its SharedFlow into a `hiltViewModel()`-resolvable
* surface so ShellScaffold can collect it without an EntryPoint
* accessor. Mirrors PlaybackErrorViewModel.
*/
@HiltViewModel
class OfflineWriteHintViewModel @Inject constructor(
mutationQueue: MutationQueue,
) : ViewModel() {
val messages: Flow<String> = mutationQueue.userEnqueueHints
}
@@ -0,0 +1,63 @@
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.stateIn
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 with the
* INTERNET + VALIDATED capability check (captive portals fail this).
* - [VersionCheckController.reachable] -- did the last `/healthz` poll
* succeed. Distinguishes "device has network but our server is down" from
* "no network at all," which the connectivity-only signal can't.
*
* `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
}
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = ServerHealth.Healthy,
)
}
/**
* Reactive [ServerHealth] snapshot provided once at the app root from the
* controller's StateFlow. Lets leaf composables (TrackRow gating, write-
* affordance disabling) branch on health without each ViewModel re-
* injecting the controller. Defaults to Healthy so unwrapped previews
* and tests don't crash.
*/
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
@@ -25,45 +25,45 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.composables.icons.lucide.CloudOff import com.composables.icons.lucide.CloudOff
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.connectivity.ConnectivityObserver import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealthController
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
private const val ONLINE_SHARE_STOP_TIMEOUT_MS = 5_000L private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L
/** /**
* Tiny VM that just lifts the [ConnectivityObserver] singleton's * Lifts [ServerHealthController]'s tri-state into a StateFlow for the banner
* Flow into a StateFlow with the standard sharing strategy. Keeps * composable. Keeps the banner pure-presentation.
* the banner composable pure-presentation.
*/ */
@HiltViewModel @HiltViewModel
class ConnectivityBannerViewModel @Inject constructor( class ConnectivityBannerViewModel @Inject constructor(
observer: ConnectivityObserver, health: ServerHealthController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle, @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val online: StateFlow<Boolean> = observer.online.stateIn( val health: StateFlow<ServerHealth> = health.state.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ONLINE_SHARE_STOP_TIMEOUT_MS), started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS),
initialValue = true, initialValue = ServerHealth.Healthy,
) )
} }
/** /**
* Banner shown at the top of the shell when the device has no usable * Banner shown at the top of the shell when the user can't reach the server.
* internet. Mirrors Flutter's ConnectionErrorBanner: red-tinted error * Tri-state so we tell the user *why*: no device network vs server-down.
* surface, CloudOff icon, "No connection — check Wi-Fi or mobile * Copy choices match the Flutter analogues. Auto-hides via slide+fade when
* data" copy. Auto-hides via slide+fade when connectivity returns. * health returns to [ServerHealth.Healthy].
*/ */
@Composable @Composable
fun ConnectionErrorBanner( fun ConnectionErrorBanner(
viewModel: ConnectivityBannerViewModel = hiltViewModel(), viewModel: ConnectivityBannerViewModel = hiltViewModel(),
) { ) {
val online by viewModel.online.collectAsStateWithLifecycle() val health by viewModel.health.collectAsStateWithLifecycle()
AnimatedVisibility( AnimatedVisibility(
visible = !online, visible = health != ServerHealth.Healthy,
enter = expandVertically() + fadeIn(), enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(), exit = shrinkVertically() + fadeOut(),
) { ) {
@@ -81,7 +81,13 @@ fun ConnectionErrorBanner(
tint = MaterialTheme.colorScheme.onErrorContainer, tint = MaterialTheme.colorScheme.onErrorContainer,
) )
Text( Text(
text = "No connection — check Wi-Fi or mobile data.", 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
) )
@@ -81,6 +81,21 @@ class MinstrelForwardingPlayer(
// CONFLATED so repeated trySend's between polls don't queue up. // CONFLATED so repeated trySend's between polls don't queue up.
private val pollTrigger = Channel<Unit>(Channel.CONFLATED) private val pollTrigger = Channel<Unit>(Channel.CONFLATED)
// External Player.Listener registry (separate from super.addListener which
// forwards to the wrapped ExoPlayer). The wrapped player is paused with
// no audio loaded while UPnP is active, so it never fires events for our
// synthesized remote state -- the MediaSession's notification card and
// lock-screen scrubber stay frozen on whatever state was last captured
// before UPnP took over. We dual-register: super.addListener keeps the
// listener attached to the delegate (so local-playback events still
// reach it), AND we hold a ref here so we can directly invoke listener
// callbacks on remote-state changes. The listener's read of isPlaying /
// duration / position then routes through our overrides to remoteState.
private val externalListeners = mutableListOf<Player.Listener>()
@Volatile private var lastNotifiedIsPlaying: Boolean = false
@Volatile private var lastNotifiedTrackIdx: Int = -1
private val lifecycleObserver = object : DefaultLifecycleObserver { private val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) { override fun onResume(owner: LifecycleOwner) {
pollTrigger.trySend(Unit) pollTrigger.trySend(Unit)
@@ -121,7 +136,10 @@ class MinstrelForwardingPlayer(
} else { } else {
scope.launch { scope.launch {
runCatching { active.avTransport.play() } runCatching { active.avTransport.play() }
.onSuccess { remoteState.applyTransportPlaying() } .onSuccess {
remoteState.applyTransportPlaying()
notifyRemoteStateChanged()
}
.onFailure { handleSoapFailure(active, it) } .onFailure { handleSoapFailure(active, it) }
} }
} }
@@ -139,7 +157,10 @@ class MinstrelForwardingPlayer(
} else { } else {
scope.launch { scope.launch {
runCatching { active.avTransport.pause() } runCatching { active.avTransport.pause() }
.onSuccess { remoteState.applyTransportPaused() } .onSuccess {
remoteState.applyTransportPaused()
notifyRemoteStateChanged()
}
.onFailure { handleSoapFailure(active, it) } .onFailure { handleSoapFailure(active, it) }
} }
} }
@@ -269,6 +290,54 @@ class MinstrelForwardingPlayer(
override fun getPlayWhenReady(): Boolean = override fun getPlayWhenReady(): Boolean =
if (isRemote()) remoteState.isPlaying else super.getPlayWhenReady() if (isRemote()) remoteState.isPlaying else super.getPlayWhenReady()
override fun addListener(listener: Player.Listener) {
super.addListener(listener)
synchronized(externalListeners) { externalListeners.add(listener) }
}
override fun removeListener(listener: Player.Listener) {
super.removeListener(listener)
synchronized(externalListeners) { externalListeners.remove(listener) }
}
/**
* Direct-invoke the externally-registered Player.Listeners so the
* MediaSession's PlaybackState publisher (notification card, lock-screen
* scrubber, BT/AVRCP, Auto, Wear OS tile) re-reads our overridden state.
* The listeners then query isPlaying / getDuration / getCurrentPosition,
* all of which route through to remoteState while UPnP is active.
*
* Posted to the player's application looper because Player.Listener
* callbacks contract on the application thread.
*/
private fun notifyRemoteStateChanged() {
if (!isRemote()) return
val playing = remoteState.isPlaying
val trackIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
val isPlayingChanged = playing != lastNotifiedIsPlaying
val trackChanged = trackIdx != lastNotifiedTrackIdx
if (!isPlayingChanged && !trackChanged) return
lastNotifiedIsPlaying = playing
lastNotifiedTrackIdx = trackIdx
val snapshot = synchronized(externalListeners) { externalListeners.toList() }
handler.post {
for (l in snapshot) {
if (isPlayingChanged) {
l.onIsPlayingChanged(playing)
l.onPlaybackStateChanged(Player.STATE_READY)
}
if (trackChanged) {
val item = if (trackIdx < delegate.mediaItemCount) {
delegate.getMediaItemAt(trackIdx)
} else {
null
}
l.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)
}
}
}
}
override fun release() { override fun release() {
pollJob?.cancel() pollJob?.cancel()
scope.cancel() scope.cancel()
@@ -288,6 +357,11 @@ class MinstrelForwardingPlayer(
private fun onActiveChanged(active: ActiveUpnp?) { private fun onActiveChanged(active: ActiveUpnp?) {
pollJob?.cancel() pollJob?.cancel()
nonPlayingPollStreak = 0 nonPlayingPollStreak = 0
// Reset notify cache so the first poll after a route flip republishes
// playing/track state to the MediaSession even if it happens to match
// the prior session's values numerically.
lastNotifiedIsPlaying = false
lastNotifiedTrackIdx = -1
if (active != null) { if (active != null) {
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName) Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
// Pause the wrapped ExoPlayer so we are not playing local audio // Pause the wrapped ExoPlayer so we are not playing local audio
@@ -373,6 +447,7 @@ class MinstrelForwardingPlayer(
} }
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
} }
notifyRemoteStateChanged()
} }
private fun maybeSyncLocalCursor(sonosTrack: Int) { private fun maybeSyncLocalCursor(sonosTrack: Int) {
@@ -0,0 +1,65 @@
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.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
* 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
* (which then surfaced as a silent decode failure to the user).
*
* Wrapping rather than substituting the OkHttp data source lets the cache
* write path remain intact for when health returns and we DO want to fetch:
* we keep the same upstream all the time, just gate `open()`.
*/
class OfflineGatedDataSource(
private val delegate: DataSource,
private val health: ServerHealthController,
) : DataSource {
override fun open(dataSpec: DataSpec): Long {
when (health.state.value) {
ServerHealth.Offline -> throw OfflineException(
"Track not in the on-device cache and the device is offline.",
)
ServerHealth.ServerDown -> throw OfflineException(
"Track not in the on-device cache and the Minstrel server is unreachable.",
)
ServerHealth.Healthy -> Unit
}
return delegate.open(dataSpec)
}
override fun close() = delegate.close()
override fun getUri() = delegate.uri
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
delegate.read(buffer, offset, length)
override fun addTransferListener(transferListener: TransferListener) =
delegate.addTransferListener(transferListener)
override fun getResponseHeaders() = delegate.responseHeaders
}
class OfflineGatedDataSourceFactory(
private val upstream: DataSource.Factory,
private val health: ServerHealthController,
) : DataSource.Factory {
override fun createDataSource(): DataSource =
OfflineGatedDataSource(upstream.createDataSource(), health)
}
/**
* Signals the audio-source error path that the request was denied because the
* device is offline / the server is unreachable. ExoPlayer's [androidx.media3
* .common.PlaybackException] catches it via [InterruptedIOException]'s
* `IOException` ancestor and surfaces it as a SOURCE error, which then flows
* through the existing [PlaybackErrorReporter] -> snackbar path.
*/
class OfflineException(message: String) : IOException(message)
@@ -666,6 +666,10 @@ class PlayerController @Inject constructor(
.setArtist(artistName) .setArtist(artistName)
.setAlbumTitle(albumTitle) .setAlbumTitle(albumTitle)
.apply { .apply {
// Server-known duration -- gives the lock-screen / notification
// scrubber a real total even when the wrapped ExoPlayer is
// paused under UPnP (it never probes a duration in that state).
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
if (source != null) setExtras(sourceExtras(source)) if (source != null) setExtras(sourceExtras(source))
} }
.build() .build()
@@ -52,6 +52,7 @@ class PlayerFactory @Inject constructor(
private val cacheConfig: CacheConfig, private val cacheConfig: CacheConfig,
private val activeUpnpHolder: ActiveUpnpHolder, private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val serverHealth: com.fabledsword.minstrel.connectivity.ServerHealthController,
) { ) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() } private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -83,9 +84,14 @@ class PlayerFactory @Inject constructor(
private fun buildExoPlayer(): ExoPlayer { private fun buildExoPlayer(): ExoPlayer {
val httpDataSource = OkHttpDataSource.Factory(okHttpClient) val httpDataSource = OkHttpDataSource.Factory(okHttpClient)
// Gate network reads on ServerHealth so a cache miss while offline
// fails fast with an OfflineException instead of hitting an OkHttp
// timeout. CacheDataSource only consults the upstream factory on a
// cache miss, so playback of cached audio is unaffected.
val gatedUpstream = OfflineGatedDataSourceFactory(httpDataSource, serverHealth)
val cacheDataSource = CacheDataSource.Factory() val cacheDataSource = CacheDataSource.Factory()
.setCache(simpleCache) .setCache(simpleCache)
.setUpstreamDataSourceFactory(httpDataSource) .setUpstreamDataSourceFactory(gatedUpstream)
.setCacheWriteDataSinkFactory( .setCacheWriteDataSinkFactory(
CacheDataSink.Factory() CacheDataSink.Factory()
.setCache(simpleCache) .setCache(simpleCache)
@@ -19,6 +19,7 @@ import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -372,6 +373,13 @@ class OutputPickerController @Inject constructor(
if (outcome.isSuccess) { if (outcome.isSuccess) {
consecutiveFailures = 0 consecutiveFailures = 0
succeeded += 1 succeeded += 1
// Throttle the burst so we don't tickle Sonos's burst-add
// rejection -- logcat 2026-06-04 showed 33 consecutive
// failures clustered at ~10ms intervals once offset 39 was
// reached, which looks like a rate-limit kicking in. The
// delay is small enough that extending 100 tracks adds
// only ~5s to background work that's already async.
delay(EXTEND_THROTTLE_MS)
} else { } else {
consecutiveFailures += 1 consecutiveFailures += 1
val e = outcome.exceptionOrNull() val e = outcome.exceptionOrNull()
@@ -423,5 +431,6 @@ class OutputPickerController @Inject constructor(
private companion object { private companion object {
const val EXTEND_ABORT_AFTER_FAILURES = 3 const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L
} }
} }
@@ -1,6 +1,11 @@
package com.fabledsword.minstrel.search.data package com.fabledsword.minstrel.search.data
import com.fabledsword.minstrel.api.endpoints.SearchApi 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.ServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealthController
import com.fabledsword.minstrel.library.data.toDomain import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.SearchResponseRef import com.fabledsword.minstrel.models.SearchResponseRef
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -8,17 +13,34 @@ import retrofit2.create
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val LOCAL_SEARCH_LIMIT = 20
/** /**
* Thin Retrofit wrapper around `/api/search`. Debouncing lives in * Cache-first when offline. When [ServerHealthController] is Healthy the
* the ViewModel, not here, so the repository stays trivial. * 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
* [SearchOutcome.localOnly] flag lets the screen draw an "offline
* results" hint instead of pretending the server answered.
*/ */
@Singleton @Singleton
class SearchRepository @Inject constructor( class SearchRepository @Inject constructor(
retrofit: Retrofit, retrofit: Retrofit,
private val serverHealth: ServerHealthController,
private val trackDao: CachedTrackDao,
private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao,
) { ) {
private val api: SearchApi = retrofit.create() private val api: SearchApi = retrofit.create()
suspend fun search(query: String): SearchResponseRef { suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
ServerHealth.Healthy -> SearchOutcome(remoteSearch(query), localOnly = false)
ServerHealth.Offline, ServerHealth.ServerDown ->
SearchOutcome(localSearch(query), localOnly = true)
}
private suspend fun remoteSearch(query: String): SearchResponseRef {
val wire = api.search(query) val wire = api.search(query)
return SearchResponseRef( return SearchResponseRef(
artists = wire.artists.items.map { it.toDomain() }, artists = wire.artists.items.map { it.toDomain() },
@@ -26,4 +48,21 @@ class SearchRepository @Inject constructor(
tracks = wire.tracks.items.map { it.toDomain() }, tracks = wire.tracks.items.map { it.toDomain() },
) )
} }
private suspend fun localSearch(query: String): SearchResponseRef = SearchResponseRef(
artists = artistDao.searchByName(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
albums = albumDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
tracks = trackDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
)
} }
/**
* Wraps the search response with the signal of whether the result came
* from the server or from the local cached entities. The screen renders
* the same SearchResponseRef either way; the flag drives the offline
* banner copy.
*/
data class SearchOutcome(
val response: SearchResponseRef,
val localOnly: Boolean,
)
@@ -158,17 +158,28 @@ private fun ResultsPane(
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}") is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
is SearchResultsState.Loaded -> { is SearchResultsState.Loaded -> {
if (state.response.isEmpty) { if (state.response.isEmpty) {
CenteredHint("No matches for that query.") CenteredHint(
} else { if (state.localOnly) {
ResultsList( "No matches in your on-device library."
response = state.response, } else {
playingTrackId = playingTrackId, "No matches for that query."
onArtistClick = onArtistClick, },
onAlbumClick = onAlbumClick,
onTrackPlay = onTrackPlay,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
) )
} else {
Column(modifier = Modifier.fillMaxSize()) {
if (state.localOnly) {
OfflineResultsHint()
}
ResultsList(
response = state.response,
playingTrackId = playingTrackId,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onTrackPlay = onTrackPlay,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
} }
} }
} }
@@ -308,6 +319,18 @@ private fun SectionHeader(label: String, count: Int) {
} }
} }
@Composable
private fun OfflineResultsHint() {
Text(
text = "Showing on-device matches only — the server is unreachable.",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable @Composable
private fun CenteredHint(text: String) { private fun CenteredHint(text: String) {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
@@ -26,7 +26,10 @@ sealed interface SearchResultsState {
/** Empty query — screen shows "type to search" hint. */ /** Empty query — screen shows "type to search" hint. */
data object Idle : SearchResultsState data object Idle : SearchResultsState
data object Loading : SearchResultsState data object Loading : SearchResultsState
data class Loaded(val response: SearchResponseRef) : SearchResultsState data class Loaded(
val response: SearchResponseRef,
val localOnly: Boolean = false,
) : SearchResultsState
data class Error(val message: String) : SearchResultsState data class Error(val message: String) : SearchResultsState
} }
@@ -98,8 +101,15 @@ class SearchViewModel @Inject constructor(
private suspend fun runSearch(q: String) { private suspend fun runSearch(q: String) {
internal.update { it.copy(results = SearchResultsState.Loading) } internal.update { it.copy(results = SearchResultsState.Loading) }
try { try {
val response = repository.search(q) val outcome = repository.search(q)
internal.update { it.copy(results = SearchResultsState.Loaded(response)) } internal.update {
it.copy(
results = SearchResultsState.Loaded(
response = outcome.response,
localOnly = outcome.localOnly,
),
)
}
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught") e: Throwable,
) { ) {
@@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
import com.fabledsword.minstrel.player.ui.MiniPlayer import com.fabledsword.minstrel.player.ui.MiniPlayer
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
@@ -48,6 +49,7 @@ fun ShellScaffold(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(), trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(), playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(),
offlineWriteHintViewModel: OfflineWriteHintViewModel = hiltViewModel(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -61,6 +63,11 @@ fun ShellScaffold(
snackbarHostState.showSnackbar(msg) snackbarHostState.showSnackbar(msg)
} }
} }
LaunchedEffect(Unit) {
offlineWriteHintViewModel.messages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
// Consume the status-bar inset once here so the banner stack sits // Consume the status-bar inset once here so the banner stack sits
// below the status bar (mirrors Flutter's SafeArea(bottom:false)). // below the status bar (mirrors Flutter's SafeArea(bottom:false)).
// statusBarsPadding consumes the inset for descendants, so the in- // statusBarsPadding consumes the inset for descendants, so the in-
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.shared.widgets package com.fabledsword.minstrel.shared.widgets
import android.widget.Toast
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -12,8 +13,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fabledsword.minstrel.connectivity.LocalServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealth
private const val OFFLINE_UNAVAILABLE_ALPHA = 0.4f
private const val OFFLINE_TAP_MESSAGE = "Not downloaded — connect to play"
/** /**
* Shared track-list row. Replaces the 5 per-screen `TrackRow`s — every * Shared track-list row. Replaces the 5 per-screen `TrackRow`s — every
@@ -30,6 +37,14 @@ import androidx.compose.ui.unit.dp
* for applying its own alpha if it should match (the row doesn't * for applying its own alpha if it should match (the row doesn't
* cascade because the trailing slot's content is the caller's, not * cascade because the trailing slot's content is the caller's, not
* ours). * ours).
*
* Reads [LocalServerHealth] + [LocalCachedTrackIds] and intercepts taps
* on tracks that aren't downloaded when the server is unreachable —
* fires a Toast instead of attempting playback. The text dims so the
* user can see at a glance which rows in a long list will work offline.
* The trailing slot stays interactive so the kebab / like / playlist-
* add affordances can still queue mutations for offline replay (Phase
* 5 of #618 gates those at the action level).
*/ */
@Composable @Composable
fun TrackRow( fun TrackRow(
@@ -45,15 +60,28 @@ fun TrackRow(
leading: @Composable () -> Unit = {}, leading: @Composable () -> Unit = {},
trailing: @Composable RowScope.() -> Unit = {}, trailing: @Composable RowScope.() -> Unit = {},
) { ) {
val context = LocalContext.current
val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy &&
trackId !in LocalCachedTrackIds.current
val titleColor = if (nowPlaying) { val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
} }
val effectiveAlpha = if (offlineUnavailable) {
minOf(contentAlpha, OFFLINE_UNAVAILABLE_ALPHA)
} else {
contentAlpha
}
val effectiveOnClick: () -> Unit = if (offlineUnavailable) {
{ Toast.makeText(context, OFFLINE_TAP_MESSAGE, Toast.LENGTH_SHORT).show() }
} else {
onClick
}
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick) .clickable(enabled = enabled, onClick = effectiveOnClick)
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = horizontalArrangement, horizontalArrangement = horizontalArrangement,
@@ -63,7 +91,7 @@ fun TrackRow(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = titleColor.copy(alpha = contentAlpha), color = titleColor.copy(alpha = effectiveAlpha),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -71,7 +99,7 @@ fun TrackRow(
Text( Text(
text = artist, text = artist,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = effectiveAlpha),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -41,6 +41,16 @@ class VersionCheckController @Inject constructor(
private val internal = MutableStateFlow(VersionResult.SKIPPED) private val internal = MutableStateFlow(VersionResult.SKIPPED)
val result: StateFlow<VersionResult> = internal.asStateFlow() 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()
init { init {
scope.launch { scope.launch {
while (true) { while (true) {
@@ -56,7 +66,13 @@ class VersionCheckController @Inject constructor(
} }
private suspend fun runOnce() { private suspend fun runOnce() {
val response = runCatching { api.check() }.getOrNull() ?: return val outcome = runCatching { api.check() }
val response = outcome.getOrNull()
if (response == null) {
internalReachable.value = false
return
}
internalReachable.value = true
val min = response.minClientVersion val min = response.minClientVersion
internal.value = when { internal.value = when {
min.isEmpty() -> VersionResult.SKIPPED min.isEmpty() -> VersionResult.SKIPPED