Mastodon

Writing a Kotlin Multiplatform App from Start to Store

Notes from writing a new toy Kotlin Multiplatform app this summer.

A banner image of the Field Spottr icon.
Field Spottr!

I recently wrote a little toy app for my pickup soccer group to check field permit statuses. In New York City most public parks' fields can be reserved or have recurring permits, so we have to check if fields are going to be in use before we try to organize games. The city parks website has this information, but it's a little awkward to use. They also have all permit information available in downloadable CSV files, which got me thinking I could just write my own little app. The rest of this post is notes about the process, things I used, things I learned, and hopefully some helpful references for myself and anyone else doing this in the future.

FieldSpottr

I wrote the app! It's called Field Spottr, and it's open source. I wrote it with Kotlin Multiplatform, Compose, and Circuit.

You can also download it on the App Store or Play Store. Note that this is really only useful for myself and the friends I play soccer with 😄.

0:00
/0:08

Video walk through of the app

The app technically supports Desktop too, but I'm omitting those details for brevity in this post and focusing on iOS and Android. At a high level, it's pretty simple.

The UI is material3 on Android and mostly Cupertino on iOS. There are a few compose widgets that look out of place on iOS, but for a toy app that very few people will use this is a fine trade off.

Versioning
Android has some baked-in patterns for versioning with BuildConfig, but to make this multiplatform friendly I use a 3rd party gradle-build-config Gradle plugin. This supports generating in KMP projects, generating Kotlin, and other more advanced uses.

One important note is that the plugin needs to be configured to generate public symbols, as its default of internal will prevent them from being visible from Swift. This is important later in the iOS section.

Platform-specific Components
There are a few platform-specific components in the app.

All of these live in a hand-written FSComponent that acts as a dependency injection component. It's hand-written for now because it's simple. Platform-specific implementations live in an encapsulated SharedPlatformFSComponent that each supply.

Compose as an App
The primary entry point of the app itself is FieldSpottrApp.kt, which is a composable entry point. Unlike your typical Compose samples though, this isn't just UI! This is actually a Circuit app, which means the whole app (including presentation business logic) is also using the compose runtime. This allows for encapsulation of the entire app within a single composable entry point.

@Composable
fun FieldSpottrApp(component: FSComponent, onRootPop: () -> Unit) {
  FSTheme {
    Surface(color = MaterialTheme.colorScheme.background) {
      val backStack = rememberSaveableBackStack(HomeScreen)
      val navigator = rememberCircuitNavigator(backStack) { onRootPop() }
      CircuitCompositionLocals(component.circuit) {
        ContentWithOverlays {
          NavigableCircuitContent(navigator = navigator, backStack = backStack)
        }
      }
    }
  }
}

This in turn is called into at each platform's canonical entry-point. Each platform is responsible for creating the FSComponent before-hand.

// Android
class MainActivity : AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    // ...
    val component = (application as FieldSpottrApplication).fsComponent
    setContent { FieldSpottrApp(component, onRootPop = ::finish) }
  }
}
// Kotlin helper in src/iosMain/kotlin
// fun makeUiViewController(component: FSComponent): UIViewController = ComposeUIViewController {
//   FieldSpottrApp(component, onRootPop = {})
// }

struct ContentView: View {
    private let component: FSComponent

    init() {
        self.component = FSComponent(shared: IosSharedPlatformFSComponent())
    }

    var body: some View {
        ComposeView(component: self.component)
            .ignoresSafeArea(.all, edges: .all)
    }
}

struct ComposeView: UIViewControllerRepresentable {
    private let component: FSComponent

    init(component: FSComponent) {
        self.component = component
    }

    func makeUIViewController(context _: Context) -> UIViewController {
        return FSUiViewControllerKt.makeUiViewController(component: component)
    }
}

Crash Reporting
I've always used Bugsnag in side projects. Big fan, lots of drop-in SDKs. They have SDKs for Android and iOS too. You can create one "Other Mobile" type project and publish events from any platform to it, no need for separate projects unless you want to.

Privacy Policy
The Play Store requires this. I generated one using https://app-privacy-policy-generator.firebaseapp.com/ and modifying it as needed. IANAL.

Publishing

Now the actual hard part — publishing. Honestly, most of the reason this blog post exists is for my own reference in the future if I ever have to do this again.

Android

The developer tooling side of this process isn't too complicated.

Signing

  1. Create a signing key
  2. Encrypt it with gpg. You can borrow from the scripts under the release directory in the project, which in turn are based on Chris Banes' scripts in Tivi.
  3. Check in the encrypted signing key to the repo. Decrypt it as-necessary for releases.
  4. Wire this key into your signing and release configuration.
signingConfigs {
  if (rootProject.file("release/app-release.jks").exists()) {
    create("release") {
      storeFile = rootProject.file("release/app-release.jks")
      storePassword = providers.gradleProperty("fs_release_keystore_pwd").orNull
      keyAlias = ...
      keyPassword = providers.gradleProperty("fs_release_key_pwd").orNull
    }
  }
}

buildTypes {
  maybeCreate("release").apply {
    // ...
    signingConfig = signingConfigs.findByName("release") ?: signingConfigs["debug"]
  }
}

Packaging
Enable app bundles by adding bundle {} to your android configuration block. Surprisingly this isn't enabled by default.

