Back to Blog
Kotlin MultiplatformCompose MultiplatformAndroid

Migrating a Native Android App to Kotlin Multiplatform: The ValeCep Journey

August 30, 202615 min read
Migrating a Native Android App to Kotlin Multiplatform: The ValeCep Journey

I migrated my native Android app on Play Store to Kotlin Multiplatform. Now the same codebase runs on both Android and iOS, with 93% shared screens and business logic. In this post, I'll share the entire process with code examples.

What is ValeCep and How Did This Journey Begin?

ValeCep is a SaaS application that digitizes valet operations at restaurants and hotels, while also offering parking management (parking spots, license plate tracking, neighbor communication) for residential complexes. It combines two different business models in a single mobile app.

I built the project entirely as native Android from scratch: Kotlin, Jetpack Compose, Hilt, Room, Firestore. Classic Android architecture, live on Play Store, active users. But as the app grew, the need to be present on iOS became inevitable. Considering user demand and the potential market, taking this step was unavoidable.

As a solo developer, maintaining two separate codebases (Android + Swift) in parallel was becoming increasingly difficult. Writing every feature twice, fixing every bug twice, managing two separate release cycles. I needed a way to keep my existing Kotlin code and bring it to iOS as well. The answer was Kotlin Multiplatform + Compose Multiplatform.

Today ValeCep is live on both Play Store and App Store. Single codebase, 93% shared code, 40+ shared screens. In this post, I'll share how I accomplished this transformation, which libraries I chose, where I struggled, and the lessons I learned in production.

Alternatives and Why KMP?

To publish my native Android app on the App Store as well, I had three options:

  1. Switch to React Native / Flutter: Means abandoning existing code and writing from scratch. At least 2-3 months of development and completely abandoning the existing Android codebase.
  2. Native iOS development: Learning Swift from scratch, maintaining two codebases in parallel, and writing every feature twice. An extremely difficult approach to sustain for a solo developer.
  3. Kotlin Multiplatform: Keep most of the existing Android code as-is and only write platform-specific layers for iOS.

Although the third option seemed risky at first, two strong arguments stood out:

  • Compose Multiplatform had become stable. It became possible to share not just business logic but even UI code between platforms.
  • KMP is no longer Beta, it's Stable. JetBrains officially recommended it for production use.

The decision was KMP + Compose Multiplatform.

First Step: Decomposing the shared Module

In the native Android project, all code lived under app/. The first thing to do was split the codebase into two parts:

  • app/: only Android-specific entry point (MainActivity, Application class, Google Play integrations)
  • shared/: common code that runs on both platforms (domain, data, presentation)

Before (Native Android Architecture):

VehicleApp/
└── app/src/main/
    ├── java/com/enons/vehicleapp/
    │   ├── data/              ← Repository, Room DAO, Firestore
    │   ├── domain/            ← Use case, model
    │   ├── presentation/      ← Compose screens, ViewModel
    │   ├── di/                ← Hilt modules
    │   └── util/              ← Helper functions
    └── res/                   ← XML resources (strings, drawables)

All code in a single module, tightly coupled to Android. Hilt, Room, Android Context spread everywhere.

After (KMP Architecture):

VehicleApp/
├── app/                       ← Android entry point only (5 files)
│   └── src/main/
│       └── MainActivity.kt, Application.kt, DI init
├── iosApp/                    ← iOS entry point only (3 Swift files)
│   └── iOSApp.swift, ContentView.swift, IosBillingBridgeImpl.swift
└── shared/src/
    ├── commonMain/            ← shared across platforms (173 files)
    │   ├── kotlin/
    │   │   ├── data/          ← SQLDelight, Repository impl
    │   │   ├── domain/        ← Use case, model, interface
    │   │   └── presentation/  ← Compose MP screens, ViewModel
    │   ├── composeResources/  ← string/drawable/font (i18n)
    │   └── sqldelight/        ← database schemas
    ├── androidMain/           ← Android-specific (16 files)
    │   └── kotlin/            ← OSMDroid, ContentResolver, etc.
    └── iosMain/               ← iOS-specific (21 files)
        └── kotlin/            ← MapKit, PHPicker, billing bridge

