Migrate a Flutter Android project's Gradle scripts from Groovy to Kotlin DSL (build.gradle to build.gradle.kts)
A tested, file-by-file conversion of a Flutter app's settings.gradle, build.gradle and app/build.gradle to Kotlin DSL on Flutter 3.44.8, AGP 9.0.1 and Gradle 9.1.0, with the real compiler errors each naive rename produces and the exact fix for signing configs, flavors, minifyEnabled and kotlinOptions.
Converting a Flutter Android project from Groovy to Kotlin DSL means renaming three files (android/settings.gradle, android/build.gradle, android/app/build.gradle) to .kts and fixing the syntax that Groovy tolerated: double quotes everywhere, = on every property, parentheses on every call, is prefixes on booleans like isMinifyEnabled, create("release") for signing configs and flavors, explicit imports for Properties and FileInputStream, and kotlin { compilerOptions { } } instead of kotlinOptions. For a typical app with a release keystore and a couple of flavors it takes 30 to 60 minutes, almost all of it in app/build.gradle. Nothing forces you to do it: Groovy files still build fine. The payoff is type-checked build scripts, real autocomplete in Android Studio, and matching every new flutter create template and current Android documentation.
Everything below was run on Flutter 3.44.8 stable (framework revision 058e0af2c2), Android Gradle Plugin (AGP) 9.0.1, Kotlin Gradle Plugin (KGP) 2.3.20, Gradle 9.1.0 and OpenJDK 17.0.20. I started from a Groovy project shaped like the pre-3.29 Flutter template plus the additions most production apps have: a key.properties release signing config, R8 shrinking, two product flavors and a desugaring dependency. Both the Groovy original and the Kotlin result built app-dev-debug.apk with flutter build apk --debug --flavor dev. Every compiler error quoted here is the actual output from that project.
Why migrate a working Groovy build
- Errors move from runtime to compile time. A typo like
minfyEnabled truein Groovy is a dynamic method call that only fails when Gradle evaluates that line, with aCould not find methodmessage. In Kotlin DSL the whole script is compiled first, and you getUnresolved reference 'minfyEnabled'with a line and column, together with every other error in the file. - Autocomplete and navigation work. Android Studio resolves every block in a
.ktsfile to the real AGP type, so you can Cmd-clickdefaultConfigand see what properties exist instead of guessing from blog posts. - The ecosystem has moved.
flutter createhas generated.ktsfiles since Flutter 3.29, and the Android build docs show Kotlin first. Copying a snippet from either into a Groovy file is a translation exercise every time. - Deprecations become visible. Groovy silently accepted my old
kotlinOptions { jvmTarget = '17' }block on KGP 2.3.20. The same block in Kotlin DSL is a hard error (more on that in the gotchas), which is exactly the kind of debt you want surfaced.
The honest cost: Google’s own migration guide notes that Kotlin build scripts tend to compile slower than Groovy. In practice you pay it once per script change, because Gradle caches compiled scripts.
What breaks when you just rename the files
| Area | Groovy | Kotlin DSL | Severity |
|---|---|---|---|
| Strings | 'single' or "double" | "double" only | high (parse error) |
| Property assignment | namespace "x" | namespace = "x" | high |
| Method calls | include ":app" | include(":app") | high |
| Local variables | def x = ... | val x = ... | high |
| Boolean build type flags | minifyEnabled true | isMinifyEnabled = true | high |
minSdkVersion / targetSdkVersion | setter methods | minSdk = ..., targetSdk = ... | high |
| Named containers | release { }, dev { } | create("release") { } unless it already exists | high |
signingConfigs.release | dynamic lookup | signingConfigs.getByName("release") | high |
| JDK classes | auto-imported | import java.util.Properties | medium |
kotlinOptions { } | still accepted | compile error on KGP 2.3 | medium |
buildDir | assignable from a String | type mismatch, use layout.buildDirectory | medium |
Pre-flight checklist
- Commit first. The conversion is three file renames;
git diff -Mwill show it as edits only if the content stays similar, so keep the commit dedicated to this change. - Know your current versions. Open
android/settings.gradleand note the AGP and KGP versions in theplugins { }block. If your project still has abuildscript { dependencies { classpath ... } }block in the rootbuild.gradle, it predates the declarative plugins layout introduced in Flutter 3.16. Do the declarative plugin migration first, in Groovy, and get it building. Converting both at once doubles the number of things that can be wrong. - Get a green build before you start. Run
flutter build apk --debug(with--flavorif you have flavors) and make sure it passes. If it does not, fix that first; this guide to the generic Gradle exit code 1 failure covers the usual suspects. - Use
./gradlew helpas your fast check. From theandroid/folder,./gradlew helpconfigures every project and compiles every build script, but runs no Android tasks. Script errors show up in seconds instead of after a full build.
Migration steps
-
Convert
android/settings.gradletosettings.gradle.kts. This is the smallest file and the one that resolves plugin versions, so do it first. A plain rename fails immediately on the Groovy closure and the space-separated plugin syntax:e: settings.gradle.kts:2:24: Expecting an element e: settings.gradle.kts:6:33: Unexpected tokens (use ';' to separate expressions on the same line) e: settings.gradle.kts:20:8: Unexpected tokens (use ';' to separate expressions on the same line) Script compilation errors: 10 errorsReplace the whole file with the Kotlin equivalent. Keep your own AGP and KGP version numbers; these are the ones Flutter 3.44.8 writes:
// android/settings.gradle.kts, Flutter 3.44.8, AGP 9.0.1, KGP 2.3.20 pluginManagement { val flutterSdkPath = run { val properties = java.util.Properties() file("local.properties").inputStream().use { properties.load(it) } val flutterSdkPath = properties.getProperty("flutter.sdk") require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } flutterSdkPath } includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { google() mavenCentral() gradlePluginPortal() } } plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "9.0.1" apply false id("org.jetbrains.kotlin.android") version "2.3.20" apply false } include(":app")The Groovy
def flutterSdkPath = { ... }()closure becomes arun { }block,withInputStreambecomesinputStream().use { }so the stream is closed, andassertbecomesrequire, which throws a readableIllegalArgumentExceptioninstead of a Groovy power assertion. Verify:cd android && ./gradlew helpprintsBUILD SUCCESSFUL. Gradle supports mixing DSLs in one build: with onlysettings.gradle.ktsconverted, my still-Groovy root and appbuild.gradlefiles configured without a single change. -
Convert the root
android/build.gradletobuild.gradle.kts. The old template setsbuildDirfrom a string, which Kotlin rejects because the property is aFile, and the API itself is deprecated:w: build.gradle.kts:8:13: 'var buildDir: File' is deprecated. Deprecated in Java. e: build.gradle.kts:8:24: Assignment type mismatch: actual type is 'String', but 'File' was expected. e: build.gradle.kts:10:24: Assignment type mismatch: actual type is 'String', but 'File' was expected.And Groovy’s
tasks.register("clean", Delete)does not parse at all (Expecting an element), because Kotlin has no bare class literals. Use the lazylayout.buildDirectoryAPI and the reifiedregister<Delete>form, which is what current templates ship:// android/build.gradle.kts, Flutter 3.44.8, Gradle 9.1.0 allprojects { repositories { google() mavenCentral() } } val newBuildDir: Directory = rootProject.layout.buildDirectory .dir("../../build") .get() rootProject.layout.buildDirectory.value(newBuildDir) subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) } subprojects { project.evaluationDependsOn(":app") } tasks.register<Delete>("clean") { delete(rootProject.layout.buildDirectory) }Note the
../../build:layout.buildDirectoryresolves toandroid/build, notandroid/, so two levels up lands on the Flutter project’sbuild/folder, the same place the Groovy"../build"pointed at (Groovy’sbuildDirstring was resolved against the project directory,android/). Keep that location: the Flutter tool looks for the APK underbuild/app/outputs/. If you have extraext { }properties here, they becomeextra["name"] = valuein Kotlin and are read back withrootProject.extra["name"]. Verify:./gradlew helppasses. -
Rename
android/app/build.gradleand fix the mechanical syntax. This is where most of the work is. Rename the file, then do the purely textual pass: single quotes to double quotes,=on every property assignment, parentheses on every method call (proguardFiles(...),implementation("...")),deftoval, and dropnew. After that pass, my project still produced 23 errors, and they are the interesting ones:e: app/build.gradle.kts:7:26: Unresolved reference 'Properties'. e: app/build.gradle.kts:10:29: Unresolved reference 'FileInputStream'. e: app/build.gradle.kts:24:9: 'var jvmTarget: String' is deprecated. Please migrate to the compilerOptions DSL. e: app/build.gradle.kts:29:9: Function invocation 'minSdkVersion(...)' expected. e: app/build.gradle.kts:38:13: Unresolved reference 'keyAlias'. e: app/build.gradle.kts:47:29: Assignment type mismatch: actual type is 'NamedDomainObjectProvider<KotlinSourceSet>', but 'ApkSigningConfig?' was expected. e: app/build.gradle.kts:48:13: Unresolved reference 'minifyEnabled'. e: app/build.gradle.kts:56:9: Unresolved reference 'dev'.Each line maps to one rule, handled in the next three steps. Verify nothing for now; the file will not compile until step 6.
-
Add imports and type the keystore properties. Groovy imports
java.util.*andjava.io.*by default, Kotlin scripts do not. Add the imports at the very top of the file, aboveplugins { }.PropertiesextendsHashtable<Any, Any>, sokeystoreProperties["keyAlias"]isAny?, and you need an explicit cast to assign it to aString?property:// android/app/build.gradle.kts, Flutter 3.44.8, AGP 9.0.1 import java.io.FileInputStream import java.util.Properties val keystoreProperties = Properties() val keystorePropertiesFile = rootProject.file("key.properties") if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) }Without the cast the compiler stops at
Assignment type mismatch: actual type is 'Any?', but 'String?' was expected.keystoreProperties.getProperty("keyAlias")is an equally valid alternative that already returnsString?, if you prefer to avoid casts. Verify: the twoUnresolved reference 'Properties'and'FileInputStream'errors are gone on the next./gradlew help. -
Create named containers instead of configuring them by name. In Groovy,
signingConfigs { release { ... } }creates thereleasesigning config if it does not exist. In Kotlin,release { }is only an accessor for something that already exists. AGP creates thedebugsigning config and thedebugandreleasebuild types for you, sobuildTypes { release { } }works as written, but the release signing config and every flavor must be created explicitly:// inside android { }, AGP 9.0.1 signingConfigs { create("release") { keyAlias = keystoreProperties["keyAlias"] as String? keyPassword = keystoreProperties["keyPassword"] as String? storeFile = keystoreProperties["storeFile"]?.let { file(it) } storePassword = keystoreProperties["storePassword"] as String? } } flavorDimensions += "env" productFlavors { create("dev") { dimension = "env" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" } create("prod") { dimension = "env" } }The type-mismatch error from step 3 is worth understanding:
signingConfigs.releasedid not fail as “unresolved”. It resolved to the Kotlin plugin’sreleasesource set accessor, aNamedDomainObjectProvider<KotlinSourceSet>, which is why the message mentionsKotlinSourceSet. Look up signing configs by name instead. The same applies to any custom build type such asstagingorprofileRelease: onlydebugandreleaseexist implicitly, everything else needscreate("staging"), which the Android guide calls out explicitly. Verify: theUnresolved reference 'dev'and'keyAlias'errors are gone. -
Rename properties that changed shape: booleans, SDK levels,
kotlinOptions. Kotlin exposes boolean properties through their getter names, sominifyEnabledisisMinifyEnabledandshrinkResourcesisisShrinkResources. The oldminSdkVersionandtargetSdkVersionare setter functions, not properties, henceFunction invocation 'minSdkVersion(...)' expected; use the propertiesminSdkandtargetSdk. ReplacekotlinOptionswith the top-levelkotlin { compilerOptions { } }block. Here is the complete converted file:// android/app/build.gradle.kts, Flutter 3.44.8, AGP 9.0.1, KGP 2.3.20 import java.io.FileInputStream import java.util.Properties plugins { id("com.android.application") id("kotlin-android") id("dev.flutter.flutter-gradle-plugin") } val keystoreProperties = Properties() val keystorePropertiesFile = rootProject.file("key.properties") if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } android { namespace = "net.startdebugging.gradle_kts_demo" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } defaultConfig { applicationId = "net.startdebugging.gradle_kts_demo" minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName multiDexEnabled = true } signingConfigs { create("release") { keyAlias = keystoreProperties["keyAlias"] as String? keyPassword = keystoreProperties["keyPassword"] as String? storeFile = keystoreProperties["storeFile"]?.let { file(it) } storePassword = keystoreProperties["storePassword"] as String? } } buildTypes { release { signingConfig = if (keystorePropertiesFile.exists()) signingConfigs.getByName("release") else signingConfigs.getByName("debug") isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) } } flavorDimensions += "env" productFlavors { create("dev") { dimension = "env" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" } create("prod") { dimension = "env" } } } kotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } flutter { source = "../.." } dependencies { implementation("androidx.window:window:1.3.0") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") }multiDexEnabledkeeps its name because AGP declares it as a nullableBoolean?, and Kotlin only applies theisgetter convention to the primitivebooleanproperties. When in doubt, let autocomplete in Android Studio tell you; that is half the reason for doing this migration. Verify:./gradlew helpprintsBUILD SUCCESSFUL. -
Build every flavor and build type you ship.
./gradlew helpproves the scripts compile and configure; it does not run R8 or signing. Run the real builds:# Flutter 3.44.8 flutter build apk --debug --flavor dev flutter build appbundle --release --flavor prodVerify: both commands end with
✓ Built build/app/outputs/.... For my project,flutter build apk --debug --flavor devproducedapp-dev-debug.apkfrom the converted scripts, and./gradlew :app:signingReportwith a throwawaykey.propertiesshowed theprodReleasevariant signed with the release keystore and alias, so thecreate("release")block and the property casts behave exactly like the Groovy original.
Verification checklist
./gradlew helpfromandroid/passes with noe:lines. Thew:deprecation warnings are fine to leave for later, but read them: they are your next migration.flutter build apk --debugandflutter build appbundle --releasepass for every flavor.git statusshows only the three renames (plus anygradle.propertieschange you made on purpose). No straybuild.gradlenext to its.ktstwin. I tested this: with bothapp/build.gradleandapp/build.gradle.ktspresent, Gradle 9.1.0 silently used the Groovy file, printed no warning, and ignored every edit in the.ktsfile. That is a very confusing way to lose an afternoon.- If you sign releases, run
./gradlew :app:signingReportand check thereleasevariant points at your keystore, not the debug one. Theif (keystorePropertiesFile.exists())fallback hides a missingkey.propertieson CI by signing with the debug key, which Play will then reject. - CI scripts that
sedorgrepbuild files (for example to bumpversionCode) need their paths and patterns updated to the.ktsnames and the=syntax.
Rollback plan
The migration is fully reversible and file-scoped: git checkout HEAD~1 -- android/ restores the Groovy files, and because Gradle allows Groovy and Kotlin scripts side by side, you can also roll back a single file. If app/build.gradle.kts is fighting you, keep the converted settings.gradle.kts and root build.gradle.kts, restore app/build.gradle, ship, and finish the app module later.
Gotchas I hit
kotlinOptions works in Groovy but is a compile error in Kotlin DSL. On KGP 2.3.20, kotlinOptions { jvmTarget = '17' } in Groovy produced no error and the APK built. The identical block in .kts failed with 'var jvmTarget: String' is deprecated. Please migrate to the compilerOptions DSL. as an e: line, because KGP marks it with error-level deprecation and only the Kotlin compiler enforces that. Groovy calls it dynamically and never sees the annotation. If you are also planning the AGP 9 built-in Kotlin migration, you need this change there too, so the Kotlin DSL conversion front-loads it.
flavorDimensions is a list, not a function. In the current Android DSL flavorDimensions is a MutableList<String>, so the forward-compatible form is flavorDimensions += "env" (or += listOf("env", "store")). A literal flavorDimensions("env") still compiled in my test, because the deprecated android extension that Flutter’s android.newDsl=false keeps alive still accepts it, but it will not survive the move to the new DSL. If you are unsure how flavors interact with build modes on the Dart side, see what a Flutter flavor is and how it differs from a build mode.
Kotlin version errors are not caused by the DSL change. If after the migration you see Module was compiled with an incompatible version of Kotlin, the cause is the KGP version on the classpath, which the DSL does not change. That error has its own walkthrough.
buildConfigField and manifestPlaceholders need Kotlin syntax inside the values. buildConfigField("String", "API_URL", "\"https://api.example.com\"") keeps the escaped inner quotes, because the third argument is pasted into Java source verbatim. manifestPlaceholders["appAuthRedirectScheme"] = "com.example" replaces Groovy’s manifestPlaceholders = [appAuthRedirectScheme: 'com.example'], and unlike the Groovy map assignment it adds to placeholders set elsewhere instead of replacing them. Since AGP 8.0, buildConfigField also requires buildFeatures { buildConfig = true }, regardless of DSL.
Do not edit plugins’ Groovy files. Your Flutter plugins in ~/.pub-cache keep their own build.gradle files, and that is fine. Mixed DSLs across projects are supported, and the plugins are compiled as separate subprojects.
Related
- Migrate a Flutter Android project to AGP 9 with built-in Kotlin
- Fix: Gradle task assembleDebug failed with exit code 1 in a Flutter Android build
- Fix: Module was compiled with an incompatible version of Kotlin
- What is a Flutter flavor and how is it different from a build mode?
Sources
- Android Developers: Migrate your build configuration from Groovy to Kotlin
- Gradle User Manual: Migrating build logic from Groovy to Kotlin
- Flutter docs: Deprecated imperative apply of Flutter’s Gradle plugins
- Flutter docs: Migrating Flutter Android projects to built-in Kotlin
- Andrea Bizzotto: Kotlin DSL in Flutter 3.29, on the template switch
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.