feat(android): connectivity observer + shell ConnectionErrorBanner (audit v2 #21)

Adds the foundational network-state plumbing the audit called out:

* ConnectivityObserver — Hilt singleton wrapping ConnectivityManager.
  Exposes a Flow<Boolean> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 16:29:49 -04:00
parent 6a932405f8
commit 59a111914c
3 changed files with 165 additions and 3 deletions
@@ -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<Boolean> = 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)
}
}
@@ -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<Boolean> = 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,
)
}
}
}
@@ -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()
}