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