Most developers meet Kotlin Multiplatform with an Android app that already exists, not a blank project. The good news is that you do not have to start again. You add a shared module beside your app, move the portable code into it, and let the Android app depend on it like any other library. If an iPhone version comes later, that module is ready.
This lesson covers Add Kotlin Multiplatform to your project, Use platform-specific APIs and Platform-specific APIs in practice from the Kotlin Multiplatform programme (https://developer.android.com/courses/pathways/kotlin-multiplatform), following Google's guide Add Kotlin Multiplatform to an existing project (https://developer.android.com/kotlin/multiplatform/migrate) and the JetBrains guide to connecting to platform APIs (https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-connect-to-apis.html).
By the end you will be able to:
- Create a shared module in an existing project with Android Studio.
- Link it to the Android app and verify the link.
- Move code safely in small steps, with tests guarding each step.
- Replace JVM-only code with multiplatform alternatives.
- Reach platform APIs with
expect/actualand interfaces, and choose between them. - Wire platform implementations into shared code (manual injection, and notes on Hilt).
Big idea: migrate upwards from the bottom. Share the code with the fewest dependencies first (models, rules), then widen the circle, running your tests after every move.
Honesty note: the New Module wizard, template names and Gradle plugins change between Android Studio releases. Google's page says the shared-module template needs a recent Android Studio and AGP; the steps below match that page at the time of writing, but menus may differ in your version. Setup that depends on a Mac (anything iOS) is covered in lesson 69. Everything in this lesson works on the Android side without a Mac.
- Why KMP,
expect/actualand interfaces (lesson 66). - Targets and source sets, the shared module's Gradle file (lesson 67).
- Layers, use cases and repositories (lessons 60 to 63).
- Hilt and modules (lesson 65).
- Unit tests and fakes (lessons 20 and 62).
Migrating a real app is a sequence of safe steps, not a rewrite.
Step 0 Make sure the Android app builds and its tests pass (your safety net).
Step 1 Create an empty :shared module and link it. Prove the link works.
Step 2 Move plain models and pure rules. Fix imports. Tests green.
Step 3 Replace JVM-only types (java.time, Gson, Retrofit) with multiplatform ones.
Step 4 Move repositories. Platform needs go behind interfaces or expect/actual.
Step 5 Optionally move state holders. Keep platform UI and services native.
Step 6 Add iOS targets and an Xcode app (lesson 69).
At every step the Android app keeps building. If something breaks, you know it was the last move, and you can undo it.
What the dependencies tell you
Before moving code, look at its imports. They reveal how hard the move is:
| Imports in the file | Difficulty | Plan |
|---|---|---|
| Only Kotlin stdlib | Trivial | Move as is |
java.util, java.time, java.io |
Easy to medium | Replace with kotlin.*, kotlinx-datetime, okio, or an interface |
| Retrofit, Gson, Moshi | Medium | Ktor client and kotlinx.serialization |
| Room 2.x entities or DAOs | Medium | Room KMP (lesson 70) |
android.*, Context, Activity |
Not shareable | Interface in common, implementation in androidMain or the app |
| Compose UI | Stays native | Unless you adopt Compose Multiplatform |
- Make a restore point: commit your work in Git. You can always go back.
- In Android Studio choose File → New → New Module.
- Select the Kotlin Multiplatform Shared Module template.
- Set a module name, for example
shared. This is both the Gradle module name and the default name of the iOS framework. Pick a package name, such ascom.example.fees.shared. - Choose Finish and let Gradle sync.
Google's page states that this template requires a recent Android Studio and a minimum Android Gradle Plugin version (at the time of the page: Meerkat and AGP 8.8.0), generates placeholder functions and tests so the module compiles, and creates a source set for each platform. Check the current page for updated minimums.
Look at what the wizard made
shared/
├─ build.gradle.kts
└─ src/
├─ commonMain/kotlin/.../Greeting.kt, Platform.kt
├─ commonTest/kotlin/.../SharedCommonTest.kt
├─ androidMain/kotlin/.../Platform.android.kt
└─ iosMain/kotlin/.../Platform.ios.kt
Read shared/build.gradle.kts with lesson 67 in mind: find the plugins, the targets, the iOS framework and the dependency blocks. The wizard-generated Greeting and Platform classes are placeholders that prove the expect/actual mechanism works; you will delete them later.
Link the app to the module
The wizard does not add the dependency for you. In the Android app module's build file:
// app/build.gradle.kts
dependencies {
implementation(project(":shared")) // or projects.shared with type-safe accessors
// ... your other dependencies
}
Sync, then prove the link by calling the placeholder from the app:
// in MainActivity.kt
import com.example.fees.shared.Greeting
setContent {
AppTheme { Text(Greeting().greet()) } // shows "Hello, Android N!" style text
}
If it runs, the plumbing is right. If not, fix it now, before you move real code.
If the new plugin rejects your app module
Google's documentation notes that the dedicated Android library plugin for KMP has no direct replacement for the application plugin. That is fine: your existing app module stays an ordinary application module, and shared is a library beside it. If your existing app uses build types, product flavours, view binding or BuildConfig, those stay in the app module; only the shared module has the limitations (no flavours, no BuildConfig there).
Start with Payment, Student, FeeSummary and CalculateFeeSummary from lessons 60 and 63.
- In Android Studio, select the file, choose Refactor → Move, and pick
shared/src/commonMain/kotlin/...as the target directory. - Let the IDE update imports across the app.
- Compile the shared module for all targets (Build → Make Module or
./gradlew :shared:buildwithout iOS if you are not on a Mac; see the note below). The compiler tells you what is not portable.
Typical errors and their fixes:
// Before: JVM-only
import java.time.Instant
data class Payment(val id: Int, val studentName: String, val amountNaira: Long, val paidAt: Instant)
// After: multiplatform (add kotlinx-datetime to commonMain)
import kotlinx.datetime.Instant
data class Payment(val id: Int, val studentName: String, val amountNaira: Long, val paidAt: Instant)
// Before
val text = String.format("%,d", amount) // JVM only
// After: write a tiny helper in common code
fun Long.withThousandsSeparators(): String =
toString().reversed().chunked(3).joinToString(",").reversed()
(Kotlin's newer kotlin.time.Instant and kotlin.uuid.Uuid are moving into the standard library. Check which your Kotlin version provides before adding a library; the principle is "prefer standard library, then a multiplatform library".)
Run your tests after the move. Tests of moved classes should move too. JUnit tests need rewriting for kotlin.test (mostly changing imports: @Test, assertEquals have the same names):
// shared/src/commonTest/...
import kotlin.test.Test
import kotlin.test.assertEquals
class CalculateFeeSummaryTest {
@Test
fun balance_is_term_fee_minus_payments() {
assertEquals(50, CalculateFeeSummary(100)(listOf(30L, 20L)).balance)
}
}
Tests that used Mockito or MockK will not compile in common code, since mocking libraries depend on the JVM. This is another reason the course taught fakes (lesson 62): they work everywhere.
Shared code often needs something only the platform has. You now have two tools. Choose by what the need looks like.
Tool A: expect/actual for small, stateless facts
// commonMain
expect fun currentTimeMillis(): Long
// androidMain
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
// iosMain
import platform.Foundation.NSDate
import platform.Foundation.timeIntervalSince1970
actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
Good for: platform name, a clock, a UUID generator, a log function, a file path to the app's data directory.
Tool B: interfaces for services
For anything with state, lifecycle or UI (secure storage, notifications, biometrics, connectivity), define an interface in common code, implement it per platform, and pass it in:
// commonMain
interface ConnectivityMonitor {
val isOnline: Flow<Boolean>
}
class SyncPolicy(private val connectivity: ConnectivityMonitor) {
fun shouldSync(): Flow<Boolean> = connectivity.isOnline.distinctUntilChanged()
}
// androidMain (or in the Android app)
class AndroidConnectivityMonitor(context: Context) : ConnectivityMonitor {
private val manager = context.getSystemService(ConnectivityManager::class.java)
override val isOnline: Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(true) }
override fun onLost(network: Network) { trySend(false) }
}
manager.registerDefaultNetworkCallback(callback)
trySend(manager.activeNetwork != null)
awaitClose { manager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}
On iOS the equivalent uses Apple's NWPathMonitor, written in Kotlin in iosMain or in Swift in the iOS app.
Choosing between them
| Question | expect/actual |
Interface |
|---|---|---|
| Is it a tiny, pure function or constant? | Yes | Overkill |
| Does it hold state or have a lifecycle? | Awkward | Yes |
| Do you want to fake it in tests? | Hard | Easy |
Must the platform app supply it (it needs Context, Keychain objects)? |
Hard | Yes |
| Do you want the compiler to force every platform to implement it? | Yes | Yes (an unimplemented interface fails to compile in the app) |
The guidance in JetBrains' "connect to platform-specific APIs" page is similar: use expect/actual for functions, properties and classes that have a natural platform implementation, and prefer interfaces when you need more flexibility or when the platform side must provide the instance.
Android-only dependencies inside shared code
The Android source set may use Android libraries freely:
// shared/build.gradle.kts
kotlin {
sourceSets {
androidMain.dependencies {
implementation("androidx.datastore:datastore-preferences:<latest stable>")
}
}
}
Anything in androidMain stays Android-only; commonMain cannot see it. This lets you migrate incrementally without losing Android-specific features.
How does the shared SyncPolicy receive an AndroidConnectivityMonitor? Several options, from simplest to most scalable.
Option 1: Manual wiring in the app
// androidApp
class FeeApplication : Application() {
val container by lazy { AppContainer(this) }
}
class AppContainer(context: Context) {
val connectivity: ConnectivityMonitor = AndroidConnectivityMonitor(context)
val syncPolicy = SyncPolicy(connectivity)
}
Option 2: Hilt in the Android app
Hilt is Android-only, so it lives in the app module and provides the platform implementations to shared classes:
@Module
@InstallIn(SingletonComponent::class)
object SharedModule {
@Provides @Singleton
fun provideConnectivity(@ApplicationContext context: Context): ConnectivityMonitor =
AndroidConnectivityMonitor(context)
@Provides @Singleton
fun provideSyncPolicy(connectivity: ConnectivityMonitor): SyncPolicy = SyncPolicy(connectivity)
@Provides @Singleton
fun providePaymentsRepository(client: HttpClient, dao: PaymentDao): PaymentsRepository =
DefaultPaymentsRepository(client, dao)
}
Shared classes have no Hilt annotations (Hilt does not work in common code); the app's module tells Hilt how to construct them. This keeps commonMain free of Android DI.
Option 3: A multiplatform DI library
If you want DI inside shared code, libraries such as Koin or kotlin-inject work on all targets. They are an alternative, not a requirement; Hilt in the app and plain constructors in shared code is a perfectly good setup.
Ktor instead of Retrofit
Shared repositories need a multiplatform HTTP client. The conversion is mechanical:
// Before (Android only)
interface PaymentApi {
@GET("payments") suspend fun getPayments(): List<PaymentDto>
}
// After (commonMain)
class PaymentApi(private val client: HttpClient, private val baseUrl: String) {
suspend fun getPayments(): List<PaymentDto> =
client.get("$baseUrl/payments").body()
}
with the imports io.ktor.client.request.get and io.ktor.client.call.body, and @Serializable DTOs (lesson 29). Error handling from lesson 62 still applies, with Ktor's exceptions (for example ResponseException) instead of Retrofit's HttpException.
Your ViewModel stays an Android ViewModel for now and simply calls shared classes:
@HiltViewModel
class FeeViewModel @Inject constructor(
observeSummary: ObserveFeeSummary // class from :shared
) : ViewModel() {
val uiState: StateFlow<FeeUiState> = observeSummary()
.map<FeeSummary, FeeUiState> { FeeUiState.Success(it) }
.catch { emit(FeeUiState.Error("Could not load fees")) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FeeUiState.Loading)
}
(ObserveFeeSummary has no @Inject annotation because it is in shared code; supply it with a @Provides function as in Option 2.)
The Android app now contains only UI, DI wiring and Android services. Everything else is shared. You have not changed what the user sees, which is the point of a good migration.
What about shared ViewModels?
The AndroidX Lifecycle ViewModel library has multiplatform support, so a ViewModel can live in commonMain and be used from Compose Multiplatform or from SwiftUI wrappers. That is a later step; check the current Lifecycle documentation for the supported targets and artifacts. A common intermediate position is to share the use cases and repositories, and keep a thin native ViewModel per platform.
- Run all tests in CI for every target you support.
- Watch build time. Multiplatform builds are slower; enable the Gradle build cache and configuration cache (lesson 3), and build only the targets you need locally.
- Review the public API of
shared: types visible to Swift should be simple. Prefer concrete classes,Flows that you can wrap, and avoid exposing complex generics. - Document the module in a README: what it contains, how to add a platform implementation, how to run tests.
- Keep the dependency rule:
sharedmust never depend on the Android app.
Exercise 1: Triage the imports
For each file say whether you would move it now, move it after changes, or leave it: (a) PaymentMapper.kt (pure Kotlin); (b) DateFormatter.kt using java.text.SimpleDateFormat; (c) SessionStore.kt using EncryptedSharedPreferences; (d) FeeScreen.kt (Compose).
(a) Move now. (b) Move after changes: replace with kotlinx-datetime formatting or an expect/actual formatter, because SimpleDateFormat is JVM-only. (c) Keep the implementation in Android; move an interface (SessionStore) to common and implement it with EncryptedSharedPreferences on Android and the Keychain on iOS. (d) Leave it in the Android app (unless you adopt Compose Multiplatform).
Exercise 2: Choose a mechanism
Decide expect/actual or interface, and say why: (a) fun randomId(): String; (b) a service that schedules local notifications; (c) the path of the app's private data directory.
(a) expect/actual (tiny, stateless) or Kotlin's standard UUID where available. (b) Interface: it has state and lifecycle, needs Context or UNUserNotificationCenter, and should be fakeable. (c) expect/actual function (stateless), or pass the path in from each platform app when constructing the shared database (this is how Room KMP does it, lesson 70).
Exercise 3: Tests that break
After moving a class to commonMain, its old test (JUnit plus MockK) fails to compile in commonTest. Explain why and how to fix it.
JUnit and MockK are JVM libraries; commonTest compiles for iOS too, so they are unavailable. Switch to kotlin.test and replace mocks with hand-written fakes. If a test truly needs JVM-only tools, move it to androidHostTest, but prefer portable tests so they run on every target.
Mistake #1: Moving everything in one go. Fix: move in small steps and run tests after each.
Mistake #2: Not proving the link first. Fix: call the wizard's Greeting from the app before moving code.
Mistake #3: Moving code that imports android.*. Fix: hide it behind an interface.
Mistake #4: Leaving Retrofit and Gson in shared code. Fix: use Ktor and kotlinx.serialization.
Mistake #5: Putting Hilt annotations in commonMain. Fix: provide shared classes from the app's Hilt module.
Mistake #6: Using expect/actual for complex services. Fix: use interfaces and injection.
Mistake #7: Keeping mocks in common tests. Fix: use fakes and kotlin.test.
Mistake #8: Exposing complicated generic types to Swift. Fix: keep the public API simple.
Mistake #9: Letting shared depend on the app. Fix: dependencies go from the app to shared only.
Mistake #10: Not committing before the migration. Fix: commit first so you can compare and roll back.
Question 1. Which template creates a shared module in Android Studio?
The Kotlin Multiplatform Shared Module template, under File, New, New Module.
Question 2. What must you do after the wizard creates the module?
Add implementation(project(":shared")) to the Android app's dependencies, because the wizard does not do it.
Question 3. Why migrate models and pure rules first?
They have the fewest dependencies, so they move easily, give early wins and exercise the build setup before harder code.
Question 4. When do you prefer an interface to expect/actual?
For services with state, lifecycle or platform-supplied objects, and whenever you want to fake them in tests.
Question 5. Where does Hilt belong in a KMP project?
In the Android app module, where its modules provide shared classes and platform implementations; it is not available in common code.
Go deeper: official resources
- Kotlin Multiplatform programme
- Add Kotlin Multiplatform to an existing project
- Connect to platform-specific APIs (JetBrains)
- Kotlin Multiplatform on Android Developers
Project goal: Migrate the domain and data logic of FeeTracker into a shared module, with the Android app still working at every step.
Requirements:
- Commit your project and make sure its tests pass.
- Create the
:sharedmodule with the wizard; link it; prove the link with the placeholder. - Move
Payment,Student,FeeSummary,CalculateFeeSummaryandGetStudentBalancesUseCaseintocommonMain. Replace any JVM-only types. - Move their tests to
commonTestusingkotlin.testand fakes. - Define a
ConnectivityMonitorinterface in common code and implement it for Android. - Convert the Retrofit service to a Ktor-based
PaymentApiin common code, with the OkHttp engine on Android. - Provide the shared classes to the app through a Hilt module.
- Verify that the app behaves exactly as before and all tests pass.
- Write a migration log: each step, what broke, how you fixed it.
Stretch goal: make the iOS targets compile (./gradlew :shared:compileKotlinIosSimulatorArm64 on a Mac) and list every place that needed an iosMain implementation.
A condensed migration log (example):
1. Commit baseline. 42 unit tests green.
2. Create :shared via wizard. Added implementation(project(":shared")). Greeting shows. OK.
3. Moved Payment/Student/FeeSummary/CalculateFeeSummary.
Broke: java.time.Instant -> replaced with kotlinx.datetime.Instant (added dependency).
Broke: String.format -> replaced with a helper.
4. Moved tests; JUnit -> kotlin.test; no mocks were used. 14 tests in commonTest green.
5. ConnectivityMonitor interface in commonMain; AndroidConnectivityMonitor in androidMain.
6. PaymentApi: Retrofit -> Ktor client; DTOs annotated @Serializable;
added ktor-client-okhttp to androidMain. HttpException -> ResponseException in runCatchingNetwork.
7. SharedModule (Hilt) provides HttpClient, PaymentApi, repositories, use cases.
8. App behaves the same. 42 + 14 tests green.
9. iOS compile not attempted (no Mac).
A Ktor client built in common code with a per-platform engine:
// commonMain
fun createHttpClient(): HttpClient = HttpClient {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(HttpTimeout) { requestTimeoutMillis = 30_000 }
}
The Hilt module in the app:
@Module
@InstallIn(SingletonComponent::class)
object SharedModule {
@Provides @Singleton fun client(): HttpClient = createHttpClient()
@Provides @Singleton fun api(client: HttpClient) = PaymentApi(client, "https://api.example.com")
}
Every step left the Android app working, which is what makes the migration low-risk.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 68 project”.
On github.com click New repository, name it android-lesson-68-add-kotlin-multiplatform-to-your-android-p, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 68 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: The Migration Plan
- Step-by-Step Build 1: Create the Shared Module
- Step-by-Step Build 2: Move Models and Rules
- Step-by-Step Build 3: Platform-Specific APIs
- Step-by-Step Build 4: Platform Implementations in Practice
- Step-by-Step Build 5: Using Shared Code in the Android UI
- Step-by-Step Build 6: Keep It Healthy
- Guided Practice Exercises
- Common Mistakes (and How to Fix Them)
- Knowledge Check