While working on my Kotlin Multiplatform project, I kept running into the same problem: I needed something, searched Maven Central, found either nothing maintained or nothing with the simple coroutine-friendly API I wanted. So I built them myself. Twice.
This is the story of KMP Network Monitor and KMP In-App Review — two small libraries, what gap each fills, why expect/actual is the right pattern for both, and what I learned publishing them to Maven Central.
Why two libraries?
Both solve the same category of problem: platform APIs that every mobile app needs, but that KMP doesn't abstract for you. In a KMP project, you either write them twice, find a library, or build one.
I tried the middle option first. What I found was either abandoned, overly complex for my needs, or missing one of the two platforms. And since I'd never built a KMP library before — I decided this was a good excuse to start. So I built both myself.
Library 1: KMP Network Monitor
The problem
Android uses ConnectivityManager with NetworkCallback. iOS uses NWPathMonitor. Different APIs, different threading models, different event semantics. I needed a single reactive stream I could consume from shared commonMain code.
The expect/actual approach
The natural instinct is a commonMain interface with two implementations. That works — but it's ordinary polymorphism, not KMP. With expect/actual, the compiler enforces that both sides exist. That's a compilation model guarantee you don't get from an interface.
// commonMain
expect class NetworkMonitor() : INetworkMonitor {
override val connectionState: Flow<ConnectionState>
}
Three states, not two
sealed class ConnectionState {
object Connected : ConnectionState()
object Disconnected : ConnectionState()
object Unknown : ConnectionState()
}
Unknown is the initial state before the platform resolves connectivity. Without it, you'd flash an "Offline" banner on every app launch.
The tricky part: validated connectivity
onAvailable fires when Android sees any network interface — including captive portals that have no real internet access. Trusting it alone means reporting "Connected" to a user who can't load anything.
The fix: wait for onCapabilitiesChanged with both NET_CAPABILITY_INTERNET and NET_CAPABILITY_VALIDATED:
override fun onAvailable(network: Network) {
// Intentionally empty — wait for NET_CAPABILITY_VALIDATED
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
val valid = networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET)
&& networkCapabilities.hasCapability(NET_CAPABILITY_VALIDATED)
if (valid) validNetworks.add(network) else validNetworks.remove(network)
updateState()
}
override fun onLost(network: Network) {
validNetworks.remove(network)
updateState()
}
We track a Set<Network> because Android fires events per interface. With Wi-Fi and mobile data active, losing one shouldn't report Disconnected.
iOS: a striking contrast
// iosMain
actual class NetworkMonitor actual constructor() : INetworkMonitor {
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Unknown)
actual override val connectionState: Flow<ConnectionState> = _connectionState
init {
val monitor = nw_path_monitor_create()
nw_path_monitor_set_update_handler(monitor) { path ->
_connectionState.value = if (nw_path_get_status(path) == nw_path_status_satisfied)
ConnectionState.Connected else ConnectionState.Disconnected
}
nw_path_monitor_set_queue(monitor, dispatch_get_main_queue())
nw_path_monitor_start(monitor)
}
}
Android: ~50 lines, capability checks, set-based tracking. iOS: ~15 lines, nw_path_monitor handles everything. Same interface. Very different roads.
Setup and testability
Android: NetworkMonitorInitializer.initialize(this) — one line in onCreate.
iOS: zero setup.
For testing, inject INetworkMonitor and use FakeNetworkMonitor in tests:
val fake = FakeNetworkMonitor(initialState = ConnectionState.Connected)
fake.emit(ConnectionState.Disconnected) // simulate network loss
Library 2: KMP In-App Review
The problem
Existing KMP in-app review libraries felt heavier than I needed — multiple modules, delegate patterns, result codes for different stores. I wanted a single suspend fun requestReview() that works for Google Play and iOS App Store, nothing more.
Same pattern, more dramatic contrast
// commonMain
public expect class ReviewManager {
public suspend fun requestReview()
}
Android — two suspendCancellableCoroutine calls: first to get a ReviewInfo token, then to launch the flow:
public actual class ReviewManager(private val activity: Activity) {
public actual suspend fun requestReview() {
val manager = ReviewManagerFactory.create(activity)
val reviewInfo = suspendCancellableCoroutine { cont ->
manager.requestReviewFlow()
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspendCancellableCoroutine { cont ->
manager.launchReviewFlow(activity, reviewInfo)
.addOnCompleteListener { cont.resume(Unit) }
}
}
}
iOS:
public actual class ReviewManager {
public actual suspend fun requestReview() {
SKStoreReviewController.requestReview()
}
}
Android takes responsibility for the full process and gives more control. iOS hands everything to the OS. expect/actual absorbs the difference so the caller never thinks about it.
The honest testing contract
Neither platform guarantees the dialog appears — not because the library is incomplete, but because both Google and Apple deliberately rate-limit review prompts to protect users from being spammed. The library contract is:
The coroutine completes without error. Not that the dialog was shown.
iOS Simulator always shows the dialog regardless of rate limits — useful during development.
The Gradle reality nobody writes about
Both libraries use vanniktech/gradle-maven-publish-plugin. Real problems I hit:
-
Configuration cache: add
--no-configuration-cacheto publish tasks - JVM target alignment: explicit targets required across all source sets — mismatch errors are cryptic
-
GPG signing: store key ID, password, and secret in
local.properties, never in version control (it may be obvious, but still worth mentioning) -
Publishing pipeline: GPG setup → Sonatype namespace verification → vanniktech plugin config →
./gradlew publishAllPublicationsToMavenCentralRepository --no-configuration-cache→ manual release in Sonatype UI.
Conclusion
expect/actual is the story. Both libraries are small. What makes them genuinely KMP is the compilation model guarantee — the compiler enforces both sides exist. That's something you don't get from a plain interface.
Sometimes small and focused beats large and complete. A library that does one thing well and integrates in five minutes can be more useful than one that does everything but nobody reads the docs for.
The gap matters more than the size. I searched, found nothing that fit, and built the thing. That's reason enough to publish.
Links
-
KMP Network Monitor —
io.github.froyder:kmp-network-monitor -
KMP In-App Review —
io.github.froyder:kmp-inapp-review
Top comments (0)