Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/lessons/LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ _No entries yet. Add one the next time something bites._
### D-003 — `expect`/`actual` cap ~20 lines, otherwise refactor to interface
If an `actual` implementation grows past ~20 lines, refactor to a `commonMain` interface and inject platform implementations at the entrypoint. Keeps the `expect`/`actual` surface minimal and testable.

### D-004 — JVM backend polls `NetworkInterface`; `isReachable` is best-effort, not validated
The JDK has no connectivity callback and no validation probe (unlike Apple's `nw_path_monitor` / Android's `NET_CAPABILITY_VALIDATED`), so `JvmReachability` polls `java.net.NetworkInterface` on the base-class scope (default 5s) and maps via the pure `mapJvmInterfaces`. Consequences baked into the public KDoc + docs: captive portals are invisible, `isDataMetered` is always `false`, transport is name-inferred (`Transport.Other` when ambiguous, e.g. macOS `en0`). Host-only bridges (`docker0`, `vmnet*`, …) are filtered so a Docker-running laptop reads offline in airplane mode; VPN tunnels (`utun*`) count. No HTTP probe by design — a library phoning home by default is worse than an honest weak signal.

### D-005 — Adding a KMP target touched zero common code
Adding `jvm` needed only: `jvm()` in the convention plugin (the existing `targets.withType<KotlinJvmTarget>` JVM_21 block, written for Android, covered it), a one-line `createSharedReachability()` actual, and the platform impl. The `expect` seam being a single function (see D-003) is what made a third platform a pure addition.

### D-004 — JVM backend polls `NetworkInterface`; reachability there is best-effort by design (2026-06-11)
The JDK has no connectivity callback and no validation probe, so `JvmReachability` polls `java.net.NetworkInterface` (default 5 s), filters loopback / link-local / host-only bridges (`docker0` etc.), infers `Transport` from interface names (`Other` when ambiguous, e.g. macOS `en0`), and always reports `isDataMetered = false`. A built-in HTTP probe was rejected: a library phoning a hardcoded endpoint by default is worse than an honest weaker signal. See `Mapping.jvm.kt` and `docs/platforms/jvm.md`.

---

## NEVER DO (N)
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ jobs:
echo "Computed CI version: $VERSION"

# `mise run check` runs ktlint + detekt + every unit test on every
# target (iosSimulatorArm64Test, macosArm64Test, testAndroidHostTest)
# in both published modules (:reachable, :reachable-testing). Sample
# apps are deliberately excluded — see mise.toml.
# target (iosSimulatorArm64Test, macosArm64Test, testAndroidHostTest,
# jvmTest) in both published modules (:reachable, :reachable-testing).
# Sample apps are deliberately excluded — see mise.toml.
# `mise run build` assembles Reachable.xcframework — the artifact that
# the sample apps under /apps/ios and /apps/macos consume via the root
# Package.swift. Maven Central publishing is a separate workflow
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ Rules for working in this repo. Read before starting any task.

**Not shared:** UI. Each platform ships its own native UI layer (SwiftUI on iOS, Jetpack Compose on Android, SwiftUI/AppKit on macOS desktop, native web on the web target). **Do not add Compose Multiplatform.** Do not propose it. The shared module is headless.

**Targets — ARM only, no exceptions:**
**Targets — native binaries are ARM only, no exceptions:**

- `iosArm64` (device) + `iosSimulatorArm64` (Apple Silicon simulator)
- Android `arm64-v8a`
- `macosArm64` (desktop)
- `jvm` (desktop / server) — JVM 21 bytecode is architecture-neutral, so the ARM-only rule constrains native slices, not this jar
- `wasmJs` — stretch goal; design to not preclude it, don't block Tier 1 work on it

**Out of scope:** all x86/x86_64, `armeabi-v7a`, Intel Macs, watchOS, tvOS, Linux, Windows, Kotlin/JS legacy.
**Out of scope:** all x86/x86_64 *native* slices, `armeabi-v7a`, Intel Macs, watchOS, tvOS, Linux/Windows *native* targets, Kotlin/JS legacy. (Linux/Windows/Intel hosts running the `jvm` jar are fine — that's the JVM's job, not a native target.)

