agentleFS
KushpawanKushpawan/kmp-mobile-skills/skills/android-to-kmp-migration/SKILL.md

android-to-kmp-migration

Skill15 starsChanged 23 days agoApache-2.0View on GitHub

Use when an existing Android app (Kotlin, Jetpack, Views or Compose) should share code with iOS through Kotlin Multiplatform - "migrate to KMP", "add iOS to our Android app", "make this module multiplatform", "create a shared module", Retrofit/Gson/Hilt/RxJava/Glide/SharedPreferences/java.time with no iOS equivalent, com.android.kotlin.multiplatform.library, or an incremental migration plan.

The file

We split this skill into the rules it gives an agent

10 rules. About 0 are common, 2 are distinctive, and 8 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 8

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Write the plan with mobile-feature-plan (one phase per step below), then execute it with mobile-feature-implement.

    line 57
  2. Very specific:

    Add include(":shared") to settings.gradle.kts, the plugins with apply false in the root build file, and implementation(project(":shared")) …

    line 144
  3. Distinctive:

    Before moving much code, prove the iOS path end to end:

    line 152
  4. Very specific:

    Create the iOS app (the KMP wizard's iosApp, or an Xcode project) and integrate the framework — direct integration runs ./gradlew …

    line 154
  5. Distinctive:

    Call one shared function from Swift, then one real use case that does I/O (for example "load the current user's profile" through the shared …

    line 157
  6. Very specific:

    Check Swift ergonomics on that slice: suspend functions, Flow, sealed classes and default arguments are where Swift friction appears …

    line 159
  7. Very specific:

    Use androidx lifecycle-viewmodel in commonMain.

    line 188
  8. Very specific:

    Only if the team chose shared UI.

    line 198
  9. Very specific:

    Remove bridges (kotlinx-coroutines-rx3, asLiveData()), unused Android-only libraries, and dead DI bindings.

    line 205
  10. Very specific:

    Run after each step. Report each check as PASS, FAIL or NOT RUN with the reason (for example "NOT RUN — no macOS host").

    line 221

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Android to Kotlin Multiplatform migration

Migrate in thin, shippable slices: create a shared module, prove one vertical slice runs on iOS early, then move code inward-out — domain, data, presentation, and only then (optionally) UI. The Android app must be releasable after every merged step. A migration that lives on a long branch, or that leaves iOS until the end, is where these projects stall.

When to use

  • An Android app needs an iOS version and the team wants to share business logic.
  • A module or package should become multiplatform without rewriting the app.
  • Deciding which Android libraries to replace, keep, or hide behind interfaces.
  • Estimating and sequencing the migration for a team.

When not to use

  • Upgrading an existing KMP project to AGP 9: use the AGP 9 migration skill (Kotlin) or the AGP 9 upgrade skill (Android), linked below.
  • Converting Java sources to Kotlin first: use the Java-to-Kotlin skill linked below, then return.
  • Adding one new feature module to a project that is already KMP: use kmp-feature-module.
  • One expect/actual pair: use kmp-expect-actual.

Step 0 — Read the project first

cat settings.gradle.kts; find . -name build.gradle.kts -not -path '*/build/*' | sort
sed -n '1,80p' gradle/libs.versions.toml               # Kotlin, AGP, Compose, library versions
cat gradle/wrapper/gradle-wrapper.properties
grep -rnE 'kotlin\("kapt"\)|kotlin-kapt|org.jetbrains.kotlin.kapt' --include='*.kts' .   # kapt users
grep -rhoE '^import (android|androidx|dagger|javax\.inject|io\.reactivex|com\.google\.gson|com\.squareup\.(retrofit2|moshi)|com\.bumptech|java\.time|timber)[.a-z]*' \
  --include='*.kt' app/src/main | sort | uniq -c | sort -rn | head -40   # what the code leans on
find . -path '*/src/main/java' -o -path '*/src/main/kotlin' | head     # source roots per module
ls app/src/main/res/layout 2>/dev/null | wc -l                         # View-based UI size

Record: modules and their dependencies, Kotlin/AGP/Gradle versions, DI framework, async style (coroutines, RxJava, LiveData), networking, persistence, image loading, logging, UI toolkit (Views, Compose, mixed), test style, and whether kapt is in use. Ask the team two decisions before planning:

  1. iOS UI: Compose Multiplatform (share UI too) or native SwiftUI over shared logic?
  2. DI: move the shared graph to a KMP DI library, or keep Hilt in the Android app and build the iOS graph separately?

Workflow

Write the plan with mobile-feature-plan (one phase per step below), then execute it with mobile-feature-implement. Each step ends with the Android release build and tests green.

1. Assess and classify

For each package or module, put it in one bucket:

BucketSignalAction
Pure Kotlinno android.*, java.* (other than trivial), or Android-only library importsMoves to commonMain as is
Android API at the edgesContext, SharedPreferences, ConnectivityManager, file paths, BuildInterface or expect/actual; Android impl moves to androidMain
Android-only libraryRetrofit, Gson, Hilt annotations, RxJava, Glide, Timber, java.timeSwap per references/library-swap.md, or hide behind an interface
UIActivities, Fragments, XML, Compose screensLast; optional

Summary of the library swaps (details, caveats and per-library notes in the reference):

AndroidKMP
Retrofit + OkHttpKtor client (OkHttp engine on Android, Darwin on iOS)
Gson / Moshikotlinx.serialization
RoomRoom KMP
Hilt / DaggerKoin (or kotlin-inject / Metro), or keep Hilt in the Android app only
java.timekotlinx-datetime + kotlin.time
RxJavacoroutines + Flow (bridge with kotlinx-coroutines-rx3)
LiveDataStateFlow
androidx ViewModelandroidx lifecycle-viewmodel (KMP artifacts)
GlideCoil 3
SharedPreferencesDataStore Preferences (KMP)
TimberKermit or Napier

Done when: every package has a bucket and every Android-only dependency has a decision.

2. Prepare the build

  • Align versions: check the Kotlin Multiplatform compatibility guide for the Kotlin / AGP / Gradle / Xcode combination.
  • AGP 9: a module can no longer apply the Kotlin Multiplatform plugin together with com.android.application (or the classic com.android.library); KMP libraries use com.android.kotlin.multiplatform.library, which has a single variant (no build types or flavours inside the shared module). AGP 9 also has built-in Kotlin, so org.jetbrains.kotlin.android is removed, and kapt must move to KSP (or AGP's legacy-kapt plugin). Do this on the Android app before adding :shared, with the AGP 9 migration skills linked below.
  • Move hardcoded dependency versions into gradle/libs.versions.toml (kmp-gradle-version-catalog).

Done when: the Android app builds and releases on the target Kotlin/AGP versions, unchanged.

3. Create :shared and ship it empty

# gradle/libs.versions.toml — reuse the project's existing version keys
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
// shared/build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.kotlin.multiplatform.library)
}

kotlin {
    android {                       // AGP 8.10–8.11: androidLibrary { }
        namespace = "com.example.app.shared"
        compileSdk = libs.versions.android.compileSdk.get().toInt()
        minSdk = libs.versions.android.minSdk.get().toInt()
        withHostTest { }            // enables src/androidHostTest and the host test task
    }
    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->   // add iosX64() only if Intel simulators matter
        target.binaries.framework {
            baseName = "Shared"
            isStatic = true
        }
    }
    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
        }
        commonTest.dependencies {
            implementation(libs.kotlin.test)
        }
    }
}

