diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index d04bb858..1b6f8e91 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt new file mode 100644 index 00000000..e0aa1f2c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt @@ -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 = 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, + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt index 3dbb33c4..c736610e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt @@ -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 = observer.online.stateIn( + val health: StateFlow = 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, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt index 3ab9087d..1aa51435 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt @@ -41,6 +41,16 @@ class VersionCheckController @Inject constructor( private val internal = MutableStateFlow(VersionResult.SKIPPED) val result: StateFlow = 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 = 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