iru-setup-kmp-app

Scaffold a new Kotlin Multiplatform mobile app (Android + iOS) at the repository root — Gradle Kotlin DSL wrapper, `settings.gradle.kts`, root `build.gradle.kts`, `gradle.properties` (holding the app

SKILL.md

GitHub

Setup KMP App

Generate a complete, buildable Kotlin Multiplatform (KMP) app for Android and iOS — a Gradle Kotlin-DSL build with a shared/ KMP module, an androidApp/ application module and an iosApp/ SwiftUI host app generated by XcodeGen — from the templates in this skill's reference/ files. Every template was rendered into throwaway projects (both ui values) and proven against the real toolchain on 2026-10-15 (Gradle 9.8.1, AGP 9.4.1, Kotlin 2.4.21, Compose Multiplatform 1.12.2, Xcode 27.0, XcodeGen 2.46.0) — see Known quirks / verification notes.

The layout is the scaffold contract the KMP coding and gate skills (iru-kmp-code-one-task, iru-kmp-test, iru-kmp-coverage, iru-kmp-code-quality, iru-kmp-dokka, iru-kmp-bump-version) and iru-setup-kmp-github-workflows detect and call — Step 8's contract table lists the confirmed task names and report paths. It mirrors iru-setup-android-app (Gradle KTS, version catalog, env-driven signing, Update/Keep/ Replace) and iru-setup-apple-app (XcodeGen manifest, the generated .xcodeproj never committed, SwiftLint, XCTest). Like iru-setup-android-app, it writes no publishing block — this is an app; a KMP library published to Maven Central is out of scope.

Reference files — read only what this run needs

Read When
reference/gradle-templates.md Always. Wrapper properties, settings.gradle.kts, gradle.properties, gradle/libs.versions.toml, root build.gradle.kts (Kover aggregation + sonar {}), detekt.yml, .editorconfig.
reference/shared-templates.md Always. shared/build.gradle.kts and every shared/src/* file (+ the ui: compose App.kt/MainViewController.kt).
reference/android-templates.md Always. androidApp/build.gradle.kts, manifest, resources, MainActivity.kt (per ui), the Robolectric test.
reference/ios-templates.md Always. iosApp/project.yml, Config.xcconfig, Info.plist, the Swift sources and tests (per ui), .swiftlint.yml.

Conditional-marker convention used in every reference file: a line // >>> <condition> (or # >>> <condition>) opens a block that is written only when the condition holds, and the matching // <<< <condition> closes it; the marker lines themselves are never written. Conditions are ui: compose, ui: native and sonar (= sonar is not none). A file heading suffixed (ui: compose)/(ui: native) exists only in that variant. <placeholder> values come from Step 5's table; <package-path> is <namespace> with dots turned into /.

Step 0 — Resolve inputs

Invoked stand-alone (/iru-setup-kmp-app) or by iru-setup-kmp-repository / the iru-setup-repository front door, which pass resolved answers as key: value lines, e.g.:

ui: compose
app-name: My App
application-id: com.example.myapp
bundle-id: com.example.myapp
namespace: com.example.myapp
min-sdk: 26
ios-deployment-target: 18.5
license: MIT License
developer-name: Jane Doe
developer-email: jane@example.com
organization-url: https://github.com/example-org
open-source: yes
visibility: public
ci: yes
sonar: cloud
sonar-organization: example-org-github
sonar-project-key: example-org_my-app
sonar-host-url: https://sonarcloud.io
distribution: store
accessibility: enforce
mode: new
on-existing: ask

Recognized keys: ui (compose/native), app-name, application-id, bundle-id (default application-id), namespace (default application-id — shared Kotlin package <namespace>, Android app package/namespace <namespace>.android, shared Android namespace <namespace>.shared), project-name (rootProject.name, default the repository name), min-sdk (default 26), compile-sdk/target-sdk (default Step 4's lookup), ios-deployment-target (default Step 4's lookup), version-name (default 1.0.0-SNAPSHOT — the gitflow integration-branch value; pass 1.0.0 for a main-only repository), version-code (default 1), team-id (Apple Developer Team ID, default empty — set later in Config.xcconfig or supplied by CI), license, developer-name, developer-email, organization-url, open-source (yes/no), visibility (public/private/internal — only keys the sonar recommendation), ci (yes/no — only keys the sonar recommendation), sonar (cloud/self-hosted/none) plus sonar-organization/sonar-project-key/sonar-host-url, distribution (none/internal/store — recorded for the report; the store/TestFlight uploads live in iru-setup-kmp-github-workflows' release.yml, so the build files are identical for every value), accessibility (enforce/report/off/none, default enforce — the automated accessibility level of the UI tests, written to .ui-quality.yml; never asked stand-alone, just defaulted), mode (new/existing), on-existing (ask/keep/update/replace, default ask). Every key found is resolved — skip its question in Step 2. Unknown keys from a full shared-args set (publish, ci-tests, pages, …) are ignored and named in the report.

mode: existing never means "gap-fill silently": Step 1 still warns about, and asks about, every pre-existing file group. Only on-existing skips that question (keep = create only missing files; update/replace applied to every existing group; the warning is still printed). Orchestrators forward on-existing only when their own args received it.

Accessibility level none and assistive-technology templates. accessibility takes enforce, report, off or none (UI contract §8; the stand-alone question gains a fourth option, None — opt out entirely, e.g. a mandated non-compliant design). With none: write accessibility: none to .ui-quality.yml, do not install or configure the accessibility test tooling (the Android Accessibility Test Framework audit and the iOS accessibility audit), and omit the accessibility-specific attributes from the generated templates. With enforce, report or off: include the assistive-technology attributes from the contract's §8 platform subsections in every generated template — contentDescription/Modifier.semantics on Android, accessibilityLabel on iOS, scalable font sizes, reduced-motion guards — and, for enforce/report only, the automated checks. In mode: existing, a prior .ui-quality.yml follows this skill's existing Update / Keep / Replace prompt and its accessibility value is never silently flipped (Update sets it only when args supplied it).

Step 1 — Survey the repository

Check for every file Step 6 would write: settings.gradle.kts, root build.gradle.kts, gradle.properties, gradle/libs.versions.toml, the wrapper (gradlew, gradlew.bat, gradle/wrapper/*), detekt.yml, .editorconfig, shared/build.gradle.kts, shared/src/**, androidApp/build.gradle.kts, androidApp/src/**, androidApp/proguard-rules.pro, iosApp/project.yml, iosApp/Configuration/Config.xcconfig, iosApp/iosApp/**, iosApp/iosAppTests/**, iosApp/.swiftlint.yml, .ui-quality.yml, and any committed iosApp/*.xcodeproj. Then tell the user which of the two this run is doing:

  • Scaffolding from scratch — none exist: go to Step 2.

  • Updating an existing KMP project — any exists: warn, listing every file found by path, that each existing file group below will be decided individually (missing files are still created). Groups (only those with a file present): settings.gradle.kts, root build.gradle.kts, gradle/libs.versions.toml, gradle.properties, the Gradle wrapper, shared/build.gradle.kts, androidApp/build.gradle.kts, quality config (detekt.yml, .editorconfig, iosApp/.swiftlint.yml), iosApp/project.yml, iosApp/Configuration/Config.xcconfig, .ui-quality.yml, and the source skeleton (everything under shared/src, androidApp/src, iosApp/iosApp, iosApp/iosAppTests). Unless on-existing decides, ask with AskUserQuestion, one question per group (batch up to 4 per call), naming each concrete conflict with this run's choices (e.g. "shared/build.gradle.kts applies org.jetbrains.compose but you chose ui: native", "the root build.gradle.kts has a sonar {} block but you chose sonar: none", ".ui-quality.yml says accessibility: report but you chose enforce", "gradle.properties has no VERSION_NAME/VERSION_CODE — the Android app's version is a literal in androidApp/build.gradle.kts"):

    • Update (Recommended for build files, wrapper, quality config, project.yml and .ui-quality.yml) — "Merge: add what's missing and correct outdated parts using this skill's template as reference — plugins, catalog aliases, the Kover/ detekt/ktlint/Dokka/lint/Sonar wiring, the version properties, toolchain versions bumped to Step 4's lookup; keep every dependency, target, source set, build type, scheme and setting this skill doesn't own. You review a diff; customizations survive, but a customized part this skill owns may be rewritten." For .ui-quality.yml, Update sets accessibility to this run's value and adds missing keys, keeping customized directions/themes. For the source skeleton, Update only creates its missing files.
    • Keep as is (Recommended for the source skeleton and Config.xcconfig) — "Leave the file(s) untouched. Nothing you customized changes, but they may stay out of date or disagree with this run's choices — the report lists each mismatch."
    • Replace — "Regenerate from this skill's template. Matches the house pattern exactly, but every customization is lost (recoverable only from git history)." Never the default. Even on Replace, never overwrite a Kotlin/Swift source file unless its content exactly matches this skill's original template (i.e. it is still the untouched placeholder).
    • Stop (first question only) — "End this skill now without changing any file." Report and end.

    Ask about groups whose expected content depends on Step 2 answers (ui, sonar) after Step 2 when args didn't already resolve them. When updating, read the current applicationId, namespace, bundle id (Config.xcconfig's BUNDLE_ID or project.yml's PRODUCT_BUNDLE_IDENTIFIER), app name and versions first and offer them as Step 2's defaults.

  • A foreign KMP layout — composeApp/build.gradle.kts (the JetBrains wizard's single module), or a shared/ module on androidTarget() + com.android.library, or an iosApp/iosApp.xcodeproj committed with no project.yml: this is not this skill's scaffold. Warn explicitly and ask (AskUserQuestion) whether to add this skill's missing pieces alongside without touching the existing modules (e.g. only the quality wiring, Kover aggregation and version properties — each still per-group Update/Keep/Replace), or stop. Never convert or move an existing module, and never replace a committed .xcodeproj with an XcodeGen one without an explicit yes.

Step 2 — Collect the required inputs

For anything Step 0 didn't resolve, ask directly (plain conversation, pre-filled from Step 1 when updating):

  • app-name — launcher label on both platforms (strings.xml app_name, Config.xcconfig APP_NAME → CFBundleDisplayName).
  • application-id — Android applicationId, reverse-DNS.
  • bundle-id — iOS PRODUCT_BUNDLE_IDENTIFIER; offer application-id as the default.
  • namespace — Kotlin base package; offer application-id as the default.
  • min-sdk (26), compile-sdk/target-sdk and ios-deployment-target — offer Step 4's looked-up values.
  • Developer name/email, organizationUrl — recorded in the report (and seen by iru-setup-readme).

Then the license via AskUserQuestion (unless resolved): MIT License (recommended for an app) / Apache License 2.0 / No license (proprietary) / Other (ask name + URL). If one is chosen, remind the user in Step 8 to add a root LICENSE (iru-check-license can backfill headers).

Then ui (unless resolved), AskUserQuestion:

  • Compose Multiplatform (Recommended) (compose) — one Compose UI in shared/commonMain (App()), hosted by MainActivity.setContent { App() } on Android and ComposeUIViewController { App() } on iOS.
  • Native UI (native) — shared/ holds logic and state only (Greeting, GreetingUiState); Android UI is Jetpack Compose (Compose BOM) in androidApp/; iOS UI is SwiftUI in iosApp/ over a small GreetingModel ObservableObject wrapping the shared state.

Then open-source, sonar, distribution (skip any resolved). Stand-alone with none resolved, ask open-source first and derive the sonar recommendation from it:

  • open-source — Yes / No.
  • sonar — SonarQube/SonarCloud via the org.sonarqube Gradle plugin (./gradlew sonar, normally run by iru-setup-kmp-github-workflows' CI). Recommend cloud only when open source and visibility is public (or unset); otherwise state SonarCloud is free only for public/open-source projects (private needs a paid plan), recommend none, offer self-hosted (Community Build free, you run the server). With ci: no, say no CI would send reports (only a manual ./gradlew sonar with a token) and recommend none; if still chosen, write the block and note it in Step 8. cloud → ask sonar.organization (suggest <owner>-github), default sonar.projectKey <owner>_<repo> and host https://sonarcloud.io, confirming both; self-hosted → ask host, key, and organization only if that server uses organizations; none → no plugin, no block.
  • distribution — none/internal/store. Informational for this skill (see Step 0); default store when open source, otherwise recommend internal. Say plainly that the release signing config is always written (it only activates when the four SIGNING_* env vars exist) and that the Play/Firebase/TestFlight steps are generated by iru-setup-kmp-github-workflows.

Step 3 — Infer repository information

Parse git remote get-url origin (git@host:owner/repo.git or https://host/owner/repo.git) for <owner>/<repo> (Sonar defaults, project-name default). No origin → ask.

Step 4 — Look up toolchain versions

Look every version up at run time; fall back to this table (verified working together on 2026-10-15) only when a lookup fails, and say so in Step 8. Resolve AGP/Kotlin/Compose Multiplatform/Compose BOM as a set and re-run Step 7 once before trusting a newer combination — bare "latest of each" is not a verified combination (the same discipline iru-setup-android-app documents).

Component Lookup October 2026 fallback
Gradle https://services.gradle.org/versions/current (version, checksum = distribution SHA-256) 9.8.1, sha256 dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
AGP (also com.android.kotlin.multiplatform.library, com.android.lint) https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle/maven-metadata.xml — highest non-alpha/beta/rc, sort -V 9.4.1
Kotlin (KMP plugin, Compose compiler plugin, kotlin-test) https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-gradle-plugin/maven-metadata.xml (never search.maven.org solrsearch — stale) 2.4.21
Compose Multiplatform (ui: compose) https://plugins.gradle.org/m2/org/jetbrains/compose/org.jetbrains.compose.gradle.plugin/maven-metadata.xml — sort -V, the file's own order is not chronological 1.12.2
Compose Multiplatform Material 3 (ui: compose, versioned separately since CMP 1.10) https://repo1.maven.org/maven2/org/jetbrains/compose/material3/material3/maven-metadata.xml 1.9.0
AndroidX Compose BOM (ui: native) https://dl.google.com/dl/android/maven2/androidx/compose/group-index.xml (compose-bom versions=) 2026.09.00
androidx.activity:activity-compose https://dl.google.com/dl/android/maven2/androidx/activity/group-index.xml 1.13.0
androidx.test:core-ktx https://dl.google.com/dl/android/maven2/androidx/test/group-index.xml 1.7.0
JUnit 4 Maven Central junit/junit 4.13.2
Robolectric https://repo1.maven.org/maven2/org/robolectric/robolectric/maven-metadata.xml 4.17 (tests pinned to sdk=35, see quirks)
AndroidX Compose UI (ui: compose — ui-test-manifest for the Robolectric UI tests; ui: native takes it from the BOM) https://dl.google.com/android/maven2/androidx/compose/ui/ui-test-manifest/maven-metadata.xml — highest stable, at most the line Compose Multiplatform maps to 1.12.1
detekt Gradle plugin https://plugins.gradle.org/m2/io/gitlab/arturbosch/detekt/io.gitlab.arturbosch.detekt.gradle.plugin/maven-metadata.xml (dev.detekt 2.x is still alpha — don't adopt it by default) 1.23.8
ktlint Gradle plugin https://plugins.gradle.org/m2/org/jlleitschuh/gradle/ktlint/org.jlleitschuh.gradle.ktlint.gradle.plugin/maven-metadata.xml 14.2.0
Kover https://plugins.gradle.org/m2/org/jetbrains/kotlinx/kover/org.jetbrains.kotlinx.kover.gradle.plugin/maven-metadata.xml (sort -V — plain tail returns 0.9.9 before 0.9.11) 0.9.11
Dokka https://repo1.maven.org/maven2/org/jetbrains/dokka/dokka-gradle-plugin/maven-metadata.xml 2.2.0
SonarQube Gradle plugin (sonar ≠ none) https://plugins.gradle.org/m2/org/sonarqube/org.sonarqube.gradle.plugin/maven-metadata.xml 7.5.0.8588
compileSdk / targetSdk the highest platforms/android-NN* in the local SDK, and at least what the Compose libraries' AAR metadata demand (see quirks) 37 / 37
iOS deployment target the lowest installed iOS simulator runtime that the Compose/Kotlin-Native binaries are built for — xcrun simctl list runtimes available 18.5
XcodeGen (never installed by this skill) https://formulae.brew.sh/api/formula/xcodegen.json .versions.stable 2.46.0
SwiftLint (never installed) https://formulae.brew.sh/api/formula/swiftlint.json 0.65.1
Xcode (informational) xcodebuild -version 27.0 (27A266a)

JDK: Gradle and AGP run on JDK 17 or 21 — both verified (export JAVA_HOME=$(/usr/libexec/java_home -v 21)); the JVM target written into the build is 17.

Step 5 — Reference templates

Read the four reference files (table above). Placeholders:

Placeholder Source
<project-name> Step 0/3
<namespace> / <package-path> Step 2 (<package-path> = dots → /; directory segment __pkg__ in the reference headings)
<application-id> / <bundle-id> / <app-name> / <team-id> Step 2 / Step 0
<version-name> / <version-code> Step 0 (defaults 1.0.0-SNAPSHOT / 1)
<marketing-version> <version-name> without -SNAPSHOT (App Store Connect rejects pre-release strings)
<min-sdk> / <compile-sdk> / <target-sdk> / <ios-deployment-target> Step 2/4
<robolectric-sdk> the newest SDK the resolved Robolectric runs without failing (35 with Robolectric 4.17)
<androidx-compose-ui-version> Step 4 (ui: compose only)
<accessibility> Step 0 (default enforce)
<gradle-version> / <gradle-distribution-sha256> / every <…-version> in the catalog Step 4
<sonar-organization> / <sonar-project-key> / <sonar-host-url> Step 2

Directory layout produced:

settings.gradle.kts  build.gradle.kts  gradle.properties  gradle/libs.versions.toml  gradle/wrapper/*  gradlew(.bat)
detekt.yml  .editorconfig  .ui-quality.yml
shared/build.gradle.kts
shared/src/commonMain/kotlin/<package-path>/{Platform,Greeting,GreetingUiState}.kt   (+ App.kt — ui: compose)
shared/src/commonTest/kotlin/<package-path>/{GreetingTest,GreetingUiStateTest}.kt     (+ GreetingScreenTest.kt, uitest/UiScreenshots.kt — ui: compose)
shared/src/androidMain/kotlin/<package-path>/AndroidPlatform.kt
shared/src/androidHostTest/kotlin/<package-path>/AndroidPlatformTest.kt             (+ uitest/UiTest.android.kt — ui: compose)
shared/src/iosMain/kotlin/<package-path>/IOSPlatform.kt                              (+ MainViewController.kt — ui: compose)
shared/src/iosTest/kotlin/<package-path>/IOSPlatformTest.kt                         (+ uitest/UiTest.ios.kt — ui: compose)
androidApp/build.gradle.kts  androidApp/proguard-rules.pro
androidApp/src/main/AndroidManifest.xml  androidApp/src/main/res/{values,drawable,mipmap-anydpi-v26}/*
androidApp/src/main/kotlin/<package-path>/android/MainActivity.kt
androidApp/src/test/kotlin/<package-path>/android/MainActivityTest.kt  androidApp/src/test/resources/robolectric.properties
androidApp/src/test/kotlin/<package-path>/android/{GreetingScreenTest,uitest/UiScreenshots}.kt   (ui: native)
iosApp/project.yml  iosApp/Configuration/Config.xcconfig  iosApp/.swiftlint.yml
iosApp/iosApp/{MainApp,ContentView}.swift  iosApp/iosApp/Info.plist                 (+ GreetingModel.swift — ui: native)
iosApp/iosAppTests/iosAppTests.swift                                                (+ GreetingScreenTests.swift, UIScreenshots.swift — ui: native)

iosApp/iosApp.xcodeproj is never written or committed — xcodegen generate (run in iosApp/) creates it, and iru-setup-kmp-gitignore ignores it (the same convention as iru-setup-apple-app). local.properties is never written (machine-specific; tell the user to let Android Studio create it or write sdk.dir=<sdk path>).

Step 6 — Scaffold the project

Apply Step 1's per-group decision to every file that already exists (Keep as is → skip; Update → merge after showing git diff --no-index <current> <draft>; Replace → overwrite within Step 1's source-file limit). Files that didn't exist are always written.

  1. Wrapper: fetch gradlew, gradlew.bat and gradle/wrapper/gradle-wrapper.jar from https://raw.githubusercontent.com/gradle/gradle/v<gradle-version>/<path> (curl -fsSL -o, the jar is binary), or run gradle wrapper --gradle-version <gradle-version> if a local Gradle exists; then write gradle-wrapper.properties from the reference (it adds distributionSha256Sum) and chmod +x gradlew.
  2. Write the Gradle files, detekt.yml, .editorconfig and .ui-quality.yml from reference/gradle-templates.md, applying the ui/sonar markers and <accessibility>.
  3. Write shared/ from reference/shared-templates.md, androidApp/ from reference/android-templates.md, iosApp/ from reference/ios-templates.md — each with __pkg__ replaced by <package-path> and only the ui-matching variant files.
  4. If team-id is empty, say that Config.xcconfig's TEAM_ID= must be filled in (or set in Xcode) before a device/archive build; simulator builds and tests don't need it.

Step 7 — Verify

Run through the iru-gate-runner agent (never inline — the logs are long), with JAVA_HOME on JDK 17 or 21 and ANDROID_HOME/local.properties pointing at an SDK whose licenses are accepted. Warn first: the first run downloads Gradle, the Android platform, the Kotlin/Native toolchain (1 GB under `/.konan`) and every dependency — several minutes.

  1. ./gradlew build — compiles every target, runs allTests (on macOS this includes iosSimulatorArm64Test, which boots a simulator — serialize it behind this catalog's .integration-test.lock when other test stages may run concurrently), Android Lint (lintDebug/:shared:lint) and the debug/release assembles.

  2. ./gradlew koverXmlReport koverVerify detekt ktlintCheck :androidApp:lint :shared:lint :shared:dokkaGenerate :androidApp:assembleDebug — the gate set; koverVerify enforces the 80% line bound.

  3. macOS only (otherwise report each as "unverified locally", never "passed"): ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64; cd iosApp && xcodegen generate (if xcodegen is not installed, say so — brew install xcodegen — and mark the Xcode steps unverified; never install it yourself); swiftlint lint --strict in iosApp/ when installed; then xcodebuild test -project iosApp/iosApp.xcodeproj -scheme iosApp -destination 'platform=iOS Simulator,name=<device>' CODE_SIGNING_ALLOWED=NO, where <device> is an iPhone listed under the newest runtime in xcrun simctl list devices available (a bare name= resolves against OS:latest; a device that only exists on an older runtime fails with "Unable to find a device"). This also exercises the embedAndSignAppleFrameworkForXcode pre-build phase. Serialize it behind .integration-test.lock too.

  4. UI captures: after 1–3, .ui-screenshots/screen/GreetingScreen/ must hold one PNG per state (default, long-content, rtl-content) × ltr/rtl × light/dark per platform — --android from testAndroidHostTest (ui: compose) or testDebugUnitTest (ui: native), --ios from iosSimulatorArm64Test (ui: compose) or the xcodebuild test run (ui: native; add TEST_RUNNER_UI_SCREENSHOTS_DIR=<dir> to send them elsewhere). A re-run after deleting the directory needs --rerun after each Gradle test task — Gradle restores up-to-date/cached test tasks without executing them, so no captures are written. iOS captures on a non-macOS host are "unverified locally".

Report pass/fail per command; quote error output verbatim for a failure only.

Step 8 — Report

Summarize: resolved ui, app-name, application-id, bundle-id, namespace, SDK levels, iOS deployment target, VERSION_NAME/VERSION_CODE/MARKETING_VERSION, license (or none), and whether the run scaffolded from scratch, updated an existing project, or stopped. Then, per Step 1 group, the action taken — created / updated / replaced / kept as is — and whether it came from the user or on-existing, listing each mismatch of a kept group and, for a replaced one, what customizations could have been lost (check git diff). .ui-quality.yml is listed with its action and the accessibility level it holds.

State what was included versus omitted and why: ui; open-source/sonar (the coordinates written, or why none; with ci: no, that no CI step sends reports); distribution (informational — release jobs come from iru-setup-kmp-github-workflows; the SIGNING_STORE_FILE/SIGNING_STORE_PASSWORD/SIGNING_KEY_ALIAS/ SIGNING_KEY_PASSWORD env vars activate release signing); which Step 4 versions were live versus fallback; and any ignored args keys.

Scaffold contract (confirmed by running it — downstream skills depend on these)

What Exact name / path
Shared Android host tests :shared:testAndroidHostTest (source set androidHostTest, enabled by withHostTest {}) → shared/build/test-results/testAndroidHostTest/*.xml; :shared:testAndroid also exists
Shared iOS tests :shared:iosSimulatorArm64Test → shared/build/test-results/iosSimulatorArm64Test/; :shared:allTests runs every target
Android app tests :androidApp:testDebugUnitTest → androidApp/build/test-results/testDebugUnitTest/*.xml (Robolectric, sdk=35)
Compile tasks :shared:compileAndroidMain, :shared:compileKotlinIosSimulatorArm64, :androidApp:compileDebugKotlin
Coverage root koverXmlReport → build/reports/kover/report.xml (aggregates :shared + :androidApp; Composable/ComposableSingletons/BuildConfig excluded there and in each module's own kover {} block, so module and root numbers agree — 100% for both variants); koverVerify (80% line); per-module :shared:koverXmlReport / :shared:koverXmlReportAndroid, :androidApp:koverXmlReportDebug; koverPrintCoverage
detekt (1.23.8) detekt per module with source set to every KMP source dir → <module>/build/reports/detekt/detekt.{xml,sarif}; type-resolution tasks :shared:detektAndroidMain, detektAndroidHostTest, detektIosArm64Main, detektIosSimulatorArm64Main, detektIosArm64Test, detektIosSimulatorArm64Test (no detektMetadataCommonMain task was registered in this combination)
ktlint ktlintCheck (per source set: ktlintCommonMainSourceSetCheck, ktlintAndroidMainSourceSetCheck, ktlintIosMainSourceSetCheck, …, ktlintKotlinScriptCheck) → <module>/build/reports/ktlint/
Android Lint :androidApp:lint/lintDebug → androidApp/build/reports/lint-results-debug.{xml,sarif,html}; :shared:lint (via com.android.lint) → shared/build/reports/lint-results-androidMain.{xml,sarif}
Dokka 2 :shared:dokkaGenerate (= dokkaGeneratePublicationHtml) → shared/build/dokka/html/index.html
iOS framework :shared:linkDebugFrameworkIosSimulatorArm64 → shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework; Xcode phase :shared:embedAndSignAppleFrameworkForXcode → shared/build/xcode-frameworks/<CONFIGURATION>/<SDK_NAME>/
iOS app iosApp/project.yml → xcodegen generate → iosApp/iosApp.xcodeproj, scheme iosApp, test target iosAppTests, module iosApp
Versions gradle.properties VERSION_NAME/VERSION_CODE; iosApp/Configuration/Config.xcconfig MARKETING_VERSION/CURRENT_PROJECT_VERSION
Debug APK androidApp/build/outputs/apk/debug/androidApp-debug.apk

Known quirks / verification notes

  • Verified for real on 2026-10-15 in $TMPDIR/iru-verify/kmp/iru-setup-kmp-app/compose and .../native (outside this repository), both with sonar: cloud (dummy coordinates — the sonar task itself was never run): ./gradlew build, koverXmlReport koverVerify detekt ktlintCheck :androidApp:lint :shared:lint :shared:dokkaGenerate :androidApp:assembleDebug :androidApp:assembleRelease (unsigned — no SIGNING_* vars), :shared:allTests, :shared:linkDebugFrameworkIosSimulatorArm64, xcodegen generate (2.46.0, downloaded to a temp directory — not installed), swiftlint lint --strict (0.65.1, same), and xcodebuild test on an iPhone 18 Pro (iOS 27.0) simulator: 2 XCTest cases passed per variant. Root Kover line coverage 100% for both variants (compose: 27/27 lines, 7 classes incl. AppKt executed through the Robolectric MainActivity test). Before the exclusions were repeated in shared's own kover {} block, :shared:koverPrintCoverage alone reported 44% for ui: compose (the composables are only executed by the app's Robolectric test) — keep the module-level filters. The native variant was run on JDK 17, compose on JDK 21.
  • compileSdk must be ≥ 37 with this Compose Multiplatform/BOM generation: at 36, checkDebugAarMetadata and :shared:checkAndroidMainAarMetadata fail ("Dependency 'androidx.compose.animation:animation-core-android:1.12.1' requires … version 37 or later"). Resolve compileSdk from the AAR metadata, not from habit.
  • Robolectric 4.17 + SDK 36 fails on JDK 21 (Failed to interact with raw FileDescriptor internals) — the test is pinned to sdk=35 in robolectric.properties, independent of compileSdk/targetSdk.
  • org.jetbrains.compose.ui.tooling.preview.Preview no longer exists in CMP 1.12 — the common @Preview is androidx.compose.ui.tooling.preview.Preview (from org.jetbrains.compose.ui:ui-tooling-preview).
  • detekt MatchingDeclarationName fails on the wizard's Platform.android.kt/Platform.ios.kt naming — the actuals live in AndroidPlatform.kt/IOSPlatform.kt instead.
  • ktlint chain-method-continuation (ktlint_official) flags libs.versions.android.compileSdk.get().toInt() in build scripts — .editorconfig disables that one rule for *.gradle.kts only.
  • AGP 9 lint DSL: lint { xmlReport/sarifReport } are deprecated no-ops (reports are always written) — omitted.
  • The AGP KMP library plugin registers no lint task; com.android.lint applied to shared adds :shared:lint.
  • kotlin { android { withHostTest {} } } is the verified form (the coding agent also confirmed the withHostTestBuilder {} spelling); androidHostTest is the source set either way. A Compose UI test (runComposeUiTest) in commonTest NPEs on the Android host test without Robolectric — which is why ui: compose runs its host tests under Robolectric through the UiTest base class (see the UI contract wiring note below).
  • Kotlin default arguments are invisible from Swift — Greeting has an explicit no-arg constructor and GreetingUiState.Companion an initial() overload, so Swift calls Greeting() / GreetingUiState.companion.initial().
  • XcodeGen/TEST_HOST: renaming the app's PRODUCT_NAME (e.g. to $(APP_NAME)) breaks iosAppTests ("Could not find test host … iosApp.app/iosApp"); the product stays iosApp.app and the launcher label comes from CFBundleDisplayName. ENABLE_USER_SCRIPT_SANDBOXING = NO is required for the Gradle pre-build phase. The test target only gets FRAMEWORK_SEARCH_PATHS (no -framework Shared) — the static framework is already linked into the host app.
  • iOS deployment target 18.0 produced ld: warning: … built for newer 'iOS-simulator' version (18.5) from the Compose/Skia objects — 18.5 links clean.
  • xcodebuild -destination 'platform=iOS Simulator,name=iPhone 16 Pro' failed although that device exists — only on the iOS 18.5 runtime; without OS= the newest runtime is assumed. Pick the device from the newest runtime.
  • xcodebuild build -destination 'generic/platform=iOS Simulator' fails in the Gradle pre-build phase with "Xcode Requested Architecture Not Configured in Gradle … ios_x64" — a generic simulator build asks for x86_64 too. Add ARCHS=arm64 (verified), use a concrete simulator destination, or add an iosX64() target for Intel Macs.
  • SwiftLint: unused_import belongs under analyzer_rules; sorted_imports sorts @testable import iosApp first.
  • UI contract wiring verified on 2026-10-16 in $TMPDIR/iru-verify/kmp/ui-contract/scaffold-{compose,native} (Robolectric 4.17, androidx.compose.ui:ui-test-manifest 1.12.1, Xcode 27.0, iPhone 18 Pro on iOS 27.0): ui: compose — GreetingScreenTest in commonTest (androidx.compose.ui.test.v2.runComposeUiTest, extending the UiTest base) passed under testAndroidHostTest and iosSimulatorArm64Test and wrote 12 --android + 12 --ios PNGs; ./gradlew build koverVerify detekt ktlintCheck :shared:dokkaGenerate stayed green and Kover stayed at 100% (27/27 lines). ui: native — the Robolectric GreetingScreenTest wrote 12 --android PNGs, xcodebuild test ran the ImageRenderer test and wrote 12 --ios PNGs (to TEST_RUNNER_UI_SCREENSHOTS_DIR, and to <repo>/.ui-screenshots through #filePath without it), swiftlint lint --strict clean, Kover 100%. Findings baked into the templates: runComposeUiTest without Robolectric NPEs on the Android host; setContent may be called only once per test on Android (the matrix drives a mutableStateOf); the simulator only sees SIMCTL_CHILD_-prefixed variables; -Xexpect-actual-classes silences the Beta warning for expect abstract class UiTest; Material's Button is 40dp tall but its touch bounds are 48dp (the check uses touchBoundsInRoot); ATF's enableAccessibilityChecks() exists only on ComposeTestRule and reported nothing under Robolectric, so the host tests use the semantics check instead. Not verified: an iOS accessibility audit for ui: native (needs an XCUITest target the scaffold doesn't add).
  • Unverified locally, never a blocker: the sonar task (needs a token and project), signed release builds, device/archive builds (need a Team ID and certificates), and anything distribution-related.

Finish with a Warn explicitly block:

  • Review every generated file before building, committing or distributing; replace the placeholder launcher icon and Greeting sample with real code.
  • Run iru-setup-kmp-gitignore — iosApp/iosApp.xcodeproj, local.properties, .kotlin/, keystores and .p8 keys must never be committed. Every clone runs cd iosApp && xcodegen generate before opening Xcode.
  • Never commit a keystore; release signing reads the four SIGNING_* env vars (CI decodes them from secrets).
  • With sonar ≠ none, a SONAR_TOKEN secret and the Sonar project must exist before ./gradlew sonar works.