
I recently finished migrating my Gradle scripts from Groovy to Kotlin DSL on my current project. It took me a lot of time searching and investigating different approaches and solutions, so I decided to collect everything in one article to simplify life for everyone else who will face a similar task in the future.
if you are from Ukraine β here isΒ UA versionΒ for you!
In this article, I will consider my case β migrating an existing project from Gradle 7+ to a newer version with Gradle Kotlin DSL, Android Kotlin Plugin, Version Catalog, and KSP. As a result, I ended up with this setup:
Android Studio:Β Narwhal 2025.1.2
Gradle:Β 8.11.1
Android Gradle Plugin (AGP):Β 8.10.1
Kotlin:Β 2.1.21
Compose:Β 2.1.21
KSP:Β 2.1.21β2.0.1
Everything is the newest, but already stable enough to be used in production. So, if youβre interested in how to set all this up β letβs dive in!
As always, a little theory firstβ¦
First of all, you need to start with the main thing β the IDE. If you want to work with the latest Gradle features, you need to update to the latest version of Android Studio. Therefore, I highly recommend checking theΒ tableΒ that shows the compatible versions of Studio and Gradle.
Personally, I useΒ Narwhal 2025.1.2Β andΒ Koala 2024.1.2Β in parallel to work with old and new projects.
If you already need to update, donβt forget to check which version of the Android SDK is supported by your Studio and AGP, so you donβt have to do it twice. Youβll have to update yourΒ target-sdkΒ once a year anyway, so my advice is to check everything in advance so that your Studio, Gradle, and AGP already support the latest SDK version.
The next step is, in fact, the Gradle version. Here is aΒ linkΒ to the AGP and Gradle compatibility table. Everything is simple here β choose the appropriate Gradle version to build your project and add it toΒ gradle/gradle-wrapper.properties:
distributionUrl=https\://services.gradle.org/distributions/gradle-[VERSION]-bin.zip
The last thing you need to choose correctly is the Kotlin version. To be honest, I couldnβt find a corresponding table for this, so I chose almost at random: I set a version and tried to compile. I was lucky, so I conclude thatΒ Kotlin 2.1.21Β works withΒ AGP 8.10.
If you, the reader, know where to find the correspondence between Kotlin and Gradle, I would be very grateful for a comment. And if youβre also annoyed that thereβs still no online generator that correctly puts all this together and gives hints β hit a like!
So, we have the versions of all the necessary components: Android Studio, AGP, Gradle, Kotlin. What else do you need? The correct answer is β Compose!
Weβre very lucky that, starting from version 2.0, Compose has become a plugin that has the same version as Kotlin, so we donβt need to look for correspondences, because the plugin will do it itself internally! And choosing the KSP version is the easiest thing of all! Here is the KSP version pattern:Β 2.1.21β2.0.1, where the first part is the compatible Kotlin version, and the second is the KSP version itself. You can find this in the releases onΒ Github.
Moving on to practice
My project has the following modules: an Android application, an Android library, and several connected binary libs, which are also separated into individual modules for ease of connection and configuration.
I recommend starting from the project root and configuring theΒ version catalogΒ first. It should be created inΒ gradle/libs.versions.toml.
As an example, hereβs what I came up with:
[versions]
sdk-minimal = "26"
sdk-target = "34"
version-kotlin = "2.1.21"
version-ksp = "2.1.21-2.0.1"
version-agp = "8.10.1"
version-dagger = "2.55"
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.10.2" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version = "1.10.2" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.4.0" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "version-dagger" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "version-dagger" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.10.1" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.10.1" }
androidx-hilt-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
compose-bom = { module = "androidx.compose:compose-bom", version = "2025.05.01" }
compose-ui = { module = "androidx.compose.ui:ui"}
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-material = { module = "androidx.compose.material:material" }
lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version = "2.2.0" }
lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version = "2.6.1" }
lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version = "2.6.1" }
lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version = "2.6.1" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "version-room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "version-room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "version-room" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version = "33.15.0" }
firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx" }
firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx" }
firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" }
[plugins]
android-application-gradle-plugin = { id = "com.android.application", version.ref = "version-agp" }
android-library-gradle-plugin = { id = "com.android.library", version.ref = "version-agp" }
kotlin-android-gradle-plugin = { id = "org.jetbrains.kotlin.android", version.ref = "version-kotlin" }
kotlin-parcelize-gradle-plugin = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "version-kotlin" }
compose-compiler-gradle-plugin = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "version-kotlin" }
google-gms-services-gradle-plugin = { id = "com.google.gms.google-services", version = "4.3.15" }
crashlytics-gradle-plugin = { id = "com.google.firebase.crashlytics", version = "2.9.6" }
ksp-devtools-gradle-plugin = { id = "com.google.devtools.ksp", version.ref = "version-ksp" }
dagger-hilt-gradle-plugin = { id = "com.google.dagger.hilt.android", version.ref = "version-dagger" }
[bundles]
kotlinx = [
"kotlinx-coroutines-core",
"kotlinx-coroutines-android",
"kotlinx-collections-immutable",
]
androidx = [
"androidx-core-ktx",
"androidx-appcompat",
"androidx-activity-compose",
]
lifecycle = [
"lifecycle-runtime-ktx",
"lifecycle-viewmodel-ktx",
"lifecycle-viewmodel-compose",
"lifecycle-runtime-compose",
"lifecycle-viewmodel-navigation3",
]
Here are just the basics, and you might have a lot more different libraries, but the point is this: separate your dependencies and keep them together in a special catalog.
The next step is to connect the catalog and configure theΒ plugin/dependency resolution strategy. If your version catalog has a standard name and path, it will be picked up automatically, so all you have to do is specify the repositories for plugins and libraries. As an example, Iβll show myΒ settings.gradle.ktsΒ configuration:
pluginManagement {
repositories {
/// By default, google maven repository has many different libs,
/// plugins, SDKs and so on. We constraint it to take from gooogle
/// only those artifacts, which contains android.*, google.*, androidx.*
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
/// My recomendation: set this config to include
/// your modules with pre-generated accessors
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io")
}
}
/// list of your modules. Generally added by Android Studio
Best practices recommend failing project synchronization if modules have custom repositories:Β RepositoriesMode.FAIL_ON_PROJECT_REPOS. But in practice, it’s not always so categorical π I had to configure the build so that the repositories in the modules had priority over the repositories inΒ settings.gradle.kts.
The next step, I recommend configuringΒ build.gradle.kts, which is in the project root. In the simplest case, here we can add all those plugins from theΒ version catalog, so that the project “knows” about their existence. In more complex cases, you can add dependencies to theΒ classpathΒ (these are also plugins, but they are not in the general Gradle plugin portal) and configureΒ resolutionStrategyβa strategy for managing identical libraries with different versions. Here is an example of myΒ build.gradle.kts:
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.android.application.gradle.plugin) apply false
alias(libs.plugins.android.library.gradle.plugin) apply false
alias(libs.plugins.kotlin.android.gradle.plugin) apply false
alias(libs.plugins.kotlin.parcelize.gradle.plugin) apply false
alias(libs.plugins.crashlytics.gradle.plugin) apply false
alias(libs.plugins.google.gms.services.gradle.plugin) apply false
alias(libs.plugins.ksp.devtools.gradle.plugin) apply false
alias(libs.plugins.compose.compiler.gradle.plugin) apply false
alias(libs.plugins.dagger.hilt.gradle.plugin) apply false
}
buildscript {
repositories {
maven("https://aws-mobile-sdk.s3.amazonaws.com/android")
}
dependencies {
classpath("com.amazonaws:aws-android-sdk-appsync-gradle-plugin:3.4.0")
}
}
subprojects {
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
/// this is just an example of compiler args.
/// Do not add them to your project, if you don't need them!
freeCompilerArgs += listOf(
"-Xcontext-receivers",
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
)
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
events = setOf(
TestLogEvent.PASSED,
TestLogEvent.SKIPPED,
TestLogEvent.FAILED,
)
}
}
configurations.all {
resolutionStrategy {
/// fix transitively added different versions of kotlin
/// with specific version we need.
eachDependency {
if (requested.group == "org.jetbrains.kotlin") {
useVersion(libs.versions.version.kotlin.get())
}
}
}
/// ensure that we are using specific version of androidx-core,
/// event if any legacy lib uses old one
force("androidx.core:core-ktx:[our_specific_version]")
}
}
}
I had to pull one of the plugins through theΒ classpathΒ because it is only distributed from AWS repositories.
In addition to the Gradle scripts at the root, there is one more configuration file that is worth paying attention to:Β gradle.properties. I’ll briefly describe what is configured here:
- Build parameter settings
- Memory usage control
- Enabling/disabling experimental features
- Parameters for the Gradle Daemon
- Passing parameters to plugins (Compose, KMP, AGP)
The simplest thing you can useΒ gradle.propertiesΒ for is to configure how much memory Gradle can use on your workstation. For example, for me it’s 16GB:
org.gradle.jvmargs=-Xmx16g -XX:MaxPermSize=16096m
Thatβs all for the project root configuration β letβs move on to the modules themselves!
I want to say right away that you can configure things here endlessly! But weβll start in order and first look at whatβs here in general:
android {
namespace = "com.yourpackage.name"
compileSdk = 35
defaultConfig {
}
buildTypes {
}
productFlavors {
}
signingConfigs {
}
compileOptions {
}
kotlinOptions {
}
buildFeatures {
}
composeOptions {
}
}
You can create kotlin extensions (after all, we have Kotlin DSL!), which will automatically configure everything we copy from module to module. For example,Β buildTypes:
val BuildTypes = buildList {
this += BuildTypeData(
type = BuildType.Debug,
applicationIdSuffix = ".dev",
resValues = buildList {
this += ResValue(
type = "string",
name = "app_name",
value = "Dev App Name",
)
},
buildConfigFields = buildList {
this += BuildConfigField(
type = "String",
name = "BASE_API_URL",
value = "\"https://dev.api.example.com/\""
)
}
)
this += BuildTypeData(
type = BuildType.Stage,
applicationIdSuffix = ".stage",
isAnalyticsEnabled = true,
isCrashlyticsEnabled = true,
resValues = buildList {
this += ResValue(
type = "string",
name = "app_name",
value = "Stage App Name",
)
},
buildConfigFields = buildList {
this += BuildConfigField(
type = "String",
name = "BASE_API_URL",
value = "\"https://stage.api.example.com/\""
)
}
)
this += BuildTypeData(
type = BuildType.Release,
isProGuardEnabled = true,
isMinifyEnabled = true,
isShrinkResources = true,
isAnalyticsEnabled = true,
resValues = buildList {
this += ResValue(
type = "string",
name = "app_name",
value = "Release App Name",
)
},
buildConfigFields = buildList {
this += BuildConfigField(
type = "String",
name = "BASE_API_URL",
value = "\"https://prod.api.example.com/\""
)
}
)
}
In my case, I createdΒ BuildTypesβa special container where all build types (debug, release, stage, etc.) are stored. And here’s how you can apply all this in practice inΒ app/build.gradle.kts:
inline fun <reified T> NamedDomainObjectContainer<T>.ensureBuildTypeBy(name: String, closure: T.() -> Unit = {}) {
closure(findByName(name) ?: create(name))
}
buildTypes {
BuildTypes.forEach {
ensureBuildTypeBy(it.type.gradleName()).apply {
versionNameSuffix = it.previewSuffix
isDebuggable = (it.type == BuildType.Debug)
it.resValues.forEach { (type, name, value) -> resValue(type, name, value) }
it.buildConfigFields.forEach { (type, name, value) -> buildConfigField(type, name, value) }
}
}
}
In addition to build types, there are two other important things that can be configured in a similar way:Β flavorsΒ andΒ signingConfig. Everything is the same: you need to create a data model with the appropriate fields, fill them with data (forΒ signingConfig, this data can be taken from a localΒ .propertiesΒ file, but don’t forget to exclude it from the commit!) and iterate through and apply it to different types inΒ signingConfig.
Letβs move on β configuring the compatible Java version (for Kotlin 2.0+, this is, at a minimum, version 11):
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
I recommend putting this in constants somewhere at theΒ root/build.gradle.ktsΒ level or inΒ buildSrcΒ orΒ build-logic, if your project has them.
Another important block for configuration isΒ buildFeatures. Below, I will list the most popular flags that can be configured in this section:
aidlΒ enables AIDL support (inter-application-process communication viaΒ.aidlΒ files).buildConfigΒ generates aΒBuildConfigΒ class with constants (disabled in new versions becauseΒBuildConfigΒ is deprecated).composeΒ enables Jetpack Compose support.dataBindingΒ enables Android DataBinding (do not confuse with ViewBinding).viewBindingΒ enables ViewBinding (safe access to Views withoutΒfindViewById).
The last block isΒ composeOptions. It’s simple here; it serves to configure Compose in the project:
kotlinCompilerExtensionVersionβspecifies the compatible version of the Kotlin compiler plugin.kotlinCompilerVersionβspecifies the compatible Kotlin version.useLiveLiteralsβenables hot reload support for string literals, numbers, etc.
I think the last flag will be the most useful π
Dependencies, where would we be without them?
Weβve covered all possible and impossible configurations above, and now for the most important thing that we use Gradle for 90% of the time βΒ dependencies.
Press enter or click to view image in full size

