cmp-network-monitor¶
cmp-network-monitor¶
Reactive network connectivity monitoring for Kotlin Multiplatform — StateFlow-based, all 21 KMP targets.
Features¶
- StateFlow-first API — hot-shared
isOnline,networkStatus, andnetworkChangesflows - Rich network info — connection type (WiFi/Cellular/Ethernet/VPN/Bluetooth), metered status, bandwidth
- Discrete change events — Connected, Disconnected, TypeChanged, MeteredChanged, CaptivePortalDetected
- Captive portal detection — opt-in HTTP redirect detection for hotel WiFi, airport networks
- Configurable validation — NativeOnly, HttpOnly, or NativeThenHttp (catches captive portals)
- Adaptive polling — polling-based monitors grow intervals during stable periods, reset on change
- Bandwidth sampling —
bandwidthSamples()Flow,currentBandwidth, quality signals (Excellent/Good/Fair/Poor) - Composite monitors — combine monitors via
monitor1 + monitor2, reports online if ANY is online - Cold-start cache — persists last-known state to disk for instant startup
- 21 KMP targets across 11 platforms — Android, iOS, macOS, tvOS, watchOS, JVM, JS, WasmJS, WasmWASI, Linux, Windows
- Zero dependencies beyond kotlinx-coroutines — no DI, no Compose required
- Test-friendly —
FakeNetworkMonitorpublished in main artifact with status/event history tracking - 15+ extension functions —
requireOnline(),ensureOnline(),ifOnline{},ifOffline{},measureLatency(),addCallback(),retryOnReconnect{}, and more
Platform Implementations¶
| Platform | API | Type |
|---|---|---|
| Android | ConnectivityManager + NetworkCallback | Push-based |
| iOS/macOS/tvOS/watchOS | NWPathMonitor | Push-based |
| JVM | NetworkInterface + HTTP HEAD | Poll + validate |
| JS | navigator.onLine + events | Push-based |
| WasmJS | navigator.onLine + events | Push-based |
| Linux | /sys/class/net polling | Poll-based |
| Windows (MinGW) | Polling stub | Poll-based |
| WasmWASI | No-op stub | Stub |
Installation¶
Gradle (libs.versions.toml)¶
[versions]
cmp-network-monitor = "3.2.1"
[libraries]
cmp-network-monitor = { module = "io.github.mobilebytelabs:cmp-network-monitor", version.ref = "cmp-network-monitor" }
Android Setup¶
The library automatically initializes via NetworkMonitorInitProvider (ContentProvider).
No manual setup needed. The ACCESS_NETWORK_STATE permission is included in the library manifest.
If you disable the ContentProvider (e.g., via tools:node="remove"), call manually:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
setApplicationContext(this)
}
}
Usage¶
Basic — Check connectivity¶
val monitor = createNetworkMonitor()
// One-shot check
if (monitor.isOnline.value) {
println("Online!")
}
// Reactive — collect in a coroutine
monitor.isOnline.collect { online ->
println("Connectivity: $online")
}
Rich network info¶
monitor.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
println("Type: ${status.info.type}") // WiFi, Cellular, etc.
println("Metered: ${status.info.isMetered}")
println("Down: ${status.info.downstreamBandwidthKbps} kbps")
}
NetworkStatus.Unavailable -> println("Offline")
}
}
Discrete change events¶
monitor.networkChanges.collect { event ->
when (event) {
is NetworkChangeEvent.Connected -> println("Connected via ${event.info.type}")
is NetworkChangeEvent.Disconnected -> println("Lost connection")
is NetworkChangeEvent.TypeChanged -> println("Switched ${event.from} -> ${event.to}")
is NetworkChangeEvent.MeteredChanged -> println("Metered: ${event.isMetered}")
}
}
Extension functions¶
// Fail-fast if offline
monitor.requireOnline() // throws NetworkUnavailableException
// Suspend until online
monitor.ensureOnline()
// Guard a network operation
val data = monitor.withNetworkGuard {
api.fetchData()
}
// Get NetworkInfo when online
val info = monitor.awaitOnline()
// Flow that completes when offline
monitor.onlyWhileOnline().collect { status -> ... }
// Retry on reconnect
val result = monitor.retryOnReconnect(maxRetries = 3) {
api.upload(payload)
}
// Check specific connection type
if (monitor.isConnectedVia(NetworkType.WiFi)) {
downloadLargeFile()
}
Custom configuration¶
val monitor = createNetworkMonitor(
NetworkMonitorConfig(
pollIntervalMs = 5_000L, // JVM/Linux/Windows only
validationUrl = "https://example.com/check", // HTTP validation endpoint
validationTimeoutMs = 3_000L,
validationStrategy = ValidationStrategy.NativeThenHttp, // default
)
)
Compose Multiplatform integration¶
The library doesn't depend on Compose to maintain full target coverage. Use these patterns in your Compose UI:
@Composable
fun NetworkAwareScreen() {
val monitor = remember { createNetworkMonitor() }
DisposableEffect(monitor) { onDispose { monitor.close() } }
val isOnline by monitor.isOnline.collectAsState()
val status by monitor.networkStatus.collectAsState()
if (!isOnline) {
OfflineBanner()
}
// Listen for change events
LaunchedEffect(monitor) {
monitor.networkChanges.collect { event ->
when (event) {
is NetworkChangeEvent.Disconnected -> showSnackbar("Connection lost")
is NetworkChangeEvent.Connected -> showSnackbar("Back online")
else -> {}
}
}
}
}
Testing¶
FakeNetworkMonitor is published in the main artifact — no separate test dependency needed:
import io.github.mobilebytelabs.kmptoolkit.networkmonitor.testing.FakeNetworkMonitor
@Test
fun viewModelHandlesOffline() = runTest {
val monitor = FakeNetworkMonitor(initialOnline = true)
val viewModel = MyViewModel(monitor)
monitor.setOnline(false) // triggers Disconnected event
assertEquals(UiState.Offline, viewModel.state.value)
monitor.setOnline(true) // triggers Connected event
assertEquals(UiState.Online, viewModel.state.value)
// Verify cleanup
viewModel.onCleared()
assertTrue(monitor.isClosed)
}
Cleanup¶
// Close when done (releases platform resources)
monitor.close()
// For singleton usage (app-level), typically never close
API Reference¶
Core Interface¶
| Property/Method | Type | Description |
|---|---|---|
isOnline |
StateFlow<Boolean> |
Hot-shared connectivity state |
networkStatus |
StateFlow<NetworkStatus> |
Rich status with type/metered/bandwidth |
networkChanges |
SharedFlow<NetworkChangeEvent> |
Discrete transition events |
currentStatus |
NetworkStatus |
One-shot synchronous read |
close() |
Unit |
Release platform resources |
Types¶
| Type | Values |
|---|---|
NetworkType |
WiFi, Cellular, FiveG, Ethernet, VPN, Bluetooth, Unknown |
NetworkStatus |
Available(info), Unavailable, CaptivePortal(info, redirectUrl) |
NetworkChangeEvent |
Connected, Disconnected, TypeChanged, MeteredChanged, CaptivePortalDetected, CaptivePortalResolved |
NetworkQuality |
Excellent, Good, Fair, Poor, Offline |
ValidationStrategy |
NativeOnly, HttpOnly, NativeThenHttp |
Advanced Usage¶
Captive Portal Detection¶
val monitor = createNetworkMonitor(NetworkMonitorConfig {
captivePortalDetection = true
})
monitor.networkStatus.collect { status ->
when (status) {
is NetworkStatus.CaptivePortal -> showCaptivePortalBanner(status.redirectUrl)
is NetworkStatus.Available -> hideAllBanners()
is NetworkStatus.Unavailable -> showOfflineBanner()
}
}
Network Quality Signal¶
monitor.networkQuality().collect { quality ->
when (quality) {
NetworkQuality.Excellent -> loadHighResImages()
NetworkQuality.Good -> loadStandardImages()
NetworkQuality.Fair -> loadThumbnails()
NetworkQuality.Poor -> showTextOnly()
NetworkQuality.Offline -> showCachedContent()
}
}
Combine Data Flow with Network State¶
dataFlow.withNetworkState(monitor).collect { (data, status) ->
if (status.isOnline) showData(data) else showOfflineBanner(data)
}
Conditional Execution¶
val data = monitor.ifOnline { info ->
if (info.type == NetworkType.WiFi) api.fetchFullData() else api.fetchLiteData()
}
monitor.ifOffline {
showCachedData()
}
Callback-style API¶
val handle = monitor.addCallback(
scope = viewModelScope,
onOnline = { info -> updateUI(info) },
onOffline = { showOfflineBanner() },
)
// Later:
handle.close()
Composite Monitor¶
val wifiMonitor = createNetworkMonitor(NetworkMonitorConfig { ... })
val cellularMonitor = createNetworkMonitor(NetworkMonitorConfig { ... })
val combined = wifiMonitor + cellularMonitor
combined.isOnline.collect { ... } // true if either is online
DI Integration¶
// Koin
val appModule = module {
single<NetworkMonitor> { provideNetworkMonitor(getOrNull()) }
}
// Hilt
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideMonitor(): NetworkMonitor = provideNetworkMonitor()
}
Android Lifecycle¶
class MyActivity : AppCompatActivity() {
private val observer = LifecycleNetworkObserver(monitor) { status ->
when (status) {
is NetworkStatus.Available -> hideOfflineBanner()
is NetworkStatus.Unavailable -> showOfflineBanner()
is NetworkStatus.CaptivePortal -> showCaptivePortalBanner()
}
}
override fun onStart() {
super.onStart()
observer.start(lifecycleScope)
}
override fun onStop() {
super.onStop()
observer.stop()
}
}
License¶
Module reference¶
Module Identity (auto-gen)
| Artifact | Package | Current version | Maven | Since | API tier |
|---|---|---|---|---|---|
io.github.mobilebytelabs:cmp-network-monitor |
com.mobilebytelabs.kmptoolkit.network.monitor |
UNKNOWN |
Central | 2026-05-30 | experimental |
Module purpose (one paragraph):
§2 Per-Platform Parity Matrix (auto-gen)¶
| Target | Source-set present | Real impl | UnsupportedPlatform stub | .kt count | Last reviewed | Coverage | Notes |
|---|---|---|---|---|---|---|---|
| androidMain | ✅ | ✅ real | 0 | 7 | 2026-06-01 | (legacy:full) | — |
| iosMain | ✅ | ✅ real | 0 | 1 | 2026-06-01 | (legacy:full) | — |
| macosMain | ✅ | ✅ real | 0 | 1 | 2026-06-01 | (legacy:full) | — |
| jvmMain | ✅ | ✅ real | 0 | 5 | 2026-06-01 | (legacy:full) | — |
| jsMain | ✅ | ✅ real | 0 | 5 | 2026-06-01 | (legacy:full) | — |
| wasmJsMain | ✅ | ✅ real | 0 | 5 | 2026-06-01 | (legacy:full) | — |
| mingwMain | ✅ | ✅ real | 0 | 5 | 2026-06-01 | (legacy:full) | — |
| linuxMain | ✅ | ✅ real | 0 | 5 | 2026-06-01 | (legacy:full) | — |
| tvosMain | ✅ | ✅ real | 0 | 1 | 2026-06-01 | (legacy:full) | — |
| watchosMain | ✅ | ✅ real | 0 | 1 | 2026-06-01 | (legacy:full) | — |
Legend (Real impl): ✅ real impl, 🟡 partial / wontfix-OS / wontfix-infra / legacy stub, ⛔ not declared, — N/A.
Legend (Coverage enum, since 2026-06-01): full (all public-API methods backed by OS primitive) · partial (most real; some typed UnsupportedPlatform fallbacks for contracts that don't apply) · wontfix-OS (OS lacks the primitive) · wontfix-infra (impl possible but CI/toolchain blocks it) · (legacy:full|stub) (auto-derived; pre-opt-in modules — add a // LD-2-coverage: {enum} comment to the platform's primary .kt file to graduate). See RULE-LIB-DEVELOPMENT-MD-001 LD-2 + ADRs for accepted wontfix cases.
API reference¶
Each release ships the module's full Dokka HTML site inside its
-javadoc.jar artifact on Maven Central.
In IntelliJ / Android Studio the IDE mounts the jar and surfaces it automatically in hover popups, Quick Documentation, and Symbol search.