From ffbce644aa31bfb50eacc88fc532ae2f467e9636 Mon Sep 17 00:00:00 2001 From: reidbaker-agent Date: Wed, 22 Jul 2026 01:41:45 +0000 Subject: [PATCH] Replace the plugin build-type copy with initWith on the public DSL Phase P3 of the AGP public-API migration (flutter/flutter#180137, flutter/flutter#166550): - PluginHandler no longer imports com.android.build.gradle.internal.dsl.BuildType. The build-type copy block that shared live legacy BuildType instances (addAll) for app-type plugin projects and hand-copied two properties for library plugin projects is replaced by a single initWith-based copy on the new-DSL containers: missing build types are created on the plugin project with initWith(appBuildType) (which carries matchingFallbacks), and isDebuggable is additionally copied when both sides are application build types. Library build types cannot receive app-specific properties through the public DSL - this is a documented behavior change of the migration (BuildConfig.DEBUG / JNI debuggability of plugins built for custom debuggable build types). - Production sources are now free of com.android.build.gradle.internal imports; InternalAgpApiImportTest locks that in (test sources may still use internals until the gradle-api dependency swap). - PluginHandlerTest: the two mock-only copy tests (which never invoked configurePlugins) are replaced with tests that run configurePlugins and assert the initWith copy for both a library plugin project and an app plugin project, including the custom-debuggable-build-type -> debug engine artifact mapping. - The planned pre-spike (afterEvaluate DSL mutation under newDsl=true) could not run in this sandbox; recorded in the migration doc with the finalizeDsl fallback. android_plugin_example_app_build and a custom build-type scratch build must confirm in CI. Verification note: FGP unit tests could not be executed in this sandbox (network policy blocks dl.google.com); run 'gradle test' (both AGP axes) in packages/flutter_tools/gradle in CI. Revert-safe until P4 lands. --- ...Flutter-Gradle-Plugin-to-AGP-public-API.md | 8 + .../src/main/kotlin/plugins/PluginHandler.kt | 32 ++-- .../test/kotlin/InternalAgpApiImportTest.kt | 51 +++++ .../test/kotlin/plugins/PluginHandlerTest.kt | 180 ++++++++---------- 4 files changed, 156 insertions(+), 115 deletions(-) create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt diff --git a/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md b/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md index 43a37eea10412..1589905bb415f 100644 --- a/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md +++ b/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md @@ -77,6 +77,14 @@ projects. That opt-out dies with AGP 10. avoiding a self-referential `.map`. Fall back to a snapshot only if read-then-set is impossible; record the outcome here. - *Spike result:* _pending (P6)_. +6. **P3 pre-spike (afterEvaluate DSL mutation under newDsl).** The planned scratch-app + spike (AGP 9.1 + `newDsl=true` + custom build type, verifying that build-type + creation from `pluginProject.afterEvaluate` still works) could not run in the + implementation sandbox (no AGP artifact access). The `initWith` copy landed on the + primary approach; the `android_plugin_example_app_build` integration test and a + custom-build-type scratch build must confirm it in CI. Documented fallback if + `afterEvaluate` mutation is rejected under newDsl: perform the copy in + `androidComponents.finalizeDsl` on the plugin project instead. 5. **`buildModeFor` semantics.** Every variant-scope call uses the `(name, debuggable)` overload with the public `Component.debuggable`. Name-based inference is confined to the one DSL-scope case with no public diff --git a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt index d5b1042a3f6a8..5a8803228e445 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt @@ -4,6 +4,7 @@ package com.flutter.gradle.plugins +import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.BuildType import com.flutter.gradle.CompileSdkVersion import com.flutter.gradle.FlutterExtension @@ -12,15 +13,11 @@ import com.flutter.gradle.FlutterPluginUtils.addApiDependencies import com.flutter.gradle.FlutterPluginUtils.buildModeFor import com.flutter.gradle.FlutterPluginUtils.getAndroidExtension import com.flutter.gradle.FlutterPluginUtils.getCompileSdkFromProject -import com.flutter.gradle.FlutterPluginUtils.getLegacyAndroidExtension -import com.flutter.gradle.FlutterPluginUtils.isBuiltAsApp import com.flutter.gradle.FlutterPluginUtils.supportsBuildMode import com.flutter.gradle.NativePluginLoaderReflectionBridge -import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Project import org.jetbrains.kotlin.gradle.plugin.extraProperties import java.io.File -import com.android.build.gradle.internal.dsl.BuildType as dslBuildType /** * Handles interactions with the flutter plugins (not Gradle plugins) used by the Flutter project, @@ -159,22 +156,19 @@ class PluginHandler( return } - // Copy build types from the app to the plugin. - // This allows to build apps with plugins and custom build types or flavors. - // However, only copy if the plugin is also an app project, since library projects - // cannot have applicationIdSuffix and other app-specific properties. - if (isBuiltAsApp(pluginProject)) { - (getLegacyAndroidExtension(pluginProject).buildTypes as NamedDomainObjectContainer) - .addAll(getLegacyAndroidExtension(project).buildTypes as NamedDomainObjectContainer) - } else { - // For library projects, create compatible build types without app-specific properties - getLegacyAndroidExtension(project).buildTypes.forEach { appBuildType -> - if (getLegacyAndroidExtension(pluginProject).buildTypes.findByName(appBuildType.name) == null) { - getLegacyAndroidExtension(pluginProject).buildTypes.create(appBuildType.name) { - // Copy library-compatible properties only + // Copy the app project's build types onto the plugin project so that its variants + // resolve. These are `initWith` copies, not live aliases: `initWith` copies the + // properties both build types understand (matchingFallbacks included), and + // app-specific properties are additionally copied when both sides are application + // build types. Library build types cannot receive app-specific properties (such + // as isDebuggable) through the public DSL. + val pluginProjectBuildTypes = getAndroidExtension(pluginProject).buildTypes + getAndroidExtension(project).buildTypes.forEach { appBuildType -> + if (pluginProjectBuildTypes.findByName(appBuildType.name) == null) { + pluginProjectBuildTypes.create(appBuildType.name) { + initWith(appBuildType) + if (this is ApplicationBuildType && appBuildType is ApplicationBuildType) { isDebuggable = appBuildType.isDebuggable - isMinifyEnabled = appBuildType.isMinifyEnabled - // Note: applicationIdSuffix and other app-specific properties are intentionally not copied } } } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt new file mode 100644 index 0000000000000..ade062a1a473f --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/InternalAgpApiImportTest.kt @@ -0,0 +1,51 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package com.flutter.gradle + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Guards the AGP public-API migration (https://github.com/flutter/flutter/issues/180137): + * production sources must not use AGP internals. AGP 10 removes access to internals + * entirely, and the Flutter Gradle Plugin will compile against the `gradle-api` artifact, + * where they do not exist. Test sources may still reference internal types until the + * dependency swap. + */ +class InternalAgpApiImportTest { + @Test + fun `main sources do not import AGP internals`() { + // The Gradle test JVM runs with the project directory + // (packages/flutter_tools/gradle) as its working directory. + val mainSources = File("src/main") + assertTrue( + mainSources.isDirectory, + "Expected to find src/main relative to the test working directory " + + "(${File(".").absolutePath})." + ) + val internalImport = Regex("""^\s*import\s+com\.android\.build\.gradle\.internal\.""") + val offendingLines = + mainSources + .walkTopDown() + .filter { it.isFile && it.extension in setOf("kt", "java", "groovy", "gradle") } + .flatMap { file -> + file.readLines().mapIndexedNotNull { index, line -> + if (internalImport.containsMatchIn(line)) { + "${file.path}:${index + 1}: ${line.trim()}" + } else { + null + } + } + }.toList() + assertTrue( + offendingLines.isEmpty(), + "AGP internal APIs must not be used in production sources; they are removed in " + + "AGP 10. Use the public com.android.build.api surface (see " + + "docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md).\n" + + offendingLines.joinToString("\n") + ) + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt index a5687d581f869..2c36c24729717 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt @@ -6,7 +6,8 @@ package com.flutter.gradle.plugins import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension -import com.android.build.gradle.BaseExtension +import com.android.build.api.dsl.LibraryBuildType +import com.android.build.api.dsl.LibraryExtension import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginUtils import com.flutter.gradle.FlutterPluginUtilsTest.Companion.EXAMPLE_ENGINE_VERSION @@ -38,13 +39,14 @@ import kotlin.test.assertTrue class PluginHandlerTest { /** * Mocks the new-DSL android extension read through [FlutterPluginUtils.getAndroidExtension] - * (compileSdk for the mismatch warning, buildTypes for the dependency-wiring loops). + * (compileSdk for the mismatch warning, buildTypes for the dependency-wiring loops and the + * build-type copy block). */ private fun mockAndroidExtension( project: Project, compileSdk: Int = 35, buildTypes: List = emptyList() - ): ApplicationExtension { + ): NamedDomainObjectContainer { val androidExtension = mockk() every { project.extensions.findByName("android") } returns androidExtension every { androidExtension.compileSdk } returns compileSdk @@ -52,8 +54,29 @@ class PluginHandlerTest { val container = mockk>() // A fresh iterator per call: the container is iterated by multiple loops. every { container.iterator() } answers { buildTypes.toMutableList().iterator() } + // By default every name already exists on the container, so the build-type copy block + // does not create copies. Tests exercising the copy override findByName per name. + every { container.findByName(any()) } returns mockk(relaxed = true) every { androidExtension.buildTypes } returns container - return androidExtension + return container + } + + /** + * Like [mockAndroidExtension], but for a library (plugin) project whose build types are + * [LibraryBuildType]s without app-specific properties. + */ + private fun mockLibraryAndroidExtension( + project: Project, + compileSdk: Int = 35 + ): NamedDomainObjectContainer { + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns compileSdk + every { androidExtension.compileSdkPreview } returns null + val container = mockk>() + every { container.iterator() } answers { mutableListOf().iterator() } + every { androidExtension.buildTypes } returns container + return container } // getPluginListWithoutDevDependencies @@ -194,7 +217,7 @@ class PluginHandlerTest { val pluginProject = mockk() val pluginDependencyProject = mockk() - val mockBuildType = mockk() + val mockBuildType = mockk() every { pluginProject.hasProperty("local-engine-repo") } returns false every { pluginProject.hasProperty("android") } returns true val mockPluginContainer = mockk() @@ -210,24 +233,11 @@ class PluginHandlerTest { every { project.afterEvaluate(any>()) } returns Unit every { pluginProject.afterEvaluate(any>()) } returns Unit - val mockProjectBuildTypes = - mockk>() - val mockPluginProjectBuildTypes = - mockk>() - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.addAll(any()) } returns true every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() - - // The new-DSL dependency-wiring loops iterate the wrapper container; the legacy - // container is only read by the build-type copy block, which this test leaves empty. - every { mockProjectBuildTypes.iterator() } answers { - mutableListOf().iterator() - } every { project.dependencies.add(any(), any()) } returns mockk() mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) - mockAndroidExtension(pluginProject) + val pluginProjectBuildTypes = mockAndroidExtension(pluginProject) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -262,8 +272,10 @@ class PluginHandlerTest { } verify { project.dependencies.add("debugApi", pluginProject) } verify { mockLogger wasNot called } - // For library projects, individual build types should be created, not addAll - verify(exactly = 0) { mockPluginProjectBuildTypes.addAll(any()) } + // The "debug" build type already exists on the plugin project, so no copy is created. + verify(exactly = 0) { + pluginProjectBuildTypes.create(any(), any>()) + } verify { pluginProject.dependencies.add("implementation", pluginDependencyProject) } } @@ -284,7 +296,7 @@ class PluginHandlerTest { every { project.logger } returns mockLogger val pluginProject = mockk() - val mockBuildType = mockk() + val mockBuildType = mockk() every { pluginProject.hasProperty("local-engine-repo") } returns false every { pluginProject.hasProperty("android") } returns true every { mockBuildType.name } returns "debug" @@ -296,16 +308,8 @@ class PluginHandlerTest { every { project.afterEvaluate(any>()) } returns Unit every { pluginProject.afterEvaluate(any>()) } returns Unit - val mockProjectBuildTypes = - mockk>() - val mockPluginProjectBuildTypes = - mockk>() - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.addAll(any()) } returns true every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() - every { project.dependencies.add(any(), any()) } returns mockk() mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) mockAndroidExtension(pluginProject) @@ -330,97 +334,83 @@ class PluginHandlerTest { } @Test - fun `configurePlugins uses addAll for app plugins`( + fun `configurePlugins copies missing app build types onto library plugin projects using initWith`( @TempDir tempDir: Path ) { val project = mockk() val pluginProject = mockk() + val appBuildType = mockk() + every { appBuildType.name } returns "staging" + every { appBuildType.isDebuggable } returns true - // Setup minimal mocks - setupBasicMocks(project, pluginProject, mockk(), tempDir) + setupBasicMocks(project, pluginProject, appBuildType, tempDir) setupPluginMocks(project) + // The plugin project is an Android library: its build types are LibraryBuildTypes, + // which cannot receive app-specific properties such as isDebuggable. + val pluginProjectBuildTypes = mockLibraryAndroidExtension(pluginProject) + every { pluginProjectBuildTypes.findByName("staging") } returns null + val createdBuildType = mockk(relaxed = true) + val createActionSlot = slot>() + every { + pluginProjectBuildTypes.create("staging", capture(createActionSlot)) + } returns createdBuildType - // Mock isBuiltAsApp to return true (app plugin) - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.isBuiltAsApp(pluginProject) } returns true - - val mockProjectBuildTypes = mockk>() - val mockPluginProjectBuildTypes = mockk>() - - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.addAll(any()) } returns true - every { mockProjectBuildTypes.iterator() } returns mutableListOf().iterator() + val pluginHandler = PluginHandler(project) + pluginHandler.configurePlugins(engineVersionValue = EXAMPLE_ENGINE_VERSION) - // Mock FlutterPluginUtils calls that our logic depends on - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.getLegacyAndroidExtension(project) } returns project.extensions.findByType(BaseExtension::class.java)!! - every { FlutterPluginUtils.getLegacyAndroidExtension(pluginProject) } returns - pluginProject.extensions.findByType(BaseExtension::class.java)!! + val capturePluginActionSlot = mutableListOf>() + verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } + capturePluginActionSlot[0].execute(pluginProject) - // For app plugins, the old addAll behavior should be used - // This is tested implicitly by verifying the absence of individual create calls - // Verify no individual create calls were made (app behavior uses addAll) - verify(exactly = 0) { - mockPluginProjectBuildTypes.create( - any(), - any>() + createActionSlot.captured.execute(createdBuildType) + verify { createdBuildType.initWith(appBuildType) } + // The custom debuggable build type maps to the debug engine artifacts. + verify { + pluginProject.dependencies.add( + "stagingApi", + "io.flutter:flutter_embedding_debug:$EXAMPLE_ENGINE_VERSION" ) } } @Test - fun `configurePlugins creates individual build types for library plugins`( + fun `configurePlugins copies app-specific properties when the plugin project is an app`( @TempDir tempDir: Path ) { val project = mockk() val pluginProject = mockk() + val appBuildType = mockk() + every { appBuildType.name } returns "staging" + every { appBuildType.isDebuggable } returns true - // Setup minimal mocks - setupBasicMocks(project, pluginProject, mockk(), tempDir) + setupBasicMocks(project, pluginProject, appBuildType, tempDir) setupPluginMocks(project) + // The plugin project is itself built as an app, so its build types are + // ApplicationBuildTypes and app-specific properties are copied. + val pluginProjectBuildTypes = mockAndroidExtension(pluginProject) + every { pluginProjectBuildTypes.findByName("staging") } returns null + val createdBuildType = mockk(relaxed = true) + val createActionSlot = slot>() + every { + pluginProjectBuildTypes.create("staging", capture(createActionSlot)) + } returns createdBuildType - // Mock isBuiltAsApp to return false (library plugin) - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.isBuiltAsApp(pluginProject) } returns false + val pluginHandler = PluginHandler(project) + pluginHandler.configurePlugins(engineVersionValue = EXAMPLE_ENGINE_VERSION) - val mockProjectBuildTypes = mockk>() - val mockPluginProjectBuildTypes = mockk>() - val mockCreatedBuildType = mockk(relaxed = true) + val capturePluginActionSlot = mutableListOf>() + verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } + capturePluginActionSlot[0].execute(pluginProject) - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.findByName("debug") } returns null - every { - mockPluginProjectBuildTypes.create( - "debug", - any>() - ) - } returns mockCreatedBuildType - - // Mock the iterator for forEach - val testBuildType = mockk() - every { testBuildType.name } returns "debug" - every { testBuildType.isDebuggable } returns true - every { testBuildType.isMinifyEnabled } returns false - every { mockProjectBuildTypes.iterator() } returns mutableListOf(testBuildType).iterator() - - // Mock FlutterPluginUtils calls that our logic depends on - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.getLegacyAndroidExtension(project) } returns project.extensions.findByType(BaseExtension::class.java)!! - every { FlutterPluginUtils.getLegacyAndroidExtension(pluginProject) } returns - pluginProject.extensions.findByType(BaseExtension::class.java)!! - - // For library plugins, individual build type creation should happen - // This is tested by verifying that create is called for the build type - // Verify that individual create was called (library behavior) - verify(exactly = 0) { mockPluginProjectBuildTypes.addAll(any()) } + createActionSlot.captured.execute(createdBuildType) + verify { createdBuildType.initWith(appBuildType) } + verify { createdBuildType.isDebuggable = true } } private fun setupBasicMocks( project: Project, pluginProject: Project, - mockBuildType: com.android.build.gradle.internal.dsl.BuildType, + mockBuildType: ApplicationBuildType, tempDir: Path ) { // Configuration for project directory @@ -432,14 +422,12 @@ class PluginHandlerTest { val mockLogger = mockk() every { project.logger } returns mockLogger - // Plugin project setup + // Plugin project setup. Callers stub mockBuildType's name and isDebuggable. every { pluginProject.hasProperty("local-engine-repo") } returns false every { pluginProject.hasProperty("android") } returns true val mockPluginContainer = mockk() every { pluginProject.plugins } returns mockPluginContainer every { mockPluginContainer.hasPlugin("com.android.application") } returns false - every { mockBuildType.name } returns "debug" - every { mockBuildType.isDebuggable } returns true every { project.rootProject.findProject(":${cameraDependency["name"]}") } returns pluginProject every { pluginProject.extensions.create(any(), any>()) } returns mockk() every { project.afterEvaluate(any>()) } returns Unit