Turning Android System State Into a Flow
Turning Android System State Into a Flow
Every Android app eventually needs to react to state that lives outside its own process. Network connectivity flips as the user walks out of Wi-Fi range, and the device time zone changes when someone crosses a border or toggles a setting. The framework reports both of these through imperative callbacks: ConnectivityManager.NetworkCallback for connectivity and a BroadcastReceiver for time zone changes. You register a listener, the system calls you back, and you unregister when you are done. Most Compose UIs, though, want to consume this state as a Flow they can collect, transform, and turn into snapshot state, and the interesting question is how you bridge that gap without leaking receivers or hand rolling lifecycle bookkeeping. The Now in Android sample answers it with a single coroutine builder:
callbackFlow.
In this article, you'll dive deep into the mechanism that converts imperative Android system callbacks into cold reactive Flows: the callbackFlow builder and its ProducerScope, the non-blocking trySend handoff, the awaitClose teardown that keeps callbacks from leaking, the back pressure operators conflate and distinctUntilChanged, the difference between a purely cold monitor and a shared multicast one, and how the resulting Flow becomes hot UI state that drives an offline aware snackbar.
The fundamental problem: imperative callbacks do not compose
Connectivity from the platform arrives as an object with override methods. You subclass NetworkCallback, register it, and the system invokes your methods on some framework thread whenever a network appears or disappears. A direct translation into app code looks like this:
var isOnline = false
val callback = object : NetworkCallback() {
override fun onAvailable(network: Network) { isOnline = true }
override fun onLost(network: Network) { isOnline = false }
}
connectivityManager.registerNetworkCallback(request, callback)
// somewhere else, later, you must remember to:
connectivityManager.unregisterNetworkCallback(callback)
This works, but it does not fit a reactive UI. The state lives in a mutable var that nothing observes, so you cannot map it, combine it with other streams, or feed it into collectAsStateWithLifecycle. The registration and unregistration are separated in code and in time, which is exactly how receivers get leaked. And because the callback needs a real ConnectivityManager, any test that touches this logic has to stand up the Android framework. What you want instead is a Flow<Boolean> that registers the callback when someone starts collecting and unregisters it when they stop, so the lifecycle of the system resource follows the lifecycle of the observer automatically.
The narrow interface: hiding Android behind a Flow
Before looking at the bridge, notice the shape the rest of the app is allowed to see. Now in Android exposes connectivity through one interface with a single property:
interface NetworkMonitor {
val isOnline: Flow<Boolean>
}
Time zone follows the same pattern, and its KDoc states a contract that the implementation must honor: it always emits at least once with the current default, then again on every change.
/**
* Utility for reporting current timezone the device has set.
* It always emits at least once with default setting and then for each TZ change.
*/
interface TimeZoneMonitor {
val currentTimeZone: Flow<TimeZone>
}
The key observation: neither interface mentions ConnectivityManager, NetworkCallback, BroadcastReceiver, or Intent. Callers in the domain and UI layers depend only on Flow<Boolean> and Flow<TimeZone>. This is the seam that makes the source swappable and the consumers testable. Everything Android specific lives behind it, in the concrete implementation.
callbackFlow: the standard callback to Flow bridge
callbackFlow is a flow builder from kotlinx.coroutines, and it exists for exactly this shape of problem. It runs its block inside a ProducerScope, which is a CoroutineScope that also wraps a buffered Channel. The plain flow { emit(value) } builder only lets you emit from inside the coroutine, and emit is a suspend function, so it cannot be called from a framework callback that is not itself a coroutine. callbackFlow removes that restriction by exposing trySend, a non-suspending function you can call from any thread, including the framework threads that deliver onAvailable or onReceive. The ProducerScope delegates to that channel, so trySend(x) and channel.trySend(x) are the same call, and the snippets below use both forms.
The block is expected to register a listener, forward each value through trySend, and then suspend on awaitClose until the collector goes away. In skeleton form, every callback to Flow bridge has this shape:
val updates: Flow<T> = callbackFlow {
val listener = Listener { value -> trySend(value) }
api.register(listener)
awaitClose { api.unregister(listener) }
}
Register, forward through trySend, unregister in awaitClose. Both of Now in Android's monitors are variations on these three lines, with extra work to seed an initial value and to shape the stream downstream.
Bridging ConnectivityManager into a Flow
The connectivity implementation follows the skeleton exactly. One note before the code: the real source wraps sections of the block in androidx.tracing.trace { } calls for Perfetto profiling. Those are instrumentation only and do not change the flow behavior, so the snippets below omit them.
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor