Skip to content

Latest commit

 

History

History
495 lines (403 loc) · 14.2 KB

File metadata and controls

495 lines (403 loc) · 14.2 KB

Kotlin Multiplatform

Standards, conventions, and project initialization guide for Compose Multiplatform projects targeting Desktop, Android, and iOS.


Table of Contents

  1. Project Initialization
  2. Test Locations
  3. Test Runners

Project Initialization

CodingStandards Submodule

Every new project adds this repo as a git submodule:

git submodule add https://github.com/x128/CodingStandards.git CodingStandards

The project's CLAUDE.md then references the standards:

## Standards
Follow `CodingStandards/CLEAN_CODE.md`
Follow `CodingStandards/KOTLIN.md`
Follow `CodingStandards/ARCHITECTURE.md`
Follow `CodingStandards/TESTING.md`
Follow `CodingStandards/KOTLIN_MULTIPLATFORM.md`

Proven Version Set

These versions are tested and known to work together (updated 2026-05-08):

Component Version
Kotlin 2.0.21
Compose Multiplatform 1.7.1
Gradle 8.13
AGP 8.5.2
JDK (build) 17

Note on newer versions: Kotlin 2.1.20 / Compose 1.8.2 work for desktop-only projects but raise the minimum iOS deployment target. Use the versions above for iOS compatibility.

Pin the build JDK — but pin two separate things, separately. Conflating them is why this bites.

What compiles the code: kotlin { jvmToolchain(17) } in the build script. Portable, committed, identical on every machine and in CI. Add it as soon as the project has a JVM or Android target — on an iOS-only project there is no JVM compilation to pin, and declaring one just asks Gradle to resolve a toolchain it may not find (Homebrew's keg-only JDKs are invisible to auto-detection without the caveat symlink).

What Gradle itself runs on: the daemon's JVM — which a toolchain cannot change, because Gradle has to start before it reads your build script. Gradle supports new JDKs only after a lag, and Homebrew's default openjdk tracks the newest release (26 as of 2026-08), so the build dies before configuration with an opaque error: the bare version string, * What went wrong: 26.0.2, no mention of Java. Set JAVA_HOME, or org.gradle.java.home in ~/.gradle/gradle.properties.

Never commit org.gradle.java.home. Its value is an absolute path — /opt/homebrew/opt/openjdk@17/… on Apple Silicon, /usr/local/… on Intel, different again on Linux CI — so committing it trades a broken build on one machine for a broken build on every other one. It is a local override, and local overrides live in ~/.gradle/gradle.properties.

brew install openjdk@17 only if it is genuinely absent — a machine that builds any other KMP project already has it. Homebrew keeps JDKs keg-only, so unless the caveat's symlink into /Library/Java/JavaVirtualMachines was created, /usr/libexec/java_home -V will not list them; its silence is not evidence of absence.

gradle.properties

# Gradle
org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M"
org.gradle.parallel=true
org.gradle.caching=true

# Kotlin
kotlin.code.style=official

# Android
android.useAndroidX=true

gradle/libs.versions.toml

Start with the minimum — add libraries as tasks require them:

[versions]
kotlin = "2.0.21"
compose-multiplatform = "1.7.1"
agp = "8.5.2"
activity-compose = "1.9.3"

[libraries]
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }

[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
androidApplication = { id = "com.android.application", version.ref = "agp" }

Project Structure

Target priority is project-specific. Wire the targets your project needs; comment out the rest.

project/
├── CodingStandards/              # submodule
├── composeApp/
│   ├── build.gradle.kts
│   └── src/
│       ├── commonMain/kotlin/    # shared UI and logic
│       ├── iosMain/kotlin/       # iOS entry point
│       ├── androidMain/          # Android manifest + activity
│       │   ├── AndroidManifest.xml
│       │   └── kotlin/
│       └── desktopMain/kotlin/   # desktop entry point (when enabled)
├── iosApp/                       # Xcode project (when needed)
├── build.gradle.kts              # root — plugins apply false
├── settings.gradle.kts
├── gradle.properties
├── gradle/
│   ├── libs.versions.toml
│   └── wrapper/
├── gradlew
└── gradlew.bat

.gitignore

*.iml
.gradle
.idea
.claude/settings.local.json
.DS_Store
build/
local.properties

# Xcode
*.xcuserdata
*.xcworkspace
DerivedData/
Pods/
xcuserdata/

# Kotlin/Native
*.klib
*.knm

composeApp/build.gradle.kts

Uncomment the targets your project needs:

import org.jetbrains.compose.desktop.application.dsl.TargetFormat

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.composeMultiplatform)
    alias(libs.plugins.composeCompiler)
    // alias(libs.plugins.androidApplication)
}

kotlin {
    // Uncomment together with any JVM or Android target — this is what
    // compiles the code. It does not set the JDK Gradle itself runs on.
    // jvmToolchain(17)

    // Android target (uncomment when needed)
    // androidTarget {
    //     compilations.all {
    //         compileTaskProvider.configure {
    //             compilerOptions {
    //                 jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
    //             }
    //         }
    //     }
    // }

    // iOS targets (uncomment when needed)
    // listOf(
    //     iosArm64(),
    //     iosSimulatorArm64()
    // ).forEach { iosTarget ->
    //     iosTarget.binaries.framework {
    //         baseName = "ComposeApp"
    //         isStatic = true
    //     }
    // }

    // Desktop target (uncomment when needed)
    // jvm("desktop")

    sourceSets {
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)
            implementation(compose.components.resources)
        }

        // androidMain.dependencies {
        //     implementation(compose.preview)
        //     implementation(libs.activity.compose)
        // }

        // val desktopMain by getting
        // desktopMain.dependencies {
        //     implementation(compose.desktop.currentOs)
        // }
    }
}

// Android configuration (uncomment when needed)
// android {
//     namespace = "com.example.app"
//     compileSdk = 35
//
//     defaultConfig {
//         applicationId = "com.example.app"
//         minSdk = 24
//         targetSdk = 35
//         versionCode = 1
//         versionName = "1.0.0"
//     }
//
//     compileOptions {
//         sourceCompatibility = JavaVersion.VERSION_17
//         targetCompatibility = JavaVersion.VERSION_17
//     }
// }

// Desktop configuration (uncomment when needed)
// compose.desktop {
//     application {
//         mainClass = "com.example.app.MainKt"
//         nativeDistributions {
//             targetFormats(TargetFormat.Dmg, TargetFormat.Deb)
//             packageName = "AppName"
//             packageVersion = "1.0.0"
//         }
//     }
// }

Gradle Wrapper

Copy gradlew, gradlew.bat, and gradle/wrapper/ from an existing project. No global gradle is installed — bootstrap from an existing wrapper.

iOS Xcode Project

Name the .xcodeproj after the app (e.g., SimpleStories.xcodeproj), not iosApp.xcodeproj. Use objectVersion 77 (Xcode 16+, PBXFileSystemSynchronizedRootGroup).

Directory structure inside iosApp/:

iosApp/
├── {App}.xcodeproj/
├── App/                    # Swift sources (sync group)
│   ├── AppDelegate.swift
│   └── SceneDelegate.swift
├── Assets/                 # Asset catalogs (sync group)
│   └── Assets.xcassets/
└── Info.plist

Swift entry point

UIKit AppDelegate + SceneDelegate in separate files.

App/AppDelegate.swift:

import UIKit

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        return true
    }

    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }
}

App/SceneDelegate.swift:

import UIKit
import ComposeApp

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = scene as? UIWindowScene else { return }
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = MainViewControllerKt.MainViewController()
        window?.makeKeyAndVisible()
    }
}

Info.plist

Place at iosApp/Info.plist (NOT inside the sync group — avoids "Multiple commands produce Info.plist" error):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CADisableMinimumFrameDurationOnPhone</key>
    <true/>
    <key>UILaunchScreen</key>
    <dict/>
    <key>UIApplicationSceneManifest</key>
    <dict>
        <key>UIApplicationSupportsMultipleScenes</key>
        <false/>
        <key>UISceneConfigurations</key>
        <dict>
            <key>UIWindowSceneSessionRoleApplication</key>
            <array>
                <dict>
                    <key>UISceneConfigurationName</key>
                    <string>Default Configuration</string>
                    <key>UISceneDelegateClassName</key>
                    <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
                </dict>
            </array>
        </dict>
    </dict>
