From 59a111914c1d1c04822ee10b0bf5771eb87c4880 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 16:29:49 -0400 Subject: [PATCH] feat(android): connectivity observer + shell ConnectionErrorBanner (audit v2 #21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the foundational network-state plumbing the audit called out: * ConnectivityObserver — Hilt singleton wrapping ConnectivityManager. Exposes a Flow sourced from registerNetworkCallback; emits false when the active network lacks INTERNET+VALIDATED capabilities (airplane mode, no carrier, captive portal) and true once a usable network appears. Seeded with the initial value so the banner doesn't flash before the first capability callback. * ConnectionErrorBanner — shell-level Compose banner that AnimatedVisibility- shrinks/expands based on the observer. Red errorContainer surface, CloudOff icon, "No connection — check Wi-Fi or mobile data." copy. Owns a tiny ConnectivityBannerViewModel that lifts the singleton's Flow into a lifecycle-scoped StateFlow. * ShellScaffold now invokes ConnectionErrorBanner() in the banner slot above the routed content. VersionTooOld / UpdateBanner will join the same slot in follow-up commits. ACCESS_NETWORK_STATE permission was already in the manifest. Downstream repositories can also collect ConnectivityObserver.online to gate retry loops once that wiring is needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../connectivity/ConnectivityObserver.kt | 70 +++++++++++++++ .../connectivity/ui/ConnectionErrorBanner.kt | 90 +++++++++++++++++++ .../minstrel/shared/widgets/ShellScaffold.kt | 8 +- 3 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt new file mode 100644 index 00000000..28390ba6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt @@ -0,0 +1,70 @@ +package com.fabledsword.minstrel.connectivity + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Single source of truth for the device's "is the internet usable + * right now" signal — wraps [ConnectivityManager] and exposes a hot + * cold-startable Flow that emits `false` while the active network + * lacks INTERNET + VALIDATED capabilities (airplane mode, no carrier, + * captive portal, etc.) and `true` once a usable network appears. + * + * Used by the shell-level ConnectionErrorBanner; downstream + * repositories can also collect this to gate retry loops. + */ +@Singleton +class ConnectivityObserver @Inject constructor( + @ApplicationContext context: Context, +) { + private val cm = context.getSystemService(ConnectivityManager::class.java) + + val online: Flow = callbackFlow { + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(hasUsableInternet()) + } + override fun onLost(network: Network) { + trySend(hasUsableInternet()) + } + override fun onCapabilitiesChanged( + network: Network, + capabilities: NetworkCapabilities, + ) { + trySend( + capabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_INTERNET, + ) && + capabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_VALIDATED, + ), + ) + } + } + cm.registerNetworkCallback(request, callback) + // Seed the initial value so the banner doesn't flash before the + // first capability callback fires. + trySend(hasUsableInternet()) + awaitClose { cm.unregisterNetworkCallback(callback) } + }.distinctUntilChanged() + + private fun hasUsableInternet(): Boolean { + val active = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(active) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } +} 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 new file mode 100644 index 00000000..3dbb33c4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt @@ -0,0 +1,90 @@ +package com.fabledsword.minstrel.connectivity.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.ConnectivityObserver +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 + +/** + * Tiny VM that just lifts the [ConnectivityObserver] singleton's + * Flow into a StateFlow with the standard sharing strategy. Keeps + * the banner composable pure-presentation. + */ +@HiltViewModel +class ConnectivityBannerViewModel @Inject constructor( + observer: ConnectivityObserver, + @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle, +) : ViewModel() { + val online: StateFlow = observer.online.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(ONLINE_SHARE_STOP_TIMEOUT_MS), + initialValue = true, + ) +} + +/** + * 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. + */ +@Composable +fun ConnectionErrorBanner( + viewModel: ConnectivityBannerViewModel = hiltViewModel(), +) { + val online by viewModel.online.collectAsStateWithLifecycle() + AnimatedVisibility( + visible = !online, + 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 = "No connection — check Wi-Fi or mobile data.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt index 5bfc5a1f..f78a3fea 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController +import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.player.ui.MiniPlayer @@ -52,9 +53,10 @@ fun ShellScaffold( } } Column(modifier = modifier.fillMaxSize()) { - // Banners (VersionTooOld, UpdateBanner) live here once those - // mechanisms exist; for now the slot is empty so the layout - // matches the Flutter structure 1:1. + // Banner slot. ConnectionErrorBanner auto-shows when the device + // loses usable internet. VersionTooOld / UpdateBanner will join + // this slot once those mechanisms exist. + ConnectionErrorBanner() Box(modifier = Modifier.fillMaxWidth().weight(1f)) { content() }