Letβs briefly consider 2 cases: configuring aΒ library-moduleΒ and anΒ app-moduleΒ (letβs start with the app):
dependencies {
implementation(projects.libModule1)
implementation(projects.libModule2)
/// other library modules
implementation(libs.bundles.androidx)
implementation(libs.bundles.lifecycle)
implementation(libs.bundles.kotlinx)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth)
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.analytics)
implementation(libs.room.ktx)
implementation(libs.room.runtime)
ksp(libs.room.compiler)
implementation(libs.hilt.android)
implementation(libs.hilt.compose)
ksp(libs.hilt.compiler)
/// ...
}
Since all our dependencies are in the version catalog, we access them through the generated type-safe accessors, which also work well with autocompletion in the IDE, so we donβt have to remember what everything is called.
Regarding dependency configuration in aΒ library-module, if your project already has modules, I recommend moving common libraries to one that is named something likeΒ commonΒ orΒ coreΒ (for example, you can moveΒ kotlinxΒ configuration orΒ lifecycleΒ dependencies, or evenΒ retrofit/ktorβin general, everything that is basic can be in a basic module):
dependencies {
api(libs.bundles.androidx)
api(libs.bundles.lifecycle)
api(libs.bundles.kotlinx)
}
Now, theΒ app-moduleΒ configuration will look simpler:
dependencies {
implementation(projects.common)
/// ...
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth)
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.analytics)
/// ...
}
In more complex cases, you can move this toΒ build-logicΒ and create a plugin there that will set up all the main dependencies.
What else can be configured?
Some plugins provide the ability to configure themselves by adding additional sections to the config:
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
}
room {
schemaDirectory("$projectDir/schemas")
}
This is usually indicated directly in the documentation, so I want to move on to less obvious but no less useful things:
The name of the output file
android {
applicationVariants.all {
val variant = this
val formattedName = "app-${it.name}-$versionName-$versionCode"
variant.outputs.all {
this as ApkVariantOutputImpl
outputFileName = "$formattedName.apk"
}
project.tasks.named("sign${variant.name.capitalized()}Bundle", FinalizeBundleTask::class.java) {
val artifactDestination = finalBundleFile.asFile.get().parentFile
finalBundleFile.set(File(artifactDestination, "$formattedName.aab"))
}
}
}
Job Offers
In this way, we can set the name of the artifact for different build types (release/debug/custom) with support for app bundles and the old APK format. This is very convenient for CI, when we also specify the version in the artifact name, so QA or a customer can distinguish what kind of build it is.
Version and code can be passed as script parameters
Usually, we manually setΒ versionNameΒ andΒ versionCode, but what if we have CI and want to automatically set the build code? This can be easily done like this:
android {
defaultConfig {
versionCode = applicationVersionCode
versionName = applicationVersionName
}
}
/// ...
val Project.applicationVersionCode: Int
get() = obtainPropertyBy<String>("versionCode")?.toInt() ?: 1
val Project.applicationVersionName: String
get() = obtainPropertyBy<String>("versionName") ?: "dev build"
inline fun <reified T : Any> Project.obtainPropertyBy(name: String): T? {
return when {
hasProperty(name) -> property(name) as T
else -> null
}
}
In such a configuration, on CI, we can pass parameters through bash/shell scripts (or through the custom tasks of your CI provider), and they will automatically get into the necessary fields of the artifact. The same applies toΒ signingConfigβwe can pass it in the parameters.
What does it all look like together?
Finally, I want to show you what my Gradle KTS file looks like after all the work:
plugins {
alias(libs.plugins.android.application.gradle.plugin)
alias(libs.plugins.kotlin.android.gradle.plugin)
alias(libs.plugins.ksp.devtools.gradle.plugin)
alias(libs.plugins.compose.compiler.gradle.plugin)
alias(libs.plugins.dagger.hilt.gradle.plugin)
}
android {
namespace = "com.sagrishin.mycleanapp"
compileSdk = 35
defaultConfig {
applicationId = "com.sagrishin.mycleanapp"
minSdk = 24
targetSdk = 35
versionCode = applicationVersionCode
versionName = applicationVersionName
}
applicationVariants.all {
val variant = this
val formattedName = "app-${it.name}-$versionName-$versionCode"
variant.outputs.all {
this as ApkVariantOutputImpl
outputFileName = "$formattedName.apk"
}
project.tasks.named("sign${variant.name.capitalized()}Bundle", FinalizeBundleTask::class.java) {
val artifactDestination = finalBundleFile.asFile.get().parentFile
finalBundleFile.set(File(artifactDestination, "$formattedName.aab"))
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
buildConfigField("String", "API_URL", "\"https://api.hostname.com/api\"")
resValue("string", "push_notification_activity", "$namespace.HOME_ACTIVITY")
}
debug {
buildConfigField("String", "API_URL", "\"https://dev.hostname.com/api\"")
resValue("string", "push_notification_activity", "$namespace.HOME_ACTIVITY")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
implementation(libs.kotlin.reflect)
implementation(libs.bundles.kotlinx)
implementation(libs.bundles.androidx)
implementation(libs.bundles.lifecycle)
implementation(libs.bundles.networking)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
debugImplementation(libs.compose.ui.tooling)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth.ktx)
implementation(libs.firebase.crashlytics.ktx)
implementation(libs.firebase.firestore.ktx)
implementation(libs.firebase.analytics.ktx)
implementation(libs.gms.play.services.auth)
implementation(libs.localstorage.room.runtime)
implementation(libs.localstorage.room.ktx)
ksp(libs.localstorage.room.compiler)
implementation(libs.hilt.android)
implementation(libs.hilt.compose)
ksp(libs.hilt.compiler)
implementation(libs.navigation.ui.ktx)
implementation(libs.navigation.compose)
implementation(libs.google.play.update)
implementation(libs.google.play.update.ktx)
implementation(libs.serialization.gson)
}
room {
schemaDirectory("$projectDir/schemas")
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
}
Thatβs all from me. Thank you very much for reading my article to the end! I would also be grateful for a like if it was useful!
This article was previously published on proandroiddev.com.