</dict>
</plist>

Key build settings (target level)

Setting Value
PRODUCT_NAME $(TARGET_NAME)
GENERATE_INFOPLIST_FILE YES
INFOPLIST_FILE Info.plist
ENABLE_USER_SCRIPT_SANDBOXING NO
IPHONEOS_DEPLOYMENT_TARGET 15.0 (Compose 1.7.1 Skia minimum)

SDK-conditional framework search paths

Use SDK-conditional settings so only the relevant platform path is active. Avoids "search path not found" warnings:

FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*] = $(SRCROOT)/../composeApp/build/bin/iosSimulatorArm64/{config}Framework
FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*] = $(SRCROOT)/../composeApp/build/bin/iosArm64/{config}Framework

Where {config} = debug or release per build configuration.

Build KMP Framework script phase

Runs before Sources. Set alwaysOutOfDate = 1 (no output dependencies). Only iosSimulatorArm64 and iosArm64 — no iosX64:

cd "$SRCROOT/.."

# Xcode build phases inherit almost no environment, so the JDK has to be found
# here. Try candidates and fail loudly — a single hardcoded path is committed
# breakage on every machine that is not the one it was written on.
#
# Test the version, not just that some java exists: an inherited JAVA_HOME
# pointing at Homebrew's default (newest) JDK is the common case, and it is
# exactly what Gradle cannot start on.
is_jdk17() {
    [ -x "$1/bin/java" ] || return 1
    "$1/bin/java" -version 2>&1 | head -1 | grep -q '"17[."]'
}

if ! is_jdk17 "$JAVA_HOME"; then
    for candidate in \
        "$(/usr/libexec/java_home -v 17 2>/dev/null)" \
        /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home \
        /usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
    do
        if is_jdk17 "$candidate"; then JAVA_HOME="$candidate"; break; fi
    done
fi
is_jdk17 "$JAVA_HOME" || {
    echo "error: no JDK 17 (JAVA_HOME=${JAVA_HOME:-unset})." >&2
    echo "       Set JAVA_HOME to a 17, or: brew install openjdk@17" >&2
    exit 1
}
export JAVA_HOME
export PATH="$JAVA_HOME/bin:$PATH"

if [ "$CONFIGURATION" = "Debug" ]; then
    CONFIG="Debug"
else
    CONFIG="Release"
fi

if [ "$PLATFORM_NAME" = "iphonesimulator" ]; then
    TASK="link${CONFIG}FrameworkIosSimulatorArm64"
else
    TASK="link${CONFIG}FrameworkIosArm64"
fi

./gradlew :composeApp:$TASK

Gotcha: simulator caches scene configuration

When switching between scene-based and non-scene app lifecycle, delete the app from the simulator before rebuilding. iOS caches the old scene configuration and the new one won't take effect.

Verification

After setup, verify compilation on all active targets:

# iOS
./gradlew :composeApp:compileKotlinIosSimulatorArm64

# Android
./gradlew :composeApp:assembleDebug

# Desktop (when enabled)
# ./gradlew :composeApp:compileKotlinDesktop

Test Locations

Type Location Framework
Unit, Integration shared/src/commonTest/kotlin/... kotlin.test, Turbine
Desktop end-to-end shared/src/desktopTest/ compose.desktop.uiTestJUnit4
Android end-to-end androidApp/src/androidTest/ androidx.compose.ui:ui-test-junit4
iOS end-to-end iosApp/{App}UITests/ XCUITest (Swift)

Unit and integration test files mirror main source package structure. End-to-end tests follow platform conventions.


Test Runners

Scope Command
Desktop (unit + integration + E2E) ./gradlew :shared:desktopTest
iOS (unit + integration) ./gradlew :shared:iosSimulatorArm64Test
iOS (E2E) xcodebuild test -project iosApp/{App}.xcodeproj -scheme {App} -destination 'platform=iOS Simulator,name=iPhone 17 Pro'
Android (E2E) ./gradlew :androidApp:connectedAndroidTest