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..1589905bb415f --- /dev/null +++ b/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md @@ -0,0 +1,213 @@ +# 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)_. +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 + 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) 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/AgpCommonExtensionWrapper.kt b/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt index 7c12c8cd2c4d5..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 @@ -40,6 +41,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) { @@ -88,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/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 50779e6d0b8d0..9a7212815bdd6 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt @@ -6,10 +6,11 @@ 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 import com.android.builder.model.BuildType import com.flutter.gradle.plugins.PluginHandler import com.flutter.gradle.tasks.DeepLinkJsonFromManifestTask @@ -28,6 +29,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 +471,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 @@ -498,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 = @@ -528,18 +549,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: @@ -794,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 } @@ -922,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 @@ -937,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}" + ) } } @@ -983,7 +1006,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/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..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,21 +4,20 @@ package com.flutter.gradle.plugins -import com.android.builder.model.BuildType +import com.android.build.api.dsl.ApplicationBuildType +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 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, @@ -111,7 +110,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) } @@ -121,12 +120,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." ) @@ -135,7 +131,7 @@ class PluginHandler( ) } - getLegacyAndroidExtension(project).buildTypes.forEach { buildType -> + getAndroidExtension(project).buildTypes.forEach { buildType -> addEmbeddingDependencyToPlugin(project, pluginProject, buildType, engineVersion) } } @@ -160,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 } } } @@ -215,7 +208,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/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..2e0feedfd1876 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt @@ -5,13 +5,14 @@ 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 @@ -20,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 @@ -528,6 +529,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`() { @@ -616,9 +645,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 @@ -1874,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 @@ -1909,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 ) { @@ -1950,23 +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 - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(capture(execActionSlot)) } answers { File(tempDir.toFile(), "ndk/29.0.13846066/source.properties").apply { @@ -1993,36 +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() - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null 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 @@ -2031,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 @@ -2085,88 +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() + val mockCmake = mockk() + val mockNdkBuild = mockk() + val mockAndroidExtension = 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 every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - 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 { mockCmakeOptions.path } 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 { 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 { @@ -2201,26 +2132,23 @@ 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() + 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) } 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.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 null every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" @@ -2229,40 +2157,7 @@ class FlutterPluginUtilsTest { 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 { 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" - every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" - every { project.gradle.startParameter.taskNames } returns emptyList() - - 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 @@ -2273,23 +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 - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(any>()) } returns mockExecResult every { mockExecResult.assertNormalExitValue() } returns mockExecResult @@ -2300,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 @@ -2322,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 "" @@ -2353,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 "" @@ -2405,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 @@ -2496,7 +2361,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>() @@ -2523,7 +2388,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 @@ -2555,7 +2420,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 @@ -2593,7 +2458,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 @@ -2647,7 +2512,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/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/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..2c36c24729717 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt @@ -4,7 +4,10 @@ package com.flutter.gradle.plugins -import com.android.build.gradle.BaseExtension +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.dsl.LibraryExtension import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginUtils import com.flutter.gradle.FlutterPluginUtilsTest.Companion.EXAMPLE_ENGINE_VERSION @@ -34,6 +37,48 @@ import kotlin.test.assertFalse 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 and the + * build-type copy block). + */ + private fun mockAndroidExtension( + project: Project, + compileSdk: Int = 35, + buildTypes: List = emptyList() + ): NamedDomainObjectContainer { + 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() } + // 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 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 @Test fun `getPluginListWithoutDevDependencies removes dev dependencies from list`() { @@ -172,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() @@ -188,34 +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() - - 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() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) + val pluginProjectBuildTypes = mockAndroidExtension(pluginProject) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -250,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) } } @@ -272,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" @@ -284,34 +308,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() - - 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() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) + mockAndroidExtension(pluginProject) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -333,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 @@ -435,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 @@ -452,8 +437,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" + mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) + mockAndroidExtension(pluginProject) } 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() -} 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'], + ); }); }