“Am I online?” looks like a boolean until a phone joins hotel Wi-Fi, switches from cellular to VPN, loses DNS, or reaches the internet while your API is down. Android can describe the current default network and whether the system has validated general internet access. Only an actual application request can prove your endpoint works now.
The states worth distinguishing
No default network is currently available.
A network exists and claims the
INTERNETcapability, but Android has not validated public internet access.The network is validated for general internet access.
A captive portal is suspected and user sign-in may be required.
The active network is metered or temporarily constrained.
The transport includes Wi-Fi, cellular, Ethernet, VPN, Bluetooth, or another platform-defined path.
Your particular request succeeds or fails for endpoint-specific reasons.
1. Declare network-state permission
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application />
</manifest>This permission observes state; it does not perform I/O
ACCESS_NETWORK_STATEis a normal install-time permission and has no runtime dialog.Actual network requests normally require
INTERNET, often already declared by the networking dependency/app.Do not request Wi-Fi/location permissions merely to learn whether the default network uses Wi-Fi.
Transport/capability observations are coarse connectivity information, not permission to collect network identifiers.
Keep the manifest limited to features the application actually uses.
2. Model connectivity without collapsing it to Boolean
data class NetworkStatus(
val available: Boolean,
val internetCapability: Boolean,
val validated: Boolean,
val captivePortal: Boolean,
val metered: Boolean,
val transports: Set<Transport>,
) {
enum class Transport { WIFI, CELLULAR, ETHERNET, VPN, BLUETOOTH, OTHER }
companion object {
val Unavailable = NetworkStatus(
available = false,
internetCapability = false,
validated = false,
captivePortal = false,
metered = true,
transports = emptySet(),
)
}
}Conservative defaults prevent accidental heavy work
Availability, internet declaration, and validation express different facts.
Metered defaults to true while unknown so large background transfers do not start optimistically.
A network can report multiple transports, especially with VPNs; avoid a single mutually exclusive network-type integer.
UI can describe offline, sign-in-required, connecting/unvalidated, or validated states.
Endpoint health remains outside this model.
3. Observe the system default network
class AndroidNetworkMonitor(context: Context) : Closeable {
private val connectivityManager =
context.applicationContext.getSystemService(ConnectivityManager::class.java)
private val _status = MutableStateFlow(NetworkStatus.Unavailable)
val status: StateFlow<NetworkStatus> = _status.asStateFlow()
private var registered = false
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
_status.value = capabilities.toStatus(connectivityManager.isActiveNetworkMetered)
}
override fun onLost(network: Network) {
_status.value = currentStatus()
}
}
fun start() {
if (registered) return
registered = true
_status.value = currentStatus()
connectivityManager.registerDefaultNetworkCallback(callback)
}
override fun close() {
if (!registered) return
connectivityManager.unregisterNetworkCallback(callback)
registered = false
}
}Register one long-lived monitor, not one per screen
registerDefaultNetworkCallback()observes the network Android selects for the app’s default traffic on API 24+.Callbacks are invoked off the main thread;
StateFlowpublishes immutable snapshots safely.onCapabilitiesChanged()supplies the capabilities associated with that callback event.When a network is lost, reread the current default because another network may already have replaced it.
Registration is idempotent and
close()unregisters exactly the same callback instance.Android limits outstanding callbacks; leaking registrations can throw or waste resources.
Own the monitor at application/DI scope or another clearly bounded lifecycle.
4. Convert capabilities into app state
private fun NetworkCapabilities.toStatus(metered: Boolean): NetworkStatus {
val transports = buildSet {
if (hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) add(Transport.WIFI)
if (hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) add(Transport.CELLULAR)
if (hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) add(Transport.ETHERNET)
if (hasTransport(NetworkCapabilities.TRANSPORT_VPN)) add(Transport.VPN)
if (hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) add(Transport.BLUETOOTH)
if (isEmpty()) add(Transport.OTHER)
}
return NetworkStatus(
available = true,
internetCapability = hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
validated = hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
captivePortal = hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL),
metered = metered,
transports = transports,
)
}Capabilities describe system observations and policy
INTERNETmeans the network is configured to reach the internet; it does not prove connectivity.VALIDATEDmeans Android successfully validated general public internet access.CAPTIVE_PORTALindicates a sign-in/interception flow may be present.Transport is not reliability: Wi-Fi may have no uplink, cellular may be excellent, Ethernet may be captive, and VPN may overlay another transport.
Metering is a policy/cost signal, not a speed measurement.
Capabilities can change on the same Network object; update state every time.
5. Read an initial snapshot safely
private fun currentStatus(): NetworkStatus {
val network = connectivityManager.activeNetwork ?: return NetworkStatus.Unavailable
val capabilities = connectivityManager.getNetworkCapabilities(network)
?: return NetworkStatus.Unavailable
return capabilities.toStatus(connectivityManager.isActiveNetworkMetered)
}A snapshot can become stale immediately
activeNetworkis the current default network for the app/process.Capabilities may disappear between calls during a handover.
Returning Unavailable is conservative until callbacks publish a newer state.
Use the snapshot to seed UI, not to guarantee a request minutes later.
The callback remains the source of ongoing transitions.
API-level strategy
registerDefaultNetworkCallback() requires API 24. Many current applications can choose minSdk 24+ and keep the monitor simple. If supporting older releases, isolate a compatibility implementation, use the APIs available for that level, and test it independently. Do not keep the deprecated CONNECTIVITY_ACTION/NetworkInfo implementation in modern code merely for historical familiarity.
Why CONNECTIVITY_CHANGE and NetworkInfo should leave
Manifest delivery of many implicit broadcasts was restricted in Android 7.0+.
NetworkInfoandgetActiveNetworkInfo()are deprecated.Connected/connecting state says nothing about validated internet.
Legacy integer types assume one transport and age poorly with VPN/multi-transport networks.
Broadcast extras can be stale or incomplete; callback capabilities express the current model.
Wi-Fi AP/hotspot state is not the same as this app’s default internet connectivity.
6. Present state without blocking user intent
fun NetworkStatus.message(): String = when {
!available -> "No network connection"
captivePortal -> "Sign in to this network to continue"
!validated -> "Network connected; internet access is not confirmed"
metered -> "Online using a metered connection"
else -> "Online"
}Make messages actionable and modest
Do not claim “server unavailable” from network capabilities alone.
Allow retry because validation may lag or the endpoint may work on a private/local network.
Keep cached content readable while offline where product/privacy rules allow.
Avoid repeated snackbars during noisy network handovers; deduplicate and debounce presentation, not the underlying state.
Localize messages and expose state accessibly without relying only on color/icons.
7. Let the request report request truth
sealed interface LoadResult<out T> {
data class Success<T>(val value: T) : LoadResult<T>
data object OfflineOrDns : LoadResult<Nothing>
data object Timeout : LoadResult<Nothing>
data class HttpError(val status: Int) : LoadResult<Nothing>
data object Unauthorized : LoadResult<Nothing>
data class Unexpected(val cause: Throwable) : LoadResult<Nothing>
}
suspend fun loadDashboard(): LoadResult<Dashboard> = try {
LoadResult.Success(api.dashboard())
} catch (error: UnknownHostException) {
LoadResult.OfflineOrDns
} catch (error: SocketTimeoutException) {
LoadResult.Timeout
} catch (error: HttpException) {
if (error.code() == 401) LoadResult.Unauthorized
else LoadResult.HttpError(error.code())
} catch (error: IOException) {
LoadResult.Unexpected(error)
}Typed failures prevent misleading “offline” screens
DNS and no-route conditions often surface as
UnknownHostException/I/O but need product-appropriate classification.Timeout is different from immediate absence and may justify bounded retry.
HTTP errors prove a server responded; they are not connectivity failures.
401 requires authentication recovery, not network settings.
TLS/certificate failures should not be silently retried or relabeled offline.
Cancellation exceptions must propagate in coroutine code; do not swallow them in broad catches.
Sanitize telemetry so URLs, tokens, payloads, and personal data do not leak.
Validated does not mean your endpoint works
Android’s validation target is general internet reachability, not your domain.
Your DNS record, CDN region, certificate, API, account, or firewall can fail independently.
Private enterprise endpoints may be reachable on an unvalidated network.
A VPN can change routing after validation state changes.
Remote service health belongs to request/health-check evidence with backoff—not constant pings from connectivity callbacks.
Metered and bandwidth policy
Use
isActiveNetworkMeteredorNOT_METEREDas a cost-policy hint.Ask for user consent before large optional transfers on metered networks.
Do not assume unmetered means fast, unlimited, powered, or stable.
Use WorkManager constraints for deferrable bulk sync.
Interactive small requests should normally proceed when the user asks, with clear progress/cancel/retry.
Respect Data Saver/background restrictions through platform APIs and product design.
Do not poll with internet pings
Repeated HTTP/ICMP probes consume radio, battery, data, and server capacity.
A successful probe to one host does not prove the target service.
Captive portals may intercept probes and return misleading success.
Callback state plus actual user/work requests provides better evidence.
If service health monitoring is necessary, use bounded backoff, a purpose-built endpoint, cache, and lifecycle awareness.
Lifecycle and threading checklist
Create one monitor with application context.
Register once and unregister exactly once.
Publish immutable snapshots through StateFlow/another thread-safe observable.
Collect from lifecycle-aware UI APIs so stopped screens do not keep rendering work.
Do not perform network calls inside connectivity callbacks.
Do not block callback threads or main thread.
Handle rapid available/lost/capability sequences and replacement networks.
Keep callbacks strongly referenced until unregistered.
Handover invariants
A loss for the old Network must not erase a newer default network’s capabilities.
Capability updates are associated with the Network supplied by the callback.
UI may briefly show conservative unknown/unavailable state but must converge on the latest snapshot.
Request cancellation/retry follows request policy, not every transport change.
Tests include Wi-Fi-to-cellular, cellular-to-Wi-Fi, VPN attach/detach, and rapid flapping.
Test network transitions with ADB/emulator controls
adb shell svc wifi disable
adb shell svc data disable
# Observe offline UI and request behavior.
adb shell svc wifi enable
adb shell svc data enableCommands may be unsupported or restricted on some devices; restore the original radio state after testing.Risk level: caution. Review the command before running it.
Radio toggles cover only part of the matrix
Commands change connectivity on the selected authorized test target and can interrupt its use.
Record initial Wi-Fi/data state and restore it.
A physical device/OEM may restrict shell radio commands.
Also test connected Wi-Fi with no uplink, captive portal, VPN, Ethernet, handover, airplane mode, DNS failure, timeout, TLS error, HTTP errors, and server outage.
Network emulation/proxy tools can add latency, loss, and response faults without conflating them with radio state.
Inspect the current network from the device
adb shell dumpsys connectivity | rg -i "default|validated|captive|metered|transport"Inspect device/version-specific connectivity diagnostics.System diagnostics are supporting evidence
dumpsys format changes across releases and vendors; it is not an app API.
Compare it with your callback timestamps and capability snapshots.
Do not publish SSIDs, VPN details, IPs, account identifiers, or enterprise network data.
The application should rely on public ConnectivityManager APIs, not parse dumpsys.
Use diagnostics to explain test failures, not as production logic.
Unit-test capability mapping
@Test
fun wifiWithInternetButNotValidatedIsNotReportedOnline() {
val status = mapper.map(
internet = true,
validated = false,
captivePortal = false,
metered = false,
transports = setOf(Transport.WIFI),
)
assertThat(status.message())
.isEqualTo("Network connected; internet access is not confirmed")
}Keep platform objects out of most domain tests
Map primitive capability observations into domain state through a small testable mapper.
Cover no network, unvalidated, captive, validated metered/unmetered, multi-transport VPN, and transitions.
Use instrumentation/device tests for real
NetworkCapabilitiesand callback ordering.Test endpoint failures independently of connectivity state.
Avoid asserting localized production strings in deep domain tests if the UI owns localization.
Common mistakes decoded
Wi-Fi means online: Wi-Fi may be captive or disconnected upstream.
isConnected proves internet: legacy state does not validate public access or your service.
One network-type enum: VPN and modern capabilities can include multiple transports.
Poll every second: wastes power/data and races transitions.
Request only when validated: blocks private endpoints and can lag; attempt explicit user requests with error handling.
Register callback in every Activity: leaks callbacks and produces conflicting state.
Call getNetworkCapabilities in onAvailable: can race callback ordering; use onCapabilitiesChanged.
Show every callback as a snackbar: handovers become notification spam; model state and deduplicate presentation.
Retry every IOException forever: classify failures, cap retries, use backoff, and respect cancellation.
Production completion checklist
ACCESS_NETWORK_STATEis declared and no unrelated sensitive permissions are added.One lifecycle-owned default network callback publishes immutable state.
No deprecated
NetworkInfo/CONNECTIVITY_CHANGE code remains in the modern path.Availability, INTERNET, VALIDATED, captive, metered, and transports remain distinct.
UI supports cached/offline/unvalidated states and manual retry.
Requests have timeouts, cancellation, typed errors, bounded retry, and privacy-safe telemetry.
Heavy background work uses platform constraints rather than callback-triggered immediate floods.
The full radio/network/service-failure transition matrix passes on supported API levels/form factors.
Callback registrations, battery/data use, and error rates are monitored.
Official Android references
Read network state documents active network, capabilities, callbacks, ordering, and monitoring.
NetworkCallback defines callback methods and lifecycle.
NetworkCapabilities defines transports and capabilities such as INTERNET, VALIDATED, CAPTIVE_PORTAL, and NOT_METERED.
Connectivity status and type covers current network state and deprecated approaches.
WorkManager constraints documents connectivity constraints for deferrable work.
Comments and corrections