14 KiB
Splitting a KMP + Android Application Module for AGP 9.0
AGP 9.0 does not support com.android.application combined with org.jetbrains.kotlin.multiplatform in the same module. You must split the monolithic composeApp module into a pure Android application module and a KMP shared library module.
Old Structure (AGP 8.x)
composeApp/
build.gradle.kts # com.android.application + kotlin.multiplatform
src/
commonMain/kotlin/ # Shared KMP code
androidMain/kotlin/ # Android-specific code + MainActivity
androidMain/res/ # Android resources
androidMain/AndroidManifest.xml
iosMain/kotlin/ # iOS-specific code
desktopMain/kotlin/ # Desktop entry point (optional)
Single composeApp/build.gradle.kts:
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions { jvmTarget = "11" }
}
}
iosX64()
iosArm64()
iosSimulatorArm64()
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(libs.kotlinx.coroutines.core)
}
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.compose.ui.tooling.preview)
}
}
}
android {
namespace = "com.example.app"
compileSdk = 34
defaultConfig {
applicationId = "com.example.app"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildFeatures { compose = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
debugImplementation(libs.compose.ui.tooling)
}
New Structure (AGP 9.x)
shared/
build.gradle.kts # kotlin.multiplatform + com.android.kotlin.multiplatform.library
src/
commonMain/kotlin/ # All shared KMP code
androidMain/kotlin/ # Android-specific implementations (expect/actual)
androidMain/res/ # Shared Android resources (if any)
iosMain/kotlin/ # iOS-specific code
androidApp/
build.gradle.kts # com.android.application ONLY (no kotlin.multiplatform)
src/
main/kotlin/ # MainActivity, Application class
main/res/ # App-level resources (launcher icons, themes, etc.)
main/AndroidManifest.xml # Full manifest with <application> and <activity>
iosApp/ # Unchanged
androidApp/build.gradle.kts
Important: In AGP 9.0, the com.android.application plugin has Kotlin support built in. Do NOT apply org.jetbrains.kotlin.android separately -- it will conflict.
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeCompiler)
}
android {
namespace = "com.example.app"
compileSdk = 35
defaultConfig {
applicationId = "com.example.app"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
dependencies {
implementation(project(":shared"))
implementation(libs.androidx.activity.compose)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
}
Key Points for androidApp
- No
kotlin.multiplatformplugin. This is a pure Android application module. - No
kotlin-androidplugin. AGP 9.0'scom.android.applicationplugin bundles Kotlin compilation. Applyingorg.jetbrains.kotlin.androidwill cause a conflict error. buildTypesandproductFlavorswork here. The application plugin still supports full variant configuration.- Compose compiler plugin is applied separately (
composeCompiler), or it can come from KGP 2.0+ if you use the compose compiler Gradle plugin. - Depends on
:sharedto access all shared KMP code.
shared/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKmpLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
android {
namespace = "com.example.shared"
compileSdk = 35
minSdk = 24
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
androidResources { enable = true }
}
iosX64()
iosArm64()
iosSimulatorArm64()
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(libs.kotlinx.coroutines.core)
}
androidMain.dependencies {
// Android-specific shared dependencies only
}
}
}
Key Points for shared
- Plugin is
com.android.kotlin.multiplatform.library, notcom.android.library. - Namespace must differ from androidApp. Use
com.example.sharedvscom.example.app. - No
applicationId,versionCode,versionName,targetSdk. These are application-only concepts. - No
buildTypesorproductFlavors. The KMP library plugin produces a single variant. - Framework exports (
binaries.framework) stay here since iOS depends on the shared module. androidTarget {}is replaced withandroid {}.
settings.gradle.kts Changes
Before
rootProject.name = "MyProject"
include(":composeApp")
include(":iosApp") // if present as a Gradle module
After
rootProject.name = "MyProject"
include(":shared")
include(":androidApp")
include(":iosApp")
Root build.gradle.kts Changes
With Version Catalog
Before:
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
}
After:
plugins {
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidKmpLibrary) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
}
Without Version Catalog
Before:
plugins {
id("com.android.application") version "8.7.3" apply false
id("com.android.library") version "8.7.3" apply false
id("org.jetbrains.kotlin.multiplatform") version "2.1.0" apply false
id("org.jetbrains.compose") version "1.7.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
}
After:
plugins {
id("com.android.application") version "9.0.1" apply false
id("com.android.kotlin.multiplatform.library") version "9.0.1" apply false
id("org.jetbrains.kotlin.multiplatform") version "2.3.20" apply false
id("org.jetbrains.compose") version "1.10.3" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false
}
Note: com.android.library is replaced with com.android.kotlin.multiplatform.library. If you still have pure Android library modules (non-KMP), you can keep com.android.library as well.
What to Move to androidApp
These items are Android application concerns and must move out of the shared KMP module:
1. MainActivity (and any other Activities)
composeApp/src/androidMain/kotlin/com/example/app/MainActivity.kt
--> androidApp/src/main/kotlin/com/example/app/MainActivity.kt
Update MainActivity to call into shared code:
// androidApp/src/main/kotlin/com/example/app/MainActivity.kt
package com.example.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import com.example.shared.App // Import from shared module
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App() // Shared composable
}
}
}
2. AndroidManifest.xml
The full application manifest with <application>, <activity>, <intent-filter> moves to androidApp:
composeApp/src/androidMain/AndroidManifest.xml
--> androidApp/src/main/AndroidManifest.xml
Important: After moving the manifest, verify the android:name attribute on <activity> points to the correct Activity class in its new location. If the old manifest relied on a default or short class name, you may need to use the fully qualified name:
<activity
android:name="com.example.app.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
The shared module may still have a minimal manifest (auto-generated or containing just <manifest> with no <application>).
3. Application Class (if any)
composeApp/src/androidMain/kotlin/.../MyApplication.kt
--> androidApp/src/main/kotlin/.../MyApplication.kt
4. App-Level Resources
- Launcher icons (
mipmap-*) - App theme definitions that reference
applicationId - Splash screen resources
- Navigation graphs (if not shared)
composeApp/src/androidMain/res/mipmap-*/
composeApp/src/androidMain/res/values/themes.xml (app-level theme)
--> androidApp/src/main/res/
5. ProGuard Rules
composeApp/proguard-rules.pro
--> androidApp/proguard-rules.pro
What Stays in shared
- All
commonMaincode (ViewModels, repositories, models, shared composables) - All
expect/actualdeclarations - All
iosMain,desktopMain,wasmJsMaincode - Framework export configuration (
binaries.framework) - Shared Android resources (strings, drawables used by shared composables)
- Shared Android-specific implementations (
actualfunctions)
Namespace Requirements
The namespace for each module must be unique:
// shared/build.gradle.kts
kotlin {
android {
namespace = "com.example.shared"
}
}
// androidApp/build.gradle.kts
android {
namespace = "com.example.app"
}
If they collide, you will get duplicate R class errors at compile time. The applicationId (in androidApp only) can be different from both namespaces.
Run Configuration Updates
Android Studio
After the split, the run configuration for the Android app must point to :androidApp instead of :composeApp:
- Edit Run Configurations
- Change Module to
androidApp - Ensure the launch activity is
com.example.app.MainActivity
Xcode (iOS)
If the shared module was renamed from composeApp to shared:
-
Update
baseNameinshared/build.gradle.ktsto match the new module name:listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { it.binaries.framework { baseName = "Shared" // was "ComposeApp" isStatic = true } } -
Update the Run Script build phase in
project.pbxproj(or via Xcode > Build Phases > Run Script) to reference the new module:# Old cd "$SRCROOT/.." ./gradlew :composeApp:embedAndSignAppleFrameworkForXcode # New cd "$SRCROOT/.." ./gradlew :shared:embedAndSignAppleFrameworkForXcode -
Update Swift imports — in all
.swiftfiles, change the framework import to matchbaseName:// Old import ComposeApp // New import Shared -
Update the app struct name if it was tied to the old module name. The
@mainstruct name in your SwiftUI app entry point is independent of the framework name, but if it referenced the old name, rename it:// Example: rename if it was called ComposeAppApp or similar @main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() } } } -
Update framework search paths in Xcode Build Settings if they reference the old module directory path.
Quick Checklist
- Create
androidApp/directory withbuild.gradle.kts - Move
MainActivityandApplicationclass toandroidApp - Move
AndroidManifest.xml(full manifest) toandroidApp - Move app-level resources (launcher icons, app theme) to
androidApp - Move ProGuard rules to
androidApp - Convert
composeApptosharedwith KMP library plugin - Remove application-only config (
applicationId,versionCode,buildTypes) from shared - Add
implementation(project(":shared"))to androidApp dependencies - Update
settings.gradle.ktsincludes - Update root
build.gradle.ktsplugin declarations - Ensure namespaces are different between modules
- Update Android Studio run configuration
- Update Xcode project if iOS target exists
- Run
./gradlew :androidApp:assembleDebugand./gradlew :shared:assembleto verify