Techbase Academy
Android · Lesson 68

Add Kotlin Multiplatform to Your Android Project

12 phasesShare FeeTracker's CoreAndroid Studio
Phase 1 of 12
Lesson Introduction

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/actual and 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.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 12
Prerequisite Concepts
  • Why KMP, expect/actual and 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).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 12
Conceptual Understanding: The Migration Plan

Migrating a real app is a sequence of safe steps, not a rewrite.

text
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
✏️ Your Task
Practise what you just learned about Conceptual Understanding: The Migration Plan. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 12
Step-by-Step Build 1: Create the Shared Module
  1. Make a restore point: commit your work in Git. You can always go back.
  2. In Android Studio choose File → New → New Module.
  3. Select the Kotlin Multiplatform Shared Module template.
  4. 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 as com.example.fees.shared.
  5. 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

text
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:

kotlin
// 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:

kotlin
// 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).

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Create the Shared Module. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 12
Step-by-Step Build 2: Move Models and Rules

Start with Payment, Student, FeeSummary and CalculateFeeSummary from lessons 60 and 63.

  1. In Android Studio, select the file, choose Refactor → Move, and pick shared/src/commonMain/kotlin/... as the target directory.
  2. Let the IDE update imports across the app.
  3. Compile the shared module for all targets (Build → Make Module or ./gradlew :shared:build without 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:

kotlin
// 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)
kotlin
// 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):

kotlin
// 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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Move Models and Rules. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 12
Step-by-Step Build 3: Platform-Specific APIs

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

kotlin
// commonMain
expect fun currentTimeMillis(): Long
kotlin
// androidMain
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
kotlin
// 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:

kotlin
// commonMain
interface ConnectivityMonitor {
 val isOnline: Flow<Boolean>
}
class SyncPolicy(private val connectivity: ConnectivityMonitor) {
 fun shouldSync(): Flow<Boolean> = connectivity.isOnline.distinctUntilChanged()
}
kotlin
// 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:

kotlin
// 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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Platform-Specific APIs. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 12
Step-by-Step Build 4: Platform Implementations in Practice

How does the shared SyncPolicy receive an AndroidConnectivityMonitor? Several options, from simplest to most scalable.

Option 1: Manual wiring in the app

kotlin
// 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:

kotlin
@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:

kotlin
// 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 Task
Practise what you just learned about Step-by-Step Build 4: Platform Implementations in Practice. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 12
Step-by-Step Build 5: Using Shared Code in the Android UI

Your ViewModel stays an Android ViewModel for now and simply calls shared classes:

kotlin
@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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: Using Shared Code in the Android UI. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 12
Step-by-Step Build 6: Keep It Healthy
  • 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: shared must never depend on the Android app.
✏️ Your Task
Practise what you just learned about Step-by-Step Build 6: Keep It Healthy. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 12
Guided Practice Exercises
🎯 Your Challenge

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).

🎯 Your Challenge

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).

🎯 Your Challenge

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.

✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 12
Common Mistakes (and How to Fix Them)

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.

✏️ Your Task
Practise what you just learned about Common Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 12
Knowledge Check

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

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Share FeeTracker's Core

Project goal: Migrate the domain and data logic of FeeTracker into a shared module, with the Android app still working at every step.

Requirements:

  1. Commit your project and make sure its tests pass.
  2. Create the :shared module with the wizard; link it; prove the link with the placeholder.
  3. Move Payment, Student, FeeSummary, CalculateFeeSummary and GetStudentBalancesUseCase into commonMain. Replace any JVM-only types.
  4. Move their tests to commonTest using kotlin.test and fakes.
  5. Define a ConnectivityMonitor interface in common code and implement it for Android.
  6. Convert the Retrofit service to a Ktor-based PaymentApi in common code, with the OkHttp engine on Android.
  7. Provide the shared classes to the app through a Hilt module.
  8. Verify that the app behaves exactly as before and all tests pass.
  9. 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):

text
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:

kotlin
// commonMain
fun createHttpClient(): HttpClient = HttpClient {
 install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
 install(HttpTimeout) { requestTimeoutMillis = 30_000 }
}

The Hilt module in the app:

kotlin
@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.

1

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.

2

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”.

3

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.

4

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.

5

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
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close