From 3240d4a01482ee0272d083bb355131c438fea4b9 Mon Sep 17 00:00:00 2001 From: reidbaker-agent Date: Wed, 22 Jul 2026 01:08:25 +0000 Subject: [PATCH 1/5] Add AGP public-API migration docs (contributor doc + breaking-change page draft) Phase P0 of the Flutter Gradle Plugin migration to the AGP public API surface (flutter/flutter#180137, flutter/flutter#166550): the contributor migration record (replacement map, decision records, phase map, revert-window table) and the draft of the user-facing breaking-change page to be published to docs.flutter.dev before the newDsl flip reaches beta. Revert-safe: always. --- ...Flutter-Gradle-Plugin-to-AGP-public-API.md | 205 ++++++++++++++++++ docs/platforms/android/website-page-draft.md | 191 ++++++++++++++++ 2 files changed, 396 insertions(+) create mode 100644 docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md create mode 100644 docs/platforms/android/website-page-draft.md diff --git a/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md b/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md new file mode 100644 index 0000000000000..43a37eea10412 --- /dev/null +++ b/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md @@ -0,0 +1,205 @@ +# 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 in-flight version bump owns the + floor; this migration builds on whatever floor is in effect at landing. + 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]-.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`) — plugin-side `BuildConfig.DEBUG`/JNI debuggability may + differ for custom debuggable build types; matching preserved via + `matchingFallbacks`. +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:mergeAssets.dependsOn` edge and + host-project lookup are removed; `flutter.hostAppProjectName` becomes a + no-op with a deprecation warning naming a removal milestone; ordering + against `copyFlutterAssets` task names may break. +7. **Task realization/type**: flutter tasks become lazy `TaskProvider`s, and + `copyFlutterAssets` 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 diff --git a/docs/platforms/android/website-page-draft.md b/docs/platforms/android/website-page-draft.md new file mode 100644 index 0000000000000..de89c1b5ba0c5 --- /dev/null +++ b/docs/platforms/android/website-page-draft.md @@ -0,0 +1,191 @@ +# Android builds use the new Android Gradle Plugin DSL and Variant APIs + +*Draft breaking-change page for `docs.flutter.dev/release/breaking-changes/`. +This file is the source of truth until the page is published to +flutter/website; publishing must complete before the newDsl flip reaches the +beta channel. Contributor-facing details live in +[Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md](Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md).* + +## Summary + +The Flutter Gradle Plugin now uses only the public Android Gradle Plugin (AGP) +API, and new and migrated Flutter projects build with AGP's new DSL enabled +(`android.newDsl` is no longer set to `false` by Flutter). Gradle build +scripts that use the legacy AGP APIs — most commonly +`android.applicationVariants` — fail to configure and must be migrated to the +AGP Variant API. + +## Background + +AGP 9 deprecated the legacy DSL and Variant APIs behind the +`android.newDsl=false` flag. AGP 10 removes them entirely. Flutter previously +added `android.newDsl=false` to your `gradle.properties` (via the project +templates and an automatic migration) to keep legacy builds working. That +opt-out stops working with AGP 10, so Flutter has migrated its own Gradle +plugin to the public API and removed the opt-out from templates. A migration +now *removes* the opt-out lines that Flutter previously added — it only touches +lines carrying Flutter's marker comments, and prints a message when it does. +Opt-outs you added by hand are left alone. + +`android.builtInKotlin=false` is **not** affected by this change. It is owned +by the separate built-in-Kotlin migration (tracked in ), which means one more (smaller) +`gradle.properties` change later. + +## Migration guide + +### Renaming APKs (`applicationVariants.all`) + +Before: + +```groovy +android { + applicationVariants.all { variant -> + variant.outputs.all { output -> + outputFileName = "myapp-${variant.versionName}.apk" + } + } +} +``` + +After (Variant API, `build.gradle` / `build.gradle.kts`): + +```kotlin +androidComponents { + onVariants(selector().all()) { variant -> + variant.outputs.forEach { output -> + // Use variant.name / output.filters and your own naming scheme. + } + } +} +``` + +For output *file* renames, prefer consuming the built APKs from +`SingleArtifact.APK` with a task wired through +`variant.artifacts.use(...)`, or copy/rename in a finalizer task. Flutter's +own copy step already places APKs at +`build/app/outputs/flutter-apk/app[-abi][-flavor]-.apk` with unchanged +names and paths. + +### Setting per-ABI or per-variant versionCode + +Before: + +```groovy +android.applicationVariants.all { variant -> + variant.outputs.each { output -> + output.versionCodeOverride = abiCodes.get(output.getFilter(OutputFile.ABI)) * 1000 + variant.versionCode + } +} +``` + +After: + +```kotlin +androidComponents { + onVariants(selector().all()) { variant -> + variant.outputs.forEach { output -> + val abi = output.filters.find { it.filterType == FilterConfiguration.FilterType.ABI }?.identifier + val base = output.versionCode.get() ?: 1 + output.versionCode.set((abiCodes[abi] ?: 0) * 1000 + base) + } + } +} +``` + +Note: Flutter itself sets per-ABI version codes for `--split-per-abi` inside +`onVariants`. If your CI mutates version codes in `afterEvaluate`, that runs at +a different time than before; Flutter prints a warning when it detects a +divergence between the DSL value and the final output value. + +### Custom build types and plugins + +Flutter copies your app's custom build types onto Flutter plugin projects so +they resolve. With the new DSL these are `initWith` copies rather than live +aliases: + +- Set `matchingFallbacks` on custom build types so dependent Android libraries + resolve, for example: + + ```kotlin + android { + buildTypes { + create("staging") { + initWith(getByName("debug")) + matchingFallbacks += listOf("debug", "release") + } + } + } + ``` + +- Library (plugin) projects cannot be marked debuggable through the public + API, so a plugin's `BuildConfig.DEBUG` and native (JNI) debuggability can + differ from before for custom *debuggable* build types. Variant matching + still works via `matchingFallbacks`. + +### Add-to-app (Flutter module in a host app) + +- Flutter no longer looks up or configures the host `:app` project from the + module. The dependency between your host's asset merging and Flutter's asset + copy is expressed through the Variant API instead of an explicit + `mergeAssets.dependsOn(...)` edge. Build scripts that reference + Flutter's `copyFlutterAssets` tasks by name or type may break: the + tasks are now registered lazily and are no longer of type + `org.gradle.api.tasks.Copy`. +- `flutter.hostAppProjectName` in `gradle.properties` is now a no-op. Flutter + prints a deprecation warning naming the removal milestone. It was only used + for the host-project lookup, which no longer exists. +- Flutter maps host build types to Flutter build modes using the public + "debuggable" flag: `profile` stays `profile`, debuggable build types map to + `debug`, everything else maps to `release`. If your host has no `profile` + build type, add `matchingFallbacks`: + + ```kotlin + create("staging") { + initWith(getByName("debug")) + isDebuggable = true // staging gets debug Flutter artifacts + matchingFallbacks += listOf("debug", "release") + } + ``` + +### Flutter plugin authors + +- Do not read `android.applicationVariants` / `android.libraryVariants` in + plugin build scripts; use `androidComponents.onVariants`. +- Do not assume Flutter's tasks exist at configuration time or have specific + types; look tasks up lazily (`tasks.named`) without a type, or better, wire + through Variant API artifacts. +- Test your plugin's example app with AGP 9+ **without** `android.newDsl=false`. + +### `flutter build aar` + +Variant enumeration for AAR builds now uses the public `components` API. If +your module's build script declares `singleVariant(...)` publishing itself, +Flutter detects the overlap and reports it with an actionable error instead of +failing inside AGP. + +## Escape hatch (temporary) + +If you cannot migrate immediately, add the opt-out by hand to +`android/gradle.properties`: + +```properties +android.newDsl=false +``` + +**This stops working with AGP 10** (removal of the legacy APIs). Treat it as a +short-term unblock only; hand-added opt-outs are never touched by Flutter's +migrator. + +## Timeline + +Landed in version: TBD
+In stable release: TBD + +## References + +- AGP 9 release notes (new DSL): + https://developer.android.com/build/releases/agp-9-0-0-release-notes +- Flutter umbrella issues: + [flutter/flutter#180137](https://github.com/flutter/flutter/issues/180137), + [flutter/flutter#166550](https://github.com/flutter/flutter/issues/166550) From 4aefffe5831273e5e171b0cc59b4e012e65ff545 Mon Sep 17 00:00:00 2001 From: reidbaker-agent Date: Wed, 22 Jul 2026 01:23:23 +0000 Subject: [PATCH 2/5] Remove AGP-internal imports from VersionFetcher and migrate compileSdk/ndk reads to the public DSL Phase P1 of the AGP public-API migration (flutter/flutter#180137, flutter/flutter#166550): - VersionFetcher no longer calls the internal com.android.build.gradle.internal.utils.getKotlinAndroidPluginVersion. getKGPVersion now relies on the existing public fallback chain (kotlin_version property -> KotlinAndroidPluginWrapper.pluginVersion -> reflection) and documents that null is the expected result when KGP is absent (e.g. under AGP built-in Kotlin). DependencyVersionChecker already treats null as "KGP not required". - AgpCommonExtensionWrapper gains compileSdkPreview. - getCompileSdkFromProject now reads compileSdk/compileSdkPreview from the new DSL via the wrapper and returns a structured CompileSdkVersion instead of parsing the legacy "android-NN" string. The PluginHandler compileSdk mismatch warning uses CompileSdkVersion.isHigherThan, which defines the preview semantics the old string comparison got wrong (resolves the PluginHandler TODO): preview > numeric, distinct preview codenames incomparable (no warning), numeric compared numerically. Warning message text is unchanged. - getConfiguredNdkVersion reads through the wrapper instead of the legacy BaseExtension fallback. - Deletes the setAgpKotlinVersionToNull test helper (existed only to stub the internal AGP call) and updates unit tests accordingly; adds tests for both preview-vs-numeric directions and the codename reset case. Verification note: the FGP unit test suite could not be executed in this sandbox (the network policy blocks dl.google.com, so AGP artifacts do not resolve); run 'gradle test' in packages/flutter_tools/gradle in CI. Revert-safe until P2 lands. --- .../main/kotlin/AgpCommonExtensionWrapper.kt | 19 +++++++ .../src/main/kotlin/FlutterPluginUtils.kt | 17 ++++--- .../gradle/src/main/kotlin/VersionFetcher.kt | 48 ++++++++++++++---- .../src/main/kotlin/plugins/PluginHandler.kt | 10 ++-- .../kotlin/DependencyVersionCheckerTest.kt | 2 - .../src/test/kotlin/FlutterPluginUtilsTest.kt | 43 +++++++++++++--- .../src/test/kotlin/VersionFetcherTest.kt | 50 +++++++++++++++++-- .../test/kotlin/plugins/PluginHandlerTest.kt | 24 ++++++--- .../testing/VersionFetcherTestHelper.kt | 22 -------- 9 files changed, 172 insertions(+), 63 deletions(-) delete mode 100644 packages/flutter_tools/gradle/src/test/kotlin/testing/VersionFetcherTestHelper.kt diff --git a/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt b/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt index 7c12c8cd2c4d5..ab0b63c276b67 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt @@ -40,6 +40,25 @@ class AgpCommonExtensionWrapper( } } + var compileSdkPreview: String? + get() = + when (backingExtension) { + is ApplicationExtension -> backingExtension.compileSdkPreview + is LibraryExtension -> backingExtension.compileSdkPreview + is DynamicFeatureExtension -> backingExtension.compileSdkPreview + is TestExtension -> backingExtension.compileSdkPreview + else -> throw IllegalArgumentException(unsupportedMessage()) + } + set(value) { + when (backingExtension) { + is ApplicationExtension -> backingExtension.compileSdkPreview = value + is LibraryExtension -> backingExtension.compileSdkPreview = value + is DynamicFeatureExtension -> backingExtension.compileSdkPreview = value + is TestExtension -> backingExtension.compileSdkPreview = value + else -> throw IllegalArgumentException(unsupportedMessage()) + } + } + var namespace: String? get() = when (backingExtension) { diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt index 50779e6d0b8d0..f9860dbd4f53a 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt @@ -528,18 +528,21 @@ object FlutterPluginUtils { internal fun getAndroidApplicationExtension(project: Project): ApplicationExtension = project.extensions.getByType(ApplicationExtension::class.java) - internal fun getConfiguredNdkVersion(project: Project): String? = - project.extensions.findByType(ApplicationExtension::class.java)?.ndkVersion - ?: getLegacyAndroidExtension(project).ndkVersion + internal fun getConfiguredNdkVersion(project: Project): String? = getAndroidExtension(project).ndkVersion /** - * Expected format of getAndroidExtension(project).compileSdkVersion is a string of the form - * `android-` followed by either the numeric version, e.g. `android-35`, or a preview version, - * e.g. `android-UpsideDownCake`. + * Returns the compile SDK configured on the project's Android extension: the numeric + * API level (`compileSdk = 36`) or a preview codename (`compileSdkPreview = "Baklava"`). */ @JvmStatic @JvmName("getCompileSdkFromProject") - internal fun getCompileSdkFromProject(project: Project): String = getLegacyAndroidExtension(project).compileSdkVersion!!.substring(8) + internal fun getCompileSdkFromProject(project: Project): CompileSdkVersion { + val androidExtension = getAndroidExtension(project) + return CompileSdkVersion( + apiLevel = androidExtension.compileSdk, + previewCodename = androidExtension.compileSdkPreview + ) + } /** * Returns: diff --git a/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt b/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt index 4d6e546feb343..0632bef409c38 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt @@ -6,7 +6,6 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion import com.android.build.api.variant.AndroidComponentsExtension -import com.android.build.gradle.internal.utils.getKotlinAndroidPluginVersion import org.gradle.api.JavaVersion import org.gradle.api.Project import org.jetbrains.kotlin.gradle.plugin.KotlinAndroidPluginWrapper @@ -42,19 +41,16 @@ internal object VersionFetcher { } /** - * Returns the version of the Kotlin Gradle plugin. + * Returns the version of the Kotlin Gradle plugin, or null if it cannot be determined. + * + * Null is an expected result when the Kotlin Gradle plugin has not been applied to the + * project — most notably under AGP's built-in Kotlin support (`android.builtInKotlin`), + * where there is no standalone KGP. Callers must treat null as "unknown/not applied", + * not as an error. */ internal fun getKGPVersion(project: Project): Version? { - // AGP and Kgp have methods for getting kotlin version. - // AGP's method is internal, we try to use it anyway. // KGP's version in org.jetbrains.kotlin.gradle.plugin.DefaultKotlinBasePlugin is not // available when this method is called. - // When testing call `setAgpKotlinVersionToNull(project)`. - val agpDefinedKgpVersion = getKotlinAndroidPluginVersion(project) - if (agpDefinedKgpVersion != null && agpDefinedKgpVersion != "unknown") { - return Version.fromString(agpDefinedKgpVersion) - } - val kotlinVersionProperty = "kotlin_version" val firstKotlinVersionFieldName = "pluginVersion" val secondKotlinVersionFieldName = "kotlinPluginVersion" @@ -127,3 +123,35 @@ internal class Version( override fun toString(): String = "$major.$minor.$patch" } + +/** + * The compile SDK configured on a project's Android extension: either a numeric API level + * (`compileSdk = 36`) or a preview codename (`compileSdkPreview = "Baklava"`). Both are null + * when the DSL has not been configured (yet). + */ +internal data class CompileSdkVersion( + val apiLevel: Int?, + val previewCodename: String? +) { + /** + * Whether this compile SDK is known to be higher than [other]. + * + * - numeric vs numeric: numeric comparison. + * - preview vs numeric: a preview codename targets an unreleased SDK, so it is + * considered higher than any numeric API level. + * - preview vs preview: codenames stopped being alphabetically ordered when the + * alphabet reset at "Baklava", so distinct codenames are incomparable and this + * returns false rather than guessing. + * - if either side is unset, returns false. + */ + fun isHigherThan(other: CompileSdkVersion): Boolean = + when { + previewCodename != null && other.previewCodename != null -> false + previewCodename != null && other.apiLevel != null -> true + apiLevel != null && other.apiLevel != null -> apiLevel > other.apiLevel + else -> false + } + + /** The human-readable form used in log messages, e.g. "35" or "Baklava". */ + override fun toString(): String = previewCodename ?: apiLevel?.toString() ?: "unknown" +} diff --git a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt index bc7717986f5d2..16a8e8d986a30 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt @@ -5,6 +5,7 @@ package com.flutter.gradle.plugins import com.android.builder.model.BuildType +import com.flutter.gradle.CompileSdkVersion import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginUtils import com.flutter.gradle.FlutterPluginUtils.addApiDependencies @@ -121,12 +122,9 @@ class PluginHandler( // Wait until the Android plugin loaded. pluginProject.afterEvaluate { // Checks if there is a mismatch between the plugin compileSdkVersion and the project compileSdkVersion. - val projectCompileSdkVersion: String = getCompileSdkFromProject(project) - val pluginCompileSdkVersion: String = getCompileSdkFromProject(pluginProject) - // TODO(gmackall): This is doing a string comparison, which is odd and also can be wrong - // when comparing preview versions (against non preview, and also in the - // case of alphabet reset which happened with "Baklava". - if (pluginCompileSdkVersion > projectCompileSdkVersion) { + val projectCompileSdkVersion: CompileSdkVersion = getCompileSdkFromProject(project) + val pluginCompileSdkVersion: CompileSdkVersion = getCompileSdkFromProject(pluginProject) + if (pluginCompileSdkVersion.isHigherThan(projectCompileSdkVersion)) { project.logger.quiet( "Warning: The plugin $pluginName requires Android SDK version $pluginCompileSdkVersion or higher." ) diff --git a/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt index 99c7b6291c44d..422097038bf64 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt @@ -30,7 +30,6 @@ import com.flutter.gradle.DependencyVersionChecker.warnAGPVersion import com.flutter.gradle.DependencyVersionChecker.warnGradleVersion import com.flutter.gradle.DependencyVersionChecker.warnKGPVersion import com.flutter.gradle.DependencyVersionChecker.warnMinSdkVersion -import com.flutter.gradle.testing.setAgpKotlinVersionToNull import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -499,7 +498,6 @@ private object MockProjectFactory { } return@answers Unit } - setAgpKotlinVersionToNull(mockProject) return mockProject } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt index 21fc5d8786f9d..d4f25869100cd 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt @@ -616,9 +616,25 @@ class FlutterPluginUtilsTest { @Test fun `getCompileSdkFromProject returns the compileSdk from the project`() { val project = mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns 35 + every { androidExtension.compileSdkPreview } returns null val result = FlutterPluginUtils.getCompileSdkFromProject(project) - assertEquals("35", result) + assertEquals(CompileSdkVersion(apiLevel = 35, previewCodename = null), result) + assertEquals("35", result.toString()) + } + + @Test + fun `getCompileSdkFromProject returns the preview codename from the project`() { + val project = mockk() + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns null + every { androidExtension.compileSdkPreview } returns "Baklava" + val result = FlutterPluginUtils.getCompileSdkFromProject(project) + assertEquals(CompileSdkVersion(apiLevel = null, previewCodename = "Baklava"), result) + assertEquals("Baklava", result.toString()) } @Test @@ -1966,7 +1982,9 @@ class FlutterPluginUtilsTest { every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() every { project.gradle.startParameter.isOffline } returns false - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + val mockAndroidExtension = mockk() + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(capture(execActionSlot)) } answers { File(tempDir.toFile(), "ndk/29.0.13846066/source.properties").apply { @@ -2016,7 +2034,9 @@ class FlutterPluginUtilsTest { every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" every { project.gradle.startParameter.taskNames } returns emptyList() - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + val mockAndroidExtension = mockk() + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" FlutterPluginUtils.forceNdkDownload(project, "/base/path") finalizeDslSlot.captured.invoke(Any()) @@ -2104,7 +2124,9 @@ class FlutterPluginUtilsTest { } returns "26.3.11579264" every { project.gradle.startParameter.taskNames } returns emptyList() every { project.gradle.startParameter.isOffline } returns false - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + val mockAndroidExtension = mockk() + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.ndkVersion } answers { configuredNdkVersion } every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(capture(execActionSlot)) } answers { File(tempDir.toFile(), "ndk/27.3.13750724/source.properties").apply { @@ -2166,6 +2188,7 @@ class FlutterPluginUtilsTest { ) } every { mockApplicationExtension.ndkVersion } answers { configuredNdkVersion } + every { project.extensions.findByName("android") } returns mockApplicationExtension every { mockCmakeOptions.path } returns null every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns tempDir.toString() @@ -2225,6 +2248,9 @@ class FlutterPluginUtilsTest { every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" every { project.gradle.startParameter.taskNames } returns emptyList() + val mockAndroidExtension = mockk() + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" FlutterPluginUtils.forceNdkDownload(project, "/base/path") finalizeDslSlot.captured.invoke(Any()) @@ -2252,6 +2278,7 @@ class FlutterPluginUtilsTest { throw AssertionError("legacy ndkVersion should not be read when ApplicationExtension is available") } every { mockApplicationExtension.ndkVersion } returns "29.0.13846066" + every { project.extensions.findByName("android") } returns mockApplicationExtension every { mockCmakeOptions.path } returns null every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" @@ -2289,7 +2316,9 @@ class FlutterPluginUtilsTest { every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() every { project.gradle.startParameter.isOffline } returns false - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + val mockAndroidExtension = mockk() + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(any>()) } returns mockExecResult every { mockExecResult.assertNormalExitValue() } returns mockExecResult @@ -2496,7 +2525,7 @@ class FlutterPluginUtilsTest { val project = mockk(relaxed = true) val androidExtension = mockk() every { androidExtension.ndkVersion } returns "29.0.13846066" - every { project.extensions.findByType(ApplicationExtension::class.java) } returns androidExtension + every { project.extensions.findByName("android") } returns androidExtension every { project.tasks.register(any(), eq(PrintTask::class.java), any()) } returns mockk() val captureSlot = slot>() diff --git a/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt index 307d3275633ee..257ed7c42f47d 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt @@ -6,7 +6,6 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion import com.android.build.api.variant.AndroidComponentsExtension -import com.flutter.gradle.testing.setAgpKotlinVersionToNull import io.mockk.every import io.mockk.mockk import org.gradle.api.Project @@ -47,7 +46,6 @@ class VersionFetcherTest { fun `getKGPVersion returns version when kotlin_version is set`() { val kgpVersion = Version(1, 9, 20) val project = mockk() - setAgpKotlinVersionToNull(project) every { project.hasProperty(eq("kotlin_version")) } returns true every { project.properties["kotlin_version"] } returns kgpVersion.toString() val result = VersionFetcher.getKGPVersion(project) @@ -58,7 +56,6 @@ class VersionFetcherTest { fun `getKGPVersion returns version from KotlinAndroidPluginWrapper`() { val kgpVersion = Version(1, 9, 20) val project = mockk() - setAgpKotlinVersionToNull(project) every { project.hasProperty(eq("kotlin_version")) } returns false every { project.plugins.findPlugin(KotlinAndroidPluginWrapper::class.java) } returns mockk { @@ -67,4 +64,51 @@ class VersionFetcherTest { val result = VersionFetcher.getKGPVersion(project) assertEquals(kgpVersion, result!!) } + + @Test + fun `getKGPVersion returns null when the Kotlin Gradle plugin is absent`() { + // Expected under AGP's built-in Kotlin support, where no standalone KGP is applied. + val project = mockk() + every { project.hasProperty(eq("kotlin_version")) } returns false + every { project.plugins.findPlugin(KotlinAndroidPluginWrapper::class.java) } returns null + val result = VersionFetcher.getKGPVersion(project) + assertEquals(null, result) + } + + // CompileSdkVersion.isHigherThan + @Test + fun `isHigherThan compares numeric api levels numerically`() { + val sdk35 = CompileSdkVersion(apiLevel = 35, previewCodename = null) + val sdk36 = CompileSdkVersion(apiLevel = 36, previewCodename = null) + assertEquals(true, sdk36.isHigherThan(sdk35)) + assertEquals(false, sdk35.isHigherThan(sdk36)) + assertEquals(false, sdk35.isHigherThan(sdk35)) + } + + @Test + fun `isHigherThan treats a preview codename as higher than any numeric api level`() { + val preview = CompileSdkVersion(apiLevel = null, previewCodename = "Baklava") + val numeric = CompileSdkVersion(apiLevel = 36, previewCodename = null) + assertEquals(true, preview.isHigherThan(numeric)) + assertEquals(false, numeric.isHigherThan(preview)) + } + + @Test + fun `isHigherThan treats distinct preview codenames as incomparable`() { + // Codenames stopped being alphabetically ordered at the "Baklava" alphabet reset, + // so neither side may claim to be higher. + val baklava = CompileSdkVersion(apiLevel = null, previewCodename = "Baklava") + val vanilla = CompileSdkVersion(apiLevel = null, previewCodename = "VanillaIceCream") + assertEquals(false, baklava.isHigherThan(vanilla)) + assertEquals(false, vanilla.isHigherThan(baklava)) + assertEquals(false, baklava.isHigherThan(baklava)) + } + + @Test + fun `isHigherThan returns false when either side is unset`() { + val unset = CompileSdkVersion(apiLevel = null, previewCodename = null) + val numeric = CompileSdkVersion(apiLevel = 36, previewCodename = null) + assertEquals(false, unset.isHigherThan(numeric)) + assertEquals(false, numeric.isHigherThan(unset)) + } } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt index 111ef311b90ce..accd03bc3d4b3 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt @@ -4,6 +4,7 @@ package com.flutter.gradle.plugins +import com.android.build.api.dsl.ApplicationExtension import com.android.build.gradle.BaseExtension import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginUtils @@ -34,6 +35,17 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class PluginHandlerTest { + /** Mocks the new-DSL android extension read by [FlutterPluginUtils.getCompileSdkFromProject]. */ + private fun mockCompileSdk( + project: Project, + compileSdk: Int + ) { + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns compileSdk + every { androidExtension.compileSdkPreview } returns null + } + // getPluginListWithoutDevDependencies @Test fun `getPluginListWithoutDevDependencies removes dev dependencies from list`() { @@ -214,8 +226,8 @@ class PluginHandlerTest { mockBuildType ).iterator() every { project.dependencies.add(any(), any()) } returns mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + mockCompileSdk(project, compileSdk = 35) + mockCompileSdk(pluginProject, compileSdk = 35) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -310,8 +322,8 @@ class PluginHandlerTest { mockBuildType ).iterator() every { project.dependencies.add(any(), any()) } returns mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + mockCompileSdk(project, compileSdk = 35) + mockCompileSdk(pluginProject, compileSdk = 35) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -452,8 +464,8 @@ class PluginHandlerTest { every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() every { project.dependencies.add(any(), any()) } returns mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + mockCompileSdk(project, compileSdk = 35) + mockCompileSdk(pluginProject, compileSdk = 35) } private fun setupPluginMocks(project: Project) { diff --git a/packages/flutter_tools/gradle/src/test/kotlin/testing/VersionFetcherTestHelper.kt b/packages/flutter_tools/gradle/src/test/kotlin/testing/VersionFetcherTestHelper.kt deleted file mode 100644 index 60dd6ef657d45..0000000000000 --- a/packages/flutter_tools/gradle/src/test/kotlin/testing/VersionFetcherTestHelper.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.flutter.gradle.testing - -import io.mockk.every -import io.mockk.mockk -import org.gradle.api.Project -import org.jetbrains.kotlin.gradle.plugin.KotlinBaseApiPlugin - -/** - * Prevent AGP's kotlin version checker from throwing `no answer found` - * - * Intended to be called by tests that call `VersionFetcher.getKGPVersion(project)` - * and who do not care about the internal implementation of - * `com.android.build.gradle.internal.utils.getKotlinAndroidPluginVersion` - */ -internal fun setAgpKotlinVersionToNull(mockProject: Project) { - // The internals of `getKotlinAndroidPluginVersion` depend on `getKotlinPluginVersionFromPlugin` - // which relies on reflection to get the value. Instead make sure fetching the plugin has valid - // response then rely on the default behavior in `getKotlinPluginVersionFromPlugin` to - // return null. - every { mockProject.plugins.findPlugin(any>()) } returns mockk() - every { mockProject.plugins.findPlugin("kotlin-android") } returns mockk() -} From 837330296239879ec44631adfde3c1978c5bdc6d Mon Sep 17 00:00:00 2001 From: reidbaker-agent Date: Wed, 22 Jul 2026 01:34:22 +0000 Subject: [PATCH 3/5] Add public buildModeFor overloads and wire Flutter dependencies through the new DSL Phase P2 of the AGP public-API migration (flutter/flutter#180137, flutter/flutter#166550): - buildModeFor gains a (buildTypeName, isDebuggable) core overload and a new-DSL BuildType overload. Application and dynamic-feature build types use their public isDebuggable flag; library build types have no public debuggable signal at DSL scope, so the conventional "debug" name is used for them. The legacy com.android.builder.model.BuildType overload remains for the variant-scope call sites that migrate in later phases. - addFlutterDependencies (engine/embedding deps) now takes a new-DSL BuildType, and FlutterPlugin registers it via the wrapper's buildTypes container instead of the legacy BaseExtension. - PluginHandler's three dependency-wiring loops (per-build-type Api wiring, embedding deps, plugin-to-plugin deps) iterate the wrapper container. The build-type copy block still uses the legacy container and internal.dsl.BuildType; that is phase P3. - build.gradle.kts accepts -PagpVersion= so CI can compile and test the plugin against the AGP 9 line in addition to the default (the public DSL is not binary-compatible between AGP 8 and 9 everywhere), and a new validateNoCommonExtensionInBytecode task fails the build if any compiled main class references CommonExtension (the known-broken type that AgpCommonExtensionWrapper exists to avoid). - android_run_flutter_gradle_plugin_tests_test.dart gains a second test running the suite with -PagpVersion=. - PluginHandlerTest no longer depends on exhausted-iterator mock behavior; the wrapper container returns a fresh iterator per call. Verification note: FGP unit tests could not be executed in this sandbox (network policy blocks dl.google.com); run 'gradle test' and 'gradle -PagpVersion=9.1.0 test' in packages/flutter_tools/gradle in CI. Revert-safe until P3 lands. --- .../flutter_tools/gradle/build.gradle.kts | 56 +++++++++++++++- .../gradle/src/main/kotlin/FlutterPlugin.kt | 4 +- .../src/main/kotlin/FlutterPluginUtils.kt | 46 +++++++++++-- .../src/main/kotlin/plugins/PluginHandler.kt | 9 +-- .../src/test/kotlin/FlutterPluginUtilsTest.kt | 38 +++++++++-- .../test/kotlin/plugins/PluginHandlerTest.kt | 65 +++++++------------ ..._run_flutter_gradle_plugin_tests_test.dart | 47 ++++++++++---- 7 files changed, 194 insertions(+), 71 deletions(-) diff --git a/packages/flutter_tools/gradle/build.gradle.kts b/packages/flutter_tools/gradle/build.gradle.kts index dd7341f6c0ee5..17606834c8f6c 100644 --- a/packages/flutter_tools/gradle/build.gradle.kts +++ b/packages/flutter_tools/gradle/build.gradle.kts @@ -49,6 +49,11 @@ tasks.withType { } } +// The AGP version the Flutter Gradle Plugin compiles and tests against. CI additionally runs +// this build with -PagpVersion= to catch public-DSL binary incompatibilities +// between the AGP major versions the plugin supports (see AgpCommonExtensionWrapper). +val agpVersion: String = providers.gradleProperty("agpVersion").getOrElse("8.11.1") + dependencies { // Versions available https://mvnrepository.com/artifact/androidx.annotation/annotation-jvm. // Version release notes https://developer.android.com/jetpack/androidx/releases/annotation @@ -61,14 +66,59 @@ dependencies { // All kotlinx implementation dependencies must work with the oldest kotlin supported versions. // Defined in packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") - // When bumping, also update: + // When bumping the default agpVersion above, also update: // * AGP version constants in packages/flutter_tools/lib/src/android/gradle_utils.dart // * ndkVersion constant in packages/flutter_tools/lib/src/android/gradle_utils.dart // * ndkVersion in FlutterExtension in packages/flutter_tools/gradle/src/main/kotlin/FlutterExtension.kt - compileOnly("com.android.tools.build:gradle:8.11.1") + compileOnly("com.android.tools.build:gradle:$agpVersion") testImplementation(kotlin("test")) - testImplementation("com.android.tools.build:gradle:8.11.1") + testImplementation("com.android.tools.build:gradle:$agpVersion") testImplementation("org.mockito:mockito-core:5.8.0") testImplementation("io.mockk:mockk:1.13.16") } + +// CommonExtension is binary-incompatible between AGP 8 and 9, which is why DSL access is +// routed through AgpCommonExtensionWrapper. If the compiler emits a reference to +// CommonExtension as the owner of a call, the plugin breaks on one of the two AGP lines at +// runtime even though it compiles on both. Fail fast if such a reference appears in the +// main bytecode (test bytecode intentionally references AGP types directly). +val validateNoCommonExtensionInBytecode by tasks.registering { + description = + "Checks that no compiled main class references com.android.build.api.dsl.CommonExtension." + dependsOn(tasks.named("compileKotlin")) + val classesDir = layout.buildDirectory.dir("classes/kotlin/main") + doLast { + val needle = "com/android/build/api/dsl/CommonExtension".toByteArray(Charsets.ISO_8859_1) + val offenders = + classesDir + .get() + .asFile + .walkTopDown() + .filter { it.isFile && it.extension == "class" } + .filter { file -> + val bytes = file.readBytes() + (0..bytes.size - needle.size).any { start -> + needle.indices.all { i -> bytes[start + i] == needle[i] } + } + }.map { it.name } + .toList() + if (offenders.isNotEmpty()) { + throw GradleException( + "CommonExtension must not be referenced from Flutter Gradle Plugin bytecode " + + "(it is binary-incompatible between AGP 8 and 9). Route DSL access through " + + "AgpCommonExtensionWrapper instead. Offending classes: $offenders" + ) + } + } +} + +// Attached to `test` (not just `check`) because CI drives this build through `gradlew test` +// (see packages/flutter_tools/test/integration.shard/android_run_flutter_gradle_plugin_tests_test.dart). +tasks.test { + dependsOn(validateNoCommonExtensionInBytecode) +} + +tasks.named("check") { + dependsOn(validateNoCommonExtensionInBytecode) +} diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt index 806fc5097a022..a7880c247a210 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt @@ -246,12 +246,12 @@ class FlutterPlugin : Plugin { } localEngineHost = engineHostOut.name } - FlutterPluginUtils.getLegacyAndroidExtension(project).buildTypes.all { + FlutterPluginUtils.getAndroidExtension(project).buildTypes.all { addFlutterDependencies(this) } } - private fun addFlutterDependencies(buildType: com.android.builder.model.BuildType) { + private fun addFlutterDependencies(buildType: BuildType) { FlutterPluginUtils.addFlutterDependencies( project!!, buildType, diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt index f9860dbd4f53a..6fed48d0e7244 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt @@ -6,7 +6,9 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.DynamicFeatureBuildType import com.android.build.api.dsl.LibraryExtension import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.gradle.BaseExtension @@ -28,6 +30,7 @@ import java.io.File import java.io.IOException import java.nio.charset.StandardCharsets import java.util.Properties +import com.android.build.api.dsl.BuildType as DslBuildType /** * A collection of static utility functions used by the Flutter Gradle Plugin. @@ -469,15 +472,50 @@ object FlutterPluginUtils { */ @JvmStatic @JvmName("buildModeFor") - internal fun buildModeFor(buildType: BuildType): String { - if (buildType.name == "profile") { + internal fun buildModeFor(buildType: BuildType): String = buildModeFor(buildType.name, buildType.isDebuggable) + + /** + * Returns a Flutter build mode for a build type identified by [buildTypeName] and its + * [isDebuggable] flag. + * + * Variant-scope callers must pass the public `Component.debuggable` flag so that custom + * debuggable build types (e.g. a host app's `staging`) map to the debug engine artifacts. + * + * @return "debug", "profile", or "release" (fall-back). + */ + @JvmStatic + @JvmName("buildModeFor") + internal fun buildModeFor( + buildTypeName: String, + isDebuggable: Boolean + ): String { + if (buildTypeName == "profile") { return "profile" - } else if (buildType.isDebuggable) { + } else if (isDebuggable) { return "debug" } return "release" } + /** + * Returns a Flutter build mode for a new-DSL [buildType]. + * + * Application and dynamic-feature build types expose a public `isDebuggable` flag. + * Library build types do not, so for them the conventional "debug" name is the only + * public signal available at DSL scope. + */ + @JvmStatic + @JvmName("buildModeFor") + internal fun buildModeFor(buildType: DslBuildType): String { + val isDebuggable = + when (buildType) { + is ApplicationBuildType -> buildType.isDebuggable + is DynamicFeatureBuildType -> buildType.isDebuggable + else -> buildType.name == "debug" + } + return buildModeFor(buildType.name, isDebuggable) + } + /** * Returns true if the build mode is supported by the current call to Gradle. * This only relevant when using a local engine. Because the engine @@ -986,7 +1024,7 @@ object FlutterPluginUtils { @JvmName("addFlutterDependencies") internal fun addFlutterDependencies( project: Project, - buildType: BuildType, + buildType: DslBuildType, pluginHandler: PluginHandler, engineVersion: String ) { diff --git a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt index 16a8e8d986a30..d5b1042a3f6a8 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt @@ -4,12 +4,13 @@ package com.flutter.gradle.plugins -import com.android.builder.model.BuildType +import com.android.build.api.dsl.BuildType import com.flutter.gradle.CompileSdkVersion import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginUtils import com.flutter.gradle.FlutterPluginUtils.addApiDependencies import com.flutter.gradle.FlutterPluginUtils.buildModeFor +import com.flutter.gradle.FlutterPluginUtils.getAndroidExtension import com.flutter.gradle.FlutterPluginUtils.getCompileSdkFromProject import com.flutter.gradle.FlutterPluginUtils.getLegacyAndroidExtension import com.flutter.gradle.FlutterPluginUtils.isBuiltAsApp @@ -112,7 +113,7 @@ class PluginHandler( // Add plugin dependency to the app project. We only want to add dependency // for dev dependencies in non-release builds. project.afterEvaluate { - getLegacyAndroidExtension(project).buildTypes.forEach { buildType -> + getAndroidExtension(project).buildTypes.forEach { buildType -> if (!(pluginObject["dev_dependency"] as Boolean) || buildType.name != "release") { project.dependencies.add("${buildType.name}Api", pluginProject) } @@ -133,7 +134,7 @@ class PluginHandler( ) } - getLegacyAndroidExtension(project).buildTypes.forEach { buildType -> + getAndroidExtension(project).buildTypes.forEach { buildType -> addEmbeddingDependencyToPlugin(project, pluginProject, buildType, engineVersion) } } @@ -213,7 +214,7 @@ class PluginHandler( } val pluginProject: Project = project.rootProject.findProject(":$pluginName") ?: return - getLegacyAndroidExtension(project).buildTypes.forEach { buildType -> + getAndroidExtension(project).buildTypes.forEach { buildType -> val flutterBuildMode: String = buildModeFor(buildType) if (flutterBuildMode == "release" && (pluginObject["dev_dependency"] as? Boolean == true)) { // This plugin is a dev dependency will not be included in the diff --git a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt index d4f25869100cd..77c1a5cb88269 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt @@ -5,7 +5,9 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion +import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryBuildType import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.Variant import com.android.build.api.variant.VariantBuilder @@ -528,6 +530,34 @@ class FlutterPluginUtilsTest { assertEquals("release", result) } + @Test + fun `buildModeFor with a name and debuggable flag prefers the profile name over debuggability`() { + assertEquals("profile", FlutterPluginUtils.buildModeFor("profile", isDebuggable = true)) + assertEquals("debug", FlutterPluginUtils.buildModeFor("staging", isDebuggable = true)) + assertEquals("release", FlutterPluginUtils.buildModeFor("staging", isDebuggable = false)) + } + + @Test + fun `buildModeFor reads isDebuggable from new-DSL application build types`() { + val buildType = mockk() + every { buildType.name } returns "staging" + every { buildType.isDebuggable } returns true + + assertEquals("debug", FlutterPluginUtils.buildModeFor(buildType)) + } + + @Test + fun `buildModeFor falls back to the conventional debug name for new-DSL library build types`() { + // LibraryBuildType has no public isDebuggable flag, so the name is the only signal. + val debugBuildType = mockk() + every { debugBuildType.name } returns "debug" + assertEquals("debug", FlutterPluginUtils.buildModeFor(debugBuildType)) + + val customBuildType = mockk() + every { customBuildType.name } returns "staging" + assertEquals("release", FlutterPluginUtils.buildModeFor(customBuildType)) + } + // supportsBuildMode @Test fun `supportsBuildMode returns true if project should not use local engine`() { @@ -2552,7 +2582,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithoutDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() every { buildType.name } returns "debug" every { buildType.isDebuggable } returns true every { project.hasProperty("local-engine-repo") } returns true @@ -2584,7 +2614,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithoutDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() val engineVersion = EXAMPLE_ENGINE_VERSION every { buildType.name } returns "debug" every { buildType.isDebuggable } returns true @@ -2622,7 +2652,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithSingleDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() val engineVersion = EXAMPLE_ENGINE_VERSION every { buildType.name } returns "release" every { buildType.isDebuggable } returns false @@ -2676,7 +2706,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithSingleDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() val engineVersion = EXAMPLE_ENGINE_VERSION every { buildType.name } returns "debug" every { buildType.isDebuggable } returns true diff --git a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt index accd03bc3d4b3..a5687d581f869 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt @@ -4,6 +4,7 @@ package com.flutter.gradle.plugins +import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension import com.android.build.gradle.BaseExtension import com.flutter.gradle.FlutterExtension @@ -35,15 +36,24 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class PluginHandlerTest { - /** Mocks the new-DSL android extension read by [FlutterPluginUtils.getCompileSdkFromProject]. */ - private fun mockCompileSdk( + /** + * Mocks the new-DSL android extension read through [FlutterPluginUtils.getAndroidExtension] + * (compileSdk for the mismatch warning, buildTypes for the dependency-wiring loops). + */ + private fun mockAndroidExtension( project: Project, - compileSdk: Int - ) { + compileSdk: Int = 35, + buildTypes: List = emptyList() + ): ApplicationExtension { val androidExtension = mockk() every { project.extensions.findByName("android") } returns androidExtension every { androidExtension.compileSdk } returns compileSdk every { androidExtension.compileSdkPreview } returns null + val container = mockk>() + // A fresh iterator per call: the container is iterated by multiple loops. + every { container.iterator() } answers { buildTypes.toMutableList().iterator() } + every { androidExtension.buildTypes } returns container + return androidExtension } // getPluginListWithoutDevDependencies @@ -210,24 +220,14 @@ class PluginHandlerTest { every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .buildTypes - .iterator() - } returns - mutableListOf( - mockBuildType - ).iterator() andThen - mutableListOf( // can't return the same iterator as it is stateful - mockBuildType - ).iterator() andThen - mutableListOf( // and again - mockBuildType - ).iterator() + // The new-DSL dependency-wiring loops iterate the wrapper container; the legacy + // container is only read by the build-type copy block, which this test leaves empty. + every { mockProjectBuildTypes.iterator() } answers { + mutableListOf().iterator() + } every { project.dependencies.add(any(), any()) } returns mockk() - mockCompileSdk(project, compileSdk = 35) - mockCompileSdk(pluginProject, compileSdk = 35) + mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) + mockAndroidExtension(pluginProject) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -306,24 +306,9 @@ class PluginHandlerTest { every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .buildTypes - .iterator() - } returns - mutableListOf( - mockBuildType - ).iterator() andThen - mutableListOf( // can't return the same iterator as it is stateful - mockBuildType - ).iterator() andThen - mutableListOf( // and again - mockBuildType - ).iterator() every { project.dependencies.add(any(), any()) } returns mockk() - mockCompileSdk(project, compileSdk = 35) - mockCompileSdk(pluginProject, compileSdk = 35) + mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) + mockAndroidExtension(pluginProject) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -464,8 +449,8 @@ class PluginHandlerTest { every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() every { project.dependencies.add(any(), any()) } returns mockk() - mockCompileSdk(project, compileSdk = 35) - mockCompileSdk(pluginProject, compileSdk = 35) + mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) + mockAndroidExtension(pluginProject) } private fun setupPluginMocks(project: Project) { diff --git a/packages/flutter_tools/test/integration.shard/android_run_flutter_gradle_plugin_tests_test.dart b/packages/flutter_tools/test/integration.shard/android_run_flutter_gradle_plugin_tests_test.dart index 2aee2296511b4..913979e1a44aa 100644 --- a/packages/flutter_tools/test/integration.shard/android_run_flutter_gradle_plugin_tests_test.dart +++ b/packages/flutter_tools/test/integration.shard/android_run_flutter_gradle_plugin_tests_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; +import 'package:flutter_tools/src/android/gradle_utils.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/process.dart'; import 'package:flutter_tools/src/globals.dart' as globals; @@ -12,22 +13,40 @@ import '../src/common.dart'; import '../src/context.dart'; import 'test_utils.dart'; +Future runFlutterGradlePluginTests({ + List extraGradleArguments = const [], +}) async { + final gradleFileName = Platform.isWindows ? 'gradlew.bat' : 'gradlew'; + final gradleExecutable = Platform.isWindows ? '.\\$gradleFileName' : './$gradleFileName'; + final Directory flutterGradlePluginDirectory = fileSystem + .directory(getFlutterRoot()) + .childDirectory('packages') + .childDirectory('flutter_tools') + .childDirectory('gradle'); + globals.gradleUtils?.injectGradleWrapperIfNeeded(flutterGradlePluginDirectory); + makeExecutable(flutterGradlePluginDirectory.childFile(gradleFileName)); + final RunResult runResult = await globals.processUtils.run([ + gradleExecutable, + 'test', + ...extraGradleArguments, + ], workingDirectory: flutterGradlePluginDirectory.path); + expect(runResult.processResult, const ProcessResultMatcher()); +} + void main() { testUsingContext('Flutter Gradle Plugin unit tests pass', () async { - final gradleFileName = Platform.isWindows ? 'gradlew.bat' : 'gradlew'; - final gradleExecutable = Platform.isWindows ? '.\\$gradleFileName' : './$gradleFileName'; - final Directory flutterGradlePluginDirectory = fileSystem - .directory(getFlutterRoot()) - .childDirectory('packages') - .childDirectory('flutter_tools') - .childDirectory('gradle'); - globals.gradleUtils?.injectGradleWrapperIfNeeded(flutterGradlePluginDirectory); - makeExecutable(flutterGradlePluginDirectory.childFile(gradleFileName)); - final RunResult runResult = await globals.processUtils.run([ - gradleExecutable, - 'test', - ], workingDirectory: flutterGradlePluginDirectory.path); - expect(runResult.processResult, const ProcessResultMatcher()); + await runFlutterGradlePluginTests(); + }); + + testUsingContext('Flutter Gradle Plugin unit tests pass against the AGP 9 line', () async { + // The public AGP DSL is not binary-compatible between major versions everywhere (see + // AgpCommonExtensionWrapper.kt), so the plugin must compile and pass its tests against + // both the default AGP version in build.gradle.kts and the AGP version used by the + // project templates. This also runs the bytecode check that no compiled class + // references CommonExtension. + await runFlutterGradlePluginTests( + extraGradleArguments: ['-PagpVersion=$templateAndroidGradlePluginVersion'], + ); }); } From a68c6915f1a7ce6ce975527accb758e3f2895f07 Mon Sep 17 00:00:00 2001 From: reidbaker-agent Date: Wed, 22 Jul 2026 01:41:45 +0000 Subject: [PATCH 4/5] Replace the plugin build-type copy with initWith on the public DSL Phase P3 of the AGP public-API migration (flutter/flutter#180137, flutter/flutter#166550): - PluginHandler no longer imports com.android.build.gradle.internal.dsl.BuildType. The build-type copy block that shared live legacy BuildType instances (addAll) for app-type plugin projects and hand-copied two properties for library plugin projects is replaced by a single initWith-based copy on the new-DSL containers: missing build types are created on the plugin project with initWith(appBuildType) (which carries matchingFallbacks), and isDebuggable is additionally copied when both sides are application build types. Library build types cannot receive app-specific properties through the public DSL - this is a documented behavior change of the migration (BuildConfig.DEBUG / JNI debuggability of plugins built for custom debuggable build types). - Production sources are now free of com.android.build.gradle.internal imports; InternalAgpApiImportTest locks that in (test sources may still use internals until the gradle-api dependency swap). - PluginHandlerTest: the two mock-only copy tests (which never invoked configurePlugins) are replaced with tests that run configurePlugins and assert the initWith copy for both a library plugin project and an app plugin project, including the custom-debuggable-build-type -> debug engine artifact mapping. - The planned pre-spike (afterEvaluate DSL mutation under newDsl=true) could not run in this sandbox; recorded in the migration doc with the finalizeDsl fallback. android_plugin_example_app_build and a custom build-type scratch build must confirm in CI. Verification note: FGP unit tests could not be executed in this sandbox (network policy blocks dl.google.com); run 'gradle test' (both AGP axes) in packages/flutter_tools/gradle in CI. Revert-safe until P4 lands. --- ...Flutter-Gradle-Plugin-to-AGP-public-API.md | 8 + .../src/main/kotlin/plugins/PluginHandler.kt | 32 ++-- .../test/kotlin/InternalAgpApiImportTest.kt | 51 +++++ .../test/kotlin/plugins/PluginHandlerTest.kt | 180 ++++++++---------- 4 files changed, 156 insertions(+), 115 deletions(-) create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt diff --git a/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md b/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md index 43a37eea10412..1589905bb415f 100644 --- a/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md +++ b/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md @@ -77,6 +77,14 @@ projects. That opt-out dies with AGP 10. 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)_. +6. **P3 pre-spike (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. 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 diff --git a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt index d5b1042a3f6a8..5a8803228e445 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt @@ -4,6 +4,7 @@ package com.flutter.gradle.plugins +import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.BuildType import com.flutter.gradle.CompileSdkVersion import com.flutter.gradle.FlutterExtension @@ -12,15 +13,11 @@ import com.flutter.gradle.FlutterPluginUtils.addApiDependencies import com.flutter.gradle.FlutterPluginUtils.buildModeFor import com.flutter.gradle.FlutterPluginUtils.getAndroidExtension import com.flutter.gradle.FlutterPluginUtils.getCompileSdkFromProject -import com.flutter.gradle.FlutterPluginUtils.getLegacyAndroidExtension -import com.flutter.gradle.FlutterPluginUtils.isBuiltAsApp import com.flutter.gradle.FlutterPluginUtils.supportsBuildMode import com.flutter.gradle.NativePluginLoaderReflectionBridge -import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Project import org.jetbrains.kotlin.gradle.plugin.extraProperties import java.io.File -import com.android.build.gradle.internal.dsl.BuildType as dslBuildType /** * Handles interactions with the flutter plugins (not Gradle plugins) used by the Flutter project, @@ -159,22 +156,19 @@ class PluginHandler( return } - // Copy build types from the app to the plugin. - // This allows to build apps with plugins and custom build types or flavors. - // However, only copy if the plugin is also an app project, since library projects - // cannot have applicationIdSuffix and other app-specific properties. - if (isBuiltAsApp(pluginProject)) { - (getLegacyAndroidExtension(pluginProject).buildTypes as NamedDomainObjectContainer) - .addAll(getLegacyAndroidExtension(project).buildTypes as NamedDomainObjectContainer) - } else { - // For library projects, create compatible build types without app-specific properties - getLegacyAndroidExtension(project).buildTypes.forEach { appBuildType -> - if (getLegacyAndroidExtension(pluginProject).buildTypes.findByName(appBuildType.name) == null) { - getLegacyAndroidExtension(pluginProject).buildTypes.create(appBuildType.name) { - // Copy library-compatible properties only + // Copy the app project's build types onto the plugin project so that its variants + // resolve. These are `initWith` copies, not live aliases: `initWith` copies the + // properties both build types understand (matchingFallbacks included), and + // app-specific properties are additionally copied when both sides are application + // build types. Library build types cannot receive app-specific properties (such + // as isDebuggable) through the public DSL. + val pluginProjectBuildTypes = getAndroidExtension(pluginProject).buildTypes + getAndroidExtension(project).buildTypes.forEach { appBuildType -> + if (pluginProjectBuildTypes.findByName(appBuildType.name) == null) { + pluginProjectBuildTypes.create(appBuildType.name) { + initWith(appBuildType) + if (this is ApplicationBuildType && appBuildType is ApplicationBuildType) { isDebuggable = appBuildType.isDebuggable - isMinifyEnabled = appBuildType.isMinifyEnabled - // Note: applicationIdSuffix and other app-specific properties are intentionally not copied } } } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt new file mode 100644 index 0000000000000..ade062a1a473f --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt @@ -0,0 +1,51 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package com.flutter.gradle + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Guards the AGP public-API migration (https://github.com/flutter/flutter/issues/180137): + * production sources must not use AGP internals. AGP 10 removes access to internals + * entirely, and the Flutter Gradle Plugin will compile against the `gradle-api` artifact, + * where they do not exist. Test sources may still reference internal types until the + * dependency swap. + */ +class InternalAgpApiImportTest { + @Test + fun `main sources do not import AGP internals`() { + // The Gradle test JVM runs with the project directory + // (packages/flutter_tools/gradle) as its working directory. + val mainSources = File("src/main") + assertTrue( + mainSources.isDirectory, + "Expected to find src/main relative to the test working directory " + + "(${File(".").absolutePath})." + ) + val internalImport = Regex("""^\s*import\s+com\.android\.build\.gradle\.internal\.""") + val offendingLines = + mainSources + .walkTopDown() + .filter { it.isFile && it.extension in setOf("kt", "java", "groovy", "gradle") } + .flatMap { file -> + file.readLines().mapIndexedNotNull { index, line -> + if (internalImport.containsMatchIn(line)) { + "${file.path}:${index + 1}: ${line.trim()}" + } else { + null + } + } + }.toList() + assertTrue( + offendingLines.isEmpty(), + "AGP internal APIs must not be used in production sources; they are removed in " + + "AGP 10. Use the public com.android.build.api surface (see " + + "docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md).\n" + + offendingLines.joinToString("\n") + ) + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt index a5687d581f869..2c36c24729717 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt @@ -6,7 +6,8 @@ package com.flutter.gradle.plugins import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension -import com.android.build.gradle.BaseExtension +import com.android.build.api.dsl.LibraryBuildType +import com.android.build.api.dsl.LibraryExtension import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginUtils import com.flutter.gradle.FlutterPluginUtilsTest.Companion.EXAMPLE_ENGINE_VERSION @@ -38,13 +39,14 @@ import kotlin.test.assertTrue class PluginHandlerTest { /** * Mocks the new-DSL android extension read through [FlutterPluginUtils.getAndroidExtension] - * (compileSdk for the mismatch warning, buildTypes for the dependency-wiring loops). + * (compileSdk for the mismatch warning, buildTypes for the dependency-wiring loops and the + * build-type copy block). */ private fun mockAndroidExtension( project: Project, compileSdk: Int = 35, buildTypes: List = emptyList() - ): ApplicationExtension { + ): NamedDomainObjectContainer { val androidExtension = mockk() every { project.extensions.findByName("android") } returns androidExtension every { androidExtension.compileSdk } returns compileSdk @@ -52,8 +54,29 @@ class PluginHandlerTest { val container = mockk>() // A fresh iterator per call: the container is iterated by multiple loops. every { container.iterator() } answers { buildTypes.toMutableList().iterator() } + // By default every name already exists on the container, so the build-type copy block + // does not create copies. Tests exercising the copy override findByName per name. + every { container.findByName(any()) } returns mockk(relaxed = true) every { androidExtension.buildTypes } returns container - return androidExtension + return container + } + + /** + * Like [mockAndroidExtension], but for a library (plugin) project whose build types are + * [LibraryBuildType]s without app-specific properties. + */ + private fun mockLibraryAndroidExtension( + project: Project, + compileSdk: Int = 35 + ): NamedDomainObjectContainer { + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns compileSdk + every { androidExtension.compileSdkPreview } returns null + val container = mockk>() + every { container.iterator() } answers { mutableListOf().iterator() } + every { androidExtension.buildTypes } returns container + return container } // getPluginListWithoutDevDependencies @@ -194,7 +217,7 @@ class PluginHandlerTest { val pluginProject = mockk() val pluginDependencyProject = mockk() - val mockBuildType = mockk() + val mockBuildType = mockk() every { pluginProject.hasProperty("local-engine-repo") } returns false every { pluginProject.hasProperty("android") } returns true val mockPluginContainer = mockk() @@ -210,24 +233,11 @@ class PluginHandlerTest { every { project.afterEvaluate(any>()) } returns Unit every { pluginProject.afterEvaluate(any>()) } returns Unit - val mockProjectBuildTypes = - mockk>() - val mockPluginProjectBuildTypes = - mockk>() - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.addAll(any()) } returns true every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() - - // The new-DSL dependency-wiring loops iterate the wrapper container; the legacy - // container is only read by the build-type copy block, which this test leaves empty. - every { mockProjectBuildTypes.iterator() } answers { - mutableListOf().iterator() - } every { project.dependencies.add(any(), any()) } returns mockk() mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) - mockAndroidExtension(pluginProject) + val pluginProjectBuildTypes = mockAndroidExtension(pluginProject) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -262,8 +272,10 @@ class PluginHandlerTest { } verify { project.dependencies.add("debugApi", pluginProject) } verify { mockLogger wasNot called } - // For library projects, individual build types should be created, not addAll - verify(exactly = 0) { mockPluginProjectBuildTypes.addAll(any()) } + // The "debug" build type already exists on the plugin project, so no copy is created. + verify(exactly = 0) { + pluginProjectBuildTypes.create(any(), any>()) + } verify { pluginProject.dependencies.add("implementation", pluginDependencyProject) } } @@ -284,7 +296,7 @@ class PluginHandlerTest { every { project.logger } returns mockLogger val pluginProject = mockk() - val mockBuildType = mockk() + val mockBuildType = mockk() every { pluginProject.hasProperty("local-engine-repo") } returns false every { pluginProject.hasProperty("android") } returns true every { mockBuildType.name } returns "debug" @@ -296,16 +308,8 @@ class PluginHandlerTest { every { project.afterEvaluate(any>()) } returns Unit every { pluginProject.afterEvaluate(any>()) } returns Unit - val mockProjectBuildTypes = - mockk>() - val mockPluginProjectBuildTypes = - mockk>() - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.addAll(any()) } returns true every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() - every { project.dependencies.add(any(), any()) } returns mockk() mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) mockAndroidExtension(pluginProject) @@ -330,97 +334,83 @@ class PluginHandlerTest { } @Test - fun `configurePlugins uses addAll for app plugins`( + fun `configurePlugins copies missing app build types onto library plugin projects using initWith`( @TempDir tempDir: Path ) { val project = mockk() val pluginProject = mockk() + val appBuildType = mockk() + every { appBuildType.name } returns "staging" + every { appBuildType.isDebuggable } returns true - // Setup minimal mocks - setupBasicMocks(project, pluginProject, mockk(), tempDir) + setupBasicMocks(project, pluginProject, appBuildType, tempDir) setupPluginMocks(project) + // The plugin project is an Android library: its build types are LibraryBuildTypes, + // which cannot receive app-specific properties such as isDebuggable. + val pluginProjectBuildTypes = mockLibraryAndroidExtension(pluginProject) + every { pluginProjectBuildTypes.findByName("staging") } returns null + val createdBuildType = mockk(relaxed = true) + val createActionSlot = slot>() + every { + pluginProjectBuildTypes.create("staging", capture(createActionSlot)) + } returns createdBuildType - // Mock isBuiltAsApp to return true (app plugin) - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.isBuiltAsApp(pluginProject) } returns true - - val mockProjectBuildTypes = mockk>() - val mockPluginProjectBuildTypes = mockk>() - - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.addAll(any()) } returns true - every { mockProjectBuildTypes.iterator() } returns mutableListOf().iterator() + val pluginHandler = PluginHandler(project) + pluginHandler.configurePlugins(engineVersionValue = EXAMPLE_ENGINE_VERSION) - // Mock FlutterPluginUtils calls that our logic depends on - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.getLegacyAndroidExtension(project) } returns project.extensions.findByType(BaseExtension::class.java)!! - every { FlutterPluginUtils.getLegacyAndroidExtension(pluginProject) } returns - pluginProject.extensions.findByType(BaseExtension::class.java)!! + val capturePluginActionSlot = mutableListOf>() + verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } + capturePluginActionSlot[0].execute(pluginProject) - // For app plugins, the old addAll behavior should be used - // This is tested implicitly by verifying the absence of individual create calls - // Verify no individual create calls were made (app behavior uses addAll) - verify(exactly = 0) { - mockPluginProjectBuildTypes.create( - any(), - any>() + createActionSlot.captured.execute(createdBuildType) + verify { createdBuildType.initWith(appBuildType) } + // The custom debuggable build type maps to the debug engine artifacts. + verify { + pluginProject.dependencies.add( + "stagingApi", + "io.flutter:flutter_embedding_debug:$EXAMPLE_ENGINE_VERSION" ) } } @Test - fun `configurePlugins creates individual build types for library plugins`( + fun `configurePlugins copies app-specific properties when the plugin project is an app`( @TempDir tempDir: Path ) { val project = mockk() val pluginProject = mockk() + val appBuildType = mockk() + every { appBuildType.name } returns "staging" + every { appBuildType.isDebuggable } returns true - // Setup minimal mocks - setupBasicMocks(project, pluginProject, mockk(), tempDir) + setupBasicMocks(project, pluginProject, appBuildType, tempDir) setupPluginMocks(project) + // The plugin project is itself built as an app, so its build types are + // ApplicationBuildTypes and app-specific properties are copied. + val pluginProjectBuildTypes = mockAndroidExtension(pluginProject) + every { pluginProjectBuildTypes.findByName("staging") } returns null + val createdBuildType = mockk(relaxed = true) + val createActionSlot = slot>() + every { + pluginProjectBuildTypes.create("staging", capture(createActionSlot)) + } returns createdBuildType - // Mock isBuiltAsApp to return false (library plugin) - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.isBuiltAsApp(pluginProject) } returns false + val pluginHandler = PluginHandler(project) + pluginHandler.configurePlugins(engineVersionValue = EXAMPLE_ENGINE_VERSION) - val mockProjectBuildTypes = mockk>() - val mockPluginProjectBuildTypes = mockk>() - val mockCreatedBuildType = mockk(relaxed = true) + val capturePluginActionSlot = mutableListOf>() + verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } + capturePluginActionSlot[0].execute(pluginProject) - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.findByName("debug") } returns null - every { - mockPluginProjectBuildTypes.create( - "debug", - any>() - ) - } returns mockCreatedBuildType - - // Mock the iterator for forEach - val testBuildType = mockk() - every { testBuildType.name } returns "debug" - every { testBuildType.isDebuggable } returns true - every { testBuildType.isMinifyEnabled } returns false - every { mockProjectBuildTypes.iterator() } returns mutableListOf(testBuildType).iterator() - - // Mock FlutterPluginUtils calls that our logic depends on - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.getLegacyAndroidExtension(project) } returns project.extensions.findByType(BaseExtension::class.java)!! - every { FlutterPluginUtils.getLegacyAndroidExtension(pluginProject) } returns - pluginProject.extensions.findByType(BaseExtension::class.java)!! - - // For library plugins, individual build type creation should happen - // This is tested by verifying that create is called for the build type - // Verify that individual create was called (library behavior) - verify(exactly = 0) { mockPluginProjectBuildTypes.addAll(any()) } + createActionSlot.captured.execute(createdBuildType) + verify { createdBuildType.initWith(appBuildType) } + verify { createdBuildType.isDebuggable = true } } private fun setupBasicMocks( project: Project, pluginProject: Project, - mockBuildType: com.android.build.gradle.internal.dsl.BuildType, + mockBuildType: ApplicationBuildType, tempDir: Path ) { // Configuration for project directory @@ -432,14 +422,12 @@ class PluginHandlerTest { val mockLogger = mockk() every { project.logger } returns mockLogger - // Plugin project setup + // Plugin project setup. Callers stub mockBuildType's name and isDebuggable. every { pluginProject.hasProperty("local-engine-repo") } returns false every { pluginProject.hasProperty("android") } returns true val mockPluginContainer = mockk() every { pluginProject.plugins } returns mockPluginContainer every { mockPluginContainer.hasPlugin("com.android.application") } returns false - every { mockBuildType.name } returns "debug" - every { mockBuildType.isDebuggable } returns true every { project.rootProject.findProject(":${cameraDependency["name"]}") } returns pluginProject every { pluginProject.extensions.create(any(), any>()) } returns mockk() every { project.afterEvaluate(any>()) } returns Unit From e38162d36eaa8c8182b4da0d1a37d91016286468 Mon Sep 17 00:00:00 2001 From: reidbaker-agent Date: Wed, 22 Jul 2026 01:51:15 +0000 Subject: [PATCH 5/5] Delete getLegacyAndroidExtension and migrate the NDK fallback to the public DSL Phase P4 of the AGP public-API migration (flutter/flutter#180137, flutter/flutter#166550): - AgpCommonExtensionWrapper gains externalNativeBuild, and both the already-configuring-a-native-build check in forceNdkDownload and the synthetic-cmake fallback now read/write cmake.path, cmake.buildStagingDirectory and the per-build-type externalNativeBuild.cmake.arguments through the public DSL (property assignment with File values instead of the legacy Any-taking CmakeOptions methods; arguments via the public MutableList). - getLegacyAndroidExtension and the BaseExtension import are deleted from FlutterPluginUtils; nothing in production sources references BaseExtension anymore. - forceNdkDownload tests move their mocks from BaseExtension/ internal CmakeOptions to the wrapper path (findByName("android") + public Cmake), assert cmake arguments by list content, and drop the defaultConfig-untouched assertions that only existed to guard the legacy extension. The two tests that asserted the old ApplicationExtension-vs-BaseExtension ndkVersion preference are deleted: getConfiguredNdkVersion has had a single source since P1. Also restores the findByType(ApplicationExtension) mocks (needed by isFlutterAppProject) that P1's test edit dropped in three tests. - FlutterPluginUtilsTest no longer imports internal.dsl.CmakeOptions or internal.dsl.DefaultConfig. Verification note: FGP unit tests could not be executed in this sandbox (network policy blocks dl.google.com); run 'gradle test' (both AGP axes) in packages/flutter_tools/gradle in CI, plus an NDK-absent scratch build exercising the synthetic-cmake fallback. Revert-safe until P5 lands. --- .../main/kotlin/AgpCommonExtensionWrapper.kt | 11 + .../src/main/kotlin/FlutterPluginUtils.kt | 46 +- .../src/test/kotlin/FlutterPluginUtilsTest.kt | 514 ++++++------------ 3 files changed, 185 insertions(+), 386 deletions(-) diff --git a/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt b/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt index ab0b63c276b67..53b97e1937d75 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt @@ -7,6 +7,7 @@ package com.flutter.gradle import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.BuildType import com.android.build.api.dsl.DynamicFeatureExtension +import com.android.build.api.dsl.ExternalNativeBuild import com.android.build.api.dsl.LibraryExtension import com.android.build.api.dsl.Splits import com.android.build.api.dsl.TestExtension @@ -107,6 +108,16 @@ class AgpCommonExtensionWrapper( else -> throw IllegalArgumentException(unsupportedMessage()) } + val externalNativeBuild: ExternalNativeBuild + get() = + when (backingExtension) { + is ApplicationExtension -> backingExtension.externalNativeBuild + is LibraryExtension -> backingExtension.externalNativeBuild + is DynamicFeatureExtension -> backingExtension.externalNativeBuild + is TestExtension -> backingExtension.externalNativeBuild + else -> throw IllegalArgumentException(unsupportedMessage()) + } + val splits: Splits get() = when (backingExtension) { diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt index 6fed48d0e7244..9a7212815bdd6 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt @@ -11,7 +11,6 @@ import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.DynamicFeatureBuildType import com.android.build.api.dsl.LibraryExtension import com.android.build.api.variant.AndroidComponentsExtension -import com.android.build.gradle.BaseExtension import com.android.builder.model.BuildType import com.flutter.gradle.plugins.PluginHandler import com.flutter.gradle.tasks.DeepLinkJsonFromManifestTask @@ -536,22 +535,6 @@ object FlutterPluginUtils { return project.property(PROP_LOCAL_ENGINE_BUILD_MODE) == flutterBuildMode } - /** - * Returns BaseExtension for the project. Used for compatibility. - * - * From BaseExtension docs: - * "Don't use this extension directly Instead, use one of the following: - * ApplicationExtension, LibraryExtension, TestExtension, DynamicFeatureExtension" - * - * For ApplicationExtension use `getAndroidApplicationExtension`. - * For LibraryExtension use `getAndroidLibraryExtension`. - */ - internal fun getLegacyAndroidExtension(project: Project): BaseExtension { - // Common supertype of the android extension types. - // But maybe this should be https://developer.android.com/reference/tools/gradle-api/8.7/com/android/build/api/dsl/TestedExtension. - return project.extensions.findByType(BaseExtension::class.java)!! - } - internal fun getAndroidExtension(project: Project): AgpCommonExtensionWrapper { // Look up by name to completely avoid importing or resolving CommonExtension val androidExtension = @@ -835,11 +818,11 @@ object FlutterPluginUtils { } // If the project is already configuring a native build, we don't need to do anything. - val gradleProjectAndroidExtension = getLegacyAndroidExtension(gradleProject) + val gradleProjectAndroidExtension = getAndroidExtension(gradleProject) val externalNativeBuild = gradleProjectAndroidExtension.externalNativeBuild val forcingNotRequired: Boolean = - externalNativeBuild?.cmake?.path != null || - externalNativeBuild?.ndkBuild?.path != null + externalNativeBuild.cmake.path != null || + externalNativeBuild.ndkBuild.path != null if (forcingNotRequired) { return } @@ -963,10 +946,9 @@ object FlutterPluginUtils { gradleProject: Project, flutterSdkRootPath: String ) { - val gradleProjectAndroidExtension = getLegacyAndroidExtension(gradleProject) - gradleProjectAndroidExtension.externalNativeBuild.cmake.path( - "$flutterSdkRootPath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt" - ) + val gradleProjectAndroidExtension = getAndroidExtension(gradleProject) + gradleProjectAndroidExtension.externalNativeBuild.cmake.path = + File("$flutterSdkRootPath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") // AGP defaults to outputting build artifacts in `android/app/.cxx`. This directory is a // build artifact, so we move it from that directory to within Flutter's build directory @@ -978,22 +960,22 @@ object FlutterPluginUtils { // but as we are not actually building anything (and are instead only tricking AGP into // downloading the NDK), it is acceptable for the buildStagingDirectory to be removed // and rebuilt when running clean builds. - gradleProjectAndroidExtension.externalNativeBuild.cmake.buildStagingDirectory( + gradleProjectAndroidExtension.externalNativeBuild.cmake.buildStagingDirectory = gradleProject.layout.buildDirectory .dir("../.cxx") .get() - .asFile.path - ) + .asFile // CMake will print warnings when you try to build an empty project. // These arguments silence the warnings - our project is intentionally // empty. gradleProjectAndroidExtension.buildTypes.forEach { buildType -> - buildType.externalNativeBuild.cmake.arguments( - "-Wno-dev", - "--no-warn-unused-cli", - "-DCMAKE_BUILD_TYPE=${buildType.name}" - ) + buildType.externalNativeBuild.cmake.arguments += + listOf( + "-Wno-dev", + "--no-warn-unused-cli", + "-DCMAKE_BUILD_TYPE=${buildType.name}" + ) } } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt index 77c1a5cb88269..2e0feedfd1876 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt @@ -7,13 +7,12 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.Cmake import com.android.build.api.dsl.LibraryBuildType +import com.android.build.api.dsl.NdkBuild import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.Variant import com.android.build.api.variant.VariantBuilder -import com.android.build.gradle.BaseExtension -import com.android.build.gradle.internal.dsl.CmakeOptions -import com.android.build.gradle.internal.dsl.DefaultConfig import com.android.builder.model.BuildType import com.flutter.gradle.FlutterPluginUtils.BUILT_IN_KOTLIN_DOCS import com.flutter.gradle.FlutterPluginUtils.BUILT_IN_KOTLIN_DOCS_FOR_APPS @@ -22,8 +21,8 @@ import com.flutter.gradle.FlutterPluginUtils.BUILT_IN_KOTLIN_DOCS_TO_REPORT_UNMI import com.flutter.gradle.FlutterPluginUtils.detectApplyingKotlinGradlePlugin import com.flutter.gradle.plugins.PluginHandler import com.flutter.gradle.tasks.PrintTask -import io.mockk.called import io.mockk.every +import io.mockk.justRun import io.mockk.mockk import io.mockk.mockkObject import io.mockk.slot @@ -1920,32 +1919,23 @@ class FlutterPluginUtilsTest { val fakeCmakeFile = tempDir.resolve("CMakeLists.txt").toFile() fakeCmakeFile.createNewFile() val project = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() + val mockCmake = mockk() + val mockNdkBuild = mockk() + val mockAndroidExtension = mockk() every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.cmake - } returns mockCmakeOptions - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.ndkBuild - } returns mockNdkBuildOptions - every { project.extensions.findByType(BaseExtension::class.java)!!.defaultConfig } returns mockDefaultConfig + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.externalNativeBuild.cmake } returns mockCmake + every { mockAndroidExtension.externalNativeBuild.ndkBuild } returns mockNdkBuild - every { mockCmakeOptions.path } returns fakeCmakeFile - every { mockNdkBuildOptions.path } returns null + every { mockCmake.path } returns fakeCmakeFile + every { mockNdkBuild.path } returns null FlutterPluginUtils.forceNdkDownload(project, "ignored") verify(exactly = 1) { - mockCmakeOptions.path + mockCmake.path } - verify(exactly = 0) { mockCmakeOptions.setPath(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } } @Test @@ -1955,38 +1945,30 @@ class FlutterPluginUtilsTest { val fakeAndroidMkFile = tempDir.resolve("Android.mk").toFile() fakeAndroidMkFile.createNewFile() val project = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() + val mockCmake = mockk() + val mockNdkBuild = mockk() + val mockAndroidExtension = mockk() every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.cmake - } returns mockCmakeOptions - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.ndkBuild - } returns mockNdkBuildOptions - every { project.extensions.findByType(BaseExtension::class.java)!!.defaultConfig } returns mockDefaultConfig + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.externalNativeBuild.cmake } returns mockCmake + every { mockAndroidExtension.externalNativeBuild.ndkBuild } returns mockNdkBuild - every { mockCmakeOptions.path } returns null - every { mockNdkBuildOptions.path } returns fakeAndroidMkFile + every { mockCmake.path } returns null + every { mockNdkBuild.path } returns fakeAndroidMkFile FlutterPluginUtils.forceNdkDownload(project, "ignored") verify(exactly = 1) { - mockCmakeOptions.path + mockCmake.path } verify(exactly = 1) { - mockNdkBuildOptions.path + mockNdkBuild.path } - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify(exactly = 0) { mockCmakeOptions.buildStagingDirectory(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } + verify(exactly = 0) { mockCmake.buildStagingDirectory = any() } } + @Test fun `forceNdkDownload installs a missing ndk when tool properties are provided`( @TempDir tempDir: Path ) { @@ -1996,25 +1978,21 @@ class FlutterPluginUtilsTest { val mockExecSpec = mockk() val mockExecResult = mockk() val mockExecOperations = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null + val mockCmake = mockk() + val mockNdkBuild = mockk() + val mockAndroidExtension = mockk() + every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.externalNativeBuild.cmake } returns mockCmake + every { mockAndroidExtension.externalNativeBuild.ndkBuild } returns mockNdkBuild + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" + every { mockCmake.path } returns null + every { mockNdkBuild.path } returns null every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns tempDir.toString() every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() every { project.gradle.startParameter.isOffline } returns false - val mockAndroidExtension = mockk() - every { project.extensions.findByName("android") } returns mockAndroidExtension - every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(capture(execActionSlot)) } answers { File(tempDir.toFile(), "ndk/29.0.13846066/source.properties").apply { @@ -2041,38 +2019,32 @@ class FlutterPluginUtilsTest { ) ) } - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } } @Test fun `forceNdkDownload skips sdkmanager install when the requested ndk is already installed`() { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null + val mockCmake = mockk() + val mockNdkBuild = mockk() + val mockAndroidExtension = mockk() + every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.externalNativeBuild.cmake } returns mockCmake + every { mockAndroidExtension.externalNativeBuild.ndkBuild } returns mockNdkBuild + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" + every { mockCmake.path } returns null + every { mockNdkBuild.path } returns null every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" every { project.gradle.startParameter.taskNames } returns emptyList() - val mockAndroidExtension = mockk() - every { project.extensions.findByName("android") } returns mockAndroidExtension - every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" FlutterPluginUtils.forceNdkDownload(project, "/base/path") finalizeDslSlot.captured.invoke(Any()) - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } } @Test @@ -2081,48 +2053,28 @@ class FlutterPluginUtilsTest { ) { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockDirectoryProperty = mockk() - val mockDirectory = mockk() - val mockBaseExtension = mockk() + val mockCmake = mockk() + val mockNdkBuild = mockk() + val mockAndroidExtension = mockk() var cmakePath: File? = null every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } answers { cmakePath } - every { mockCmakeOptions.path(any()) } returns Unit - every { mockCmakeOptions.buildStagingDirectory(any()) } returns Unit + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.externalNativeBuild.cmake } returns mockCmake + every { mockAndroidExtension.externalNativeBuild.ndkBuild } returns mockNdkBuild + every { mockNdkBuild.path } returns null + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" + every { mockCmake.path } answers { cmakePath } every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() - every { project.layout.buildDirectory } returns mockDirectoryProperty - every { mockDirectoryProperty.dir(any()) } returns mockDirectoryProperty - every { mockDirectoryProperty.get() } returns mockDirectory - every { mockDirectory.asFile.path } returns "/randomapp/build/app/" - - val mockBuildType = mockk() - every { mockBaseExtension.buildTypes.iterator() } returns mutableListOf(mockBuildType).iterator() - every { mockBuildType.name } returns "Debug" - every { mockBuildType.externalNativeBuild.cmake.arguments(any(), any(), any()) } returns Unit FlutterPluginUtils.forceNdkDownload(project, "/base/path") cmakePath = tempDir.resolve("CMakeLists.txt").toFile() finalizeDslSlot.captured.invoke(Any()) - verify(exactly = 0) { - mockCmakeOptions.path( - "/base/path/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt" - ) - } - verify(exactly = 0) { mockCmakeOptions.buildStagingDirectory(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } + verify(exactly = 0) { mockCmake.buildStagingDirectory = any() } } @Test @@ -2135,91 +2087,17 @@ class FlutterPluginUtilsTest { val mockExecSpec = mockk() val mockExecResult = mockk() val mockExecOperations = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - var configuredNdkVersion = "26.3.11579264" - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } answers { configuredNdkVersion } - every { mockCmakeOptions.path } returns null - every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" - every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns tempDir.toString() - every { - project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) - } returns "26.3.11579264" - every { project.gradle.startParameter.taskNames } returns emptyList() - every { project.gradle.startParameter.isOffline } returns false + val mockCmake = mockk() + val mockNdkBuild = mockk() val mockAndroidExtension = mockk() + var configuredNdkVersion = "26.3.11579264" + every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.externalNativeBuild.cmake } returns mockCmake + every { mockAndroidExtension.externalNativeBuild.ndkBuild } returns mockNdkBuild every { mockAndroidExtension.ndkVersion } answers { configuredNdkVersion } - every { project.serviceOf() } returns mockExecOperations - every { mockExecOperations.exec(capture(execActionSlot)) } answers { - File(tempDir.toFile(), "ndk/27.3.13750724/source.properties").apply { - parentFile.mkdirs() - createNewFile() - } - mockExecResult - } - every { mockExecResult.assertNormalExitValue() } returns mockExecResult - every { mockExecSpec.commandLine(any>()) } returns mockExecSpec - - FlutterPluginUtils.forceNdkDownload(project, "/base/path") - configuredNdkVersion = "27.3.13750724" - finalizeDslSlot.captured.invoke(Any()) - execActionSlot.captured.execute(mockExecSpec) - - verify(exactly = 1) { mockExecOperations.exec(any>()) } - verify { - mockExecSpec.commandLine( - listOf( - "/sdkmanager", - "--sdk_root=$tempDir", - "--install", - "ndk;27.3.13750724" - ) - ) - } - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } - } - - @Test - fun `forceNdkDownload waits for finalized ApplicationExtension ndkVersion before checking installed versions`( - @TempDir tempDir: Path - ) { - val project = mockk() - val finalizeDslSlot = captureFinalizeDslAction(project) - val execActionSlot = slot>() - val mockExecSpec = mockk() - val mockExecResult = mockk() - val mockExecOperations = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - val mockApplicationExtension = mockk() - var configuredNdkVersion = "26.3.11579264" - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { - project.extensions.findByType(ApplicationExtension::class.java) - } returns mockApplicationExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } answers { - throw AssertionError( - "legacy ndkVersion should not be read when ApplicationExtension is available" - ) - } - every { mockApplicationExtension.ndkVersion } answers { configuredNdkVersion } - every { project.extensions.findByName("android") } returns mockApplicationExtension - every { mockCmakeOptions.path } returns null + every { mockCmake.path } returns null + every { mockNdkBuild.path } returns null every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns tempDir.toString() every { @@ -2254,63 +2132,24 @@ class FlutterPluginUtilsTest { ) ) } - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } } @Test fun `forceNdkDownload skips fallback when sdkmanager is unavailable but the requested ndk is already installed`() { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null - every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null - every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" - every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" - every { project.gradle.startParameter.taskNames } returns emptyList() + val mockCmake = mockk() + val mockNdkBuild = mockk() val mockAndroidExtension = mockk() + every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.externalNativeBuild.cmake } returns mockCmake + every { mockAndroidExtension.externalNativeBuild.ndkBuild } returns mockNdkBuild every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" - - FlutterPluginUtils.forceNdkDownload(project, "/base/path") - finalizeDslSlot.captured.invoke(Any()) - - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } - } - - @Test - fun `forceNdkDownload reads ndkVersion from ApplicationExtension when legacy extension does not expose it`() { - val project = mockk() - val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - val mockApplicationExtension = mockk() - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { project.extensions.findByType(ApplicationExtension::class.java) } returns mockApplicationExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } answers { - throw AssertionError("legacy ndkVersion should not be read when ApplicationExtension is available") - } - every { mockApplicationExtension.ndkVersion } returns "29.0.13846066" - every { project.extensions.findByName("android") } returns mockApplicationExtension - every { mockCmakeOptions.path } returns null - every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" + every { mockCmake.path } returns null + every { mockNdkBuild.path } returns null + every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" every { project.gradle.startParameter.taskNames } returns emptyList() @@ -2318,8 +2157,7 @@ class FlutterPluginUtilsTest { FlutterPluginUtils.forceNdkDownload(project, "/base/path") finalizeDslSlot.captured.invoke(Any()) - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } } @Test @@ -2330,25 +2168,21 @@ class FlutterPluginUtilsTest { val finalizeDslSlot = captureFinalizeDslAction(project) val mockExecResult = mockk() val mockExecOperations = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null + val mockCmake = mockk() + val mockNdkBuild = mockk() + val mockAndroidExtension = mockk() + every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.externalNativeBuild.cmake } returns mockCmake + every { mockAndroidExtension.externalNativeBuild.ndkBuild } returns mockNdkBuild + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" + every { mockCmake.path } returns null + every { mockNdkBuild.path } returns null every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns tempDir.toString() every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() every { project.gradle.startParameter.isOffline } returns false - val mockAndroidExtension = mockk() - every { project.extensions.findByName("android") } returns mockAndroidExtension - every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(any>()) } returns mockExecResult every { mockExecResult.assertNormalExitValue() } returns mockExecResult @@ -2359,20 +2193,14 @@ class FlutterPluginUtilsTest { finalizeDslSlot.captured.invoke(Any()) } - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } } @Test fun `forceNdkDownload skips when invoking the ndk metadata task`() { val project = mockk() - val mockCmakeOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockCmakeOptions.path } returns null + val mockCmake = mockk() + val mockNdkBuild = mockk() every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns null every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns null @@ -2381,30 +2209,28 @@ class FlutterPluginUtilsTest { FlutterPluginUtils.forceNdkDownload(project, "/base/path") - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } } @Test fun `forceNdkDownload falls back when tool properties are present but sdkmanager is unavailable`() { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() + val mockCmake = mockk() + val mockNdkBuild = mockk() val mockDirectoryProperty = mockk() val mockDirectory = mockk() - val mockBaseExtension = mockk() + val mockAndroidExtension = mockk() + val cmakeArguments = mutableListOf() every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null - every { mockCmakeOptions.path(any()) } returns Unit - every { mockCmakeOptions.buildStagingDirectory(any()) } returns Unit + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.externalNativeBuild.cmake } returns mockCmake + every { mockAndroidExtension.externalNativeBuild.ndkBuild } returns mockNdkBuild + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" + every { mockCmake.path } returns null + every { mockNdkBuild.path } returns null + justRun { mockCmake.path = any() } + justRun { mockCmake.buildStagingDirectory = any() } every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" @@ -2412,50 +2238,47 @@ class FlutterPluginUtilsTest { every { project.layout.buildDirectory } returns mockDirectoryProperty every { mockDirectoryProperty.dir(any()) } returns mockDirectoryProperty every { mockDirectoryProperty.get() } returns mockDirectory - every { mockDirectory.asFile.path } returns "/randomapp/build/app/" + every { mockDirectory.asFile } returns File("/randomapp/build/app/") val basePath = "/base/path" - val mockBuildType = mockk() - every { mockBaseExtension.buildTypes.iterator() } returns mutableListOf(mockBuildType).iterator() + val mockBuildType = mockk() + every { mockAndroidExtension.buildTypes.iterator() } returns + mutableListOf(mockBuildType).iterator() every { mockBuildType.name } returns "Debug" - every { mockBuildType.externalNativeBuild.cmake.arguments(any(), any(), any()) } returns Unit + every { mockBuildType.externalNativeBuild.cmake.arguments } returns cmakeArguments FlutterPluginUtils.forceNdkDownload(project, basePath) finalizeDslSlot.captured.invoke(Any()) verify(exactly = 1) { - mockCmakeOptions.path("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") - } - verify(exactly = 1) { mockCmakeOptions.buildStagingDirectory(any()) } - verify(exactly = 1) { - mockBuildType.externalNativeBuild.cmake.arguments( - "-Wno-dev", - "--no-warn-unused-cli", - "-DCMAKE_BUILD_TYPE=Debug" - ) + mockCmake.path = File("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") } + verify(exactly = 1) { mockCmake.buildStagingDirectory = any() } + assertEquals( + listOf("-Wno-dev", "--no-warn-unused-cli", "-DCMAKE_BUILD_TYPE=Debug"), + cmakeArguments + ) } @Test fun `forceNdkDownload falls back when Gradle is offline`() { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() + val mockCmake = mockk() + val mockNdkBuild = mockk() val mockDirectoryProperty = mockk() val mockDirectory = mockk() - val mockBaseExtension = mockk() + val mockAndroidExtension = mockk() + val cmakeArguments = mutableListOf() every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null - every { mockCmakeOptions.path(any()) } returns Unit - every { mockCmakeOptions.buildStagingDirectory(any()) } returns Unit + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.externalNativeBuild.cmake } returns mockCmake + every { mockAndroidExtension.externalNativeBuild.ndkBuild } returns mockNdkBuild + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" + every { mockCmake.path } returns null + every { mockNdkBuild.path } returns null + justRun { mockCmake.path = any() } + justRun { mockCmake.buildStagingDirectory = any() } every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" @@ -2464,90 +2287,73 @@ class FlutterPluginUtilsTest { every { project.layout.buildDirectory } returns mockDirectoryProperty every { mockDirectoryProperty.dir(any()) } returns mockDirectoryProperty every { mockDirectoryProperty.get() } returns mockDirectory - every { mockDirectory.asFile.path } returns "/randomapp/build/app/" + every { mockDirectory.asFile } returns File("/randomapp/build/app/") val basePath = "/base/path" - val mockBuildType = mockk() - every { mockBaseExtension.buildTypes.iterator() } returns mutableListOf(mockBuildType).iterator() + val mockBuildType = mockk() + every { mockAndroidExtension.buildTypes.iterator() } returns + mutableListOf(mockBuildType).iterator() every { mockBuildType.name } returns "Debug" - every { mockBuildType.externalNativeBuild.cmake.arguments(any(), any(), any()) } returns Unit + every { mockBuildType.externalNativeBuild.cmake.arguments } returns cmakeArguments FlutterPluginUtils.forceNdkDownload(project, basePath) finalizeDslSlot.captured.invoke(Any()) verify(exactly = 1) { - mockCmakeOptions.path("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") - } - verify(exactly = 1) { mockCmakeOptions.buildStagingDirectory(any()) } - verify(exactly = 1) { - mockBuildType.externalNativeBuild.cmake.arguments( - "-Wno-dev", - "--no-warn-unused-cli", - "-DCMAKE_BUILD_TYPE=Debug" - ) + mockCmake.path = File("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") } + verify(exactly = 1) { mockCmake.buildStagingDirectory = any() } + assertEquals( + listOf("-Wno-dev", "--no-warn-unused-cli", "-DCMAKE_BUILD_TYPE=Debug"), + cmakeArguments + ) } @Test fun `forceNdkDownload sets externalNativeBuild properties`() { val project = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() + val mockCmake = mockk() + val mockNdkBuild = mockk() val mockDirectoryProperty = mockk() val mockDirectory = mockk() + val mockAndroidExtension = mockk() + val cmakeArguments = mutableListOf() every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns null every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns null - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.cmake - } returns mockCmakeOptions - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.ndkBuild - } returns mockNdkBuildOptions - every { project.extensions.findByType(BaseExtension::class.java)!!.defaultConfig } returns mockDefaultConfig + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.externalNativeBuild.cmake } returns mockCmake + every { mockAndroidExtension.externalNativeBuild.ndkBuild } returns mockNdkBuild val basePath = "/base/path" val fakeBuildPath = "/randomapp/build/app/" - every { mockCmakeOptions.path } returns null - every { mockNdkBuildOptions.path } returns null - every { mockCmakeOptions.path(any()) } returns Unit - every { mockCmakeOptions.buildStagingDirectory(any()) } returns Unit + every { mockCmake.path } returns null + every { mockNdkBuild.path } returns null + justRun { mockCmake.path = any() } + justRun { mockCmake.buildStagingDirectory = any() } every { project.layout.buildDirectory } returns mockDirectoryProperty every { mockDirectoryProperty.dir(any()) } returns mockDirectoryProperty every { mockDirectoryProperty.get() } returns mockDirectory - val realFile = File(fakeBuildPath) - every { mockDirectory.asFile } returns realFile + every { mockDirectory.asFile } returns File(fakeBuildPath) - val mockBuildType = mockk() - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .buildTypes - .iterator() - } returns mutableListOf(mockBuildType).iterator() + val mockBuildType = mockk() + every { mockAndroidExtension.buildTypes.iterator() } returns + mutableListOf(mockBuildType).iterator() every { mockBuildType.name } returns "Debug" - every { mockBuildType.externalNativeBuild.cmake.arguments(any(), any(), any()) } returns Unit + every { mockBuildType.externalNativeBuild.cmake.arguments } returns cmakeArguments FlutterPluginUtils.forceNdkDownload(project, basePath) verify(exactly = 1) { - mockCmakeOptions.path - } - verify(exactly = 1) { mockCmakeOptions.path("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") } - verify(exactly = 1) { mockCmakeOptions.buildStagingDirectory(any()) } - verify(exactly = 1) { - mockBuildType.externalNativeBuild.cmake.arguments( - "-Wno-dev", - "--no-warn-unused-cli", - "-DCMAKE_BUILD_TYPE=Debug" - ) + mockCmake.path } + verify(exactly = 1) { mockCmake.path = File("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") } + verify(exactly = 1) { mockCmake.buildStagingDirectory = any() } + assertEquals( + listOf("-Wno-dev", "--no-warn-unused-cli", "-DCMAKE_BUILD_TYPE=Debug"), + cmakeArguments + ) } @Test