Standards, conventions, and project initialization guide for Compose Multiplatform projects targeting Desktop, Android, and iOS.
Every new project adds this repo as a git submodule:
git submodule add https://github.com/x128/CodingStandards.git CodingStandardsThe 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`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
openjdktracks 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. SetJAVA_HOME, ororg.gradle.java.homein~/.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@17only 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/JavaVirtualMachineswas created,/usr/libexec/java_home -Vwill not list them; its silence is not evidence of absence.
# 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=trueStart 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" }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
*.iml
.gradle
.idea
.claude/settings.local.json
.DS_Store
build/
local.properties
# Xcode
*.xcuserdata
*.xcworkspace
DerivedData/
Pods/
xcuserdata/
# Kotlin/Native
*.klib
*.knmUncomment 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"
// }
// }
// }Copy gradlew, gradlew.bat, and gradle/wrapper/ from an existing
project. No global gradle is installed — bootstrap from an existing wrapper.
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
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()
}
}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>| 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) |
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.
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:$TASKWhen 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.
After setup, verify compilation on all active targets:
# iOS
./gradlew :composeApp:compileKotlinIosSimulatorArm64
# Android
./gradlew :composeApp:assembleDebug
# Desktop (when enabled)
# ./gradlew :composeApp:compileKotlinDesktop| 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.
| 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 |