Crash Reporting
The Bugsnag Android SDK only works in Android, so you have to configure it manually in androidMain code. In this case - in the app's Application class.

Their Gradle plugin (important for uploading R8 mapping files, etc) is easy enough to drop in, but I recommend setting it up to be disabled unless you're cutting a release build. It adds UUIDs to every build that invalidate certain packaging tasks, and the plugin itself appears to be in maintenance mode while they build a new plugin.

Play Store
This is the worst part of the process. The play store's publishing docs are all over the place. Some are several years old, some are buried, some are clearly written by Google APIs people, some are clearly written by Play Store product managers. The console page is overwhelming at best, littered with product up-sells. But, in short, the path looked like this.

Eventually, you probably want to automate this step with something like Fastlane or the play-publisher Gradle plugin. Here's a helpful link for setting up API access to do so (just skip the parts that involve connecting to PushPay).

iOS

iOS is a fairly new space to me. I've known basic swift and xcode use for awhile, but never gone seriously through things like KMP apps (not from a shared library like all the KMP docs focus around), crash reporting, publishing, signing, etc.

Swift Interop
I've found this area of KMP to be surprisingly limited. You can hit platform APIs from Kotlin sources and you can call Kotlin code from Swift, but anything that isn't covered by those two is essentially a dead end.

I'm hopeful that Circuit can, at some point, offer APIs that make it easy to use SwiftUI views with shared Circuit presenters. We have a basic sample that does this but it currently requires SwiftUI views to manually instantiate Circuit presenters, sort of breaking the convenience of Circuit's more automatic infra. The lack of bidirectional Swift interop support in KMP at the moment makes doing anything beyond this pretty challenging.

Star this: https://youtrack.jetbrains.com/issue/KT-49521

Crash Reporting
Once again, Bugsnag comes in here. However, there's an added spin for KMP.

Note: I was actually unable to get their iOS SDK working with SPM, so YMMV. The below is what I attempted to do.

The short answer is to use CrashKiOS from Touchlab, which nicely papers over all this with tools to help. Their docs are a good runbook for integration with Bugsnag. My configuration ended up like this:

// build.gradle.kts
plugins {
  // ...
  alias(libs.plugins.crashKiosBugsnag)
}

kotlin {
  listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
    it.binaries.framework {
      baseName = "FieldSpottrKt"
      // crashKios -> "co.touchlab.crashkios:bugsnag" dependency
      // Important for it to be visible in Swift
      export(libs.crashKios)
    }
  }
}
// in FieldSpottrApp.swift
import SwiftUI
import Bugsnag
import FieldSpottrKt

@main
struct FieldSpottrApp: App {
    init() {
        // Gate init on our build config
        if BuildConfig.shared.IS_RELEASE {
            if let key = BuildConfig.shared.BUGSNAG_NOTIFIER_KEY {
                // Create a bugsnag config from Bugsnag's framework
                let config = BugsnagConfiguration(_: key)

                // Plug it into CrashKiOS's Bugsnag wrapper. This
                // will start bugsnag under the hood too.
                BugsnagConfigKt.startBugsnag(config: config)
                // This is, surprisingly, also necessary and not 
                // implicitly done by the start call above.
                BugsnagKotlinKt.enableBugsnag()
            }
        }
    }
    // ...
}

Building
Building in regular development is usually done through Xcode. As long as you do the usual setup from the KMP docs, you should be set up. It is a fairly opaque system though, so debugging build issues can be tedious. Especially as Xcode seems fairly reluctant to make this button actually do anything.

A screenshot from Xcode of a dropdown menu highlighting "Reveal in Log"

Compose Multiplatform UI
To make the iOS app look a little more native, I opted to use the compose-cupertino project to adaptively render UIs per-platform and Calf to bridge to native components like bottom sheets as needed. They work well enough for a simple app like this, though I'm not sure they're mature enough yet to recommend for a serious project as they has no tests. The calf maintainer is very responsive though, the compose-cupertino issue tracker sees acknowledgement though and multiple components are broken. My hope is that JetBrains tries to fill this space long term with first party APIs.

In some cases, Skiko components that came with Compose UI on iOS were just bad and unstable for use. Namely — modals like dialogs or bottom sheets were inconsistent at best and crashed at worst. For these cases, I found myself opting for just simple navigation instead (Circuit lends itself well to this!), but I'd love to see more attention in Compose UI to making these components' inner UIs more reusable without the cruft of the popup/window/dialog system.

Publishing
Just use Fastlane + match. An interesting pattern I noticed when talking to iOS friends is that they always mention adding things to Info.plist, a file that is no longer generated in newer Xcode projects and appears to act similarly to AndroidManifest.xml.

Set up match. This helps set up all your certificates and signing.

Note when using GitHub for storage, it appears to hardcode the branch to master and you should handle this.
How to setup Fastlane and Match to release iOS apps automatically on CI/CD server
In this article I’ll be telling how this workflow should work both locally and on a CD server and what variables you should keep secure
How to setup Fastlane and Match to release iOS apps automatically on CI/CD server
In this article I’ll be telling how this workflow should work both locally and on a CD server and what variables you should keep secure
match - fastlane docs

Big thank you to Ben Pious and Alan Zeino for humoring a million questions about Xcode. Big thanks also to Chris Banes for helping me with all the Fastlane/match/iOS publishing madness.