---

Expand Down Expand Up @@ -88,6 +89,7 @@ Everything else we author — classes, files, top-level functions, top-level `va
/src/androidMain
/src/iosMain
/src/macosMain
/src/jvmMain desktop / server JVM
/src/wasmJsMain stretch
/apps/ios Xcode project, native SwiftUI, consumes /shared via SPM
/apps/android Android entrypoint, native Jetpack Compose UI
Expand Down
71 changes: 56 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
![iOS 18+](https://img.shields.io/badge/iOS-18%2B-blue.svg?style=for-the-badge&logo=apple)
![macOS 15+](https://img.shields.io/badge/macOS-15%2B-blue.svg?style=for-the-badge&logo=apple)
![Android 11+](https://img.shields.io/badge/Android-11%2B-3DDC84.svg?style=for-the-badge&logo=android&logoColor=white)
![JVM 21+](https://img.shields.io/badge/JVM-21%2B-ED8B00.svg?style=for-the-badge&logo=openjdk&logoColor=white)
![Kotlin 2.3](https://img.shields.io/badge/Kotlin-2.3-7F52FF.svg?style=for-the-badge&logo=kotlin&logoColor=white)
[![CI](https://img.shields.io/github/actions/workflow/status/happycodelucky/reachable/ci.yml?style=for-the-badge&label=ci)](https://github.com/happycodelucky/reachable/actions/workflows/ci.yml)
[![Docs](https://img.shields.io/github/actions/workflow/status/happycodelucky/reachable/docs.yml?style=for-the-badge&label=docs)](https://github.com/happycodelucky/reachable/actions/workflows/docs.yml)
Expand All @@ -18,6 +19,9 @@ internet and lets you observe changes as they happen, behind one API:
- **Android 11+ (API 30)**: `ConnectivityManager.NetworkCallback`
registered against `NET_CAPABILITY_INTERNET + NET_CAPABILITY_VALIDATED`,
so captive portals correctly register as not reachable.
- **JVM 21+ (desktop / server)**: polling over `java.net.NetworkInterface`.
Best-effort — the JDK has no validation probe, so captive portals are
not detected and transport is inferred from interface names.

UI is out of scope — Reachable is the headless `:reachable` KMP module
(CLAUDE.md §1). Each platform app consumes it natively.
Expand All @@ -30,10 +34,11 @@ 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`.
Native targets are ARM-only: `iosArm64`, `iosSimulatorArm64`, `macosArm64`,
Android `arm64-v8a`. A `jvm` target (architecture-neutral JVM 21 bytecode)
covers desktop and server JVMs. SKIE bridges the Swift surface — enums
become exhaustive Swift enums, `StateFlow<T>` becomes `AsyncSequence<T>`,
`suspend fun` becomes `async throws`.

---

Expand Down Expand Up @@ -66,7 +71,8 @@ Highlights:

Reachable publishes to Maven Central. From a Kotlin Multiplatform project,
depend on `:reachable` from `commonMain` — KMP resolves the right per-target
slice (Android AAR, `iosArm64`, `iosSimulatorArm64`, `macosArm64`) for you:
slice (Android AAR, JVM jar, `iosArm64`, `iosSimulatorArm64`, `macosArm64`)
for you:

```kotlin
// shared/build.gradle.kts
Expand All @@ -79,7 +85,7 @@ kotlin {
}
```

Android-only consumers depend on the artifact directly:
Android-only and JVM-only consumers depend on the artifact directly:

```kotlin
// app/build.gradle.kts
Expand Down Expand Up @@ -133,6 +139,8 @@ For explicit-lifecycle needs (tests, per-feature observers):
```kotlin
// Android
val r: Reachability = Reachability(applicationContext)
// JVM (desktop / server) — optional poll cadence, default 5 seconds
val r: Reachability = Reachability(pollInterval = 10.seconds)
// Apple
val r: any Reachability = Reachability()
r.close() // honours close() normally
Expand Down Expand Up @@ -239,16 +247,49 @@ normal-protection permission, so no runtime grant is needed.

