feat(android): ServerHealth tri-state composite + banner distinguishes offline vs server-down
android / Build + lint + test (push) Has been cancelled

Phase 1 of #618. VersionCheckController gains reachable: StateFlow<Boolean> from the same /healthz poll (no double polling). ServerHealthController combines connectivity.online + versionCheck.reachable into ServerHealth { Healthy, Offline, ServerDown }. ConnectionErrorBanner now branches on the tri-state, distinguishing 'no Wi-Fi' from 'server unreachable.' Phases 2-5 (local search, cache-only audio source, row-level not-cached affordance, write-affordance gray-out) ship separately as independent slices.
This commit is contained in:
2026-06-04 11:25:22 -04:00
parent 4d0a0b8e09
commit 80a6be25aa
4 changed files with 102 additions and 17 deletions
@@ -19,6 +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 dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
@@ -121,6 +122,15 @@ class MinstrelApplication :
*/
@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
* /api/client/version at launch + every 24h and drives the shell's
@@ -0,0 +1,53 @@
package com.fabledsword.minstrel.connectivity
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,
)
}
@@ -25,45 +25,45 @@ 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.ConnectivityObserver
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealthController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
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
* Flow into a StateFlow with the standard sharing strategy. Keeps
* the banner composable pure-presentation.
* Lifts [ServerHealthController]'s tri-state into a StateFlow for the banner
* composable. Keeps the banner pure-presentation.
*/
@HiltViewModel
class ConnectivityBannerViewModel @Inject constructor(
observer: ConnectivityObserver,
health: ServerHealthController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
val online: StateFlow<Boolean> = observer.online.stateIn(
val health: StateFlow<ServerHealth> = health.state.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ONLINE_SHARE_STOP_TIMEOUT_MS),
initialValue = true,
started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS),
initialValue = ServerHealth.Healthy,
)
}
/**
* Banner shown at the top of the shell when the device has no usable
* internet. Mirrors Flutter's ConnectionErrorBanner: red-tinted error
* surface, CloudOff icon, "No connection — check Wi-Fi or mobile
* data" copy. Auto-hides via slide+fade when connectivity returns.
* 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].
*/
@Composable
fun ConnectionErrorBanner(
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
) {
val online by viewModel.online.collectAsStateWithLifecycle()
val health by viewModel.health.collectAsStateWithLifecycle()
AnimatedVisibility(
visible = !online,
visible = health != ServerHealth.Healthy,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
@@ -81,7 +81,13 @@ fun ConnectionErrorBanner(
tint = MaterialTheme.colorScheme.onErrorContainer,
)
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,
color = MaterialTheme.colorScheme.onErrorContainer,
)
@@ -41,6 +41,16 @@ class VersionCheckController @Inject constructor(
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()
init {
scope.launch {
while (true) {
@@ -56,7 +66,13 @@ class VersionCheckController @Inject constructor(
}
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
internal.value = when {
min.isEmpty() -> VersionResult.SKIPPED