| # 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. |
| |
| ## 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:* _pending (P6)_. |
| 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`). This |
| preserves add-to-app custom-debuggable matching (a host `staging` |
| debuggable build type maps to debug engine artifacts). |
| |
| ## 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`); read-then-set `VariantOutput.versionCode` | P6 | |
| | `libraryVariants.all` × host `applicationVariants.all` cross-wiring | `FlutterPlugin.kt` (add-to-app) | library-side `onVariants` with `Component.debuggable`; no host-project lookup | P7 | |
| | dynamic Groovy legacy API in `aar_init_script.gradle` | `aar_init_script.gradle` | `components`-based enumeration; ext-property guard | 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 current concatenation order), but an UP-TO-DATE-capable |
| finalizer task replaces the `doLast` block; new task names appear in |
| `gradlew tasks`. |
| 3. **Per-ABI versionCode**: post-`finalizeDsl` user mutations (`afterEvaluate` |
| CI patterns) may behave differently; a runtime divergence warning is added. |
| 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 explicit `:app:merge<V>Assets.dependsOn` edge and |
| host-project lookup are removed; `flutter.hostAppProjectName` becomes a |
| no-op with a deprecation warning naming a removal milestone. Build scripts that |
| reference Flutter's `copyFlutterAssets<V>` tasks by name (e.g. `tasks.getByPath(...)`) |
| will crash with a `Task with path ... not found` error because the tasks are now |
| registered lazily. |
| 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`**: the singleVariant dedup guard becomes an |
| ext-property/try-catch with a specified error message; variant enumeration |
| moves from `libraryVariants` to `components` — partial user `singleVariant` |
| declarations surface differently. |
| 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 |