A Kotlin Multiplatform library that tells you whether the device is on the internet and lets you observe changes as they happen, behind one API:
- iOS 18+, iPadOS 18+, macOS 15+: Apple's Network framework
nw_path_monitor(via Kotlin/Native cinterop), serving the same code path on all three platforms. - Android 11+ (API 30):
ConnectivityManager.NetworkCallbackregistered againstNET_CAPABILITY_INTERNET + NET_CAPABILITY_VALIDATED, so captive portals correctly register as not reachable.
UI is out of scope — Reachable is the headless :reachable KMP module
(CLAUDE.md §1). Each platform app consumes it natively.
Reachability.shared is the recommended entry point: a process-lifetime
singleton with no Context plumbing. On Android, the library's bundled
androidx.startup initializer attaches it before Application.onCreate.
On Apple, first access constructs the nw_path_monitor eagerly and starts
observing immediately. The explicit-lifecycle factories
(Reachability(context) / Reachability()) remain available for tests
and any code that wants per-instance teardown.
ARM-only targets: iosArm64, iosSimulatorArm64, macosArm64, Android
arm64-v8a. SKIE bridges the Swift surface — enums become exhaustive
Swift enums, StateFlow<T> becomes AsyncSequence<T>, suspend fun
becomes async throws.
The full mkdocs site is published to happycodelucky.github.io/reachable. Highlights:
- Getting started: three steps from install to
a UI-bound
Reachability. - Installation: Maven Central, KMP-side Apple consumption, local development override.
- Concepts → API design: the public type,
the asymmetric factories, why no
Result. - Concepts → Lifecycle: when to construct, when to close, threading.
- Concepts → Validated vs available:
why
INTERNET + VALIDATED, the wired-Ethernet quirk, captive portals. - Recipes: SwiftUI binding, Compose binding, React to changes, Captive portals.
- Contributing: development environment, reporting bugs, PR expectations.
// Singleton path — no Context plumbing, no construction boilerplate.
val reachability: Reachability = Reachability.shared
if (reachability.isReachable) {
// online
}
reachability.status.collect { status ->
// every state change
}
// close() on .shared is a no-op — the singleton lives for the process.From Swift:
let reachability: any Reachability = Reachability.sharedFor explicit-lifecycle needs (tests, per-feature observers):
// Android
val r: Reachability = Reachability(applicationContext)
// Apple
val r: any Reachability = Reachability()
r.close() // honours close() normallystatus.value for a synchronous read, status.collect {} for a reactive
listener, status.first() for a one-shot suspend.
The same appleMain factory covers all three platforms. Use
Reachability.shared for zero-setup access:
import Reachable
@MainActor
@Observable
final class ConnectivityModel {
var status: ReachabilityStatus = ReachabilityStatus.companion.Unknown
@ObservationIgnored
private var task: Task<Void, Never>?
init() {
// Reachability.shared — process-lifetime singleton, no close needed.
let reachability: any Reachability = Reachability.shared
task = Task { [weak self] in
for await s in reachability.status { self?.status = s }
}
}
deinit { task?.cancel() }
}For explicit-lifecycle code (tests or per-feature observers), use the
top-level factory and call close() on teardown:
@main
struct MyApp: App {
private let reachability: any Reachability = Reachability()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(ConnectivityModel(reachability: reachability))
}
}
}nw_path_monitor doesn't require any entitlement. The network.client
sandbox entitlement on macOS is about the app's own outgoing traffic, not
the reachability check.
The library bundles an androidx.startup initializer that attaches
Reachability.shared to the application Context automatically during
the InitializationProvider ContentProvider pass — before
Application.onCreate. No setup required:
import com.happycodelucky.reachable.Reachability
// No Application subclass, no Context plumbing needed.
@Composable
fun ConnectivityBanner() {
val status by Reachability.shared.status.collectAsStateWithLifecycle()
if (!status.isReachable) {
Text("You're offline")
}
}If you disable InitializationProvider entirely (rare), or need explicit
lifecycle control, use the factory instead:
class MyApp : Application() {
lateinit var reachability: Reachability
override fun onCreate() {
super.onCreate()
reachability = Reachability(applicationContext)
}
override fun onTerminate() {
reachability.close()
super.onTerminate()
}
}android.permission.ACCESS_NETWORK_STATE is declared in the library's own
AndroidManifest.xml and merged into your app at build time. It's a
normal-protection permission, so no runtime grant is needed.
| iOS / iPadOS | macOS | Android | |
|---|---|---|---|
| Reachability backend | nw_path_monitor (satisfied) |
nw_path_monitor (satisfied) |
NetworkCallback (INTERNET + VALIDATED) |
| Captive-portal handling | OS-internal probe | OS-internal probe | NET_CAPABILITY_VALIDATED |
Transport.Wifi / Cellular |
yes | yes | yes |
Transport.Ethernet |
n/a | falls through to Other (cinterop gap) |
yes (TRANSPORT_ETHERNET) |
isDataMetered = true |
nw_path_is_expensive || nw_path_is_constrained |
nw_path_is_expensive || nw_path_is_constrained |
!(NET_CAPABILITY_NOT_METERED || TEMPORARILY_NOT_METERED) |
| Status seeded synchronously | no (first emission within tens of ms) | no | yes (from activeNetwork) |
Apple's Low Data Mode signal (nw_path_is_constrained) folds into
isDataMetered alongside nw_path_is_expensive; there's no separate
"Low Data Mode" axis in the public API. The macOS Ethernet cinterop gap
is documented in
Concepts → Validated vs available.
The two most-asked questions get dedicated properties so callers don't
unpack status.value for a one-line read:
reachability.isReachable // sync online check
reachability.isDataMetered // sync metered-path check
reachability.reachable // StateFlow<Boolean>, conflated, online/offline only
reachability.dataMetered // StateFlow<Boolean>, conflated, metered/unmetered onlyThe reactive variants are dedicated MutableStateFlows the library
updates synchronously alongside status — transport-only changes don't
trigger emissions on reachable or dataMetered.
A companion artifact ships test fakes so consumers can drive
Reachability.shared in unit tests without a live platform observer:
// shared/build.gradle.kts
kotlin {
sourceSets {
commonTest.dependencies {
implementation("com.happycodelucky.reachable:reachable-testing:VERSION")
}
}
}Then from any test:
@Test
fun deviceIsOnline() = runTest {
withFakeReachability(
initial = ReachabilityStatus(
isReachable = true,
transport = Transport.Wifi,
isDataMetered = false,
),
) { fake ->
val vm = MyViewModel() // reads Reachability.shared
assertTrue(vm.online)
fake.setReachable(false)
assertFalse(vm.online)
}
}withFakeReachability installs the fake as Reachability.shared,
runs the block, then uninstalls and closes the fake in finally — even
when the block throws. See Installation
for the full dependency snippet and the Gradle coordinate.
mise pins JDK, Gradle, Python, xcodegen, gh,
and the Swift tooling. Bootstrap once per machine:
brew install mise
mise trust # accept mise.toml in this checkout
mise install # provision every tool at the pinned versionThen the task surface:
mise run check # ktlint + all unit tests (iOS sim, macOS, Android host)
mise run build:ios # iOS device + simulator debug frameworks
mise run build:macos # macOS desktop debug framework
mise run build # release Reachable.xcframework (sample-app local SPM)
mise run build:android # Android AAR
mise run open:ios # spm:dev + xcodegen + open iOSApp in Xcode
mise run open:macos # spm:dev + xcodegen + open macOSApp in XcodeEach task is a thin wrapper over ./gradlew (or xcodegen for the
open:* tasks); see mise.toml for the exact mapping, or
run mise tasks to list everything. Raw ./gradlew invocations still
work — mise just ensures everyone (and CI) runs the same versions.
For the iOS and macOS sample apps see iOSApp/README.md
and macOSApp/README.md.
- Versions (
gradle/libs.versions.toml) are the single source of truth. Web-search before bumping any dependency (CLAUDE.md §2). Kotlin is pinned at the highest version SKIE supports — currently 2.3.20 with SKIE 0.10.11. - Every public method on the Swift-facing surface follows
CLAUDE.md §8 conventions:
@ObjCNamewhere the default Swift name is awkward,@Throwson everysuspend funthat can throw across the boundary. internalby default; widen visibility only when needed (CLAUDE.md §3).- DI is a user choice. The primary entry point is
Reachability.shared(zero-setup singleton). For tests or explicit lifecycle, the asymmetric platform factories (Reachability(context)/Reachability()) are also public. No DI container is required by the library.
See CLAUDE.md for the full project conventions.
Reachable is released under the Apache License 2.0.
Copyright 2026 Paul Bates
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.