
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.



