Blog My Happy PlaceGemma Lara Savill

Bringing my AI news classifier to iOS with Kotlin Multiplatform

By Gemma Lara Savill
Published at November 30, 2025

Bringing

The "One Codebase" Dream Finally Real

I've been chasing the "one codebase, two apps" dream since around 2013, back in the pre-Android Studio era when we built Android apps using Java + Eclipse. I still remember experimenting with Intel's Multi-OS Engine, which promised Java-based iOS apps long before it was cool. The idea felt magical... but I don't think the equipment I had back then was up to the task and I never managed to get a stable iOS app running.

Fast-forward to today, and it genuinely feels like stepping into a different world. Working with Kotlin Multiplatform (KMP) is the first time in my career that the "one codebase" vision feels not only possible, but elegant. Migrating Headline Duel, a Kotlin/Compose, clean-architecture Android app, into a true multiplatform project has been delightful.

This project, Headline Duel, is the evolution of my previous work on fine-tuning AI models. It started with Applying Parameter-Efficient Fine-Tuning with Hugging Face, where I trained the model, and continued with Notebook to Mobile, where I built the initial Android application. Now, I'm taking it to the next level by making it truly cross-platform.

The process has become incredibly streamlined. The official Android Studio plugin handles the project setup effortlessly, and Compose Multiplatform has matured into a stable, reliable framework. Perhaps most impressively, Android Studio now builds and runs the iOS app directly on the simulator, a moment that, all on its own, is worth celebrating.

Android

iOS

How I Migrated Headline Duel to Kotlin Multiplatform

When I built Headline Duel as an Android app, I already had KMP in mind, which led me to choose libraries like Ktor and Koin from the outset. I then created a new KMP project in Android Studio and copied the existing app's code into commonMain. Because the project already followed clean architecture (UI / Domain / Data), the structure transferred beautifully. Most of the business logic moved over with almost no changes, which was incredibly satisfying.

Here is the full layer-by-layer breakdown of what I did:

1. Domain Layer: The Easiest Win

The Domain Layer proved to be the easiest part of the migration. Being pure Kotlin and 100% sharable, I simply moved the entire domain package directly into shared/commonMain. This process was remarkably smooth, requiring no significant changes and serving as a true testament to the benefits of clean architecture principles.

2. Data Layer: Moderate Refactor, Very Reusable

Most of the Data layer also saw its components integrated into the shared codebase. For Ktor Networking, the base Ktor Client was kept in commonMain, with the platform-specific engine then injected via Koin—using OkHttp/Android engine for Android and the Darwin engine for iOS. Similarly, all repository implementations were moved to shared code, requiring only minimal changes to adapt.

3. Dependency Injection (Koin)

For Dependency Injection, Koin proved to be fully supportive of the multiplatform setup. I established a sharedModule within commonMain to manage UseCases, Repositories, and ViewModels. Additionally, platform-specific modules were crafted for components like the HTTP engine. Initialization was straightforward: in MainApplication.kt for Android and directly in Swift during iOS startup, with minimal setup required on the iOS side.

val sharedModule = module {
    single<HttpClient> { KtorClientProvider.createHttpClient(get()) }
    single<NewsClassifierRemoteDataSource> { NewsClassifierRemoteDataSourceImpl(get()) }
    single<NewsClassifierRepository> { NewsClassifierRepositoryImpl(get()) }
    factory { ClassifyHeadlineUseCase(get()) }
    factory { GetHeadlineUseCase(get()) }
    single<HeadlineDataSource> { LocalHeadlineDataSource() }
    single<NewsHeadlineRepository> { NewsHeadlineRepositoryImpl(get()) }
}

4. UI Layer The Big Shift, But Worth It

The UI Layer represented "The Big Shift", but the effort was certainly worth it. Instead of developing a native SwiftUI interface, I leveraged Compose Multiplatform for both applications, which proved to be an amazing experience. Most Compose UI components, such as HeadlineScreen, moved into shared code with only minor adjustments. The biggest refactor involved handling resources; with no R.string.* in KMP, I adopted the Compose Multiplatform Resources library. This meant migrating strings to composeResources/values/strings.xml and accessing them via Res.string.error_title. For ViewModels, the migration was smooth once AndroidViewModel was removed, as the new KMP lifecycle libraries allowed ViewModels to reside in commonMain.

// Before (Android)
HeadlineScreenAnimationState.Error -> {
    state.error?.let { error ->
        val errorMessage = when (error) {
            HeadlineError.SERVER_WARMING_UP -> stringResource(id = R.string.error_server_warming_up)
            HeadlineError.GENERIC_CONNECTION_ERROR -> stringResource(id = R.string.error_generic_connection)
        }
        ErrorState(error = errorMessage, onRetry = viewModel::retry)
    }
}

// After (Compose Multiplatform)
HeadlineScreenAnimationState.Error -> {
    state.error?.let { error ->
        val errorMessage = when (error) {
            HeadlineError.SERVER_WARMING_UP -> stringResource(Res.string.error_server_warming_up)
            HeadlineError.GENERIC_CONNECTION_ERROR -> stringResource(Res.string.error_generic_connection)
        }
        ErrorState(error = errorMessage, onRetry = onRetry)
    }
}

5. Migrating the Tests to Kotlin Multiplatform

One of the biggest surprises in this migration was how smoothly the tests moved over. In the original Android project, the test suite lived entirely in androidTest and test, using JUnit, Coroutines Test, and Ktor's MockEngine. When moving to KMP, I recreated the same structure inside commonTest and found that nearly all the tests could run unchanged. The coroutine tests migrated cleanly thanks to kotlinx-coroutines-test, and the network layer tests still use Ktor MockEngine, which is fully multiplatform. The primary adjustments involved reorganizing some platform-specific utilities and removing Android-only expectations. However, the core test logic-mocking API calls, validating repository behavior, and verifying ViewModel state transitions now seamlessly runs on both platforms from a single shared test suite.

Having cross-platform tests validating shared business logic was one of the most satisfying parts of the entire migration.

Final Thoughts

After a decade of trying various cross-platform approaches, this is the first time I've felt productive, confident, excited, and genuinely not fighting the framework.

Seeing Headline Duel run on my personal iPad for the first time, after a bit of fiddling with Apple's settings, was an unforgettable experience. It brought back a similar feeling of awe and accomplishment I had when I first saw one of my apps running on an Android device back in 2010. That moment of holding your own creation in your hands, running seamlessly on an entirely new platform, truly solidifies the magic of Kotlin Multiplatform.

Now, Headline Duel stands as a practical proof of concept for my work in fine-tuning a Generative Model for a specific task, showcasing real-world application of advanced AI, now in both Android and iOS.

Kotlin Multiplatform finally delivers the promise I was dreaming about back in 2013, a shared codebase powering elegant apps on Android and iOS, without compromising the platform experience. Migrating Headline Duel to KMP has been one of the most enjoyable learning experiences I've had in years. And this is just the beginning, there is no turning back.

Related Posts:

Project Links:

You can find the original Android project on GitHub: Headline Duel (Android)
And the migrated Kotlin Multiplatform project here: Headline Duel (Kotlin Multiplatform)