Add include(":shared") to settings.gradle.kts, the plugins with apply false in the root build file, and implementation(project(":shared")) to the Android app. Merge this alone.

Done when: :androidApp (or :app) builds a release with :shared on its classpath, and ./gradlew :shared:iosSimulatorArm64Test runs on macOS.

4. Bring up an iOS vertical slice early

Before moving much code, prove the iOS path end to end:

  1. Create the iOS app (the KMP wizard's iosApp, or an Xcode project) and integrate the framework — direct integration runs ./gradlew :shared:embedAndSignAppleFrameworkForXcode from an Xcode build phase; SPM or CocoaPods integration is the alternative (kmp-xcframework-distribution).
  2. Call one shared function from Swift, then one real use case that does I/O (for example "load the current user's profile" through the shared repository).
  3. Check Swift ergonomics on that slice: suspend functions, Flow, sealed classes and default arguments are where Swift friction appears (kmp-swiftui-interop).

Done when: the iOS app shows real data from shared code on a simulator, built from a clean checkout.

5. Move the domain

Move models, validators, use cases and pure business rules to commonMain. Keep the same package names so Android imports do not change. Replace java.time, java.util.UUID, String.format and other JVM-only APIs (kotlinx-datetime; kotlin.uuid.Uuid, which needs an opt-in on some Kotlin versions; string templates). Move their unit tests to commonTest so they run on both platforms.

Done when: domain tests pass in testAndroidHostTest and iosSimulatorArm64Test; Android release builds.

6. Move the data layer

One data source at a time, behind the repository interface the app already uses:

  • Networking: Ktor + kotlinx.serialization in commonMain; per-platform engines. Keep the Retrofit implementation bound in Android DI until the Ktor one passes the same tests, then switch the binding and delete the old one.
  • Persistence: Room KMP (or SQLDelight if the team prefers); key-value with DataStore.
  • Platform pieces (connectivity, file paths, secure storage) behind interfaces with androidMain/iosMain implementations (kmp-expect-actual, kmp-secure-storage).

Done when: each repository has one implementation in commonMain, tested with fakes, used by Android.

7. Move presentation (ViewModels)

Use androidx lifecycle-viewmodel in commonMain. Replace LiveData with StateFlow (Android screens can collect it, or bridge with asLiveData() temporarily). Inject dispatchers. Android Fragments/Compose screens keep working against the same ViewModels; the iOS app consumes them through a Swift wrapper.

Done when: ViewModel tests run on both platforms and both apps use the shared ViewModels for the migrated features.

8. Optionally move UI to Compose Multiplatform

Only if the team chose shared UI. View-based screens go to Jetpack Compose first (Android's XML to Compose migration skill), then the Compose code moves to commonMain: replace R.string/R.drawable with Compose Multiplatform resources, Android-only Compose APIs with multiplatform equivalents, and Coil 2/Glide with Coil 3. Migrate screen by screen behind the existing navigation.

9. Clean up

Remove bridges (kotlinx-coroutines-rx3, asLiveData()), unused Android-only libraries, and dead DI bindings. Re-run kmp-module-health on :shared.

Quick reference

RuleWhy
Android shippable after every mergeThe migration never blocks a release
iOS slice in the first few stepsToolchain, framework and Swift-interop problems surface while cheap
Inward-out: domain → data → presentation → UIEach layer depends only on layers already shared
Same package names when moving filesNo import churn in the Android app
Interface + platform impl when no KMP library existsNever block on a missing library
Old and new implementations behind one interfaceSwitch the DI binding, not the callers

Verify

Run after each step. Report each check as PASS, FAIL or NOT RUN with the reason (for example "NOT RUN — no macOS host"). Never mark a check PASS that you did not run.

./gradlew :shared:compileCommonMainKotlinMetadata
./gradlew :shared:testAndroidHostTest                 # legacy com.android.library: testDebugUnitTest
./gradlew :shared:iosSimulatorArm64Test               # macOS
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 # macOS
./gradlew :androidApp:assembleRelease                 # or :app:assembleRelease — Android still shippable
./gradlew :androidApp:testDebugUnitTest               # existing Android tests still pass
grep -rlE '^import (android|java\.time|io\.reactivex|com\.google\.gson|retrofit2)\.' shared/src/commonMain   # expect no output
xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \
  -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build                  # macOS

Then kmp-build-check before each PR.

Common mistakes

MistakeWhy it bitesFix
Big-bang branchMonths of conflicts; Android releases freezeSmall PRs to main; Android shippable each time
iOS left until "the shared code is done"Interop, toolchain and API-shape problems found after the investmentVertical slice in step 4
Moving UI firstDrags every layer below it along at onceDomain → data → presentation → UI
KMP plugin added to the com.android.application module on AGP 9Not supported; the build failsSeparate :shared library module
Keeping kapt for Hilt/Room on AGP 9Incompatible with AGP's built-in KotlinKSP (or the legacy-kapt plugin)
@Inject / Hilt annotations in commonMainHilt is JVM/Android-only; iOS cannot compile itPlain constructors; provide them from an Android Hilt module
Gson → kotlinx.serialization without payload testsUnknown keys and missing fields now throwignoreUnknownKeys, defaults, tests on captured JSON
Room DAO functions left blockingNon-Android targets require suspend or FlowConvert DAOs while moving them
KSP for Room added only to kspAndroidiOS has no generated database code: the build fails on the missing actual, or construction fails at runtimeKSP on every declared target
-lsqlite3 added with BundledSQLiteDriverReported to let the link pick system SQLite over the bundled copyOne driver per framework; link the system library only for NativeSQLiteDriver
Exporting the whole shared API to SwiftBigger framework, slower links, awkward Swift surfaceExport only what Swift needs; design that API for Swift
Copying files instead of moving themTwo diverging copies of the same logicMove, keep the package, delete the original

Related skills

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.