The generated screen ran flawlessly in the emulator. The trouble started the moment I copied its folder into the module of my wallpaper app. ./gradlew assembleRelease passed, but the debug build alone died with Unsupported class file major version. It took me half a day to reach the cause, and the culprit was neither my code nor the generated code — it was the AGP and Kotlin versions declared inside the build.gradle.kts that AI Studio had shipped along with the screen.
Now that AI Studio can generate a Kotlin and Jetpack Compose app from text and carry it from emulator run to physical-device transfer to a Google Play internal test track on a single screen, the distance between prototyping and shipping has shrunk considerably for solo developers. But the output is only coherent as a new app. It is not built on the assumption that you will mix it into an app that already runs. That is where the quiet trap lives.
This article covers a design that prevents the version drift you hit when importing generated Compose into an existing app: pin a version catalog as the single source of truth, and add a gate that mechanically inspects the generated declarations at the import boundary. I will center it on a failure I hit running my own Android wallpaper apps, and the concrete setup that stopped it.
Why "code that ran" breaks inside an existing app
AI Studio output declares dependencies pinned to whatever was the latest stable at generation time. A long-running app, on the other hand, carries its own version constellation that you raised slowly while verifying compatibility. Merge the two naively and Gradle, during resolution, biases toward the newer side — so a version you were deliberately holding back gets silently bumped the moment you import.
Here is how that broke for me, organized by blast radius.
| Drifted element | What the generated side tends to declare | What happened in the existing app |
|---|---|---|
| AGP (Android Gradle Plugin) | Latest major (e.g. 9.x line) | NoClassDefFoundError in an older library using Java 8 APIs; desugaring became a prerequisite |
| Kotlin / Compose compiler | Latest stable | Drift from the Compose-compiler-to-Kotlin matrix; stopped with Compose Compiler requires Kotlin version |
| Core libraries (image, DI, etc.) | Latest | API signature changes broke compilation on the existing screens |
| minSdk / targetSdk | Generation-time recommendation | minSdk rose, dropping older devices that had been in the supported set |
The AGP bump was the nastiest. My app's image library internally referenced Java 8's Supplier, and after the AGP major update, devices on the Android 6.0.1 band started crashing right at launch. The fix itself was one line — enable coreLibraryDesugaringEnabled and add the desugaring library — but the real problem was that the causal chain "mixing in generated code raised my crash rate" was nearly invisible. I wrote up the triage steps for this desugaring behavior in the record of stopping Java 8-derived crashes on old devices with desugaring.
This is where the idea of a single source of truth earns its keep. Instead of scattering versions across each module's build.gradle.kts, concentrate them in one place — the gradle/libs.versions.toml version catalog — and any version a generated artifact declares on its own must be reconciled against the catalog at import time.
Pin the version catalog as the single source of truth
First, move the whole project's versions into the version catalog. The starting point is to eliminate the inline declarations the output writes, like implementation("androidx.compose...:1.x.x"), and route everything through catalog references.
# gradle/libs.versions.toml — the only place versions are declared in the project
[versions]
agp = "9.0.1" # hold here. don't bump even if the output declares 9.1.x
kotlin = "2.2.20"
composeCompiler = "2.2.20" # match kotlin (Kotlin 2.x needs no composeOptions, but manage explicitly)
composeBom = "2026.05.01"
coreDesugar = "2.1.5" # guard against Java 8-derived crashes after the AGP major update
minSdk = "23" # Android 6.0. keep it even if the output raises it
targetSdk = "36"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "coreDesugar" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }Then the modules declare no versions at all and reference only the catalog aliases. Compose aligns its versions through the BOM, so the key is to attach no version to the individual libraries.
// app/build.gradle.kts — write no versions. use only catalog aliases
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
compileSdk = libs.versions.targetSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
}
compileOptions {
// keep desugaring always on so old devices don't fall over after an AGP major bump
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
val composeBom = platform(libs.compose.bom)
implementation(composeBom)
implementation(libs.compose.ui)
implementation(libs.compose.material3)
coreLibraryDesugaring(libs.desugar.jdk.libs) // always pair this with the TOML entry above
}Now "the version, seen from any module, lives in one place in the catalog." When you mix a generated artifact into this project, any version it inline-declared does not flow through the catalog as-is, so you can detect it at the import boundary.