diff --git a/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt b/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt index 7c12c8cd2c4d5..ab0b63c276b67 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt @@ -40,6 +40,25 @@ class AgpCommonExtensionWrapper( } } + var compileSdkPreview: String? + get() = + when (backingExtension) { + is ApplicationExtension -> backingExtension.compileSdkPreview + is LibraryExtension -> backingExtension.compileSdkPreview + is DynamicFeatureExtension -> backingExtension.compileSdkPreview + is TestExtension -> backingExtension.compileSdkPreview + else -> throw IllegalArgumentException(unsupportedMessage()) + } + set(value) { + when (backingExtension) { + is ApplicationExtension -> backingExtension.compileSdkPreview = value + is LibraryExtension -> backingExtension.compileSdkPreview = value + is DynamicFeatureExtension -> backingExtension.compileSdkPreview = value + is TestExtension -> backingExtension.compileSdkPreview = value + else -> throw IllegalArgumentException(unsupportedMessage()) + } + } + var namespace: String? get() = when (backingExtension) { diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt index 50779e6d0b8d0..f9860dbd4f53a 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt @@ -528,18 +528,21 @@ object FlutterPluginUtils { internal fun getAndroidApplicationExtension(project: Project): ApplicationExtension = project.extensions.getByType(ApplicationExtension::class.java) - internal fun getConfiguredNdkVersion(project: Project): String? = - project.extensions.findByType(ApplicationExtension::class.java)?.ndkVersion - ?: getLegacyAndroidExtension(project).ndkVersion + internal fun getConfiguredNdkVersion(project: Project): String? = getAndroidExtension(project).ndkVersion /** - * Expected format of getAndroidExtension(project).compileSdkVersion is a string of the form - * `android-` followed by either the numeric version, e.g. `android-35`, or a preview version, - * e.g. `android-UpsideDownCake`. + * Returns the compile SDK configured on the project's Android extension: the numeric + * API level (`compileSdk = 36`) or a preview codename (`compileSdkPreview = "Baklava"`). */ @JvmStatic @JvmName("getCompileSdkFromProject") - internal fun getCompileSdkFromProject(project: Project): String = getLegacyAndroidExtension(project).compileSdkVersion!!.substring(8) + internal fun getCompileSdkFromProject(project: Project): CompileSdkVersion { + val androidExtension = getAndroidExtension(project) + return CompileSdkVersion( + apiLevel = androidExtension.compileSdk, + previewCodename = androidExtension.compileSdkPreview + ) + } /** * Returns: diff --git a/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt b/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt index 4d6e546feb343..0632bef409c38 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt @@ -6,7 +6,6 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion import com.android.build.api.variant.AndroidComponentsExtension -import com.android.build.gradle.internal.utils.getKotlinAndroidPluginVersion import org.gradle.api.JavaVersion import org.gradle.api.Project import org.jetbrains.kotlin.gradle.plugin.KotlinAndroidPluginWrapper @@ -42,19 +41,16 @@ internal object VersionFetcher { } /** - * Returns the version of the Kotlin Gradle plugin. + * Returns the version of the Kotlin Gradle plugin, or null if it cannot be determined. + * + * Null is an expected result when the Kotlin Gradle plugin has not been applied to the + * project — most notably under AGP's built-in Kotlin support (`android.builtInKotlin`), + * where there is no standalone KGP. Callers must treat null as "unknown/not applied", + * not as an error. */ internal fun getKGPVersion(project: Project): Version? { - // AGP and Kgp have methods for getting kotlin version. - // AGP's method is internal, we try to use it anyway. // KGP's version in org.jetbrains.kotlin.gradle.plugin.DefaultKotlinBasePlugin is not // available when this method is called. - // When testing call `setAgpKotlinVersionToNull(project)`. - val agpDefinedKgpVersion = getKotlinAndroidPluginVersion(project) - if (agpDefinedKgpVersion != null && agpDefinedKgpVersion != "unknown") { - return Version.fromString(agpDefinedKgpVersion) - } - val kotlinVersionProperty = "kotlin_version" val firstKotlinVersionFieldName = "pluginVersion" val secondKotlinVersionFieldName = "kotlinPluginVersion" @@ -127,3 +123,35 @@ internal class Version( override fun toString(): String = "$major.$minor.$patch" } + +/** + * The compile SDK configured on a project's Android extension: either a numeric API level + * (`compileSdk = 36`) or a preview codename (`compileSdkPreview = "Baklava"`). Both are null + * when the DSL has not been configured (yet). + */ +internal data class CompileSdkVersion( + val apiLevel: Int?, + val previewCodename: String? +) { + /** + * Whether this compile SDK is known to be higher than [other]. + * + * - numeric vs numeric: numeric comparison. + * - preview vs numeric: a preview codename targets an unreleased SDK, so it is + * considered higher than any numeric API level. + * - preview vs preview: codenames stopped being alphabetically ordered when the + * alphabet reset at "Baklava", so distinct codenames are incomparable and this + * returns false rather than guessing. + * - if either side is unset, returns false. + */ + fun isHigherThan(other: CompileSdkVersion): Boolean = + when { + previewCodename != null && other.previewCodename != null -> false + previewCodename != null && other.apiLevel != null -> true + apiLevel != null && other.apiLevel != null -> apiLevel > other.apiLevel + else -> false + } + + /** The human-readable form used in log messages, e.g. "35" or "Baklava". */ + override fun toString(): String = previewCodename ?: apiLevel?.toString() ?: "unknown" +} diff --git a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt index bc7717986f5d2..16a8e8d986a30 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt @@ -5,6 +5,7 @@ package com.flutter.gradle.plugins import com.android.builder.model.BuildType +import com.flutter.gradle.CompileSdkVersion import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginUtils import com.flutter.gradle.FlutterPluginUtils.addApiDependencies @@ -121,12 +122,9 @@ class PluginHandler( // Wait until the Android plugin loaded. pluginProject.afterEvaluate { // Checks if there is a mismatch between the plugin compileSdkVersion and the project compileSdkVersion. - val projectCompileSdkVersion: String = getCompileSdkFromProject(project) - val pluginCompileSdkVersion: String = getCompileSdkFromProject(pluginProject) - // TODO(gmackall): This is doing a string comparison, which is odd and also can be wrong - // when comparing preview versions (against non preview, and also in the - // case of alphabet reset which happened with "Baklava". - if (pluginCompileSdkVersion > projectCompileSdkVersion) { + val projectCompileSdkVersion: CompileSdkVersion = getCompileSdkFromProject(project) + val pluginCompileSdkVersion: CompileSdkVersion = getCompileSdkFromProject(pluginProject) + if (pluginCompileSdkVersion.isHigherThan(projectCompileSdkVersion)) { project.logger.quiet( "Warning: The plugin $pluginName requires Android SDK version $pluginCompileSdkVersion or higher." ) diff --git a/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt index 99c7b6291c44d..422097038bf64 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt @@ -30,7 +30,6 @@ import com.flutter.gradle.DependencyVersionChecker.warnAGPVersion import com.flutter.gradle.DependencyVersionChecker.warnGradleVersion import com.flutter.gradle.DependencyVersionChecker.warnKGPVersion import com.flutter.gradle.DependencyVersionChecker.warnMinSdkVersion -import com.flutter.gradle.testing.setAgpKotlinVersionToNull import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -499,7 +498,6 @@ private object MockProjectFactory { } return@answers Unit } - setAgpKotlinVersionToNull(mockProject) return mockProject } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt index 21fc5d8786f9d..d4f25869100cd 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt @@ -616,9 +616,25 @@ class FlutterPluginUtilsTest { @Test fun `getCompileSdkFromProject returns the compileSdk from the project`() { val project = mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns 35 + every { androidExtension.compileSdkPreview } returns null val result = FlutterPluginUtils.getCompileSdkFromProject(project) - assertEquals("35", result) + assertEquals(CompileSdkVersion(apiLevel = 35, previewCodename = null), result) + assertEquals("35", result.toString()) + } + + @Test + fun `getCompileSdkFromProject returns the preview codename from the project`() { + val project = mockk() + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns null + every { androidExtension.compileSdkPreview } returns "Baklava" + val result = FlutterPluginUtils.getCompileSdkFromProject(project) + assertEquals(CompileSdkVersion(apiLevel = null, previewCodename = "Baklava"), result) + assertEquals("Baklava", result.toString()) } @Test @@ -1966,7 +1982,9 @@ class FlutterPluginUtilsTest { every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() every { project.gradle.startParameter.isOffline } returns false - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + val mockAndroidExtension = mockk() + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(capture(execActionSlot)) } answers { File(tempDir.toFile(), "ndk/29.0.13846066/source.properties").apply { @@ -2016,7 +2034,9 @@ class FlutterPluginUtilsTest { every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" every { project.gradle.startParameter.taskNames } returns emptyList() - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + val mockAndroidExtension = mockk() + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" FlutterPluginUtils.forceNdkDownload(project, "/base/path") finalizeDslSlot.captured.invoke(Any()) @@ -2104,7 +2124,9 @@ class FlutterPluginUtilsTest { } returns "26.3.11579264" every { project.gradle.startParameter.taskNames } returns emptyList() every { project.gradle.startParameter.isOffline } returns false - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + val mockAndroidExtension = mockk() + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.ndkVersion } answers { configuredNdkVersion } every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(capture(execActionSlot)) } answers { File(tempDir.toFile(), "ndk/27.3.13750724/source.properties").apply { @@ -2166,6 +2188,7 @@ class FlutterPluginUtilsTest { ) } every { mockApplicationExtension.ndkVersion } answers { configuredNdkVersion } + every { project.extensions.findByName("android") } returns mockApplicationExtension every { mockCmakeOptions.path } returns null every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns tempDir.toString() @@ -2225,6 +2248,9 @@ class FlutterPluginUtilsTest { every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" every { project.gradle.startParameter.taskNames } returns emptyList() + val mockAndroidExtension = mockk() + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" FlutterPluginUtils.forceNdkDownload(project, "/base/path") finalizeDslSlot.captured.invoke(Any()) @@ -2252,6 +2278,7 @@ class FlutterPluginUtilsTest { throw AssertionError("legacy ndkVersion should not be read when ApplicationExtension is available") } every { mockApplicationExtension.ndkVersion } returns "29.0.13846066" + every { project.extensions.findByName("android") } returns mockApplicationExtension every { mockCmakeOptions.path } returns null every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" @@ -2289,7 +2316,9 @@ class FlutterPluginUtilsTest { every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() every { project.gradle.startParameter.isOffline } returns false - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + val mockAndroidExtension = mockk() + every { project.extensions.findByName("android") } returns mockAndroidExtension + every { mockAndroidExtension.ndkVersion } returns "29.0.13846066" every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(any>()) } returns mockExecResult every { mockExecResult.assertNormalExitValue() } returns mockExecResult @@ -2496,7 +2525,7 @@ class FlutterPluginUtilsTest { val project = mockk(relaxed = true) val androidExtension = mockk() every { androidExtension.ndkVersion } returns "29.0.13846066" - every { project.extensions.findByType(ApplicationExtension::class.java) } returns androidExtension + every { project.extensions.findByName("android") } returns androidExtension every { project.tasks.register(any(), eq(PrintTask::class.java), any()) } returns mockk() val captureSlot = slot>() diff --git a/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt index 307d3275633ee..257ed7c42f47d 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt @@ -6,7 +6,6 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion import com.android.build.api.variant.AndroidComponentsExtension -import com.flutter.gradle.testing.setAgpKotlinVersionToNull import io.mockk.every import io.mockk.mockk import org.gradle.api.Project @@ -47,7 +46,6 @@ class VersionFetcherTest { fun `getKGPVersion returns version when kotlin_version is set`() { val kgpVersion = Version(1, 9, 20) val project = mockk() - setAgpKotlinVersionToNull(project) every { project.hasProperty(eq("kotlin_version")) } returns true every { project.properties["kotlin_version"] } returns kgpVersion.toString() val result = VersionFetcher.getKGPVersion(project) @@ -58,7 +56,6 @@ class VersionFetcherTest { fun `getKGPVersion returns version from KotlinAndroidPluginWrapper`() { val kgpVersion = Version(1, 9, 20) val project = mockk() - setAgpKotlinVersionToNull(project) every { project.hasProperty(eq("kotlin_version")) } returns false every { project.plugins.findPlugin(KotlinAndroidPluginWrapper::class.java) } returns mockk { @@ -67,4 +64,51 @@ class VersionFetcherTest { val result = VersionFetcher.getKGPVersion(project) assertEquals(kgpVersion, result!!) } + + @Test + fun `getKGPVersion returns null when the Kotlin Gradle plugin is absent`() { + // Expected under AGP's built-in Kotlin support, where no standalone KGP is applied. + val project = mockk() + every { project.hasProperty(eq("kotlin_version")) } returns false + every { project.plugins.findPlugin(KotlinAndroidPluginWrapper::class.java) } returns null + val result = VersionFetcher.getKGPVersion(project) + assertEquals(null, result) + } + + // CompileSdkVersion.isHigherThan + @Test + fun `isHigherThan compares numeric api levels numerically`() { + val sdk35 = CompileSdkVersion(apiLevel = 35, previewCodename = null) + val sdk36 = CompileSdkVersion(apiLevel = 36, previewCodename = null) + assertEquals(true, sdk36.isHigherThan(sdk35)) + assertEquals(false, sdk35.isHigherThan(sdk36)) + assertEquals(false, sdk35.isHigherThan(sdk35)) + } + + @Test + fun `isHigherThan treats a preview codename as higher than any numeric api level`() { + val preview = CompileSdkVersion(apiLevel = null, previewCodename = "Baklava") + val numeric = CompileSdkVersion(apiLevel = 36, previewCodename = null) + assertEquals(true, preview.isHigherThan(numeric)) + assertEquals(false, numeric.isHigherThan(preview)) + } + + @Test + fun `isHigherThan treats distinct preview codenames as incomparable`() { + // Codenames stopped being alphabetically ordered at the "Baklava" alphabet reset, + // so neither side may claim to be higher. + val baklava = CompileSdkVersion(apiLevel = null, previewCodename = "Baklava") + val vanilla = CompileSdkVersion(apiLevel = null, previewCodename = "VanillaIceCream") + assertEquals(false, baklava.isHigherThan(vanilla)) + assertEquals(false, vanilla.isHigherThan(baklava)) + assertEquals(false, baklava.isHigherThan(baklava)) + } + + @Test + fun `isHigherThan returns false when either side is unset`() { + val unset = CompileSdkVersion(apiLevel = null, previewCodename = null) + val numeric = CompileSdkVersion(apiLevel = 36, previewCodename = null) + assertEquals(false, unset.isHigherThan(numeric)) + assertEquals(false, numeric.isHigherThan(unset)) + } } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt index 111ef311b90ce..accd03bc3d4b3 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt @@ -4,6 +4,7 @@ package com.flutter.gradle.plugins +import com.android.build.api.dsl.ApplicationExtension import com.android.build.gradle.BaseExtension import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginUtils @@ -34,6 +35,17 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class PluginHandlerTest { + /** Mocks the new-DSL android extension read by [FlutterPluginUtils.getCompileSdkFromProject]. */ + private fun mockCompileSdk( + project: Project, + compileSdk: Int + ) { + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns compileSdk + every { androidExtension.compileSdkPreview } returns null + } + // getPluginListWithoutDevDependencies @Test fun `getPluginListWithoutDevDependencies removes dev dependencies from list`() { @@ -214,8 +226,8 @@ class PluginHandlerTest { mockBuildType ).iterator() every { project.dependencies.add(any(), any()) } returns mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + mockCompileSdk(project, compileSdk = 35) + mockCompileSdk(pluginProject, compileSdk = 35) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -310,8 +322,8 @@ class PluginHandlerTest { mockBuildType ).iterator() every { project.dependencies.add(any(), any()) } returns mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + mockCompileSdk(project, compileSdk = 35) + mockCompileSdk(pluginProject, compileSdk = 35) val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) @@ -452,8 +464,8 @@ class PluginHandlerTest { every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() every { project.dependencies.add(any(), any()) } returns mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + mockCompileSdk(project, compileSdk = 35) + mockCompileSdk(pluginProject, compileSdk = 35) } private fun setupPluginMocks(project: Project) { diff --git a/packages/flutter_tools/gradle/src/test/kotlin/testing/VersionFetcherTestHelper.kt b/packages/flutter_tools/gradle/src/test/kotlin/testing/VersionFetcherTestHelper.kt deleted file mode 100644 index 60dd6ef657d45..0000000000000 --- a/packages/flutter_tools/gradle/src/test/kotlin/testing/VersionFetcherTestHelper.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.flutter.gradle.testing - -import io.mockk.every -import io.mockk.mockk -import org.gradle.api.Project -import org.jetbrains.kotlin.gradle.plugin.KotlinBaseApiPlugin - -/** - * Prevent AGP's kotlin version checker from throwing `no answer found` - * - * Intended to be called by tests that call `VersionFetcher.getKGPVersion(project)` - * and who do not care about the internal implementation of - * `com.android.build.gradle.internal.utils.getKotlinAndroidPluginVersion` - */ -internal fun setAgpKotlinVersionToNull(mockProject: Project) { - // The internals of `getKotlinAndroidPluginVersion` depend on `getKotlinPluginVersionFromPlugin` - // which relies on reflection to get the value. Instead make sure fetching the plugin has valid - // response then rely on the default behavior in `getKotlinPluginVersionFromPlugin` to - // return null. - every { mockProject.plugins.findPlugin(any>()) } returns mockk() - every { mockProject.plugins.findPlugin("kotlin-android") } returns mockk() -}