diff --git a/packages/flutter_tools/gradle/build.gradle.kts b/packages/flutter_tools/gradle/build.gradle.kts index dd7341f6c0ee5..17606834c8f6c 100644 --- a/packages/flutter_tools/gradle/build.gradle.kts +++ b/packages/flutter_tools/gradle/build.gradle.kts @@ -49,6 +49,11 @@ tasks.withType { } } +// The AGP version the Flutter Gradle Plugin compiles and tests against. CI additionally runs +// this build with -PagpVersion= to catch public-DSL binary incompatibilities +// between the AGP major versions the plugin supports (see AgpCommonExtensionWrapper). +val agpVersion: String = providers.gradleProperty("agpVersion").getOrElse("8.11.1") + dependencies { // Versions available https://mvnrepository.com/artifact/androidx.annotation/annotation-jvm. // Version release notes https://developer.android.com/jetpack/androidx/releases/annotation @@ -61,14 +66,59 @@ dependencies { // All kotlinx implementation dependencies must work with the oldest kotlin supported versions. // Defined in packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") - // When bumping, also update: + // When bumping the default agpVersion above, also update: // * AGP version constants in packages/flutter_tools/lib/src/android/gradle_utils.dart // * ndkVersion constant in packages/flutter_tools/lib/src/android/gradle_utils.dart // * ndkVersion in FlutterExtension in packages/flutter_tools/gradle/src/main/kotlin/FlutterExtension.kt - compileOnly("com.android.tools.build:gradle:8.11.1") + compileOnly("com.android.tools.build:gradle:$agpVersion") testImplementation(kotlin("test")) - testImplementation("com.android.tools.build:gradle:8.11.1") + testImplementation("com.android.tools.build:gradle:$agpVersion") testImplementation("org.mockito:mockito-core:5.8.0") testImplementation("io.mockk:mockk:1.13.16") } + +// CommonExtension is binary-incompatible between AGP 8 and 9, which is why DSL access is +// routed through AgpCommonExtensionWrapper. If the compiler emits a reference to +// CommonExtension as the owner of a call, the plugin breaks on one of the two AGP lines at +// runtime even though it compiles on both. Fail fast if such a reference appears in the +// main bytecode (test bytecode intentionally references AGP types directly). +val validateNoCommonExtensionInBytecode by tasks.registering { + description = + "Checks that no compiled main class references com.android.build.api.dsl.CommonExtension." + dependsOn(tasks.named("compileKotlin")) + val classesDir = layout.buildDirectory.dir("classes/kotlin/main") + doLast { + val needle = "com/android/build/api/dsl/CommonExtension".toByteArray(Charsets.ISO_8859_1) + val offenders = + classesDir + .get() + .asFile + .walkTopDown() + .filter { it.isFile && it.extension == "class" } + .filter { file -> + val bytes = file.readBytes() + (0..bytes.size - needle.size).any { start -> + needle.indices.all { i -> bytes[start + i] == needle[i] } + } + }.map { it.name } + .toList() + if (offenders.isNotEmpty()) { + throw GradleException( + "CommonExtension must not be referenced from Flutter Gradle Plugin bytecode " + + "(it is binary-incompatible between AGP 8 and 9). Route DSL access through " + + "AgpCommonExtensionWrapper instead. Offending classes: $offenders" + ) + } + } +} + +// Attached to `test` (not just `check`) because CI drives this build through `gradlew test` +// (see packages/flutter_tools/test/integration.shard/android_run_flutter_gradle_plugin_tests_test.dart). +tasks.test { + dependsOn(validateNoCommonExtensionInBytecode) +} + +tasks.named("check") { + dependsOn(validateNoCommonExtensionInBytecode) +} diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt index 806fc5097a022..a7880c247a210 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt @@ -246,12 +246,12 @@ class FlutterPlugin : Plugin { } localEngineHost = engineHostOut.name } - FlutterPluginUtils.getLegacyAndroidExtension(project).buildTypes.all { + FlutterPluginUtils.getAndroidExtension(project).buildTypes.all { addFlutterDependencies(this) } } - private fun addFlutterDependencies(buildType: com.android.builder.model.BuildType) { + private fun addFlutterDependencies(buildType: BuildType) { FlutterPluginUtils.addFlutterDependencies( project!!, buildType, diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt index f9860dbd4f53a..6fed48d0e7244 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt @@ -6,7 +6,9 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.DynamicFeatureBuildType import com.android.build.api.dsl.LibraryExtension import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.gradle.BaseExtension @@ -28,6 +30,7 @@ import java.io.File import java.io.IOException import java.nio.charset.StandardCharsets import java.util.Properties +import com.android.build.api.dsl.BuildType as DslBuildType /** * A collection of static utility functions used by the Flutter Gradle Plugin. @@ -469,15 +472,50 @@ object FlutterPluginUtils { */ @JvmStatic @JvmName("buildModeFor") - internal fun buildModeFor(buildType: BuildType): String { - if (buildType.name == "profile") { + internal fun buildModeFor(buildType: BuildType): String = buildModeFor(buildType.name, buildType.isDebuggable) + + /** + * Returns a Flutter build mode for a build type identified by [buildTypeName] and its + * [isDebuggable] flag. + * + * Variant-scope callers must pass the public `Component.debuggable` flag so that custom + * debuggable build types (e.g. a host app's `staging`) map to the debug engine artifacts. + * + * @return "debug", "profile", or "release" (fall-back). + */ + @JvmStatic + @JvmName("buildModeFor") + internal fun buildModeFor( + buildTypeName: String, + isDebuggable: Boolean + ): String { + if (buildTypeName == "profile") { return "profile" - } else if (buildType.isDebuggable) { + } else if (isDebuggable) { return "debug" } return "release" } + /** + * Returns a Flutter build mode for a new-DSL [buildType]. + * + * Application and dynamic-feature build types expose a public `isDebuggable` flag. + * Library build types do not, so for them the conventional "debug" name is the only + * public signal available at DSL scope. + */ + @JvmStatic + @JvmName("buildModeFor") + internal fun buildModeFor(buildType: DslBuildType): String { + val isDebuggable = + when (buildType) { + is ApplicationBuildType -> buildType.isDebuggable + is DynamicFeatureBuildType -> buildType.isDebuggable + else -> buildType.name == "debug" + } + return buildModeFor(buildType.name, isDebuggable) + } + /** * Returns true if the build mode is supported by the current call to Gradle. * This only relevant when using a local engine. Because the engine @@ -986,7 +1024,7 @@ object FlutterPluginUtils { @JvmName("addFlutterDependencies") internal fun addFlutterDependencies( project: Project, - buildType: BuildType, + buildType: DslBuildType, pluginHandler: PluginHandler, engineVersion: String ) { diff --git a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt index 16a8e8d986a30..d5b1042a3f6a8 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt @@ -4,12 +4,13 @@ package com.flutter.gradle.plugins -import com.android.builder.model.BuildType +import com.android.build.api.dsl.BuildType import com.flutter.gradle.CompileSdkVersion import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginUtils import com.flutter.gradle.FlutterPluginUtils.addApiDependencies import com.flutter.gradle.FlutterPluginUtils.buildModeFor +import com.flutter.gradle.FlutterPluginUtils.getAndroidExtension import com.flutter.gradle.FlutterPluginUtils.getCompileSdkFromProject import com.flutter.gradle.FlutterPluginUtils.getLegacyAndroidExtension import com.flutter.gradle.FlutterPluginUtils.isBuiltAsApp @@ -112,7 +113,7 @@ class PluginHandler( // Add plugin dependency to the app project. We only want to add dependency // for dev dependencies in non-release builds. project.afterEvaluate { - getLegacyAndroidExtension(project).buildTypes.forEach { buildType -> + getAndroidExtension(project).buildTypes.forEach { buildType -> if (!(pluginObject["dev_dependency"] as Boolean) || buildType.name != "release") { project.dependencies.add("${buildType.name}Api", pluginProject) } @@ -133,7 +134,7 @@ class PluginHandler( ) } - getLegacyAndroidExtension(project).buildTypes.forEach { buildType -> + getAndroidExtension(project).buildTypes.forEach { buildType -> addEmbeddingDependencyToPlugin(project, pluginProject, buildType, engineVersion) } } @@ -213,7 +214,7 @@ class PluginHandler( } val pluginProject: Project = project.rootProject.findProject(":$pluginName") ?: return - getLegacyAndroidExtension(project).buildTypes.forEach { buildType -> + getAndroidExtension(project).buildTypes.forEach { buildType -> val flutterBuildMode: String = buildModeFor(buildType) if (flutterBuildMode == "release" && (pluginObject["dev_dependency"] as? Boolean == true)) { // This plugin is a dev dependency will not be included in the diff --git a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt index d4f25869100cd..77c1a5cb88269 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt @@ -5,7 +5,9 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion +import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryBuildType import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.Variant import com.android.build.api.variant.VariantBuilder @@ -528,6 +530,34 @@ class FlutterPluginUtilsTest { assertEquals("release", result) } + @Test + fun `buildModeFor with a name and debuggable flag prefers the profile name over debuggability`() { + assertEquals("profile", FlutterPluginUtils.buildModeFor("profile", isDebuggable = true)) + assertEquals("debug", FlutterPluginUtils.buildModeFor("staging", isDebuggable = true)) + assertEquals("release", FlutterPluginUtils.buildModeFor("staging", isDebuggable = false)) + } + + @Test + fun `buildModeFor reads isDebuggable from new-DSL application build types`() { + val buildType = mockk() + every { buildType.name } returns "staging" + every { buildType.isDebuggable } returns true + + assertEquals("debug", FlutterPluginUtils.buildModeFor(buildType)) + } + + @Test + fun `buildModeFor falls back to the conventional debug name for new-DSL library build types`() { + // LibraryBuildType has no public isDebuggable flag, so the name is the only signal. + val debugBuildType = mockk() + every { debugBuildType.name } returns "debug" + assertEquals("debug", FlutterPluginUtils.buildModeFor(debugBuildType)) + + val customBuildType = mockk() + every { customBuildType.name } returns "staging" + assertEquals("release", FlutterPluginUtils.buildModeFor(customBuildType)) + } + // supportsBuildMode @Test fun `supportsBuildMode returns true if project should not use local engine`() { @@ -2552,7 +2582,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithoutDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() every { buildType.name } returns "debug" every { buildType.isDebuggable } returns true every { project.hasProperty("local-engine-repo") } returns true @@ -2584,7 +2614,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithoutDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() val engineVersion = EXAMPLE_ENGINE_VERSION every { buildType.name } returns "debug" every { buildType.isDebuggable } returns true @@ -2622,7 +2652,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithSingleDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() val engineVersion = EXAMPLE_ENGINE_VERSION every { buildType.name } returns "release" every { buildType.isDebuggable } returns false @@ -2676,7 +2706,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithSingleDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() val engineVersion = EXAMPLE_ENGINE_VERSION every { buildType.name } returns "debug" every { buildType.isDebuggable } returns true diff --git a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt index accd03bc3d4b3..a5687d581f869 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt @@ -4,6 +4,7 @@ package com.flutter.gradle.plugins +import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension import com.android.build.gradle.BaseExtension import com.flutter.gradle.FlutterExtension @@ -35,15 +36,24 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class PluginHandlerTest { - /** Mocks the new-DSL android extension read by [FlutterPluginUtils.getCompileSdkFromProject]. */ - private fun mockCompileSdk( + /** + * Mocks the new-DSL android extension read through [FlutterPluginUtils.getAndroidExtension] + * (compileSdk for the mismatch warning, buildTypes for the dependency-wiring loops). + */ + private fun mockAndroidExtension( project: Project, - compileSdk: Int - ) { + compileSdk: Int = 35, + buildTypes: List = emptyList() + ): ApplicationExtension { val androidExtension = mockk() every { project.extensions.findByName("android") } returns androidExtension every { androidExtension.compileSdk } returns compileSdk every { androidExtension.compileSdkPreview } returns null + val container = mockk>() + // A fresh iterator per call: the container is iterated by multiple loops. + every { container.iterator() } answers { buildTypes.toMutableList().iterator() } + every { androidExtension.buildTypes } returns container + return androidExtension } // getPluginListWithoutDevDependencies @@ -210,24 +220,14 @@ class PluginHandlerTest { every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .buildTypes - .iterator() - } returns - mutableListOf( - mockBuildType - ).iterator() andThen - mutableListOf( // can't return the same iterator as it is stateful - mockBuildType - ).iterator() andThen - mutableListOf( // and again - mockBuildType - ).iterator() + // The new-DSL dependency-wiring loops iterate the wrapper container; the legacy + // container is only read by the build-type copy block, which this test leaves empty. + every { mockProjectBuildTypes.iterator() } answers { + mutableListOf().iterator() + } every { project.dependencies.add(any(), any()) } returns mockk() - mockCompileSdk(project, compileSdk = 35) - mockCompileSdk(pluginProject, compileSdk = 35) + mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) + mockAndroidExtension(pluginProject) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -306,24 +306,9 @@ class PluginHandlerTest { every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .buildTypes - .iterator() - } returns - mutableListOf( - mockBuildType - ).iterator() andThen - mutableListOf( // can't return the same iterator as it is stateful - mockBuildType - ).iterator() andThen - mutableListOf( // and again - mockBuildType - ).iterator() every { project.dependencies.add(any(), any()) } returns mockk() - mockCompileSdk(project, compileSdk = 35) - mockCompileSdk(pluginProject, compileSdk = 35) + mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) + mockAndroidExtension(pluginProject) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -464,8 +449,8 @@ class PluginHandlerTest { every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() every { project.dependencies.add(any(), any()) } returns mockk() - mockCompileSdk(project, compileSdk = 35) - mockCompileSdk(pluginProject, compileSdk = 35) + mockAndroidExtension(project, buildTypes = listOf(mockBuildType)) + mockAndroidExtension(pluginProject) } private fun setupPluginMocks(project: Project) { diff --git a/packages/flutter_tools/test/integration.shard/android_run_flutter_gradle_plugin_tests_test.dart b/packages/flutter_tools/test/integration.shard/android_run_flutter_gradle_plugin_tests_test.dart index 2aee2296511b4..8503695b9fb8b 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,38 @@ 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'], + ); }); }