Telegram

✅ SIMPLE AND CLEAN NETWORK CONNECTIVITY HANDLING IN JETPACK COMPOSE

✅ Simple & Clean Network Connectivity Handling in Jetpack Compose

In the modern landscape of Android application development, the transition to declarative UI frameworks has fundamentally changed how we approach state management and user interaction. Jetpack Compose has emerged as the industry standard for building native UI, offering a streamlined, Kotlin-first approach. However, as applications become more complex and reliant on real-time data, one critical aspect remains a constant requirement: robust network connectivity handling. A seamless user experience is impossible without a reliable mechanism to detect, monitor, and respond to changes in network availability. We understand that developers require a solution that is not only functional but also adheres to the principles of clean architecture, resulting in code that is maintainable, testable, and scalable.

The challenge lies in integrating Android’s legacy connectivity services, such as ConnectivityManager, into the reactive and state-driven world of Compose. Traditional approaches often lead to boilerplate code, memory leaks due to improper lifecycle management, and tightly coupled components. We will explore a comprehensive, production-ready strategy to implement simple and clean network connectivity handling in Jetpack Compose. This guide focuses on architectural patterns that leverage Kotlin Coroutines, StateFlow, and Lifecycle-aware components to create a solution that is both elegant and efficient. By the end of this article, you will possess the knowledge to build a resilient connectivity monitoring system that enhances your application’s reliability and user satisfaction.

Understanding the Core Challenge of Connectivity in Modern Android Apps

Before diving into the implementation, it is crucial to understand the underlying mechanics of network monitoring on the Android platform. Since Android 10 (API 29), Google has deprecated the NetworkInfo class and encouraged the use of the NetworkCapabilities API. This shift provides a more granular view of the network state, distinguishing between different types of connections (e.g., cellular, Wi-Fi, Ethernet) and their specific capabilities (e.g., internet access, validated, not metered).

However, interacting with the ConnectivityManager directly requires managing callbacks and ensuring that these callbacks are registered and unregistered correctly according to the Android Activity/Fragment lifecycle. In a traditional View system, developers often relied on LifecycleObserver or manually tying logic to onStart and onStop. In Jetpack Compose, we aim to avoid these imperative patterns. We seek a solution where the network state is treated as a stream of data—much like a flow of database entries—that the UI can simply observe and react to. The primary goal is to decouple the logic of detecting connectivity from the logic of displaying it.

We must also address the distinction between “connected” and “connected to the internet.” A device might be connected to a Wi-Fi network that requires a captive portal (like a hotel or airport Wi-Fi) and does not actually provide internet access. A robust solution must check for validated internet capabilities. Furthermore, we need to handle the transient states: the momentary flicker when switching from Wi-Fi to LTE, or the brief gap before a network becomes active. Handling these edge cases ensures the UI does not show misleading loading spinners or error messages, which is vital for a polished user experience.

Architectural Strategy: The Single Source of Truth

To achieve “clean” handling, we must adhere to the Unidirectional Data Flow (UDF) principle, which is central to Jetpack Compose. The network state should originate from a single source of truth, typically a repository or a dedicated manager class, and flow downstream to the UI layer. We will implement this using a NetworkMonitor class that exposes the connectivity status as a Kotlin StateFlow.

This approach offers several advantages:

  1. Lifecycle Independence: The monitoring logic runs independently of any specific UI component, allowing it to persist across configuration changes (like screen rotation) or even when the app is in the background (if required).
  2. Testability: By abstracting the connectivity logic into its own class, we can easily unit test the state emission and mock the ConnectivityManager system service.
  3. Reusability: The NetworkMonitor can be injected via Dependency Injection (e.g., Hilt or Koin) into any ViewModel that requires connectivity information, making the codebase modular.

We will construct this system in layers:

Defining the Network State Model

A clean codebase starts with clear data modeling. We need a sealed class (or sealed interface in newer Kotlin versions) to represent the distinct network states. This allows us to use Kotlin’s powerful when expressions in our Composables to handle every possible state exhaustively, ensuring we never miss an edge case.

sealed interface NetworkState {
    object Available : NetworkState
    object Unavailable : NetworkState
    data class Losing(val timeoutMs: Int) : NetworkState
    data class Lost(val timeoutMs: Int) : NetworkState
}

While the Android system provides many granular states, for most application use cases, we can simplify this into a binary state (Available/Unavailable) or a more detailed state if necessary. However, for the purpose of this guide, we will focus on a robust Available and Unavailable distinction, ensuring we only mark a network as available if it has internet access.

We will also introduce a wrapper for the capabilities to determine the network type (Wi-Fi, Cellular, etc.) if the application requires specific routing logic (e.g., downloading large files only on Wi-Fi).

data class NetworkStatus(
    val isConnected: Boolean,
    val networkType: NetworkType
)

enum class NetworkType {
    WIFI, CELLULAR, ETHERNET, OTHER, NONE
}

