Converting an Android app to an Android + iOS app using Kotlin Multiplatform

Converting an Android app to an Android + iOS app using Kotlin Multiplatform

·

7 min read

For over a decade, I have been experimenting with ways to "write once, run anywhere" - where anywhere was really Android and iOS. The problem I always had was that this seemed to turn a complex problem of dealing with 2 platforms into a complex problem of dealing with 3 platforms: Android, iOS, and the new platform. The new platform had its own learning curve and its own limitations - getting you to a 90% solution, but you still have to drop into the native platforms to deliver the desired 100%.

Welcome Kotlin Multiplatform

Kotlin Multiplatform or KMP is a clever approach that essentially doubles down on the Android investment, and generates some or all of an iOS app "for free." Let's walk through how this works.

KMP leverages the fundamental language and structure of an Android app. Kotlin is the language of modern Android apps, and KMP apps are written in Kotlin. Many Android libraries are "KMP Ready" and because KMP supports "progressive adoption" you can start using just a bit of KMP in your Android and iOS app. One approach is to add a bit of shared KMP business logic to your existing Android and iOS apps. Let's walk through how that works.

Let's Convert an Android App to a KMP App for Android + iOS

As a reference, I will use JetBrains' tutorial entitled "Make your Android application work on iOS" at https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-integrate-in-existing-app.html

To start, I am going to just use their sample app so that I can familiarize myself with how a shared KMP module can be leveraged within Android and iOS, but eventually we will get to adding a shared KMP module to my own (amazing:) apps. Let's get started.

Create a New App from Version Control

First, let's pull down a starter app. As of the time of this writing, you need to use a custom repo (not the one in the blog post linked above).

NO: https://github.com/Kotlin/kmm-integration-sample
YES: https://github.com/SebastianAigner/kmm-integration-sample

