$npx -y skills add rcosteira79/android-skills --skill kmp-boundariesUse when designing Kotlin Multiplatform boundaries — choosing between expect/actual, common interfaces with platform bindings, or separate platform screens. Covers platform services (clipboard, share, haptics, permissions, files, settings, sensors, biometrics), native SDKs, sourc
| 1 | # Kotlin Multiplatform Boundary Design |
| 2 | |
| 3 | Core rules for any KMP boundary: |
| 4 | |
| 5 | - **Keep `commonMain` semantic** — describe *what* the product needs, not Android/iOS mechanics: `currentRegion()`, never `currentRegionFromAndroidLocale(context)`. |
| 6 | - **Split by capability** — `Clipboard`, `ShareSheet`, `Haptics`, `Biometrics` as separate interfaces, not one `Platform` god object. |
| 7 | - **Keep actuals thin** — they translate, they don't decide; a business `if`/`when` inside an actual belongs in common, tested with a fake. |
| 8 | - **Prefer a common `interface` + per-platform binding over `expect class`** whenever you need fakes / DI / lifecycle / runtime selection. |
| 9 | - **Introduce an intermediate source set** (`skikoMain`, `appleMain`) only when two platforms genuinely share an actual. |
| 10 | |
| 11 | Two boundaries get the most detail below: the **Activity-owned** platform-UI boundary, and the **AGP-9 KMP-library** constraints. |
| 12 | |
| 13 | **Related:** `android-skills:kmp-ktor` (network boundary), `compose/references/multiplatform.md` (Compose-MP mechanics), `android-skills:kotlin-coroutines` (scope ownership). For the iOS↔Swift bridge — Kotlin→Swift naming, type widths (`Int` is 32-bit), SKIE `suspend`→`async` / `Flow`→`AsyncSequence`, sealed-class exhaustiveness, SwiftUI embedding — load `references/ios-interop.md` when authoring the iOS-side actual. |
| 14 | |
| 15 | ## Platform-UI bindings are Activity-owned, not Context-owned |
| 16 | |
| 17 | The single most common Android boundary mistake: passing `applicationContext` / `LocalContext.current` into a binding that actually needs an `Activity`, then papering over the lifecycle gap with `Intent.FLAG_ACTIVITY_NEW_TASK`. That flag is a smell — it hides that this is a foreground-UI operation. Hold an `Activity` instead. |
| 18 | |
| 19 | ```kotlin |
| 20 | // commonMain — semantic interface; DOCUMENT what `suspend` means |
| 21 | interface ShareSheet { |
| 22 | /** Launches the system share sheet. Returns when the sheet is PRESENTED — not when the user |
| 23 | * completes or cancels. (Otherwise callers write incorrect retry/confirmation logic.) */ |
| 24 | suspend fun shareText(text: String) |
| 25 | } |
| 26 | |
| 27 | // androidMain — thin: build the intent and launch it. Activity-owned. |
| 28 | class AndroidShareSheet(private val activity: Activity) : ShareSheet { |
| 29 | override suspend fun shareText(text: String) { |
| 30 | activity.startActivity(Intent.createChooser( |
| 31 | Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text), null, |
| 32 | )) |
| 33 | } |
| 34 | } |
| 35 | ``` |
| 36 | |
| 37 | You don't app-wide-inject an `Activity` (it's framework-created and lifecycle-bound) — construct the binding in an **activity scope** in the Android app module (Hilt `@InstallIn(ActivityComponent::class)`, where `Activity` is a default binding; Koin `scoped`). `commonMain` only ever sees the interface; the `Activity` never leaves the app module. If a longer-lived (app-scoped) object needs it, hold it behind a lifecycle-aware provider (set in `onResume`, cleared in `onPause`) so a destroyed Activity can't leak. |
| 38 | |
| 39 | ## AGP-9 KMP-library constraints (structural — they shape what can live in shared code) |
| 40 | |
| 41 | AGP 9 replaces `com.android.library` with **`com.android.kotlin.multiplatform.library`** for the Android side of a KMP module, and rejects `com.android.application` + `kotlin.multiplatform` outright. The new plugin enforces a single-variant architecture: |
| 42 | |
| 43 | - **`BuildConfig` is unavailable** — compile-time constants come from [BuildKonfig](https://github.com/yshrsmz/BuildKonfig) or an injected `AppConfiguration` interface. Don't design `commonMain` APIs that assume `BuildConfig.X` exists. |
| 44 | - **No build variants** — variant-specific deps/resources/signing live in the app module; a debug/release decision surfaces as a runtime config value injected into common code, not a build-variant split inside the KMP module. |
| 45 | - **No NDK / JNI** — extract native (C/C++) into a separate `com.android.library` module, wrapped behind a common interface the KMP module consumes. |
| 46 | - **Compose-MP resources need explicit enable** — add `androidResources { enable = true }` inside `kotlin { android { … } }`, or `Res.string.*` / `Res.drawable.*` crash at runtime on Android (the build still succeeds — easy to miss). |
| 47 | - **Consumer ProGuard rules need migration** — `consumerProguardFiles("rules.pro")` from the old `android {}` block is silently dropped; use `consumerProguardFiles.add(file("rules.pro"))` in the new DSL. |
| 48 | - **The KMP module can't also be `com.android.application`** — the Android entry point (`MainActivity`, Application class |