diff --git a/.claude/lessons/LESSONS.md b/.claude/lessons/LESSONS.md index 77b22e4..4f08b7a 100644 --- a/.claude/lessons/LESSONS.md +++ b/.claude/lessons/LESSONS.md @@ -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` 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) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d91269b..9198fc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 21baec1..2be3924 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.) --- @@ -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 diff --git a/README.md b/README.md index c6fcc4e..f589928 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. @@ -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` becomes `AsyncSequence`, `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` becomes `AsyncSequence`, +`suspend fun` becomes `async throws`. --- @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/docs/concepts/api-design.md b/docs/concepts/api-design.md index 422ba01..77c1651 100644 --- a/docs/concepts/api-design.md +++ b/docs/concepts/api-design.md @@ -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 @@ -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 @@ -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 ``` @@ -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. diff --git a/docs/concepts/lifecycle.md b/docs/concepts/lifecycle.md index 96fb6f1..b4babeb 100644 --- a/docs/concepts/lifecycle.md +++ b/docs/concepts/lifecycle.md @@ -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 @@ -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 @@ -71,6 +73,11 @@ 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 | @@ -78,24 +85,27 @@ let reachability: any Reachability = Reachability() | 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 @@ -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 @@ -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. diff --git a/docs/concepts/validated-vs-available.md b/docs/concepts/validated-vs-available.md index 59dcde5..4c9743f 100644 --- a/docs/concepts/validated-vs-available.md +++ b/docs/concepts/validated-vs-available.md @@ -67,6 +67,30 @@ The library does not add an HTTP probe on top of either platform. Both platforms already probe internally; layering another probe slows the first emission, drains battery, and produces two slightly disagreeing signals. +### JVM + +The exception to everything above. The JVM has no OS-level validation +probe to read, so on desktop / server JVMs `reachable` *is* the weaker +"available" signal this page warns about: + +```kotlin +// JVM semantics: at least one interface that is +// up && !loopback && has a routable (non-link-local) address +// — no probe, no captive-portal detection. +``` + +The library deliberately does not ship its own probe endpoint either — +a library phoning a hardcoded host every few seconds is a worse default +than an honest, weaker signal. If your desktop app needs proof of a +working path (a download manager, a sync engine), make the real request +and treat its failure as the signal, exactly as the +[captive-portal recipe](../recipes/captive-portal.md) recommends. + +On JVM the library does filter out the classic false positives it *can* +see: loopbacks, link-local-only adapters (`169.254.x.x` after a failed +DHCP), and host-only container/hypervisor bridges (`docker0`, `vmnet*`, +…) that stay up with a private address while the machine is offline. + ## Wired Ethernet on Apple Wired connections surface as `Transport.Ethernet` on Apple platforms via diff --git a/docs/getting-started.md b/docs/getting-started.md index 3101c04..1b20f0d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -65,6 +65,17 @@ with no construction or Context plumbing required. let reachability: any Reachability = Reachability.shared ``` +=== "JVM" + + ```kotlin + // Desktop or server — no setup needed. + val reachability: Reachability = Reachability.shared + ``` + + On first access, starts polling the interface table eagerly. + Reachability on the JVM is best-effort — see + [Platforms → JVM](platforms/jvm.md) for the exact semantics. + ### Explicit-lifecycle alternative For tests or any code that needs a fresh observer with explicit teardown, @@ -92,6 +103,14 @@ use the platform factories instead: let reachability: any Reachability = Reachability() ``` +=== "JVM" + + ```kotlin + val reachability: Reachability = Reachability(pollInterval = 10.seconds) + // ... + reachability.close() + ``` + Calling `close()` on `Reachability.shared` is intentionally a no-op — the singleton's lifetime is the process. Use the factories above when you need `close()` to actually tear down the observer. diff --git a/docs/index.md b/docs/index.md index 69225ce..b8dacfc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,8 +7,8 @@ hide: Reachable is a Kotlin Multiplatform library that tells you whether the device is on the internet, and lets you observe changes as they happen. -It targets iOS, iPadOS, macOS, and Android, and presents the same API to -Kotlin and Swift consumers. +It targets iOS, iPadOS, macOS, Android, and the JVM (desktop / server), +and presents the same API to Kotlin and Swift consumers. ```kotlin // Singleton — no Context plumbing, callable from anywhere. @@ -42,6 +42,8 @@ factories `Reachability(context)` / `Reachability()` instead. - iOS 18, iPadOS 18, macOS 15. ARM only (`iosArm64`, `iosSimulatorArm64`, `macosArm64`). - Android 11 (API 30). `arm64-v8a` only. +- JVM 21 (desktop / server). Architecture-neutral bytecode — any OS with a + JVM 21 runtime. ## Implementation @@ -55,6 +57,11 @@ Apple's `nw_path_is_expensive` and `nw_path_is_constrained` (cellular, hotspot, and Low Data Mode) both fold into `isDataMetered`. Android uses `NET_CAPABILITY_NOT_METERED` for the same signal. +The JVM has neither a connectivity callback nor a validation probe, so +the desktop / server backend polls the interface table and reports +best-effort reachability — see [Platforms → JVM](platforms/jvm.md) for +the exact semantics. + The Swift surface is idiomatic: Kotlin enums arrive as exhaustive Swift enums, `StateFlow` is consumed as an `AsyncSequence`, and `suspend fun` becomes `async throws`. diff --git a/docs/installation.md b/docs/installation.md index 24273cd..6e6265f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,7 +4,7 @@ Reachable ships through two channels: | Channel | For | Artifacts | |---|---|---| -| **Maven Central** | Gradle — Android, JVM, Kotlin Multiplatform | Android AAR, `kotlinMultiplatform` metadata, per-target klibs (`iosArm64`, `iosSimulatorArm64`, `macosArm64`) | +| **Maven Central** | Gradle — Android, JVM, Kotlin Multiplatform | Android AAR, JVM jar, `kotlinMultiplatform` metadata, per-target klibs (`iosArm64`, `iosSimulatorArm64`, `macosArm64`) | | **Swift Package Manager** | Pure-Swift iOS / macOS apps, no Kotlin toolchain | Prebuilt `Reachable.xcframework`, hosted as a GitHub Release asset | Kotlin Multiplatform projects should use the Maven artifact from @@ -20,6 +20,7 @@ Swift package is for apps with no Kotlin in them at all — see | iOS / iPadOS | iOS 18 | | macOS | macOS 15 | | Android | API 30 (Android 11), `arm64-v8a` only | +| JVM | 21 (desktop / server, any OS) | | Kotlin | 2.3.x (K2) | ## Gradle (Android, JVM, KMP) @@ -36,6 +37,15 @@ block changes are needed. } ``` +=== "JVM module" + + ```kotlin + // desktop-app/build.gradle.kts + dependencies { + implementation("com.happycodelucky.reachable:reachable:{{ version }}") + } + ``` + === "Kotlin Multiplatform module" ```kotlin @@ -51,7 +61,9 @@ block changes are needed. `android.permission.ACCESS_NETWORK_STATE` is declared in the library's own manifest and merged in at build time. It's a normal-protection permission, -so no runtime grant is needed. +so no runtime grant is needed. The JVM target needs no permissions — +reachability is read from the local interface table, with no network +traffic. ## Testing support diff --git a/docs/platforms/jvm.md b/docs/platforms/jvm.md new file mode 100644 index 0000000..d49fac7 --- /dev/null +++ b/docs/platforms/jvm.md @@ -0,0 +1,96 @@ +# JVM + +The JVM target covers desktop apps (Compose Desktop, Swing, JavaFX) and +server-side services on JVM 21+. It presents the same `Reachability` API as +every other platform, with one honest difference: the JVM has no +connectivity-change callback and no OS validation probe, so this backend +**polls the interface table** and reports **best-effort** reachability. + +## Singleton entry point — `Reachability.shared` + +```kotlin +import com.happycodelucky.reachable.Reachability + +val reachability: Reachability = Reachability.shared +``` + +No Context, no initializer: the first access starts observing eagerly, +exactly like Apple. Calling `close()` on this instance is a no-op. + +## Explicit-lifecycle factory + +```kotlin +import com.happycodelucky.reachable.Reachability +import kotlin.time.Duration.Companion.seconds + +val reachability: Reachability = Reachability(pollInterval = 10.seconds) +// ... +reachability.close() // stops the poll loop +``` + +`pollInterval` defaults to 5 seconds. Each poll reads the local interface +table — a cheap syscall, **no network traffic** — and identical consecutive +readings are conflated, so a shorter interval changes detection latency, +not emission volume. Status changes surface within one poll tick rather +than within milliseconds. + +## What `isReachable` means here + +On Apple and Android, `isReachable` is a *validated* signal — the OS has +probed a public endpoint. The JVM has no such probe, so here it means: + +> At least one network interface is up, is not a loopback, and holds a +> routable (non-link-local) address. + +Consequences: + +- **Captive portals are not detected.** A hotel Wi-Fi with an + unauthenticated portal reads as reachable. If you need proof of a + working path, make a real request and treat its failure as the signal — + see [Concepts → Validated vs available](../concepts/validated-vs-available.md#jvm). +- Host-only bridges created by container and hypervisor runtimes + (`docker0`, `veth*`, `br-*`, `virbr*`, `vmnet*`, `vboxnet*`, `bridge*`) + are ignored — a laptop running Docker in airplane mode correctly reads + as offline. +- An active VPN tunnel (`utun*`, `tun*`) counts as reachable. +- A DHCP-failed adapter squatting on `169.254.x.x` does not count. + +## Transport classification + +The JDK exposes no transport type, so `Transport` is inferred from +interface naming — best-effort by design: + +| Interface | Reported transport | +|---|---| +| `wlan0`, `wlp3s0`, `wlx…` (Linux), Windows adapters whose name contains "Wi-Fi" / "Wireless" | `Transport.Wifi` | +| `eth0`, `enp3s0`, `eno1`, `ens33`, `enx…`, `em1`, Windows "Ethernet" adapters | `Transport.Ethernet` | +| `wwan0`, `wwp…`, `rmnet0`, `ppp0`, "Mobile Broadband" adapters | `Transport.Cellular` | +| Anything unrecognised — notably macOS's `en0`, which may be Wi-Fi or wired | `Transport.Other` | + +When several interfaces are active at once, the highest-priority transport +wins: `Wifi > Ethernet > Cellular > Other` — the same rule as every other +platform. + +```kotlin +when (reachability.status.value.transport) { + Transport.Wifi, Transport.Ethernet, Transport.Cellular -> { /* classified */ } + Transport.Other -> { /* up and routable, type unknown — common on macOS JVMs */ } + Transport.None -> { /* not reachable */ } +} +``` + +## Metering + +`isDataMetered` is always `false` on the JVM — the JDK has no metering +signal. Don't branch download policy on it for desktop/server builds. + +## Platform floor + +JVM 21. The published jar is architecture-neutral bytecode, so any OS and +CPU with a JVM 21 runtime works — including the Linux and Windows hosts +that have no native Reachable target. + +## See also + +- [Concepts → Validated vs available](../concepts/validated-vs-available.md): why validated matters, and the JVM gap. +- [Concepts → Lifecycle](../concepts/lifecycle.md): when to construct one Reachability vs many. diff --git a/gradle/plugins/src/main/kotlin/reachable.kmp-library.gradle.kts b/gradle/plugins/src/main/kotlin/reachable.kmp-library.gradle.kts index c0eb340..4937131 100644 --- a/gradle/plugins/src/main/kotlin/reachable.kmp-library.gradle.kts +++ b/gradle/plugins/src/main/kotlin/reachable.kmp-library.gradle.kts @@ -3,9 +3,10 @@ * libraries (`:reachable`, `:reachable-testing`). * * Owns everything the two modules previously duplicated (CLAUDE.md §1, §2, - * §4): the ARM-only target matrix, the apple intermediate source set, the - * Android library block, compiler options, JVM target wiring, and the - * SKIE settings that must match across modules. Per-module identity + * §4): the target matrix (ARM-only natives, Android, desktop/server JVM), + * the apple intermediate source set, the Android library block, compiler + * options, JVM toolchain wiring, and the SKIE settings that must match + * across modules. Per-module identity * (framework base name, bundle id, Android namespace) is derived from the * project name so adding a module means applying this plugin and nothing * else: @@ -70,6 +71,12 @@ kotlin { } } + // --- JVM target (CLAUDE.md §1) ------------------------------------------- + // Desktop / server JVM. Bytecode is architecture-neutral, so the ARM-only + // rule constrains the native slices above, not this jar. The + // `targets.withType` block below pins it to JVM 21. + jvm() + // --- Android target (CLAUDE.md §1, §4) ---------------------------------- // The new com.android.kotlin.multiplatform.library plugin's android {} block. // diff --git a/mise.toml b/mise.toml index 30fab6c..92600b7 100644 --- a/mise.toml +++ b/mise.toml @@ -47,7 +47,7 @@ swiftformat = "0.61.1" # apps/ios, apps/macos Swift formatt # samples are demo scaffolding, excluded from lint and CI by policy (see # the subprojects block in build.gradle.kts). [tasks.check] -description = "Run ktlint + detekt + every unit test in both published modules (iOS simulator, macOS, Android host)." +description = "Run ktlint + detekt + every unit test in both published modules (iOS simulator, macOS, Android host, JVM)." run = "./gradlew :reachable:check :reachable-testing:check" [tasks.test] @@ -97,6 +97,10 @@ run = "./gradlew :reachable:linkDebugFrameworkMacosArm64" description = "Assemble the Android AAR." run = "./gradlew :reachable:assemble" +[tasks."build:jvm"] +description = "Assemble the desktop/server JVM jar." +run = "./gradlew :reachable:jvmJar" + [tasks.xcframework] description = "Alias for `build` — produces the release Reachable.xcframework." depends = ["build"] diff --git a/mkdocs.yml b/mkdocs.yml index 26675a0..ea22100 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -119,6 +119,7 @@ nav: - platforms/ios.md - platforms/macos.md - platforms/android.md + - platforms/jvm.md - Changelog: changelog.md # Strict mode: fail the build on a broken link or a dead anchor. diff --git a/reachable/src/appleMain/kotlin/com/happycodelucky/reachable/Reachability.apple.kt b/reachable/src/appleMain/kotlin/com/happycodelucky/reachable/Reachability.apple.kt index 1df7c1f..350a13d 100644 --- a/reachable/src/appleMain/kotlin/com/happycodelucky/reachable/Reachability.apple.kt +++ b/reachable/src/appleMain/kotlin/com/happycodelucky/reachable/Reachability.apple.kt @@ -15,7 +15,8 @@ package com.happycodelucky.reachable * * Available on iOS, iPadOS, and macOS (the `appleMain` source set covers all * Apple targets in this module). Android consumers use the `Reachability` - * factory in `androidMain`, which takes a `Context`. + * factory in `androidMain`, which takes a `Context`; JVM consumers use the + * `Reachability(pollInterval:)` factory in `jvmMain`. */ @Suppress("FunctionName") public fun Reachability(): Reachability = AppleReachability() diff --git a/reachable/src/commonMain/kotlin/com/happycodelucky/reachable/Reachability.kt b/reachable/src/commonMain/kotlin/com/happycodelucky/reachable/Reachability.kt index 0563358..eaa46bf 100644 --- a/reachable/src/commonMain/kotlin/com/happycodelucky/reachable/Reachability.kt +++ b/reachable/src/commonMain/kotlin/com/happycodelucky/reachable/Reachability.kt @@ -45,10 +45,15 @@ public annotation class TestingOnly * * Maps from Apple's `nw_interface_type_*` enum and Android's * `NetworkCapabilities.TRANSPORT_*` flags. A path can use multiple interfaces - * simultaneously (VPN over Wi-Fi, for example); both platforms collapse to + * simultaneously (VPN over Wi-Fi, for example); all platforms collapse to * the highest-quality transport using the priority * `WIFI > ETHERNET > CELLULAR > OTHER`. * + * On the JVM (desktop / server) the OS exposes no transport type, so the + * value is inferred from interface naming — best-effort. Interfaces the + * heuristic can't classify (notably macOS's `en0`, which may be Wi-Fi or + * wired) report [Other]. + * * [None] is reported when [ReachabilityStatus.isReachable] is `false`. A * usable path with an unrecognised interface type reports [Other]. */ @@ -81,6 +86,9 @@ public enum class Transport { * that has been *validated* (Android `NET_CAPABILITY_VALIDATED`) or is reported * as `nw_path_status_satisfied` (Apple). Bare "interface up" is **not** * sufficient — captive portals and DNS holes will set this to `false`. + * Exception: the JVM offers no validation probe, so on desktop / server JVMs + * this *is* best-effort interface-up detection (a non-loopback interface with + * a routable address) — captive portals cannot be detected there. * @property transport Which interface is carrying the active connection. See * [Transport]. * @property isDataMetered `true` when the active path is reported as metered @@ -89,6 +97,7 @@ public enum class Transport { * `nw_path_is_expensive(path) || nw_path_is_constrained(path)`, so the Low * Data Mode signal folds into this single bit. On Android it is the negation * of `NET_CAPABILITY_NOT_METERED || NET_CAPABILITY_TEMPORARILY_NOT_METERED`. + * The JVM has no metering signal; there it is always `false`. */ public data class ReachabilityStatus( val isReachable: Boolean, @@ -148,8 +157,9 @@ public data class ReachabilityStatus( * [companion object][Reachability.Companion.shared] for semantics. * * For explicit-lifecycle use (tests, per-feature observers), use the - * platform factories: `Reachability()` in `appleMain` (no arguments) and - * `Reachability(context:)` in `androidMain` (takes an Android `Context`). + * platform factories: `Reachability()` in `appleMain` (no arguments), + * `Reachability(context:)` in `androidMain` (takes an Android `Context`), + * and `Reachability(pollInterval:)` in `jvmMain` (optional poll cadence). * Call [close] when the owning scope tears down. [close] is idempotent. */ public interface Reachability : AutoCloseable { @@ -210,8 +220,9 @@ public interface Reachability : AutoCloseable { /** * Tear down the platform observer (Apple: `nw_path_monitor_cancel`; - * Android: `unregisterNetworkCallback`) and cancel the internal coroutine - * scope. Idempotent; subsequent calls are no-ops. + * Android: `unregisterNetworkCallback`; JVM: stop the interface poll + * loop) and cancel the internal coroutine scope. Idempotent; subsequent + * calls are no-ops. * * After [close], [status] continues to expose its last value but will * never emit again. @@ -233,6 +244,12 @@ public interface Reachability : AutoCloseable { * eagerly. Subsequent accesses return the same instance. There is * no Context dependency on Apple — the monitor is self-contained. * + * **JVM (desktop / server)**: on first access, constructs an + * instance that polls the interface table at the default cadence + * and starts observing eagerly — self-contained, like Apple. See + * the `Reachability(pollInterval:)` factory in `jvmMain` for the + * best-effort semantics on this platform. + * * **Android**: on first access, returns an unattached instance * whose [status] is [ReachabilityStatus.Unknown]. The library's * bundled `androidx.startup` initializer attaches it to the @@ -249,8 +266,8 @@ public interface Reachability : AutoCloseable { * coming online. * * Calling [close] on this instance is an intentional **no-op** — - * the singleton's lifetime is the process, and the OS reaps the - * platform observer at process exit on both platforms. Code that + * the singleton's lifetime is the process, and the platform + * observer ends with the process on every platform. Code that * needs explicit lifecycle should use the `Reachability(context)` * / `Reachability()` top-level factories instead, which return a * fresh observer per call and honour [close] normally. diff --git a/reachable/src/commonMain/kotlin/com/happycodelucky/reachable/internal/SharedReachabilityHolder.kt b/reachable/src/commonMain/kotlin/com/happycodelucky/reachable/internal/SharedReachabilityHolder.kt index 5b2d4c4..b7267fa 100644 --- a/reachable/src/commonMain/kotlin/com/happycodelucky/reachable/internal/SharedReachabilityHolder.kt +++ b/reachable/src/commonMain/kotlin/com/happycodelucky/reachable/internal/SharedReachabilityHolder.kt @@ -9,9 +9,9 @@ * Why this layout (CLAUDE.md §4): * * - The `expect` surface is one function. Apple's `actual` is one line - * (`AppleReachability()`); Android's is one line (`AndroidReachability()`). - * Both well under the §4 "refactor to interface if it grows past - * ~20 lines" threshold. + * (`AppleReachability()`); Android's is one line (`AndroidReachability()`); + * the JVM's is one line (`JvmReachability()`). All well under the §4 + * "refactor to interface if it grows past ~20 lines" threshold. * - The singleton wrapping into [NonClosingReachability] happens here, * once, in common code — neither platform `actual` needs to know about * the decorator. @@ -36,11 +36,12 @@ import kotlinx.atomicfu.atomic /** * Platform factory for the singleton's underlying instance. On Apple this - * constructs an `AppleReachability` (begins observing eagerly). On Android - * this constructs an `AndroidReachability` with no Context — the bundled - * `ReachabilityInitializer` (or, for consumers who disable - * `androidx.startup`'s `InitializationProvider`, no one) calls `attach` - * later. + * constructs an `AppleReachability` (begins observing eagerly). On the JVM + * this constructs a `JvmReachability` at the default poll cadence (also + * eager). On Android this constructs an `AndroidReachability` with no + * Context — the bundled `ReachabilityInitializer` (or, for consumers who + * disable `androidx.startup`'s `InitializationProvider`, no one) calls + * `attach` later. */ internal expect fun createSharedReachability(): Reachability diff --git a/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/JvmReachability.kt b/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/JvmReachability.kt new file mode 100644 index 0000000..709a55c --- /dev/null +++ b/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/JvmReachability.kt @@ -0,0 +1,117 @@ +/* + * Reachable — JVM (desktop / server) implementation. + * + * The JDK has no connectivity-change callback (nothing like Apple's + * `nw_path_monitor` or Android's `ConnectivityManager.NetworkCallback`) and + * no OS validation probe to read, so this backend *polls* + * `java.net.NetworkInterface` on the base class scope and maps each snapshot + * via the pure `mapJvmInterfaces`. Two consequences, both documented on the + * public surface: + * + * - `isReachable` is best-effort "a usable interface is up", not + * validated internet. Captive portals are invisible here. + * - Changes surface within one poll tick (default 5 seconds), not + * within milliseconds. + * + * An active probe (HTTP HEAD against a known endpoint) would close the + * validation gap but ships phone-home traffic in a library by default — + * deliberately not done. Revisit as an opt-in knob if asked for. + */ + +// `StateFlowReachability` is `@TestingOnly` to keep its constructor out of +// production consumer reach — but `:reachable`'s own platform subclasses are +// the original consumers. Opt in at the file level so the inheritance is +// allowed; the opt-in does not leak (`JvmReachability` itself is `internal`). +@file:OptIn(TestingOnly::class) + +package com.happycodelucky.reachable + +import com.happycodelucky.reachable.internal.JvmNetworkInterface +import com.happycodelucky.reachable.internal.StateFlowReachability +import com.happycodelucky.reachable.internal.mapJvmInterfaces +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.net.NetworkInterface +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Default cadence for the interface poll loop — shared by the public + * `Reachability(pollInterval:)` factory and the `Reachability.shared` + * singleton. Enumerating interfaces is a cheap local syscall (no traffic), + * so 5 seconds buys near-zero overhead while keeping status staleness + * tolerable for desktop UI. + */ +internal val defaultJvmPollInterval: Duration = 5.seconds + +/** + * JVM-side `Reachability` over polled `java.net.NetworkInterface` snapshots. + * + * Constructor side effects: launches the poll loop on the base class [scope]. + * The first poll runs immediately (no initial delay), so the first real + * emission lands as soon as the dispatcher schedules it — parity with the + * Apple backend's "within tens of milliseconds". Until then, `status.value` + * returns [ReachabilityStatus.Unknown] from the base class. + * + * [onClose] has nothing platform-side to release — the base class cancels + * [scope] right after it, which ends the loop at its next suspension point; + * `publish` is already a no-op by then. + * + * @param pollInterval Delay between interface polls. `MutableStateFlow` + * conflation drops identical successive readings, so a short interval costs + * syscalls, not emissions. + * @param interfaceSource Injection seam for tests. Production uses + * [systemNetworkInterfaces]; jvmTest swaps in a scripted source to drive the + * loop deterministically. + */ +internal class JvmReachability internal constructor( + private val pollInterval: Duration = defaultJvmPollInterval, + private val interfaceSource: () -> List = ::systemNetworkInterfaces, +) : StateFlowReachability() { + init { + scope.launch { + while (isActive) { + publish(mapJvmInterfaces(interfaceSource())) + delay(pollInterval) + } + } + } + + override fun onClose() { + // No platform observer to release: close() cancels the scope right + // after this hook, which is what stops the poll loop. + } +} + +/** + * Snapshot every `NetworkInterface` the JDK can see into pure data for + * [mapJvmInterfaces]. Defensive throughout: enumeration and the per-interface + * flag reads can throw `SocketException` when an interface disappears + * mid-poll (dock unplugged, VPN dropped), in which case the affected + * interface degrades to "not usable" and the poll as a whole to "no + * interfaces" — i.e. not reachable — rather than crashing the loop. + */ +internal fun systemNetworkInterfaces(): List = + runCatching { + NetworkInterface + .getNetworkInterfaces() + ?.toList() + .orEmpty() + .map { nif -> nif.toSnapshot() } + }.getOrDefault(emptyList()) + +private fun NetworkInterface.toSnapshot(): JvmNetworkInterface = + JvmNetworkInterface( + name = name.orEmpty(), + displayName = displayName.orEmpty(), + isUp = runCatching { isUp }.getOrDefault(false), + isLoopback = runCatching { isLoopback }.getOrDefault(true), + hasRoutableAddress = + inetAddresses + ?.toList() + .orEmpty() + .any { address -> + !address.isLoopbackAddress && !address.isLinkLocalAddress && !address.isAnyLocalAddress + }, + ) diff --git a/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/Reachability.jvm.kt b/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/Reachability.jvm.kt new file mode 100644 index 0000000..b33c411 --- /dev/null +++ b/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/Reachability.jvm.kt @@ -0,0 +1,36 @@ +/* + * Reachable — JVM-side public factory. + * + * Top-level function (not a companion-object factory) for symmetry with the + * Apple and Android factories — `Reachability()` reads like a constructor at + * the call site on every platform. + */ +package com.happycodelucky.reachable + +import kotlin.time.Duration + +/** + * Construct a [Reachability] backed by polling `java.net.NetworkInterface`. + * Begins observing immediately; call [Reachability.close] to stop the poll + * loop when you're done with it. + * + * **Best-effort semantics.** The JVM offers no OS validation probe, so + * [ReachabilityStatus.isReachable] means "a non-loopback interface is up + * with a routable address" — weaker than the validated signal on Apple and + * Android. Captive portals are not detected, transport classification is + * inferred from interface names (unrecognised ones report + * [Transport.Other]), and [ReachabilityStatus.isDataMetered] is always + * `false`. + * + * Available on desktop and server JVMs. Apple consumers use the no-argument + * `Reachability()` factory in `appleMain`; Android consumers use + * `Reachability(context)` in `androidMain`. + * + * @param pollInterval How often to re-read the interface table. Defaults to + * 5 seconds — each poll is a cheap local syscall with no network traffic. + * Identical successive readings are conflated, so a shorter interval changes + * detection latency, not emission volume. + */ +@Suppress("FunctionName") +public fun Reachability(pollInterval: Duration = defaultJvmPollInterval): Reachability = + JvmReachability(pollInterval = pollInterval) diff --git a/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/internal/JvmNetworkInterface.kt b/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/internal/JvmNetworkInterface.kt new file mode 100644 index 0000000..f7d066a --- /dev/null +++ b/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/internal/JvmNetworkInterface.kt @@ -0,0 +1,34 @@ +/* + * Reachable — pure-data snapshot of one JVM network interface. + * + * Sits between the impure projection in `JvmReachability` (which reads + * `java.net.NetworkInterface`) and the pure mapping in `Mapping.jvm.kt`. + */ +package com.happycodelucky.reachable.internal + +/** + * Pure-data projection of one `java.net.NetworkInterface`, captured at poll + * time. Holding primitives instead of the live JDK object keeps + * [mapJvmInterfaces] deterministic — `NetworkInterface.isUp` can throw + * `SocketException` if the interface vanishes mid-read, so the impure + * projection happens once, in `JvmReachability`. + * + * @property name OS-level interface name (`wlan0`, `en0`, `eth0`, …). On + * Windows the JDK synthesises Unix-style names (`eth0`, `wlan0`, `ppp0`). + * @property displayName Human-readable adapter name. Mostly equals [name] on + * macOS and Linux; on Windows it carries the vendor string + * (`Intel(R) Wi-Fi 6 AX201 160MHz`), which is why classification checks both. + * @property isUp `NetworkInterface.isUp` — administratively up and running. + * @property isLoopback `NetworkInterface.isLoopback`. + * @property hasRoutableAddress `true` when at least one bound address is not + * loopback, link-local, or wildcard. Filters out interfaces that are "up" but + * unusable — a DHCP-failed adapter squatting on 169.254.x.x, or Apple's + * AWDL links which only ever hold fe80:: addresses. + */ +internal data class JvmNetworkInterface( + val name: String, + val displayName: String, + val isUp: Boolean, + val isLoopback: Boolean, + val hasRoutableAddress: Boolean, +) diff --git a/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/internal/Mapping.jvm.kt b/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/internal/Mapping.jvm.kt new file mode 100644 index 0000000..f0efa9b --- /dev/null +++ b/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/internal/Mapping.jvm.kt @@ -0,0 +1,97 @@ +/* + * Reachable — pure mapping from JVM network-interface snapshots to ReachabilityStatus. + * + * The JDK exposes no connectivity-change callback, no validation probe, and no + * metering signal — `java.net.NetworkInterface` link state, names, and + * addresses are all there is. `JvmReachability` projects each interface to a + * [JvmNetworkInterface] snapshot and calls into here, keeping this file pure + * and unit-testable from jvmTest without touching real sockets (the same + * split `Mapping.kt` gives the Apple and Android backends). + */ +package com.happycodelucky.reachable.internal + +import com.happycodelucky.reachable.ReachabilityStatus +import com.happycodelucky.reachable.Transport + +/** + * Interface-name prefixes for host-only bridges created by container and + * hypervisor runtimes (Docker, libvirt, VMware, VirtualBox, macOS internet + * sharing). These sit "up" with a routable private address even when the + * machine has no WAN link at all, so counting them would report a + * Docker-running laptop as reachable in airplane mode. Real VPN tunnels + * (`utun*`, `tun*`, `tailscale*`) are deliberately *not* listed — an active + * tunnel is genuine connectivity. + */ +private val hostOnlyBridgePrefixes = listOf("docker", "veth", "br-", "virbr", "vmnet", "vboxnet", "bridge") + +// Name prefixes are matched against `name`, keywords against both `name` and +// `displayName`, all lowercased. Sources: Linux predictable interface naming +// (wl*/en*/ww*), classic kernel names (eth*/wlan*/ppp*), the JDK's synthetic +// Windows names, and common Windows adapter vendor strings. +private val wifiNamePrefixes = listOf("wlan", "wl", "ath") +private val wifiKeywords = listOf("wi-fi", "wifi", "wireless", "802.11", "airport") +private val cellularNamePrefixes = listOf("wwan", "wwp", "rmnet", "ppp") +private val cellularKeywords = listOf("cellular", "mobile broadband", "wwan") +private val ethernetNamePrefixes = listOf("eth", "enp", "eno", "ens", "enx", "em") +private val ethernetKeywords = listOf("ethernet") + +/** + * Map one poll's interface snapshots to a [ReachabilityStatus]. + * + * Reachability on the JVM is **best-effort, not validated**: `isReachable` is + * `true` when at least one interface is up, non-loopback, holds a routable + * address, and is not a host-only container/hypervisor bridge. There is no + * JDK equivalent of Android's `NET_CAPABILITY_VALIDATED` probe or Apple's + * `nw_path_status_satisfied`, so a captive portal or DNS blackhole still + * reports `true` here. Consumers who need proof of a working path must make + * a real request and treat its failure as the signal. + * + * `isDataMetered` is always `false`: the JDK has no metering signal. + */ +internal fun mapJvmInterfaces(interfaces: List): ReachabilityStatus { + val usable = + interfaces.filter { nif -> + nif.isUp && + !nif.isLoopback && + nif.hasRoutableAddress && + hostOnlyBridgePrefixes.none { prefix -> nif.name.lowercase().startsWith(prefix) } + } + val reachable = usable.isNotEmpty() + val transports = usable.map(::classifyTransport) + return ReachabilityStatus( + isReachable = reachable, + transport = + pickTransport( + reachable = reachable, + wifi = Transport.Wifi in transports, + ethernet = Transport.Ethernet in transports, + cellular = Transport.Cellular in transports, + other = Transport.Other in transports, + ), + isDataMetered = false, + ) +} + +/** + * Best-effort transport classification from interface naming. Checked in + * Wi-Fi → cellular → Ethernet order so a `wlan0` never falls through to a + * broader Ethernet pattern. Anything unrecognised — notably macOS's `en0`, + * which is Wi-Fi on laptops and wired on desktops with no way to tell from + * the JDK — honestly reports [Transport.Other]. + */ +private fun classifyTransport(nif: JvmNetworkInterface): Transport { + val name = nif.name.lowercase() + val labels = listOf(name, nif.displayName.lowercase()) + return when { + wifiNamePrefixes.any(name::startsWith) || + wifiKeywords.any { keyword -> labels.any { label -> keyword in label } } -> Transport.Wifi + + cellularNamePrefixes.any(name::startsWith) || + cellularKeywords.any { keyword -> labels.any { label -> keyword in label } } -> Transport.Cellular + + ethernetNamePrefixes.any(name::startsWith) || + ethernetKeywords.any { keyword -> labels.any { label -> keyword in label } } -> Transport.Ethernet + + else -> Transport.Other + } +} diff --git a/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/internal/SharedReachabilityHolder.jvm.kt b/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/internal/SharedReachabilityHolder.jvm.kt new file mode 100644 index 0000000..9839150 --- /dev/null +++ b/reachable/src/jvmMain/kotlin/com/happycodelucky/reachable/internal/SharedReachabilityHolder.jvm.kt @@ -0,0 +1,15 @@ +/* + * Reachable — JVM `actual` for the singleton holder. + * + * Like Apple, the JVM has no Context to wait for: `JvmReachability` is + * constructable from anywhere and starts its poll loop eagerly. The first + * read of `Reachability.shared` therefore produces a fully-functional, + * already-observing instance whose first real reading lands on the first + * poll tick. + */ +package com.happycodelucky.reachable.internal + +import com.happycodelucky.reachable.JvmReachability +import com.happycodelucky.reachable.Reachability + +internal actual fun createSharedReachability(): Reachability = JvmReachability() diff --git a/reachable/src/jvmTest/kotlin/com/happycodelucky/reachable/JvmReachabilityTest.kt b/reachable/src/jvmTest/kotlin/com/happycodelucky/reachable/JvmReachabilityTest.kt new file mode 100644 index 0000000..6f88ae4 --- /dev/null +++ b/reachable/src/jvmTest/kotlin/com/happycodelucky/reachable/JvmReachabilityTest.kt @@ -0,0 +1,127 @@ +/* + * Reachable — behavioural tests for the JVM poll loop. + * + * The loop runs on the instance's own scope (Dispatchers.Default), so these + * tests run against short *real* poll intervals and let Turbine suspend until + * emissions arrive — no virtual-time scheduler reaches that dispatcher, and + * no Thread.sleep is involved (CLAUDE.md §11). + * + * Determinism note: a collector may attach before or after the first poll + * lands. Every test therefore starts the scripted interface source in a + * state that maps to ReachabilityStatus.Unknown (equal to the StateFlow + * seed, so conflation makes the race invisible) and only then steps it. + */ +package com.happycodelucky.reachable + +import app.cash.turbine.test +import com.happycodelucky.reachable.internal.JvmNetworkInterface +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +class JvmReachabilityTest { + private val wifi = + ReachabilityStatus(isReachable = true, transport = Transport.Wifi, isDataMetered = false) + private val wired = + ReachabilityStatus(isReachable = true, transport = Transport.Ethernet, isDataMetered = false) + + private fun iface( + name: String, + isUp: Boolean = true, + ) = JvmNetworkInterface( + name = name, + displayName = name, + isUp = isUp, + isLoopback = false, + hasRoutableAddress = true, + ) + + /** + * Scripted stand-in for [systemNetworkInterfaces]. AtomicReference (not a + * plain `var`) because the poll loop reads from a Dispatchers.Default + * thread while the test thread writes. + */ + private class ScriptedInterfaces( + initial: List, + ) : () -> List { + private val current = AtomicReference(initial) + + fun set(next: List) = current.set(next) + + override fun invoke(): List = current.get() + } + + @Test + fun pollLoopPublishesInterfaceChanges() = + runTest { + val source = ScriptedInterfaces(listOf(iface("wlan0", isUp = false))) + JvmReachability(pollInterval = 25.milliseconds, interfaceSource = source).use { reachability -> + reachability.status.test { + // Down interface maps to Unknown — identical to the seed, + // so exactly one item regardless of poll/collect ordering. + assertEquals(ReachabilityStatus.Unknown, awaitItem()) + + source.set(listOf(iface("wlan0"))) + assertEquals(wifi, awaitItem()) + + source.set(emptyList()) + assertEquals(ReachabilityStatus.Unknown, awaitItem()) + } + } + } + + @Test + fun singleAxisFlowsTrackThePollLoop() = + runTest { + val source = ScriptedInterfaces(emptyList()) + JvmReachability(pollInterval = 25.milliseconds, interfaceSource = source).use { reachability -> + reachability.reachable.test { + assertEquals(false, awaitItem()) + source.set(listOf(iface("eth0"))) + assertEquals(true, awaitItem()) + assertTrue(reachability.isReachable) + } + } + } + + @Test + fun closeStopsThePollLoop() = + runTest { + val source = ScriptedInterfaces(listOf(iface("eth0"))) + val reachability = JvmReachability(pollInterval = 25.milliseconds, interfaceSource = source) + reachability.status.test { + // First item is the seed (Unknown) or the first poll result, + // depending on who wins the startup race — consume until the + // wired reading arrives so nothing is left buffered. + if (awaitItem() != wired) { + assertEquals(wired, awaitItem()) + } + + reachability.close() + source.set(emptyList()) + + // Real-time wait spanning several would-be poll ticks; the + // runTest scheduler's virtual time never touches this delay. + withContext(Dispatchers.Default) { delay(150.milliseconds) } + expectNoEvents() + + // close() retains the last published value. + assertEquals(wired, reachability.status.value) + } + } + + @Test + fun publicFactoryConstructsAndClosesCleanly() { + // Smoke test against the real NetworkInterface table: must construct, + // read, and close (idempotently) without throwing on any host. + val reachability = Reachability(pollInterval = 25.milliseconds) + reachability.use { /* status.value is the seed or a real reading */ } + reachability.close() + } +} diff --git a/reachable/src/jvmTest/kotlin/com/happycodelucky/reachable/internal/JvmMappingTest.kt b/reachable/src/jvmTest/kotlin/com/happycodelucky/reachable/internal/JvmMappingTest.kt new file mode 100644 index 0000000..156aa00 --- /dev/null +++ b/reachable/src/jvmTest/kotlin/com/happycodelucky/reachable/internal/JvmMappingTest.kt @@ -0,0 +1,139 @@ +/* + * Reachable — unit tests for the pure JVM interface→status mapping. + * + * Exercises the name/displayName classification heuristics, the host-only + * bridge exclusions, and the Wifi > Ethernet > Cellular > Other transport + * priority — all without touching java.net.NetworkInterface. + */ +package com.happycodelucky.reachable.internal + +import com.happycodelucky.reachable.ReachabilityStatus +import com.happycodelucky.reachable.Transport +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JvmMappingTest { + private fun iface( + name: String, + displayName: String = name, + isUp: Boolean = true, + isLoopback: Boolean = false, + hasRoutableAddress: Boolean = true, + ) = JvmNetworkInterface( + name = name, + displayName = displayName, + isUp = isUp, + isLoopback = isLoopback, + hasRoutableAddress = hasRoutableAddress, + ) + + @Test + fun noInterfacesMapsToUnknown() { + assertEquals(ReachabilityStatus.Unknown, mapJvmInterfaces(emptyList())) + } + + @Test + fun loopbackOnlyIsNotReachable() { + val status = mapJvmInterfaces(listOf(iface("lo", isLoopback = true))) + assertFalse(status.isReachable) + assertEquals(Transport.None, status.transport) + } + + @Test + fun downInterfaceIsNotReachable() { + val status = mapJvmInterfaces(listOf(iface("eth0", isUp = false))) + assertFalse(status.isReachable) + assertEquals(Transport.None, status.transport) + } + + @Test + fun linkLocalOnlyInterfaceIsNotReachable() { + // DHCP-failed adapter on 169.254.x.x, or Apple AWDL with only fe80::. + val status = mapJvmInterfaces(listOf(iface("awdl0", hasRoutableAddress = false))) + assertFalse(status.isReachable) + } + + @Test + fun linuxWirelessNamesClassifyAsWifi() { + for (name in listOf("wlan0", "wlp3s0", "wlx001122334455")) { + val status = mapJvmInterfaces(listOf(iface(name))) + assertTrue(status.isReachable, name) + assertEquals(Transport.Wifi, status.transport, name) + } + } + + @Test + fun windowsAdapterDisplayNameClassifiesAsWifi() { + // The JDK synthesises generic names on Windows; the vendor string + // lives in displayName. + val status = mapJvmInterfaces(listOf(iface("net4", displayName = "Intel(R) Wi-Fi 6 AX201 160MHz"))) + assertEquals(Transport.Wifi, status.transport) + } + + @Test + fun wiredNamesClassifyAsEthernet() { + for (name in listOf("eth0", "enp3s0", "eno1", "ens33", "enx0a1b2c3d4e5f", "em1")) { + val status = mapJvmInterfaces(listOf(iface(name))) + assertEquals(Transport.Ethernet, status.transport, name) + } + } + + @Test + fun cellularNamesClassifyAsCellular() { + for (name in listOf("wwan0", "wwp0s20f0u6", "rmnet0", "ppp0")) { + val status = mapJvmInterfaces(listOf(iface(name))) + assertEquals(Transport.Cellular, status.transport, name) + } + } + + @Test + fun macOSAmbiguousEnNamesClassifyAsOther() { + // en0 is Wi-Fi on MacBooks and wired on desktop Macs; the JDK can't + // tell, so the mapping must not guess. + val status = mapJvmInterfaces(listOf(iface("en0"))) + assertTrue(status.isReachable) + assertEquals(Transport.Other, status.transport) + } + + @Test + fun vpnTunnelCountsAsReachableOther() { + val status = mapJvmInterfaces(listOf(iface("utun2"))) + assertTrue(status.isReachable) + assertEquals(Transport.Other, status.transport) + } + + @Test + fun hostOnlyBridgesAreExcluded() { + for (name in listOf("docker0", "veth1a2b3c", "br-4d5e6f", "virbr0", "vmnet8", "vboxnet0", "bridge100")) { + val status = mapJvmInterfaces(listOf(iface(name))) + assertFalse(status.isReachable, name) + assertEquals(Transport.None, status.transport, name) + } + } + + @Test + fun bridgeExclusionStillCountsRealInterfaces() { + val status = mapJvmInterfaces(listOf(iface("docker0"), iface("eth0"))) + assertTrue(status.isReachable) + assertEquals(Transport.Ethernet, status.transport) + } + + @Test + fun transportPriorityPrefersWifiThenEthernetThenCellular() { + val wifiAndEthernet = mapJvmInterfaces(listOf(iface("enp3s0"), iface("wlan0"))) + assertEquals(Transport.Wifi, wifiAndEthernet.transport) + + val ethernetAndCellular = mapJvmInterfaces(listOf(iface("wwan0"), iface("eth0"))) + assertEquals(Transport.Ethernet, ethernetAndCellular.transport) + } + + @Test + fun dataMeteredIsAlwaysFalseEvenOnCellular() { + // The JDK exposes no metering signal — documented contract. + val status = mapJvmInterfaces(listOf(iface("wwan0"))) + assertEquals(Transport.Cellular, status.transport) + assertFalse(status.isDataMetered) + } +}