Captive portals¶
reachable already handles captive portals correctly — it's false for
any network where the device hasn't reached a public endpoint. This page
covers how to build on that, and what not to build on top of it.
Default behaviour: captive portals look offline¶
When the device is on a captive-portal Wi-Fi where the user hasn't authenticated:
- Android:
NET_CAPABILITY_VALIDATEDis absent, soisReachable = false. - Apple:
nw_path_get_status(path)returnsnw_path_status_unsatisfiedornw_path_status_requires_connection, soisReachable = false.
Treat that the same as offline:
when {
!status.isReachable -> showOfflineState()
status.isDataMetered -> showMeteredState()
else -> showFullExperience()
}
Don't show "captive portal detected, please authenticate" UI from your own app. Both Apple and Android handle the auth flow at the OS level — the captive-portal sheet pops up automatically. Second-guessing that produces a worse experience than trusting the platform.
When the user authenticates¶
After the user completes captive-portal auth, both platforms re-probe and
emit a fresh status with reachable = true. The app sees a normal state
transition; no special handling needed.
reachability.status
.map { it.isReachable }
.distinctUntilChanged()
.collect { isReachable ->
if (isReachable) syncQueue.flush()
}
Edge case: "online" but DNS is failing¶
If a network passes INTERNET + VALIDATED but its DNS is broken for your
domain specifically, Reachable still reports isReachable = true. That's
correct: Reachable answers "can the device reach the public internet?",
not "can your app's endpoints resolve and respond?".
For an app-level "is my backend reachable?" check, layer your own probe on top:
class BackendReachability(
private val reachability: Reachability,
private val httpClient: HttpClient,
) {
val available: Flow<Boolean> = reachability.status
.map { it.isReachable }
.distinctUntilChanged()
.map { osReachable ->
if (!osReachable) false
else runCatching { httpClient.get("https://api.example.com/healthz").status.isSuccess() }
.getOrDefault(false)
}
}
The OS-level status acts as a gate; the probe only fires when the network is up, so it doesn't burn battery during genuine offline periods.
What Reachable deliberately doesn't expose¶
- A
Captiveenum case onTransport. The OS handles captive-portal UX; surfacing "you're on a captive portal" in the library would invite apps to build worse versions of that flow. - A
NET_CAPABILITY_CAPTIVE_PORTALreading on Android. The default of "captive portal == not reachable" handles the common case. Open an issue if you have a specific use case for the raw signal. - An HTTP-level reachability probe. Both platforms probe internally; layering another probe slows the first emission and duplicates work the OS is already doing.
See also¶
- Concepts → Validated vs available: the
INTERNET + VALIDATEDrequirement. - Recipes → React to changes: patterns for reacting to the
false → truetransition.