The difference is clear: previously ~200 files tied to Android in a single module, now 173 files live platform-independently in commonMain. Platform-specific code reduced to a total of 37 files (16 Android + 21 iOS).

Layered Architecture

shared/src/commonMain/kotlin/com/enons/vehicleapp/
├── data/
│   ├── local/           ← SQLDelight
│   ├── repository/      ← Repository implementations
│   └── session/         ← Session holder
├── domain/
│   ├── billing/         ← BillingRepository contract
│   ├── model/           ← Vehicles, ParkingSpot, Plan, Organization
│   ├── repository/      ← Repository interfaces
│   ├── usecase/         ← Business logic
│   └── util/            ← Formatting, DateTime, Fee calculation
└── presentation/
    ├── navigation/      ← Compose Navigation graphs
    ├── screens/         ← 40+ screens (auth, home, vehicle, site, premium, ...)
    ├── ui/              ← Design system (colors, tokens, components)
    └── viewmodel/       ← 22 ViewModels

A structure adhering to Clean Architecture principles, entirely within commonMain.

Critical Library Transformations

Native Android used Hilt + Room + Firebase native SDK. Migrating to KMP required finding multiplatform equivalents for each:

Native AndroidKMP Equivalent
HiltKoin (KMP-friendly DI, 4.1.0)
RoomSQLDelight (KMP native, type-safe)
Firebase native SDKGitLive Firebase Kotlin SDK (dev.gitlive:firebase-*)
SharedPreferencesmultiplatform-settings (russhwolf)
Jetpack ComposeCompose Multiplatform (JetBrains)
Android NavigationNavigation Compose Multiplatform (2.9.0-beta)
Android ViewModelandroidx.lifecycle:lifecycle-viewmodel (KMP variant)
kotlinx.coroutines / DateTime / SerializationSame (already multiplatform)

shared/build.gradle.kts dependency configuration:

kotlin
kotlin {
    androidTarget { ... }
    val xcf = XCFramework("shared")
    listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "shared"
            isStatic = true
            xcf.add(this)
        }
    }
    sourceSets {
        commonMain.dependencies {
            implementation("dev.gitlive:firebase-auth:2.1.0")
            implementation("dev.gitlive:firebase-firestore:2.1.0")
            implementation("dev.gitlive:firebase-storage:2.1.0")
            implementation("dev.gitlive:firebase-functions:2.1.0")
            implementation("io.insert-koin:koin-core:4.1.0")
            implementation("app.cash.sqldelight:coroutines-extensions:2.0.2")
            implementation("com.russhwolf:multiplatform-settings:1.2.0")
            implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.0")
            implementation(compose.runtime)
            implementation(compose.material3)
            implementation(compose.components.resources)
            implementation("org.jetbrains.androidx.navigation:navigation-compose:2.9.0-beta03")
            implementation("io.insert-koin:koin-compose-viewmodel:4.1.0")
        }
        androidMain.dependencies {
            implementation("app.cash.sqldelight:android-driver:2.0.2")
            implementation("org.osmdroid:osmdroid-android:6.1.18")
        }
        iosMain.dependencies {
            implementation("app.cash.sqldelight:native-driver:2.0.2")
        }
    }
}

Note: When I carried out the ValeCep migration, SQLDelight for the database layer and dev.gitlive for Firebase were the most stable options available. Today, Google has announced official KMP support with Room 2.7+ and has also started gradually releasing official Firebase KMP SDKs. I recommend evaluating the current production-readiness of official libraries versus community solutions based on your project's needs. In my scenario, the SQLDelight and dev.gitlive combination worked flawlessly in production.

Sharing the UI Layer with Compose Multiplatform

The most productive part of the process was unifying the UI layer. When migrating a native Compose component like the one below to KMP, no code changes were required:

