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:
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:
- The block that unlocks the task. Writing
ios { rewrite { } }arms the Xcode tasks, and the app icon task needs alogo { }block as well. - An explicitly named task that you run yourself.
- 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¶
README The full guide: DSL reference, the task table, compatibility, and the current limits.
API reference Every extension property and task type, generated from source.
FEATURES.md Behavior reference: what each switch does, and every default value.
Changelog Release history, including the 2.x to 3.0 migration.
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:
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:
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:
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.