---

## Launch sequence — JVM (desktop / server)

No Context, no initializer — `Reachability.shared` starts the interface
poll loop on first access, exactly like Apple:

```kotlin
import com.happycodelucky.reachable.Reachability

val reachability = Reachability.shared
reachability.status.collect { status ->
// re-rendered every time an interface comes up or goes down
}
```

For explicit lifecycle (or a custom poll cadence), use the factory:

```kotlin
import kotlin.time.Duration.Companion.seconds

val reachability = Reachability(pollInterval = 10.seconds) // default 5.seconds
// ...
reachability.close() // stops the poll loop
```

Know the trade-offs on this platform: the JDK offers no validation probe,
so `isReachable` means "a non-loopback interface is up with a routable
address" — a captive portal still reads as reachable. Changes surface
within one poll tick rather than milliseconds, `isDataMetered` is always
`false`, and transport classification is inferred from interface names
(`Transport.Other` when ambiguous — e.g. macOS's `en0`).

---

## What each platform actually surfaces

| | 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` | yes (USB-C / dock adapters) | yes (`nw_interface_type_wired`) | 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`) |
| | iOS / iPadOS | macOS | Android | JVM (desktop / server) |
| -------------------------------- | ---------------------------------- | ---------------------------------- | --------------------------------------- | --------------------------------------- |
| Reachability backend | `nw_path_monitor` (satisfied) | `nw_path_monitor` (satisfied) | `NetworkCallback` (`INTERNET + VALIDATED`) | `NetworkInterface` polling (best-effort) |
| Captive-portal handling | OS-internal probe | OS-internal probe | `NET_CAPABILITY_VALIDATED` | **not detected** |
| `Transport.Wifi` / `Cellular` | yes | yes | yes | best-effort (interface names) |
| `Transport.Ethernet` | yes (USB-C / dock adapters) | yes (`nw_interface_type_wired`) | yes (`TRANSPORT_ETHERNET`) | best-effort (`Other` when ambiguous) |
| `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)` | never (no JDK signal) |
| Status seeded synchronously | no (first emission within tens of ms) | no | **yes** (from `activeNetwork`) | no (first poll, then every interval) |

Apple's Low Data Mode signal (`nw_path_is_constrained`) folds into
`isDataMetered` alongside `nw_path_is_expensive`; there's no separate
Expand Down Expand Up @@ -333,7 +374,7 @@ mise install # provision every tool at the pinned version
Then the task surface:

```bash
mise run check # ktlint + all unit tests (iOS sim, macOS, Android host)
mise run check # ktlint + all unit tests (iOS sim, macOS, Android host, JVM)
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)
Expand Down
11 changes: 8 additions & 3 deletions docs/concepts/api-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public fun Reachability(): Reachability

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

// jvmMain — desktop / server
public fun Reachability(pollInterval: Duration = 5.seconds): Reachability
```

Everything else — platform observers, the shared base class, the mapping
Expand Down Expand Up @@ -106,7 +109,8 @@ 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.
drive the flag. The JVM has no metering signal, so the flag is always
`false` there.

## Singleton vs explicit lifecycle

Expand All @@ -116,7 +120,7 @@ ordering bugs that arise when a module tries to read reachability before the
entrypoint has had a chance to run the factory.

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

