| # Migrating the Flutter Gradle Plugin to the AGP Public API Surface | |
| This document is the contributor-facing record of the migration of the Flutter | |
| Gradle Plugin (FGP) off the legacy Android Gradle Plugin (AGP) DSL/Variant API | |
| and AGP internals, onto the public API surface shipped in the | |
| `com.android.tools.build:gradle-api` artifact. | |
| Umbrella issues: | |
| - newDsl flip: https://github.com/flutter/flutter/issues/180137 | |
| - Variant API migration: https://github.com/flutter/flutter/issues/166550 | |
| The user-facing breaking-change page draft lives next to this file in | |
| [`website-page-draft.md`](website-page-draft.md). It must be published to | |
| `docs.flutter.dev/release/breaking-changes/` before the newDsl flip (phase P9) | |
| reaches the beta channel. Tracking issue: | |
| https://github.com/flutter/flutter/issues/193713 | |
| ## Why | |
| AGP 9 (January 2026) deprecated the old DSL and Variant APIs behind the | |
| `android.newDsl=false` escape hatch. AGP 10 (late 2026) removes those APIs | |
| entirely **and** removes access to AGP internals — only the public surface of | |
| the `gradle-api` artifact remains. Today the FGP: | |
| - compiles against the FULL `com.android.tools.build:gradle` artifact | |
| (`packages/flutter_tools/gradle/build.gradle.kts`); | |
| - uses the legacy variant API (`applicationVariants`, `libraryVariants`, | |
| `variant.outputs`, `assembleProvider`, `packageApplicationProvider`, | |
| `versionCodeOverride`); | |
| - uses the legacy `BaseExtension` | |
| (`FlutterPluginUtils.getLegacyAndroidExtension`); | |
| - imports one internal DSL class (`com.android.build.gradle.internal.dsl.BuildType` | |
| in `plugins/PluginHandler.kt`); | |
| - imports one internal utility | |
| (`com.android.build.gradle.internal.utils.getKotlinAndroidPluginVersion` in | |
| `VersionFetcher.kt`); | |
| - drives `flutter build aar` with legacy dynamic Groovy in | |
| `aar_init_script.gradle`. | |
| Flutter templates pin AGP 9.1.0 but ship `android.newDsl=false`, and a tool | |
| migrator (`disable_new_dsl_migration.dart`) adds the opt-out to existing | |
| projects. That opt-out dies with AGP 10. | |
| ## End state | |
| - The FGP uses only public APIs and compiles against `gradle-api`. | |
| - Templates no longer ship `android.newDsl=false`. | |
| - The opt-out **add** migrator is replaced by a **removal** migrator that | |
| deletes only the Flutter-added opt-out lines. | |
| - A fresh `flutter create` app builds with newDsl on. | |
| ## Decision records | |
| 1. **Min AGP floor: out of scope.** A separate version bump owned the | |
| floor, which has now landed on `master` (PRs #176858 and #177416 bumped the | |
| floor to `8.11.1`). This migration builds seamlessly on that new floor. | |
| Every replacement API used here was verified public in `gradle-api:8.11.1` | |
| (decompiled jar inspection). If implementation finds a replacement API that | |
| genuinely requires a higher min AGP: document which API and why no | |
| compatible alternative exists in this file, then bump — otherwise version | |
| floors are untouched by this work. | |
| 2. **"Public in 8.x" does not mean binary-compatible on 9.x.** | |
| `AgpCommonExtensionWrapper.kt` exists precisely because the public | |
| `CommonExtension` broke between AGP 8 and 9. Mitigation: a CI/test axis | |
| compiling the FGP against gradle-api 9.x is mandatory from phase P2 onward, | |
| plus a bytecode check (javap grep) that no compiled FGP class references | |
| `CommonExtension` as an owner. | |
| 3. **`android.builtInKotlin=false` stays out of scope.** Flipping it requires | |
| the separate built-in-Kotlin migration workstream. Users get a second | |
| (smaller) gradle.properties churn later; the breaking-change page states | |
| this explicitly. Corollary: the P9 removal migrator must anchor on the | |
| `android.newDsl` property line — never on marker-comment wording alone — | |
| because the template's builtInKotlin marker comment is nearly identical. | |
| 4. **Per-ABI versionCode mechanism.** Do NOT re-implement AGP's flavor-merge | |
| precedence via a `finalizeDsl` snapshot. Preferred mechanism (spiked first | |
| in P6): read-then-set on `VariantOutput.versionCode` inside `onVariants` — | |
| it is seeded with the merged value; set `abiOffset * 1000 + current`, | |
| avoiding a self-referential `.map`. Fall back to a snapshot only if | |
| read-then-set is impossible; record the outcome here. | |
| - *Spike result:* read-then-set works only while AGP's compatibility mode is on | |
| (`android.compatibility.enableLegacyApi`, default `true` through AGP 9.x and marked | |
| `FeatureStage.Deprecated(VERSION_10_0)`). With it set to `false`, AGP calls | |
| `disallowUnsafeRead()` on `VariantOutput.versionCode` and the read fails with | |
| "configuration of project ':app' has not completed yet". `Property` has no lazy | |
| self-transform to fall back on. AGP deprecates the option for removal in 10.0, and the | |
| [AGP roadmap](https://developer.android.com/build/releases/gradle-plugin-roadmap) | |
| describes AGP 10.0 as completing the move to a fully lazy build model, so P6 does not | |
| depend on the option and ships the `finalizeDsl` snapshot | |
| instead: `DslVersionCodes` reads `defaultConfig.versionCode` and each product | |
| flavor's `versionCode` in `finalizeDsl`, and `configureSplitPerAbiVersionCodes` in | |
| `FlutterPlugin.kt` sets `abiOffset * 1000 + base` without reading the property. | |
| `DslVersionCodes.forVariant` matches AGP's merge (the first flavor in dimension order | |
| that sets a versionCode, then `defaultConfig`). A versionCode set only in the | |
| manifest is not offset, because AGP reads it at execution time. The | |
| `split_per_abi` integration test covers `enableLegacyApi=false`. See "Features that | |
| must break" item 3 for the effect on apps that change `versionCode` in their own | |
| `onVariants`. | |
| 5. **`buildModeFor` semantics.** Every variant-scope call uses the | |
| `(name, debuggable)` overload with the public `Component.debuggable`. | |
| Name-based inference is confined to the one DSL-scope case with no public | |
| signal (the library-plugin build-type copy in `PluginHandler`). In an | |
| add-to-app module the call reads the module's own variant, so the build mode | |
| follows the module variant that the host's `matchingFallbacks` select (see | |
| "Features that must break" item 6). | |
| 6. **P3 pre-spike / PR 4 (afterEvaluate DSL mutation under newDsl).** The planned scratch-app | |
| spike (AGP 9.1 + `newDsl=true` + custom build type, verifying that build-type | |
| creation from `pluginProject.afterEvaluate` still works) could not run in the | |
| implementation sandbox (no AGP artifact access). The `initWith` copy landed on the | |
| primary approach; the `android_plugin_example_app_build` integration test and a | |
| custom-build-type scratch build must confirm it in CI. Documented fallback if | |
| `afterEvaluate` mutation is rejected under newDsl: perform the copy in | |
| `androidComponents.finalizeDsl` on the plugin project instead. | |
| ## Replacement map | |
| | Legacy usage | Where | Public replacement | Phase | | |
| | --- | --- | --- | --- | | |
| | `internal.utils.getKotlinAndroidPluginVersion` | `VersionFetcher.kt` | delete; rely on existing fallback chain (`kotlin_version` property → `KotlinAndroidPluginWrapper.pluginVersion` → reflection); null when KGP absent is OK | P1 | | |
| | `compileSdkVersion` string compare (`"android-NN"` substring) | `FlutterPluginUtils.getCompileSdkFromProject`, `PluginHandler` warning | wrapper `compileSdk` / `compileSdkPreview`; numeric compare with defined preview semantics | P1 | | |
| | `BaseExtension.ndkVersion` | `FlutterPluginUtils.getConfiguredNdkVersion` | wrapper `ndkVersion` | P1 | | |
| | `buildModeFor(BuildType)` (legacy model type) | `FlutterPluginUtils.kt` | `buildModeFor(name, debuggable)` overload | P2 | | |
| | `getLegacyAndroidExtension(project).buildTypes` loops | `PluginHandler.kt` | wrapper new-DSL `buildTypes` container | P2 | | |
| | `internal.dsl.BuildType` live aliasing into plugin projects | `PluginHandler.kt` | `initWith`-based copy on new-DSL `BuildType`; app-specific props only when both sides are `ApplicationBuildType` | P3 | | |
| | `BaseExtension` / `getLegacyAndroidExtension` (remaining call sites) | `FlutterPluginUtils.kt` | wrapper accessors incl. `externalNativeBuild` | P4 | | |
| | eager `applicationVariants.configureEach` task creation; mergeAssets/processResources hooks | `FlutterPlugin.kt`, `FlutterPluginUtils.kt` | consolidated `onVariants` block; `CopyFlutterAssetsTask` + `variant.sources.assets.addGeneratedSourceDirectory` | P5 | | |
| | `variant.outputs` + `packageApplicationProvider` + `doLast` APK copy; `versionCodeOverride` | `FlutterPluginUtils.kt` | `CopyFlutterApksTask` (`SingleArtifact.APK` + `BuiltArtifactsLoader`); `VariantOutput.versionCode` set from a `finalizeDsl` snapshot (`DslVersionCodes`) | P6 | | |
| | `libraryVariants.all` × host `applicationVariants.all` cross-wiring | `FlutterPlugin.kt` (add-to-app) | library-side `onVariants` with `Component.debuggable`; no host-project lookup | P7 | | |
| | `android.libraryVariants`; internal `publishing.singleVariants`; module-to-plugin `assembleAar<V>.dependsOn` | `aar_init_script.gradle` | `com.android.library` plugin check; per-variant `singleVariant` that keeps the build file's declaration; the tool requests `assembleAar<V>` without a project path, so it runs in the module and in every plugin that has it (no `dependsOn`) | P8 | | |
| | `android.newDsl=false` template/migrator | templates, `disable_new_dsl_migration.dart` | drop from templates; `RemoveNewDslOptOutMigration` | P9 | | |
| | FULL `gradle` artifact dependency | `build.gradle.kts` | `gradle-api` artifact (compile-time proof of zero internal usage) | P10 | | |
| ## Phase map | |
| Each phase is one PR-sized change on its own branch. P8 is an independent lane | |
| (Groovy script, disjoint files); P0/P1 are disjoint from each other; everything | |
| else serializes through `FlutterPlugin.kt` / `FlutterPluginUtils.kt`. | |
| | Phase | Branch | Size | Summary | | |
| | --- | --- | --- | --- | | |
| | P0 | `agp-api-doc` | S | this doc + website page draft | | |
| | P1 | `agp-internal-utils` | S | VersionFetcher internal util removal; numeric compileSdk compare; ndkVersion via wrapper | | |
| | P2 | `agp-buildmode-deps` | M | `buildModeFor` overloads; new-DSL flutter dependencies; 9.x compile axis | | |
| | P3 | `agp-plugin-buildtypes` | M | `initWith` copy for plugin build types; drop internal import; internal-import lint | | |
| | P4 | `agp-ndk-fallback` | S | delete `BaseExtension`; externalNativeBuild via wrapper | | |
| | P5 | `agp-assets-onvariants` | L | lazy task registration (5a) + generated-asset-dir wiring (5b) | | |
| | P6 | `agp-apk-copy-versioncode` | L | `CopyFlutterApksTask`; per-ABI versionCode; app path legacy-free | | |
| | P7 | `agp-add-to-app` | L | library-side `onVariants`; delete host cross-wiring + P5a legacy fork | | |
| | P8 | `agp-aar-script` | M | aar_init_script public-API cleanup | | |
| | P9 | `agp-newdsl-flip` | M | templates drop opt-out; removal migrator; new error handlers | | |
| | P10 | `agp-gradle-api` | M | dependency swap to `gradle-api`; test migration | | |
| ## Cross-cutting rules | |
| - **R1 Lockstep:** any PR changing FGP-emitted message text updates the | |
| matching `gradle_errors.dart` matcher and its Dart test in the same PR. | |
| - **R2 Revert notes:** each PR description carries "revert-safe until phase X | |
| lands"; once superseded, policy is fix-forward. At least one full post-submit | |
| CI soak between dependent phases (no same-day stacking of P2–P4). | |
| - **R3 9.x axis:** from P2, gradle unit tests additionally compile against | |
| gradle-api 9.x in CI, plus the javap `CommonExtension` bytecode check. | |
| - **R4 Config-cache:** master baseline established first; the per-phase | |
| assertion is "no NEW config-cache violations", not full reuse. | |
| - **R5 Internal-import lint:** once P3 lands, a checked-in test forbids | |
| `com.android.build.gradle.internal.*` imports in `src/main`. | |
| - **R6 Staged newDsl=true axis:** app flows green from end of P6; add-to-app | |
| from P7; aar from P8. The full matrix is the P9 gate. | |
| ## Revert-window table | |
| | Phase | Revert window | | |
| | --- | --- | | |
| | P0 | always revert-safe | | |
| | P1–P4 | each until the next phase in the chain lands; then fix-forward | | |
| | P5 | until P6 lands | | |
| | P6 / P7 | mutually tolerant (disjoint app/module paths) until P10 | | |
| | P8 | revert-safe even after P10, but not after P9 | | |
| | P9 | cleanly revertible in isolation | | |
| | P10 | cleanly revertible in isolation | | |
| ## Features that must break (tracked; updated as implementation learns) | |
| 1. **User build scripts using legacy APIs** (`applicationVariants.all` | |
| APK-rename recipes) fail under newDsl — the biggest break. Mitigated by new | |
| error handlers (P9) and the website page. | |
| 2. **flutter-apk copy**: same names/paths (`app[-abi][-flavor]-<mode>.apk`, | |
| byte-matching the order `listApkPaths` in the Flutter tool expects). `CopyFlutterApksTask` | |
| (`copyFlutterApks<Variant>`, which `assemble<Variant>` depends on) replaces the | |
| `doLast` block, so `copyFlutterApks<Variant>` tasks appear in `gradlew tasks`. It reads | |
| `SingleArtifact.APK` through `BuiltArtifactsLoader`. Its declared outputs are the | |
| individual APK files rather than the shared `flutter-apk` directory, which would | |
| overlap between variants. The file names come from the variant's outputs (the APKs | |
| AGP will build), not from `target-platform`. A single naming function produces both | |
| the declared outputs and the copied files, and the task fails if AGP built an APK it | |
| did not declare. | |
| 3. **Per-ABI versionCode** (`--split-per-abi`): Flutter sets | |
| `VariantOutput.versionCode` in its own `onVariants` callback, from the versionCode the | |
| `android {}` DSL declares for the variant (captured in `finalizeDsl`). It never reads | |
| `output.versionCode`. AGP runs callbacks in registration order, so for an app that | |
| applies Flutter in its `plugins {}` block, Flutter's callback runs *before* the app's | |
| `androidComponents.onVariants` block. | |
| `ApkVariantOutput.versionCodeOverride`, which this replaces, was applied *after* that | |
| block. Apps that set `versionCode` only in the `android {}` DSL see no change. Two | |
| cases change: | |
| - A versionCode declared only in `AndroidManifest.xml`, with none in the DSL, is not | |
| offset. `versionCodeOverride` offset the merged value, which included the manifest. | |
| Flutter's templates set `versionCode = flutter.versionCode` in `defaultConfig`. | |
| - An app that transforms `output.versionCode` in its own `onVariants` transforms | |
| Flutter's offset value. For example, an app that multiplies by 10000, built with | |
| `--build-number 42`: | |
| | ABI (offset) | Before: `offset * 1000 + 42 * 10000` | After: `(offset * 1000 + 42) * 10000` | | |
| | --- | --- | --- | | |
| | `armeabi-v7a` (1) | 421000 | 10420000 | | |
| | `arm64-v8a` (2) | 422000 | 20420000 | | |
| | `x86_64` (4) | 424000 | 40420000 | | |
| In this example the values stay distinct, keep the same ABI order, and only go up, so | |
| Play Store upgrades keep working. Going back to an earlier Flutter release would make | |
| them go down. An app that needs the "Before" numbers can compute them from its own base | |
| instead of from `output.versionCode`, for example | |
| `output.versionCode.set(offset * 1000 + flutter.versionCode * 10000)` when the base is | |
| the template's `versionCode = flutter.versionCode` (apps with per-flavor versionCodes | |
| use the flavor's value). That form also works with AGP's compatibility mode off, where | |
| reading `output.versionCode` fails. | |
| No runtime divergence warning is emitted. Flutter cannot tell an intended change in | |
| the app's `onVariants` (the pattern AGP recommends) from an unintended one, so a | |
| warning would fire on correct build scripts. Flutter does warn when a split-per-abi | |
| variant has no DSL versionCode to offset. The snapshot is taken in Flutter's | |
| `finalizeDsl` callback, so a versionCode change made in a `finalizeDsl` callback | |
| registered after it (for example in the app's build script) is not seen: Flutter offsets | |
| the value from before that change. | |
| 4. **Custom build types → plugins**: live-aliased instances become `initWith` | |
| copies; library plugins cannot receive `isDebuggable` (no public setter on | |
| `LibraryBuildType`). **Warning:** If an app uses a custom build type (like `staging`), | |
| the plugin's `BuildConfig.DEBUG` and native (C++/JNI) code may silently compile | |
| in release mode instead of debug mode. Matching is preserved via `matchingFallbacks`, | |
| but C++ debugging will be broken for those custom build types. | |
| 5. **Asset merge**: flutter assets become a merged source dir instead of a | |
| post-merge overwrite; collisions resolve by AGP source-set priority. | |
| 6. **Add-to-app**: the module registers its Flutter tasks in its own `onVariants` and | |
| adds the staged assets and native libraries as generated source directories of each | |
| library variant. The host consumes them like any Android library's, so the | |
| host-project lookup, the `libraryVariants` × host `applicationVariants` loop and the | |
| `:app:merge<V>Assets.dependsOn` edge are removed. | |
| - `flutter.hostAppProjectName` has no effect; setting it logs a warning that names no | |
| removal milestone. | |
| - Every module variant is configured whatever the command line names, because the | |
| host's `matchingFallbacks`, not the task name, decide which module variant a host | |
| task consumes. Registration is lazy, so only that variant runs. Cost: a host build | |
| of only `assemble<Variant>AndroidTest` runs the module's `flutter assemble`. | |
| - The build mode comes from the consumed module variant, not the host build type. A | |
| debuggable host build type that falls back to the module's `release` gets release | |
| artifacts. Built as the only task, the deleted code ran the module's debug Flutter | |
| build for it, and the APK had no `flutter_assets` and no `libapp.so`. | |
| - Module assets are a generated source directory (item 5), and `copyFlutterAssets<V>` | |
| is a `CopyFlutterAssetsTask` (item 7). | |
| 7. **Task realization/type**: flutter tasks become lazy `TaskProvider`s, and | |
| `copyFlutterAssets<V>` changes type from `org.gradle.api.tasks.Copy` to a | |
| custom task class — `tasks.named(..., Copy::class)` casts fail. | |
| 8. **`flutter build aar`**: Flutter declares `singleVariant` publishing (with sources and | |
| javadoc jars) for each variant of the module and of each Android plugin. For a variant that | |
| the project's build file declares, the build file's declaration wins. The requested variant | |
| must exist in the module and in each Android plugin; otherwise the build fails, and the fix | |
| is to build a variant that all of them declare (`--flavor`, matching product flavors). | |
| 9. **newDsl flip**: new projects lose the opt-out; the removal migrator deletes | |
| only marker-tagged `android.newDsl` lines (template marker "This newDsl flag | |
| was added by the Flutter template"; migrator marker "This newDsl flag was | |
| added automatically by Flutter migrator"), anchored on the property line so | |
| the adjacent builtInKotlin lines are never touched; hand-added opt-outs are | |
| respected. | |
| 10. **compileSdk mismatch warning** becomes a numeric compare with defined | |
| preview-vs-numeric semantics; the message keeps a distinctive substring of | |
| the old phrasing for searchability. | |
| ## Verification matrix | |
| Full matrix at P6, P7, P9, P10; targeted per-phase otherwise. | |
| 1. `cd packages/flutter_tools/gradle && ./gradlew test` (+ the R3 9.x axis) | |
| 2. Targeted `integration.shard` tests named in each phase | |
| 3. Scratch-app matrix: apk/appbundle × 3 modes; `--flavor`; `--split-per-abi` | |
| (+ apkanalyzer versionCode assertions, including the | |
| flavor-defined-versionCode case); `--deferred-components`; plugin with a | |
| custom build type; `flutter build aar`; add-to-app source & AAR host flows; | |
| `flutter run` / hot restart / `flutter attach`; Windows smoke for the copy | |
| tasks | |
| 4. AGP axis: current floor AND 9.1 + `newDsl=false`; staged `newDsl=true` per R6 | |
| 5. Config-cache per R4 |