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:
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
+90
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user