kotlin
@Composable
fun VehicleCard(vehicle: Vehicles, onClick: () -> Unit) {
    Card(onClick = onClick) {
        Column(Modifier.padding(16.dp)) {
            Text(vehicle.vehicleName, style = MaterialTheme.typography.titleMedium)
            Text(vehicle.customerName, style = MaterialTheme.typography.bodyMedium)
        }
    }
}

Nothing needed to change in this code to move it to Compose Multiplatform. The same Composable function worked flawlessly on both Android and iOS.

All 40+ screens in the project were built with Compose Multiplatform. There's not a single XML layout or SwiftUI View — all UI is fed from a single source.

Shared application entry point (App.kt):

kotlin
@Composable
fun App() {
    val authRepo: FirebaseAuthRepository = koinInject()
    val sessionState by authRepo.sessionState.collectAsState()
    ValeTheme {
        when (val s = sessionState) {
            is SessionState.Loading -> SplashPlaceholder()
            is SessionState.SignedOut -> AuthNavGraph()
            is SessionState.NeedsVerification -> VerifyEmailScreen(email = s.email)
            is SessionState.SignedIn -> {
                if (s.session.isSite) SiteNavGraph(session = s.session)
                else MainNavGraph(session = s.session)
            }
        }
    }
}

This function runs inside ComposeView on Android and UIViewController on iOS. Not a single line of platform-specific code inside — all navigation and state management is fully shared.

Platform-Specific Layers: The expect / actual Pattern

Every app eventually needs to touch platform-specific APIs. KMP addresses this need with the expect/actual mechanism:

Contract in commonMain:

kotlin
expect class DatabaseDriverFactory {
    fun createDriver(): SqlDriver
}

In androidMain:

kotlin
actual class DatabaseDriverFactory(private val context: Context) {
    actual fun createDriver(): SqlDriver =
        AndroidSqliteDriver(VehicleAppDatabase.Schema, context, "vehicleapp.db")
}

In iosMain:

kotlin
actual class DatabaseDriverFactory {
    actual fun createDriver(): SqlDriver =
        NativeSqliteDriver(VehicleAppDatabase.Schema, "vehicleapp.db")
}

Same contract, two different implementations. Business logic depends on this contract and doesn't need to know which platform it's running on.

Areas where this pattern is extensively used:

  • Map Integration: OSMDroid on Android, MapKit on iOS
  • Photo & Media Selection: ContentResolver on Android, PHPickerViewController on iOS
  • Push Notification Tokens: FirebaseMessaging.getToken on Android, APNs ➔ FCM mapping on iOS
  • Language & Localization: Configuration on Android, NSLocale on iOS
  • Local Storage (Preferences): Custom expect/actual solutions for edge cases where multiplatform-settings fell short

In total, 16 androidMain + 21 iosMain files. All remaining code is fully shared.

The Most Critical Point: Swift ↔ Kotlin Interoperability (Interop) on iOS

There's a scenario KMP can't directly solve: using a library that's only accessible through Swift on the iOS side (in this project, RevenueCat billing SDK).

The core problem: Kotlin/Native's generated shared framework can't directly see classes defined in Swift. The direction of the dependency must be reversed — Kotlin defines an interface, Swift implements it.

Solution: Dependency Inversion

Kotlin side in iosMain/:

kotlin
interface IosBillingBridge {
    fun configure(apiKey: String)
    fun logIn(uid: String, callback: (success: Boolean, message: String?) -> Unit)
    fun purchase(vertical: String, planId: String, callback: (result: Int, error: String?) -> Unit)
    fun restore(callback: (result: Int, error: String?) -> Unit)
}

internal var iosBillingBridgeInstance: IosBillingBridge? = null

fun installIosBillingBridge(bridge: IosBillingBridge) {
    iosBillingBridgeInstance = bridge
}

Repository implementation on the Kotlin side uses this bridge:

kotlin
class RevenueCatBillingRepositoryIos : BillingRepository {
    override suspend fun purchase(...): PurchaseOutcome =
        suspendCancellableCoroutine { cont ->
            iosBillingBridgeInstance?.purchase(vertical, planId) { code, err ->
                cont.resume(mapResult(code, err))
            }
        }
}

Swift side in iosApp/:

