Skip to content

API design

The whole public surface:

package com.happycodelucky.reachable

public enum class Transport { Wifi, Cellular, Ethernet, Other, None }

public data class ReachabilityStatus(
    val isReachable: Boolean,
    val transport: Transport,
    val isDataMetered: Boolean,
) {
    public companion object { public val Unknown: ReachabilityStatus }
}

public interface Reachability : AutoCloseable {
    public val status: StateFlow<ReachabilityStatus>

    // Convenience shortcuts — synchronous reads off `status.value`.
    public val isReachable: Boolean
    public val isDataMetered: Boolean

    // Reactive shortcuts — derived StateFlows, conflated, started eagerly.
    public val reachable: StateFlow<Boolean>
    public val dataMetered: StateFlow<Boolean>

    override fun close()

    public companion object {
        // Process-lifetime singleton. close() is a no-op on this instance.
        public val shared: Reachability
    }
}

// appleMain — iOS, iPadOS, macOS
public fun Reachability(): Reachability

// androidMain
public fun Reachability(context: Context): Reachability

Everything else — platform observers, the shared base class, the mapping helpers, the singleton holder, and the NonClosingReachability decorator — is internal.

One observable for both questions

Need Call
Synchronous read reachability.status.value
Reactive listener reachability.status.collect { }
One-shot suspend reachability.status.first()
Swift AsyncSequence for await s in reachability.status

A separate suspend fun current() would force consumers to choose between two equivalent calls, so there isn't one.

Single-axis shortcuts

Need Call
Sync online check reachability.isReachable
Sync metered check reachability.isDataMetered
Reactive online/offline reachability.reachable.collect { }
Reactive metered/unmetered reachability.dataMetered.collect { }

The reactive variants are dedicated MutableStateFlows that the shared base writes synchronously alongside status on every status update. Identical consecutive values are conflated, so transport-only changes don't trigger emissions on reachable, and reachability-only changes don't trigger emissions on dataMetered.

isDataMetered is always false when the device is not reachable — unreachable paths always report isDataMetered = false.

Composition over a sealed hierarchy

ReachabilityStatus is a data class, not a sealed interface with cases like Online, Offline, Metered, etc.

The three axes — reachable, transport, data metered — are orthogonal. A device can be online over cellular and metered at the same time. A sealed hierarchy would either cross-product the cases (5 transports × reachable × metered = many cases) or stuff axes inside one case's payload, defeating exhaustiveness.

Enum-per-axis still gives Swift consumers exhaustive switch over transport individually via SKIE: switch status.transport { case .wifi: …; case .cellular: …; … }. data class brings equals, hashCode, copy(), and destructuring for free.

The cost: the type system can't catch "you forgot to check isReachable before reading transport". The mapping enforces transport == Transport.None whenever isReachable is false, and enforces isDataMetered == false whenever isReachable is false, so this is rarely a real hazard.

Data metering across platforms

isDataMetered is true when the active path is metered — cellular, hotspot, or (on Apple) Low Data Mode active. On Apple, both nw_path_is_expensive and nw_path_is_constrained set this flag; the distinction between "expensive" and "constrained" is not surfaced in the public API because no consumer use case required it beyond what transport == Transport.Cellular already conveys. On Android, NET_CAPABILITY_NOT_METERED and NET_CAPABILITY_TEMPORARILY_NOT_METERED drive the flag.

Singleton vs explicit lifecycle

Reachability.shared is the recommended entry point for application code. The design goal is eliminating "did I construct Reachability early enough?" ordering bugs that arise when a module tries to read reachability before the entrypoint has had a chance to run the factory.

// Android, iOS, macOS — one call, callable from anywhere.
val reachability: Reachability = Reachability.shared

From Swift: Reachability.shared (via a Swift extension compiled into the framework by SKIE; no companion prefix needed).

Why we use a singleton, not a mandatory factory. On Android, the Context requirement means that anything consuming reachability via constructor injection has a transitive dependency on Android lifecycle ordering. In multi-module apps, that ordering is fragile — a module initialised during ContentProvider startup can't safely call Reachability(context) if the DI graph hasn't wired it yet. The singleton is attached during the InitializationProvider ContentProvider pass (earlier than any Application.onCreate), so the Unknown → live transition happens before any consumer can observe it.

StateFlow makes the Unknown seed safe. The singleton's status starts at ReachabilityStatus.Unknown. A collector that starts before the Android attach will see Unknown first, then the live value. A collector that starts after will immediately see the most-recent live value. No race, no special-casing needed.

The singleton doesn't preclude injection. Shared code can still take a Reachability interface parameter and be tested with a mock; tests just pass a freshly-constructed per-instance factory instead of the singleton.

// Test: inject a fresh instance, close it afterwards.
val r = Reachability(context)
try {
    assertEquals(true, r.isReachable)
} finally {
    r.close()
}

Asymmetric factories

Reachability() on Apple, Reachability(context) on Android. There's no expect class ReachabilityFactory to paper over the asymmetry — wrapping it that way only moves the Context requirement one layer down.

The consumer's DI container (Koin, Hilt, hand-wired) calls the platform- specific factory at the entrypoint and binds the Reachability interface for shared code:

// Common shared-module code: depends on the interface only.
class ConnectivityModel(private val reachability: Reachability)

// Android entrypoint (explicit lifecycle)
val r: Reachability = Reachability(application)
val model = ConnectivityModel(r)

// iOS entrypoint (explicit lifecycle)
let r: any Reachability = Reachability()
let model = ConnectivityModel(reachability: r)

No kotlin.Result<T>, no Pair/Triple at the boundary

The public API never returns kotlin.Result<T> and never uses Pair or Triple. Both render as opaque wrappers in Swift (KotlinResult, KotlinPair) with no exhaustive switch and no value-type semantics. Named data classes and project-defined sealed interfaces are the substitute. Reachable's current surface needs neither.

(See CLAUDE.md §8 for the rule.)

AutoCloseable.close()

Per-instance handles (built via Reachability(context) / Reachability()) own a platform observer (nw_path_monitor or NetworkCallback) and a SupervisorJob-rooted coroutine scope. Neither has a reliable finaliser path on both Apple and Android, so cleanup is explicit.

AutoCloseable.close() is the universal idiom across Kotlin, Java, and Swift; SKIE renders it as close() without any name mangling. The implementation is idempotent and synchronous.

Reachability.shared is an exception: close() is an intentional no-op on the singleton. The singleton's lifetime is the process; there is no scope owner that will naturally go out of scope and trigger teardown. See Concepts → Lifecycle for the full rationale.

See Concepts → Lifecycle for when to construct and close.