By defining these models clearly, we ensure that the data passing through our application is type-safe and self-descriptive.

Implementing the NetworkMonitor with Callbacks and Flow

The core of our solution lies in the NetworkMonitor. This class will be responsible for registering a NetworkCallback with the system’s ConnectivityManager. Since ConnectivityManager is a system service, we will inject it via the Context.

We will use callbackFlow from Kotlin Coroutines. This builder allows us to convert callback-based APIs into a Flow. It provides a ProducerScope where we can register the callback and send the current state immediately upon collection.

import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
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

@Singleton
class NetworkMonitor @Inject constructor(
    private val context: Context
) {
    private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

    fun networkStatus(): Flow<NetworkStatus> = callbackFlow {
        val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) {
                super.onAvailable(network)
                trySend(getCurrentNetworkStatus())
            }

            override fun onLost(network: Network) {
                super.onLost(network)
                trySend(getCurrentNetworkStatus())
            }

            override fun onCapabilitiesChanged(
                network: Network, 
                networkCapabilities: NetworkCapabilities
            ) {
                super.onCapabilitiesChanged(network, networkCapabilities)
                // Only send if the internet capability changes to avoid spamming
                if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
                    trySend(getCurrentNetworkStatus())
                }
            }
        }

        val request = NetworkRequest.Builder()
            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
            .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
            .build()

        connectivityManager.registerNetworkCallback(request, callback)

        // Send the current status immediately
        trySend(getCurrentNetworkStatus())

        // Unregister the callback when the flow is cancelled (e.g., ViewModel cleared)
        awaitClose {
            connectivityManager.unregisterNetworkCallback(callback)
        }
    }.distinctUntilChanged()

    private fun getCurrentNetworkStatus(): NetworkStatus {
        val network = connectivityManager.activeNetwork
        val capabilities = connectivityManager.getNetworkCapabilities(network)
        
        return if (capabilities != null && 
            capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
            capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
            
            val networkType = when {
                capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> NetworkType.WIFI
                capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> NetworkType.CELLULAR
                capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> NetworkType.ETHERNET
                else -> NetworkType.OTHER
            }
            
            NetworkStatus(isConnected = true, networkType = networkType)
        } else {
            NetworkStatus(isConnected = false, networkType = NetworkType.NONE)
        }
    }
}

Key Implementation Details

In the code above, we observe several critical design choices:

  1. callbackFlow and awaitClose: This is the standard way to bridge imperative callbacks into a declarative Flow. awaitClose ensures that unregisterNetworkCallback is called when the collector cancels the flow, preventing memory leaks.
  2. distinctUntilChanged: We apply this operator to prevent the UI from recomposing if the network state hasn’t actually changed (e.g., receiving a capability update that doesn’t alter the connected status).
  3. Validation Check: We explicitly check for NET_CAPABILITY_VALIDATED. This is crucial. A device might be connected to a router, but without internet access. By checking validation, we ensure the user actually has working internet.
  4. Immediate Emission: We call trySend(getCurrentNetworkStatus()) immediately after creating the callback. This ensures that the collector receives the current state right away, rather than waiting for the next network event.

Integrating with Jetpack Compose via ViewModel

Now that we have a data source, we need to expose it to the UI. The ViewModel serves as the perfect intermediary. We will use StateFlow and the viewModel-scope to collect the network status and expose it as a read-only state.

We will use the stateIn operator to convert the hot Flow into a StateFlow that is shared and caches the latest value.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject

@HiltViewModel
class MainViewModel @Inject constructor(
    networkMonitor: NetworkMonitor
) : ViewModel() {

    val networkStatus: StateFlow<NetworkStatus> = networkMonitor.networkStatus()
        .map { status ->
            // We can do light mapping here if needed
            status
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000), // Keep stream active 5s after last subscriber
            initialValue = NetworkStatus(isConnected = false, networkType = NetworkType.NONE)
        )
}

Understanding SharingStarted.WhileSubscribed

The choice of SharingStarted strategy is vital for performance and battery life.

By using WhileSubscribed, we ensure we respect the user’s battery while maintaining state across UI re-creation.

Building the Reactive UI Layer

With the ViewModel providing the state, the Composable function becomes incredibly simple. We utilize collectAsStateWithLifecycle() to observe the flow in a lifecycle-aware manner. This is the modern, recommended way to collect flows in Compose, replacing the older observeAsState().

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun MainScreen(
    viewModel: MainViewModel = hiltViewModel()
) {
    val networkStatus by viewModel.networkStatus.collectAsStateWithLifecycle()

    // Main content container
    // We wrap the content in a Box or Scaffold to overlay the connectivity indicator
    Scaffold(
        topBar = { /* ... */ }
    ) { padding ->
        // Content based on connectivity
        if (networkStatus.isConnected) {
            OnlineContent(padding)
        } else {
            OfflineContent(padding)
        }
        
        // Floating Connectivity Indicator (Optional but recommended)
        ConnectivityIndicator(networkStatus)
    }
}

