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:

The user-facing breaking-change page draft lives next to this file in 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 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 usageWherePublic replacementPhase
internal.utils.getKotlinAndroidPluginVersionVersionFetcher.ktdelete; rely on existing fallback chain (kotlin_version property → KotlinAndroidPluginWrapper.pluginVersion → reflection); null when KGP absent is OKP1
compileSdkVersion string compare ("android-NN" substring)FlutterPluginUtils.getCompileSdkFromProject, PluginHandler warningwrapper compileSdk / compileSdkPreview; numeric compare with defined preview semanticsP1
BaseExtension.ndkVersionFlutterPluginUtils.getConfiguredNdkVersionwrapper ndkVersionP1
buildModeFor(BuildType) (legacy model type)FlutterPluginUtils.ktbuildModeFor(name, debuggable) overloadP2
getLegacyAndroidExtension(project).buildTypes loopsPluginHandler.ktwrapper new-DSL buildTypes containerP2
internal.dsl.BuildType live aliasing into plugin projectsPluginHandler.ktinitWith-based copy on new-DSL BuildType; app-specific props only when both sides are ApplicationBuildTypeP3
BaseExtension / getLegacyAndroidExtension (remaining call sites)FlutterPluginUtils.ktwrapper accessors incl. externalNativeBuildP4
eager applicationVariants.configureEach task creation; mergeAssets/processResources hooksFlutterPlugin.kt, FlutterPluginUtils.ktconsolidated onVariants block; CopyFlutterAssetsTask + variant.sources.assets.addGeneratedSourceDirectoryP5
variant.outputs + packageApplicationProvider + doLast APK copy; versionCodeOverrideFlutterPluginUtils.ktCopyFlutterApksTask (SingleArtifact.APK + BuiltArtifactsLoader); VariantOutput.versionCode set from a finalizeDsl snapshot (DslVersionCodes)P6
libraryVariants.all × host applicationVariants.all cross-wiringFlutterPlugin.kt (add-to-app)library-side onVariants with Component.debuggable; no host-project lookupP7
android.libraryVariants; internal publishing.singleVariants; module-to-plugin assembleAar<V>.dependsOnaar_init_script.gradlecom.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/migratortemplates, disable_new_dsl_migration.dartdrop from templates; RemoveNewDslOptOutMigrationP9
FULL gradle artifact dependencybuild.gradle.ktsgradle-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.

PhaseBranchSizeSummary
P0agp-api-docSthis doc + website page draft
P1agp-internal-utilsSVersionFetcher internal util removal; numeric compileSdk compare; ndkVersion via wrapper
P2agp-buildmode-depsMbuildModeFor overloads; new-DSL flutter dependencies; 9.x compile axis
P3agp-plugin-buildtypesMinitWith copy for plugin build types; drop internal import; internal-import lint
P4agp-ndk-fallbackSdelete BaseExtension; externalNativeBuild via wrapper
P5agp-assets-onvariantsLlazy task registration (5a) + generated-asset-dir wiring (5b)
P6agp-apk-copy-versioncodeLCopyFlutterApksTask; per-ABI versionCode; app path legacy-free
P7agp-add-to-appLlibrary-side onVariants; delete host cross-wiring + P5a legacy fork
P8agp-aar-scriptMaar_init_script public-API cleanup
P9agp-newdsl-flipMtemplates drop opt-out; removal migrator; new error handlers
P10agp-gradle-apiMdependency 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

PhaseRevert window
P0always revert-safe
P1–P4each until the next phase in the chain lands; then fix-forward
P5until P6 lands
P6 / P7mutually tolerant (disjoint app/module paths) until P10
P8revert-safe even after P10, but not after P9
P9cleanly revertible in isolation
P10cleanly 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 * 10000After: (offset * 1000 + 42) * 10000
    armeabi-v7a (1)42100010420000
    arm64-v8a (2)42200020420000
    x86_64 (4)42400040420000

    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 TaskProviders, 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