Now in Android Studio go to File > New > Project from Version Control and paste in the URL above (https://github.com/SebastianAigner/kmm-integration-sample). x

You will likely get an option to run an AGP upgrade...gopher it!

Switch to the Project View

Now we are off to the races!

Add a Shared KMP Module

The shared KMP module is where you add your shared business logic.

Click File > New > New Module > Kotlin Multiplatform Shared Module.

Call your shared module either shared (or shared2 if there is already a shared module). Do not "Add sample tests" and choose Regular framework.

Click Finish.

Test out building your application - if it looks like this, continue.

Get Shared KMP Module Wired Up

  1. In the build.gradle.kts file of the shared module, ensure that compileSdk and minSdk are the same as those in the build.gradle.kts of your Android application in the app module.

    • If they're different, update them in the build.gradle.kts of the shared module.
  2. Add a dependency on the shared module to the build.gradle.kts in the app directory of your Android application.

     dependencies {
         implementation (project(":shared2"))
     }
    
  3. Synchronize the Gradle files by clicking Sync Now in the notification.

  4. Ensure that the Shared KMP Module is properly wired up by updating the LoginActivity class in the com.jetbrains.simplelogin.androidapp.ui.login package of the the app/src/main/java/ directory to include the following line within the onCreate function:

    Log.i("Login Activity", "Hello from shared module: " + (Greeting().greet()))

You will need to import the Greeting library, and you should see an option to do this that resolves to your newly created shared2 KMP module.

Restart the application, search for "Login Activity" or something like that so you can see your informational log message "Hello from shared module" - it works💥!

Make the Business Logic Multiplatform

If you are following along on the JetBrains tutorial, we are at https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-integrate-in-existing-app.html#make-the-business-logic-cross-platform

You are going to move the data folder from app > src > main > java > <Android Package> to shared > src > commonMain > kotlin > <Shared Package> as shown below.

Drag and drop the package with the business logic code

You will be presented with an option to Refactor - take it. You will be presented with some warnings about platform-specific capabilities that will not be available in commonMain. Review them for your own understanding of the changes will we need to make to "commonize" the Android-specific code, and then continue.

Replace Android-specific code with cross-platform code

In LoginDataSource replace IOException (which does not exist in iOS) with RuntimeException (a Kotlin function that works across platforms).

// Before
// return Result.Error(IOException("Error logging in", e))
// After
return Result.Error(RuntimeException("Error logging in", e))

In LoginDataValidator make the following change:

// Before
// private fun isEmailValid(email: String) = Patterns.EMAIL_ADDRESS.matcher(email).matches()
// After
private fun isEmailValid(email: String) = emailRegex.matches(email)

companion object {
    private val emailRegex =
        ("[a-zA-Z0-9\\+\\.\\_\\%\\-\\+]{1,256}" +
            "\\@" +
            "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" +
            "(" +
            "\\." +
            "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25}" +
            ")+").toRegex()
}

Replace Android-specific code with Platform-Specific code

  • Create a Utils.kt file in the com.jetbrains.simplelogin.shared2 package of the shared/src/commonMain directory and provide the expect declaration:
package com.jetbrains.simplelogin.shared2

expect fun randomUUID(): String
  • You will see an error highlighted for randomUUID - right-click it and select the option to "Add missing actual declarations."

  • This will cause a new file to be created at kotlin/com/dyor/shared2/Utils.android.kt - add the following code:
package com.dyor.shared2

import java.util.*
actual fun randomUUID() = UUID.randomUUID().toString()
  • And another new file to be created at kotlin/com/dyor/shared2/Utils.ios.kt - add the following code:
package com.dyor.shared2   

import platform.Foundation.NSUUID 
actual fun randomUUID(): String = NSUUID().UUIDString()
  • Now wire up the new randomUUID
import com.dyor.shared2.randomUUID
...
//Replace the fakeUser that uses the Android-specific code to commonMain reference
//Each platform will rely on the appropriate platform-specific code at build time.  
//val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), "Jane Doe")
val fakeUser = LoggedInUser(randomUUID(), "Jane Doe")

Connect the framework to your iOS project

  • In Xcode, click new project > iOS (not Multiplatform) > iOS. Set Product Name to simpleLoginIOS, select your Team, add your organizational identifier, and click next.

  • Choose the root folder of your KMP project (e.g., kmm-integration-sample). Close out Xcode. Back in Android Studio, you should see the new iOS app directory (simpleLoginIOS) in your Project view:

  • Rename (Refactor > Rename) simpleLoginIOS to iosApp per the KMP naming conventions, and we are ready to start wiring it together.

Connect the Framework to iOS Project

  • Back in Xcode, click simpleLoginIOS (you only renamed the folder, not the iOS project) > Build Phases > + > New Run Script Phase

  • Paste this code, using the proper name for your shared directory:
cd "$SRCROOT/.."
./gradlew :shared2:embedAndSignAppleFrameworkForXcode
  • Move this Run Script phase above the Compile Sources phase.

  • Disable User Script Sandboxing

  • Build the project in Xcode. This did not work for me. I got the following error:
/Users/mattdyor/Library/Developer/Xcode/DerivedData/simpleLoginIOS-gxdlxcirlhjolrarssjplwsezbae/Build/Intermediates.noindex/simpleLoginIOS.build/Debug-iphonesimulator/simpleLoginIOS.build/Script-C2BBDA972C3C493E0009B647.sh: line 3: ./gradlew: No such file or directory

🤔 Seems that this error was caused by no Gradle installed on my machine. This is a new-ish laptop, so I got some work to do...starting with installing Brew

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

Add Brew to your PATH (directions are provided after you run the above command). Then run brew install gradle - wait for the internet to download, and then in a refreshed terminal (e.g., close it and open a new terminal so that Gradle can be found by the terminal) navigate to the root directory of your KMP project and type gradle wrapper. This may have been caused because I failed to include cd "$SRCROOT/.." in my Run Script Phase. Leaving this here just in case. 🤔

Use the Shared Module from Swift

Update your ContentView.swift to import the Shared KMP Module and use the Greeting function from commmonMain

import SwiftUI
import shared

struct ContentView: View {
    var body: some View {
        Text(Greeting().greet())
        .padding()
    }
}

Run it in iOS and you will have an AMAZING KMP app rendering in iOS, leveraging some shared business logic.

This was just warm-up. In the next post, we will get into adding a shared KMP module that includes a database. We will use Android's KMP-ready Room library. Get ready.