Designing the Connectivity Indicator

A good UX pattern for network handling is a subtle, non-intrusive indicator rather than a blocking dialog. We can implement a small banner or a status bar that appears at the top or bottom of the screen.

@Composable
fun ConnectivityIndicator(networkStatus: NetworkStatus) {
    AnimatedVisibility(
        visible = !networkStatus.isConnected,
        enter = slideInVertically(initialOffsetY = { -it }),
        exit = slideOutVertically(targetOffsetY = { -it })
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .background(Color.Red)
                .padding(vertical = 8.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "No Internet Connection",
                color = Color.White,
                fontWeight = FontWeight.Bold
            )
        }
    }
}

Using AnimatedVisibility ensures that the transition is smooth, preventing jarring UI shifts. This feedback loop informs the user immediately that the app cannot fetch data without blocking their interaction with previously loaded content.

Handling UI Logic Based on State

When building complex screens, we often need to disable specific actions (like “Refresh” or “Submit”) when offline. We can derive Boolean states from our NetworkStatus.

@Composable
fun ActionButtons(networkStatus: NetworkStatus) {
    val isOnline = networkStatus.isConnected
    
    Button(
        onClick = { /* Refresh data */ },
        enabled = isOnline
    ) {
        Text("Refresh Data")
    }
    
    Button(
        onClick = { /* Upload file */ },
        enabled = isOnline && networkStatus.networkType == NetworkType.WIFI
    ) {
        Text("Upload Large File (Wi-Fi Only)")
    }
}

This granular control allows us to optimize data usage and prevent errors before they happen. By checking the networkType, we can enforce business rules, such as restricting large uploads to Wi-Fi to save user mobile data.

Advanced Handling: Caching and Optimistic Updates

Network handling isn’t just about detecting connection loss; it’s about managing data consistency. When the network is unavailable, the app should degrade gracefully.

Local Caching Strategy

We recommend integrating Room Database or DataStore to cache API responses. When the NetworkStatus indicates Unavailable, the UI should switch to displaying cached data.

  1. Repository Pattern: Your Repository should check the network state. If online, fetch fresh data and update the cache. If offline, serve the last cached data.
  2. Stale Data Indication: You might want to show a small icon indicating that the data is “Cached” and potentially outdated.

Offline-First Approach

In the ViewModel, we can combine the network status with database flows:

// Pseudo-code for ViewModel
val uiState: StateFlow<UiState> = combine(
    networkMonitor.networkStatus(),
    localDatabase.getItemsFlow()
) { status, items ->
    when {
        items.isEmpty() && !status.isConnected -> UiState.Error("No data available offline")
        items.isNotEmpty() && !status.isConnected -> UiState.Success(items, isCached = true)
        else -> UiState.Success(items, isCached = false)
    }
}.stateIn(...)

This ensures that the user always sees data, even if it’s old, which is significantly better than a blank screen.

Testing the Connectivity Logic

To ensure our solution is robust, we must write unit tests. We can mock the ConnectivityManager and verify that our NetworkMonitor emits the correct states.

@Test
fun `network monitor emits available when validated internet is present`() = runTest {
    val mockManager = mockk<ConnectivityManager>()
    val context = mockk<Context>()
    
    every { context.getSystemService(any()) } returns mockManager
    
    // Mock the active network and capabilities
    // ... setup mock behavior ...
    
    val monitor = NetworkMonitor(context)
    
    launch {
        monitor.networkStatus().test {
            val status = awaitItem()
            assertTrue(status.isConnected)
            cancelAndIgnoreRemainingEvents()
        }
    }
}

While complex to mock fully, this approach validates that the logic in getCurrentNetworkStatus functions as expected.

Best Practices and Common Pitfalls

When implementing network handling in Jetpack Compose, we should adhere to these best practices:

  1. Permissions: Ensure you have the ACCESS_NETWORK_STATE permission in your manifest. You do not need INTERNET permission to check for connectivity, but you need it to actually access the internet.
  2. Avoid Blocking the Main Thread: The ConnectivityManager callbacks execute on the main thread. Keep the logic inside the callback lightweight (as we did with simple checks) to avoid ANRs (Application Not Responding).
  3. Handling Background Work: If you need to retry API calls when the network comes back, use WorkManager. Do not rely on the ViewModel for long-running background tasks, as the ViewModel can be cleared if the app goes into the background for too long.
  4. Don’t Trust the Network: “The network is unreliable.” Never assume a connection is stable. Implement exponential backoff for retries and handle timeouts gracefully.
  5. Edge Case - Tethering: Be aware that tethering (Hotspot) often shows up as TRANSPORT_WIFI on the client device.
Explore More
Redirecting in 20 seconds...