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.
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 ๐.
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.
- Fetches CSVs from the city parks site for the relevant areas we play in using Ktor.
- Reads them with Okio into a local database using SqlDelight. This is refreshed either manually or after one week.
- Presents a simple calendar-esque Compose UI with a date picker, “group” picker (one park can have multiple fields), and permit events for that date and group.
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.
- Version code โ the app version number. Incremented for every release.
- Version name โ the semantic version name, i.e.
1.0.0
. Honestly? More just here for show, version code is what really matters. IS_RELEASE
โ a boolean indicating if this is a release build or not. Essentially just to gate crash reporting or debug tools.
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.
FSAppDirs
โ a simple abstraction API on top of Okio that exposes directories for common locations like user cache, user data, etc.ContextFSAppDirs
is an Android implementation that derives these locations fromContext
.NSFileManagerFSAppDirs
is an iOS implementation that derives these locations fromNSFileManager
.
SqlDriverFactory
โ A factory abstraction over creating SqlDelightSqlDriver
instances.AndroidSqlDriverFactory
is an Android implementation that works on top ofContext
and returnsAndroidSqliteDriver
instances.NativeSqlDriverFactory
is anative
implementation that works on top of SQLiter and returnsNativeSqliteDriver
instances.
- Ktor โ the networking layer. There’s no expect/actual needed for this case however, as just adding the appropriate engine dependencies (i.e. OkHttp for Android and Darwin for iOS) is enough for it to automatically init.
FSTheme
โ the expect/actual theme entry point. This is only expect/actual’d because Android supports dynamic theming and needs the extra indirection.
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.
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
- Create a signing key
- 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. - Check in the encrypted signing key to the repo. Decrypt it as-necessary for releases.
- 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.
- Make a separate Google account for this. Don’t use your personal account, or any other account you care about losing if Google strikes ya.
- Set up your new app project and go through its preliminary onboarding flows + anything you need to do in the “Publishing overview” section.
- Start a testing track, you can start publishing to this immediately. Under Testing > Internal Testing. There you can create a new release and manually upload .aab files to it.
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 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.
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.
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.