- Understand how Kotlin code gets into the iOS app through the Shared framework and an Xcode build phase
- Create Config.xcconfig from the template and follow the config path from xcconfig to AppBuildConfig
- Run the app on the iOS Simulator from Xcode and from xcodebuild, and fix the common setup errors
Run The App On iOS (macOS + Xcode)
Run the same Kotlin Multiplatform app on the iOS Simulator with Xcode. Optional lesson; needs a Mac with Apple Silicon.
Lesson video is on the way
While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.
Lesson Goal
By the end of this lesson you can run the same course app on the iOS Simulator, from Xcode and from the terminal, and you know the path Kotlin code takes into the iOS app.
This lesson is optional and needs a Mac. If you use Windows or Linux, skip it: everything else in the course works on Android.
What You Need
| Requirement | Notes |
|---|---|
| A Mac with Apple Silicon | The KMP modules only declare the iosArm64() and iosSimulatorArm64() targets, so the Simulator on an Intel Mac can't run it |
| Xcode | Get it from the Mac App Store. Open it once so it installs extra components and the iOS Simulator |
| JDK 17 | The same JDK 17 you use for Android. Xcode calls Gradle to build the Kotlin part |
| A working Android build | Android builds and tests pass first, so iOS errors don't get mixed up with general setup errors |
Running on the Simulator doesn't need the Apple Developer Program. Running on a real iPhone needs an Apple ID and a team in Xcode, covered at the end of this lesson.
Part 1: How Kotlin Gets Into The iOS App
On Android, :app is a Gradle module that depends directly on the Kotlin code. iOS can't read Gradle modules. Xcode only links against frameworks.
So the :shared-app module builds all the shared Kotlin code (the features, :core, :data, :domain...) into a static framework called Shared. The Xcode project in iosApp/ has a build phase called "Build Shared framework" that runs:
./gradlew :shared-app:embedAndSignAppleFrameworkForXcode
The Swift side has just two small files:
iosApp/iosApp/iOSApp.swiftcallsSharedKoinKt.doInitApp()when the app starts.iosApp/iosApp/ContentView.swiftwrapsMainViewControllerKt.MainViewController(), aUIViewControllerthat holds the whole Compose Multiplatform UI.
Every screen you see on iOS is Kotlin code in commonMain, exactly as on Android.
This model (one Kotlin framework, a thin Swift app shell, Xcode calling Gradle in a build phase) is the common way KMP projects integrate. Reference: iOS integration methods.
Part 2: Create Config.xcconfig
Android reads the Supabase URL, keys and flags from local.properties. iOS has no BuildConfig, so the values take a different route:
iosApp/Configuration/Config.xcconfig
-> Info.plist (the $(SUPABASE_URL), $(SUPABASE_PUBLISHABLE_KEY)... entries)
-> iosAppBuildConfig() in shared-app/src/iosMain/.../SharedKoin.kt
-> AppBuildConfig, the same data class Android fills from BuildConfig
Create the file from the template at the repo root:
cp iosApp/Configuration/Config.xcconfig.example iosApp/Configuration/Config.xcconfig
Config.xcconfig is already in .gitignore. Open it and fill in:
| Key | Value |
|---|---|
SUPABASE_URL | Your Supabase Project URL, written as https:$(SLASH)$(SLASH)your-project.supabase.co |
SUPABASE_PUBLISHABLE_KEY | The same publishable key (sb_publishable_...) you use in local.properties |
SUPABASE_ANON_KEY | Leave empty. It's a temporary fallback, only read when SUPABASE_PUBLISHABLE_KEY is empty |
GOOGLE_OAUTH_CLIENT_ID | The web client ID, the same value as Android's google.server.clientId |
GOOGLE_IOS_CLIENT_ID | A separate iOS OAuth client. Can stay empty if you haven't done Google Sign-In yet |
GOOGLE_IOS_CLIENT_ID_REVERSED | The iOS client ID written in reverse, in the form com.googleusercontent.apps.<id> |
PRODUCT_BUNDLE_IDENTIFIER | Keep com.creative.kta |
DEVELOPMENT_TEAM | Leave empty for the Simulator |
REVENUECAT_API_KEY | RevenueCat's appl_ key. Don't use Android's goog_ key |
PREMIUM_GATE_ENABLED | Keep false |
Why the SLASH = / line? In an xcconfig file, // starts a comment. If you paste the full URL as is, the value gets cut off right after https:. The template defines SLASH separately and joins the pieces.
The PREMIUM_GATE_ENABLED flag only turns on when the value is exactly true. A typo leaves the paid features off, which is the safe direction.
Google Sign-In On iOS
Google won't let a web client redirect to an app's custom URL scheme, so iOS needs its own iOS OAuth client in the Google Cloud Console, created with the bundle ID com.creative.kta. Once it exists:
- Put the client ID in
GOOGLE_IOS_CLIENT_IDand the reversed form inGOOGLE_IOS_CLIENT_ID_REVERSED.Info.plistdeclares the reversed form inCFBundleURLSchemesso Google can redirect back to the app. - Add this iOS client ID to the Google provider in Supabase Auth, or Supabase rejects the token for the wrong audience.
At startup, the checkGoogleSignInConfiguration function logs a warning if GOOGLE_IOS_CLIENT_ID is empty, and logs an error if the URL scheme in Info.plist doesn't match the client ID.
Part 3: Run From Xcode
- Open
iosApp/iosApp.xcodeprojin Xcode. - Pick the
iosAppscheme and an iPhone Simulator in the toolbar. - Click Run (
Cmd + R).
The first build is very slow. Gradle downloads the Kotlin/Native toolchain and compiles every module for iOS. Later builds are much faster thanks to caching. Follow progress in Xcode's Report navigator, under the "Build Shared framework" build phase.
The app opens on the same Splash and Menu screens as Android, including every string you changed in commonMain.
Part 4: Build From The Terminal
xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 16' build
Replace iPhone 16 with the name of a Simulator on your machine (xcrun simctl list devices available lists them).
Don't use -destination 'generic/platform=iOS Simulator'. A generic destination also asks for the x86_64 architecture, which no module declares as a target, and the Compose resources sync step fails.
Part 5: Run The Tests On iOS
On a Mac, each KMP module's allTests runs the tests on both the JVM and the iOS Simulator:
./gradlew :core:iosSimulatorArm64Test
./gradlew :feature:game:allTests
commonTest runs twice, once on the JVM and once on Kotlin/Native. A test can pass on the JVM and fail on iOS, for example when shared code accidentally relies on behavior only the JVM has. So a green Android run doesn't mean iOS is green.
Differences You Will See On iOS
| Feature | Android | iOS |
|---|---|---|
| AI hint | Gemini if Remote Config has a key, otherwise local hints | Always local hints. iOS isn't wired to Remote Config yet |
| Analytics | Firebase Analytics | An empty implementation (NoOpAnalyticsManager) |
| Daily challenge | Follows the daily.challenge.enabled flag | Always off |
These differences live in each platform's Koin bindings (DataModule.ios.kt versus DataModule.android.kt). The shared UI has no if (platform) branches.
Troubleshooting Table
| Error | Check first |
|---|---|
The "Build Shared framework" build phase can't find Java (for example Unable to locate a Java Runtime) | Xcode build phases don't read ~/.zshrc, so a JAVA_HOME set there has no effect. Install JDK 17 with the .pkg file or brew install --cask temurin@17 so the JDK sits in /Library/Java/JavaVirtualMachines |
Cannot find a Java installation ... languageVersion=17 | The machine has no JDK 17. Install JDK 17 |
Undefined symbols: __swift_FORCE_LOAD_$_swiftCompatibility56 | KmpLibraryConventionPlugin.linkSwiftRuntimeShims uses xcode-select -p to find the Swift libraries. Run xcode-select -p and confirm it points at the Xcode app, not the Command Line Tools |
Xcode says it can't open Config.xcconfig | You haven't created iosApp/Configuration/Config.xcconfig yet (Part 2) |
| Email login works on Android but fails on iOS | Check whether SUPABASE_URL got cut off at //. Use $(SLASH)$(SLASH) |
Errors with generic/platform=iOS Simulator | Use a destination with a specific Simulator name (Part 4) |
| Google Sign-In opens the browser and never comes back to the app | GOOGLE_IOS_CLIENT_ID_REVERSED doesn't match GOOGLE_IOS_CLIENT_ID. Read the error log from checkGoogleSignInConfiguration |
| Signing error on a real iPhone | Set DEVELOPMENT_TEAM to the team ID your Apple ID on this Mac belongs to |
Checkpoint
You are done with this lesson when:
- The app runs on the iOS Simulator and reaches the Menu,
- Splash and Menu look the same as on Android,
- The
xcodebuildcommand in Part 4 ends withBUILD SUCCEEDED, git statusdoesn't listiosApp/Configuration/Config.xcconfig.