Expand Down Expand Up @@ -155,7 +159,8 @@ try {

## Asymmetric factories

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

Expand Down
37 changes: 31 additions & 6 deletions docs/concepts/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ return the same instance.
### Close semantics

Calling `close()` on `Reachability.shared` is an intentional **no-op**.
The singleton's lifetime is the process; neither platform has a usable
The singleton's lifetime is the process; no platform has a usable
"natural deinit" path for a long-lived singleton:

- On Android, `ConnectivityManager.registerNetworkCallback` causes the OS
Expand All @@ -50,6 +50,8 @@ The singleton's lifetime is the process; neither platform has a usable
- On Apple, `nw_path_monitor_set_update_handler` retains the update
handler, which captures the instance. ARC won't drop it until
`nw_path_monitor_cancel`. Same kernel-reaps-at-exit story.
- On the JVM, the running poll-loop coroutine roots the instance until
`close()` cancels it. The loop ends with the process.

A misplaced `use { }` block, an `AutoCloseable`-aware DI container, or a
test fixture that closes everything in `@AfterEach` must not accidentally
Expand All @@ -71,31 +73,39 @@ val reachability: Reachability = Reachability(applicationContext)
let reachability: any Reachability = Reachability()
```

```kotlin
// JVM — optional poll cadence, default 5 seconds
val reachability: Reachability = Reachability(pollInterval = 10.seconds)
```

### When to construct

| Where you are | Where to construct |
|----------------------|----------------------------------------------------------------------------------------------------------------------|
| Android | `Application.onCreate()` — bind to the application context. Or just use `Reachability.shared`. |
| iOS | App `init` (the `@main` `App` struct) or your composition root. Or just use `Reachability.shared`. |
| macOS | Same as iOS — both Apple platforms share `appleMain`. |
| JVM | Your composition root (desktop) or service bootstrap (server). Or just use `Reachability.shared`. |
| Test (`runTest`) | Per-test, in a `try-with-resources`-style block. Don't share across tests (use the factory, not `Reachability.shared`). |

The Apple factory creates a per-instance serial dispatch queue and starts
an `nw_path_monitor`. The Android factory grabs `applicationContext`'s
`ConnectivityManager` and registers a `NetworkCallback`. Both are cheap
(microseconds) but each instance is a small allocation plus a platform
observer registration.
`ConnectivityManager` and registers a `NetworkCallback`. The JVM factory
launches a coroutine that re-reads the interface table at the poll
cadence. All are cheap (microseconds) but each instance is a small
allocation plus a platform observer registration or poll loop.

### When to close

`close()` cancels the platform observer (`nw_path_monitor_cancel` on Apple,
`unregisterNetworkCallback` on Android) and cancels the internal coroutine
scope.
`unregisterNetworkCallback` on Android, the poll loop on the JVM) and
cancels the internal coroutine scope.

| Where you are | Where to close |
|-------------------|-------------------------------------------------------------------------------------------------|
| Android | `Application.onTerminate()`. The OS rarely calls it; in practice the process dies first. |
| iOS / macOS | `deinit` on the owning view-model or composition root. |
| JVM | App / service shutdown hook, or wherever the owning scope tears down. |
| Test | At the end of the test. `runTest` and Turbine leak the scope otherwise. |

`close()` is idempotent and synchronous; multiple calls are no-ops. After
Expand Down Expand Up @@ -138,6 +148,19 @@ that transition see `Unknown` first, then the live value. The transition
happens early enough that in practice the `Unknown` window is
sub-millisecond by the time any UI is drawn.

### JVM

The poll loop runs on the instance's coroutine scope
(`Dispatchers.Default`); each tick is a single `MutableStateFlow.value`
write, which is concurrency-safe. Collectors observe on whatever
dispatcher they collect on.

The first poll runs immediately at construction, so the first real
emission lands as soon as the dispatcher schedules it — `status.value`
returns `ReachabilityStatus.Unknown` only briefly. *Changes* after that
surface within one poll tick (default 5 seconds), not within
milliseconds.

## Multiple collectors

Multiple collectors share one underlying platform observer; the library
Expand Down Expand Up @@ -180,6 +203,8 @@ A construction allocates:
~kB).
- Android: one `NetworkCallback` (~kB) plus a binder transaction to register
it.
- JVM: one coroutine on the shared default dispatcher; each poll tick is a
read-only interface-table syscall, no network traffic.

A `close()` releases both. The `SupervisorJob` cancels any in-flight
collectors. The `MutableStateFlow` is GC-eligible after collectors complete.
Loading