Skip to content

KiteConfig

One place to declare a Kotlin Multiplatform app's identity: name, version, bundle ID, locales and SDK levels. KiteConfig propagates it to the Android, Xcode and Kotlin files that each normally keep their own copy.

A typical Kotlin Multiplatform repo records its app name and version in four places:

  • the Android module's defaultConfig
  • the Xcode project's build settings
  • the source Info.plist
  • whatever constant the shared Kotlin code reads

Nothing keeps those four copies in agreement, so eventually they stop matching.

The shortest setup that does something

In the root build.gradle.kts:

plugins {
    kotlin("multiplatform") version "2.4.10" apply false
    id("com.android.application") version "9.3.1" apply false
    id("io.github.yuroyami.kiteconfig") version "1.0.0"
}

kiteConfig {
    appName = "Jetzy"
    version = "1.4.0"
    id = "com.example.jetzy"

    android {
        compileSdk = 36
        minSdk = 26
        targetSdk = 36
    }
}

Then:

./gradlew kiteVerify

That prints the resolved model and writes nothing. Run it again whenever a value is not where you expected it.

Two required preconditions

The plugin goes on the root project. Applying it in a submodule throws immediately, because it aggregates across allprojects from the root.

Add apply false to the Kotlin and Android plugin lines. KiteConfig integrates with typed classes from KGP (the Kotlin Gradle plugin) and AGP (the Android Gradle plugin). Those integrations run only when KiteConfig can load the plugin classes from its own classloader. Declare kotlin("multiplatform") only inside a subproject and Gradle loads KGP with a different classloader. KiteConfig cannot read the plugin classes from there.

Two tiers of switch

KiteConfig splits its work into two tiers.

Gradle configuration is automatic and continuous. On every build, KiteConfig applies the Android identity and SDK levels, aligns the Java and Kotlin JVM targets, and generates Kotlin under build/. This happens inside AGP's finalizeDsl hook, which runs after a module's own android { } block. A value set in kiteConfig { } therefore replaces the same value set in the module. Set the value. Nothing else is needed.

Edits to files you own are opt-in and manual. project.pbxproj, Info.plist, Podfile, Swift imports and launcher icons are yours. Editing them takes three things:

  1. The block that unlocks the task. Writing ios { rewrite { } } arms the Xcode tasks, and the app icon task needs a logo { } block as well.
  2. An explicitly named task that you run yourself.
  3. A set of containment, ownership, checksum, backup and rollback checks, which must all pass first.

The block is the switch. There is no separate = true flag: an empty logo { } counts as on, and leaving the block out counts as off.

This surprises people. Adding logo { } installs nothing. It unlocks kiteRewriteLogo only when you also add logo { rewrite { } }, and you then run that task yourself. That much covers the Android launcher icons. The Apple AppIcon half additionally needs an ios { rewrite { } } block and ios { deploymentTarget }. A plain ./gradlew build never writes outside build/, and CI asserts that on every commit.

Run ./gradlew kitePlan before you run any mutating task. It lists which mutations your current configuration authorizes, and the exact paths they would change. Set dryRun = true to make the mutating tasks report without writing.

Where things live

Reading values back

Everything KiteConfig resolves is readable from any build file in the project, not just the root. One import, then use it:

import io.github.yuroyami.kiteconfig.kiteConfig

android {
    defaultConfig {
        versionCode = kiteConfig.versionCode.get()
    }
}

Eighteen values are available.

Group Values
Version version, versionCode, iosBuildNumber, iosMarketingVersion, desktopBuildNumber
Identity appName, appNameFor(platform), id, androidApplicationId, iosBundleId, desktopBundleId
Build canonicalLocales, jvmTarget, resolvedSharedProjectPath, minSdk, targetSdk, compileSdk, ndk

Every one is a lazy Provider, so wiring one into another task's property costs nothing at configuration time:

someOtherTask.someProperty.set(kiteConfig.androidApplicationId)

These accessors supply no defaults and never return null. A value the root build file never set has no value at all, and reading it stops the build. That is on purpose: quietly falling back to something like ?: 24 would put a second copy of that number in the consumer, which is the duplication this plugin exists to remove.

Reading across projects means this is not compatible with Gradle Isolated Projects. Neither is the rest of the plugin.

When values resolve

Most values are settled before any subproject build file runs, so reading them eagerly during configuration is safe:

versionCode = kiteConfig.versionCode.get()

Two resolve later, because they depend on inspecting every project first:

Value Reading it eagerly
canonicalLocales returns an empty list, unless the list is pinned
resolvedSharedProjectPath has no value, unless modules { shared } is declared

Neither affects what the build itself uses; the cost is only to the caller that asked too early. Wire them into a task instead and let them resolve at execution time:

someTask.localeList.set(kiteConfig.canonicalLocales)

Compatibility

Gradle 8.5 and newer, AGP 8.5.2 through 9.3.x, KGP 2.4.x, on a JDK 17 or 21 daemon.

A dedicated agpCompatibilityTest builds real consumer projects on Gradle 8.5, 8.9 and 9.5.1, against AGP 8.5.2 and 9.3.1. CI runs on Linux with JDK 17 and 21, on macOS with JDK 21, and on Windows with JDK 21. Each build runs twice. CI then checks that the second run reuses the configuration cache entry, and that no tracked file changed.