A Gradle plugin for Kotlin Multiplatform projects. You write your app's values once, in one block, in the root build file: app name, app id, version, version code, build number, locales, app logo, splash screen, generated build config, SDK levels, JVM level. KiteConfig applies them to Android, iOS, and Compose Desktop for you. Without it, each platform keeps its own copy of these values, and the copies slowly stop matching.
Documentation has the setup guide and the generated API reference.
The whole DSL works by three simple rules:
-
Every value you write is applied automatically. On every build, to every platform that exists in your project. It is applied in memory, or as generated files under
build/. Writing the value is all you have to do. To stop one value from reaching one platform, putskip(platform)next to it.only(platform)is the allowlist version of the same thing. -
KiteConfig never edits your project files by itself. A normal build (
assemble,run, anything) only sets values in memory and writes into thebuild/folder. Files likeproject.pbxproj,Info.plist,Podfile, and your Androidres/stay untouched.Editing those files takes two separate steps, and you do both:
- Write a
rewrite { }block in the DSL. This only gives permission. Nothing happens yet. - Run the matching task yourself:
./gradlew kiteRewriteXcodeor./gradlew kiteRewriteLogo.
Skip either step and no file is touched. Ever. And when a rewrite does run:
dryRun = trueprints the planned changes instead of writing them,backups = truesaves a copy of each file first, andonConflictdecides what happens when a file already holds a different value.If you hate running tasks,
rewrite { auto = true }makes the rewrite run before ordinary builds. It needs the@OptIn(DiscouragedKiteApi)line, because builds then edit committed files and clean checkouts build dirty.kiteDoctorreminds you it is on. - Write a
-
Everything about one thing is in one place. All version settings live in
version { }. All id settings live inid { }. Platform details go inside the topic (likeversion { android { ... } }), not the other way around.
Apply the plugin to the root project only:
plugins {
id("io.github.yuroyami.kiteconfig") version "1.0.0"
}Three lines are a complete setup. Locales auto-detect from Compose resources, the shared module and the app modules auto-detect too:
kiteConfig {
appName = "Jetzy"
version = "1.4.0"
id = "com.example.jetzy"
}Everything else below is optional, and every value stays a lazy Gradle
Property you can wire into your own build logic.
Every property, function, corner, and modifier that exists, in one block.
kiteConfig {
/**
* THE THREE RULES
* 1. Every value here is applied automatically, on every build,
* to every platform found. skip()/only() stop it per platform.
* 2. Your project files are edited only when BOTH happened:
* you wrote rewrite { }, and you ran the kiteRewrite* task.
* 3. Everything about one thing lives in one block.
*/
/** APP NAME. Simple form: appName = "Jetzy". Detailed form below. */
appName("Jetzy") {
/** Platform value overrides: that platform shows its own name. */
android("Jetzy Droid")
ios("Jetzy Lite")
desktop("Jetzy Desk")
/** Flow control, available in every topic the same way. */
skip(ios)
only(android, desktop)
}
/** Java and Kotlin JVM level for the whole build. No modifiers. */
jvmTarget = 21
/** IDENTITY. Simple form: id = "com.example.jetzy". */
id("com.example.jetzy") {
/** Corners: suffix is appended to the base per platform. */
android { suffix = ".android" } /** applicationId = base + suffix */
ios { suffix = ".ios" } /** bundle id = base + suffix */
desktop { suffix = ".desktop" }
/** One-liner corner style works too. */
android.suffix = ".android"
skip(desktop)
only(android)
}
/** VERSION. Simple form: version = "1.4.0". */
version("1.4.0") {
/** One formula turns the version into every store's build number. */
formula { v -> 1_000_000 * v.major + 10_000 * v.minor + 100 * v.patch + v.reupload }
android {
reupload = 1 /** re-upload counter, feeds the formula */
shipped = 1001003090 /** guard floor: new codes must beat it */
pin = 123 /** hard versionCode, formula skipped */
formula { v -> 1 } /** platform-only formula override */
}
ios {
reupload = 1
shipped = "1001003090"
pin = "42" /** hard buildNumber */
marketingVersion = "1.4.0" /** shown version, defaults to base */
formula { v -> 1 }
}
desktop {
reupload = 1
shipped = "1001003090"
pin = "42"
formula { v -> 1 }
}
skip(desktop)
only(android, ios)
}
/** LOCALES. Omit the whole block to auto-detect from Compose resources. */
locales {
pin("en", "ar", "fr") /** hand list, detection skipped */
filterAndroidRes = true /** drop Android res outside the list */
skip(ios) /** knownRegions untouched */
only(android)
}
/** LOGO. Art is a fact; declaring it alone never touches your source. */
logo {
foreground = file("art/logo-fg.png")
background = file("art/logo-bg.png")
backgroundColor = "#0B0B0F" /** set this or background, never both */
android { safeZone = 0.611 } /** adaptive-icon safe-zone ratio */
desktop { roundMac = true } /** round the generated macOS icon */
/**
* Presence + a desktop app found = installer icons flow into build/
* and get packaged. skip(desktop) stops exactly that.
*/
skip(desktop)
only(android, ios)
/**
* Arms kiteRewriteLogo: writes Android res + the iOS asset catalog.
* replaceOld also claims and removes legacy launcher icons (backed up).
* auto = true runs it before Android app builds (discouraged, @OptIn).
*/
rewrite { replaceOld = true }
}
/** SPLASH. Empty block already works: art defaults to logo. */
splash {
image = file("art/splash.png") /** default: logo.foreground */
backgroundColor = "#101014" /** default: logo.backgroundColor */
/** Optional dark-mode variant. Unset members fall back to light. */
dark {
image = file("art/splash-dark.png")
backgroundColor = "#000000"
}
/**
* Android needs two one-time things, both checked by kiteDoctor:
* the theme corner below, and one Manifest line:
* android:theme="${kiteSplashTheme}". The generated KiteSplash style
* inherits your theme and only adds attributes on Android 12+.
*/
android { theme = "AppTheme" }
/**
* Presence = Android splash res into build/ + desktop JVM -splash
* image, packaged. iOS is a source edit, so it needs this rewrite
* AND ios { rewrite { } }, and runs with kiteRewriteXcode.
*/
rewrite { }
skip(desktop)
}
/** Kotlin/Native interop opt-in markers, selected by project. */
optIns {
add("kotlinx.cinterop.ExperimentalForeignApi")
projects(":shared", ":composeApp") /** default: all detected */
builtIns = true /** include the built-in marker list */
}
/** PLATFORM BLOCKS: only platform-exclusive facts live here. */
android {
sdk(min = 26, target = 36, compile = 36) /** any subset of the three */
ndk = "27.1.12297006"
}
ios {
deploymentTarget = "15.0"
/** Paths: set only when detection guesses wrong. */
pbxproj = file("iosApp/iosApp.xcodeproj/project.pbxproj")
podfile = file("iosApp/Podfile")
infoPlist = file("iosApp/iosApp/Info.plist")
appDirectory = file("iosApp")
appIconDirectory = file("iosApp/iosApp/Assets.xcassets/AppIcon.appiconset")
/** Arms kiteRewriteXcode: pbxproj, Info.plist, Podfile, Swift imports. */
rewrite {
targets("iosApp") /** pbxproj application target names */
cleanPlist = true /** maintain KiteConfig keys in the source plist */
onConflict = io.github.yuroyami.kiteconfig.PlistConflictPolicy.FAIL /** FAIL | KEEP | REPLACE */
nonExemptEncryption = false /** ITSAppUsesNonExemptEncryption */
proMotion = true /** CADisableMinimumFrameDurationOnPhone */
renameSharedModule(from = "shared", to = "Shared")
}
}
desktop {
linuxPackageName = "jetzy"
deriveUpgradeUuid = true /** stable Windows MSI upgrade id from id */
}
web {
/** Presence generates the browser IO worker source into build/. */
ioWorker {
targets("js") /** browser Kotlin/JS targets */
projects(":composeApp") /** default: all web-capable */
packageName = "kiteconfig.generated"
}
}
/** BUILD CONFIG. Presence generates a Kotlin object into commonMain. */
buildConfig {
packageName = "com.example.jetzy"
className = "AppInfo"
includeIdentity = true /** bake appName/id/version/locales in */
allowBuildCache = false /** opt out when fields are volatile */
stringField("API_HOST", "api.jetzy.app")
intField("MAX_RETRIES", 3)
longField("BUILT_AT", 0L)
booleanField("STAGING", false)
doubleField("PI_ISH", 3.14)
}
/** MASTER FLOW CONTROL: same words as everywhere, at the root. */
skip(desktop) /** platform receives NOTHING at all */
only(android, ios) /** allowlist form of the same thing */
/** PLUMBING: only when auto-detection picks wrong. */
modules {
shared = ":shared" /** the umbrella KMP module */
androidApps(":androidApp")
desktopApps(":desktopApp")
androidAppDirectory = file("androidApp")
composeResources = file("shared/src/commonMain/composeResources")
}
/** SAFETY. Both have CLI twins that win for one invocation. */
dryRun = false /** armed rewrites print, write nothing */
backups = true /** recovery copy before any rewrite */
/**
* Run on AGP/KGP/Compose outside the tested range: hard guards become
* one loud warning. Needs a forced opt-in in the build script:
* @file:OptIn(io.github.yuroyami.kiteconfig.DiscouragedKiteApi::class)
*/
/** ignoreVersionGuards = true */
}Everything KiteConfig resolves is readable from any build file. See Reading values back.
CLI overrides, per invocation, beat the build file: -Pkiteconfig.dryRun=true,
-Pkiteconfig.backups=false.
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:
kiteConfig.version.get() // declared -> the value
// not declared -> throwsThat is on purpose. Reading a value you never declared is a mistake in the build
file, and 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.
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)Capture the provider inside the task configuration block, not at script level:
tasks.register("printId") {
val appId = kiteConfig.androidApplicationId // inside the block
doLast { println(appId.get()) }
}A script-level val makes the doLast lambda capture the build script object,
which the configuration cache cannot serialize. This is a general Gradle rule,
not specific to KiteConfig, but it is the first thing people hit.
Configure the plugin in the root build file and read it everywhere else. The view is read-only, and the model is frozen before subprojects are evaluated, so what you read is what the build uses.
Reading across projects means this is not compatible with Gradle Isolated Projects. Neither is the rest of the plugin.
All in the kiteconfig group. Nothing attaches to build or check, so the
source-editing tasks run only when you name them.
| Task | Writes | What it does |
|---|---|---|
kiteVerify |
nothing | Prints the resolved model |
kiteDoctor |
nothing | Diagnoses the setup; never fails the build |
kitePlan |
nothing | Lists what the armed rewrites would change, with exact paths |
kiteCheck |
build/ |
Same checks as doctor, writes JSON or SARIF, fails on errors |
kiteRewriteLogo |
source | Installs the logo into Android res and the iOS asset catalog |
kiteRewriteXcode |
source | Applies Xcode build settings, plist, Podfile, Swift, and the iOS splash |
kiteInternalBuildConfig, kiteInternalIoWorker*, kiteInternalDesktopIcons, kiteInternal*Splash |
build/ |
Generators, wired automatically |
kiteInternalAndroidLogo, kiteInternalIosLogo, kiteInternalIosConfig, kiteInternalPlistClean, kiteInternalLegacyIconCleanup |
source | The installers behind the two kiteRewrite* umbrellas; they run only when you name one |
Run ./gradlew kitePlan before any rewrite. It shows the full mutation plan
and writes nothing.
- When is a value applied? Always. Every build, every platform in your
project, unless you wrote
skip()next to it. That is the whole answer. - Does opening a block do something by itself? Writing a value applies
it (rule 1). Nothing touches your own files unless you wrote
rewrite { }AND ran the task (rule 2). - What does dryRun cover? Only the
kiteRewrite*tasks. Files generated intobuild/ignore it, because your build needs them and they are safe to delete. - I set up iOS values, why did nothing change? iOS changes edit your
files, so rule 2 applies: add
ios { rewrite { } }(andsplash { rewrite { } }for the splash), then run./gradlew kiteRewriteXcode. - Why did my desktop app get icons I never asked for? You wrote
logo { }and a desktop app exists, so the icons were applied (rule 1). Writelogo { skip(desktop) }to stop that. version = "1.4.0"orversion("1.4.0") { }? Both set the same value. Use the short one when you have no details. If you use both, the last one wins andkiteDoctorwarns you.- My AGP/KGP version is unsupported. Now what? The features that need
deep AGP/KGP access turn off and tell you why.
ignoreVersionGuards = true(with its required@OptInline) keeps them on. If it breaks, that is the risk you chose.
Apache 2.0. See LICENSE.
This plugin has had three names. It shipped as kmp-ssot at 0.1.0, became
KiteSSOT, and is now KiteConfig. The KTCNFG diagnostic prefix replaced an
older KMPS prefix left over from the first name.
The version resets to 1.0.0 here because the DSL surface has reached a stable form that is not planned to change. Earlier version numbers belong to the older names, and their published artifacts stay where they are.