swift
import RevenueCat
import shared

final class IosBillingBridgeImpl: NSObject, IosBillingBridge {
    func configure(apiKey: String) {
        Purchases.configure(withAPIKey: apiKey)
    }
    func purchase(vertical: String, planId: String,
                  callback: @escaping (KotlinInt, String?) -> Void) {
        Purchases.shared.purchase(package: pkg) { _, _, error, userCancelled in
            if userCancelled { callback(KotlinInt(int: 1), nil); return }
            if let error = error { callback(KotlinInt(int: 2), error.localizedDescription); return }
            callback(KotlinInt(int: 0), nil)
        }
    }
}

At startup, Swift code on the iOS side injects the bridge instance into Kotlin:

swift
@main
struct iOSApp: App {
    init() {
        KoinInitKt.doInitKoin()
        let billingBridge = IosBillingBridgeImpl()
        IosBillingBridgeKt.installIosBillingBridge(bridge: billingBridge)
        billingBridge.configure(apiKey: "appl_XXXXXXXX")
    }
}

Critical Tips:

  1. Kotlin/Native primitives are compiled (generated) as boxed types. Boolean in a protocol callback becomes KotlinBoolean, Int becomes KotlinInt. Writing Bool/Int32 on the Swift side causes a protocol conformance error.
  2. Swift files must be manually added to the Xcode target. Simply placing the file in the file system is not sufficient on its own. This step can be automated with the xcodeproj Ruby gem.

Once this pattern is understood, Swift ↔ Kotlin bridging becomes a routine engineering step.

Transformation Results in Metrics

  • ~223 commits — Total commit count across the entire migration process
  • 173 Kotlin files in commonMain — All shared code including business logic, UI, and data layer
  • 16 files androidMain, 21 files iosMain — Platform-specific implementations only
  • 5 files app/ — Android entry point (MainActivity, Application, DI init)
  • 3 Swift files iosApp/ — ComposeUI wrapper, RevenueCat bridge, and app startup
  • ~93% code sharing — Including business logic and UI
  • 40+ screens with Compose Multiplatform — Not a single XML layout or Storyboard in the project

When a new feature is added, the "I did it on Android, now let me do it on iOS" cycle is eliminated. The code written is directly ready for release on both stores.

Backend Infrastructure: Firebase Cloud Functions

Firebase Cloud Functions (Node.js) is used on the backend layer. Since this layer is platform-independent, it serves both Android and iOS from a single center:

  • RevenueCat webhooks — Subscription lifecycle (INITIAL_PURCHASE, RENEWAL, EXPIRATION, ...)
  • Anonymous neighbor communication — License plate → owner resolution handled server-side (for privacy protection)
  • Automatic vehicle counters — Counting operations are not done client-side for security reasons

Any change made to the backend is instantly reflected on both platforms.

When Should KMP Be Preferred?

KMP may not be suitable for every project. Here are the criteria I evaluated when making this choice:

Scenarios where KMP excels:

  • Strong foundation in the Kotlin ecosystem
  • UI/UX consistency between platforms is important
  • Business logic and state management is complex
  • Native performance is critical (React Native/Flutter's JS bridge layer can be a constraint)
  • Working with a solo or small team

Situations requiring careful evaluation:

  • If iOS-native look and feel is a critical requirement (Compose Multiplatform uses its own render engine on iOS)
  • If iOS developers on the team don't know Kotlin/Native
  • If platform-specific dependencies (like StoreKit) are heavy — you need to write a Swift bridge for each
  • Compose Multiplatform's iOS side hasn't fully matured in some animation details yet

For this project, the answer was clear: KMP was the most pragmatic and sustainable way to ship on two platforms as a solo developer.

Conclusion

ValeCep, which started as a native Android app, now runs in production on two platforms with the same codebase. When a new feature is added, it's written once and ships to both stores. A goal that seemed quite ambitious a year ago has become a natural part of the daily engineering routine thanks to the maturation of the KMP and Compose Multiplatform ecosystem.

Try the app:

Feel free to reach out if you have any questions or suggestions.