diff --git a/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt b/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt index 68622f40e9..d1823125b7 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt @@ -3,12 +3,15 @@ package com.flutter.gradle import androidx.annotation.VisibleForTesting import com.android.build.api.AndroidPluginVersion import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.Variant import org.gradle.api.JavaVersion import org.gradle.api.Project +import org.gradle.api.logging.Logger import org.gradle.kotlin.dsl.extra import org.jetbrains.kotlin.gradle.plugin.KotlinAndroidPluginWrapper object DependencyVersionChecker { + // Logging constants. @VisibleForTesting internal const val GRADLE_NAME: String = "Gradle" @VisibleForTesting internal const val JAVA_NAME: String = "Java" @@ -17,11 +20,20 @@ object DependencyVersionChecker { @VisibleForTesting internal const val KGP_NAME: String = "Kotlin" + @VisibleForTesting internal const val MIN_SDK_NAME: String = "minimum Android SDK" + // String constant that defines the name of the Gradle extra property that we set when // detecting that the project is using versions outside of Flutter's support range. // https://docs.gradle.org/current/kotlin-dsl/gradle/org.gradle.api/-project/index.html#-2107180640%2FProperties%2F-1867656071. @VisibleForTesting internal const val OUT_OF_SUPPORT_RANGE_PROPERTY = "usesUnsupportedDependencyVersions" + // The task prefix for assemble builds. + @VisibleForTesting + internal const val ASSEMBLE_PREFIX = "assemble" + + // The task postfix to use when checking the minimum SDK version for each flavor. + internal const val MIN_SDK_CHECK_TASK_POSTFIX = "MinSdkCheck" + // The following messages represent best effort guesses at where a Flutter developer should // look to upgrade a dependency that is below the corresponding threshold. Developers can // change some of these locations, so they are not guaranteed to be accurate. @@ -58,6 +70,11 @@ object DependencyVersionChecker { "likely defined in the top-level build.gradle file " + "($projectDirectory/build.gradle) by the ext.kotlin_version property.\n" + @VisibleForTesting internal fun getPotentialSDKFix(projectDirectory: String): String = + "Your project's minimum Android SDK version is typically " + + "defined in the android block of the app-level `build.gradle(.kts)` file " + + "($projectDirectory/app/build.gradle(.kts))." + // The following versions define our support policy for Gradle, Java, AGP, and KGP. // Before updating any "error" version, ensure that you have updated the corresponding // "warn" version for a full release to provide advanced warning. See @@ -78,6 +95,13 @@ object DependencyVersionChecker { @VisibleForTesting internal val errorKGPVersion: Version = Version(1, 7, 0) + // If this value is changed, then make sure to change the documentation on https://docs.flutter.dev/reference/supported-platforms + @VisibleForTesting + internal val warnMinSdkVersion: Int = 21 + + @VisibleForTesting + internal val errorMinSdkVersion: Int = 1 + /** * Checks if the project's Android build time dependencies are each within the respective * version range that we support. When we can't find a version for a given dependency @@ -88,6 +112,9 @@ object DependencyVersionChecker { checkGradleVersion(getGradleVersion(project), project) checkJavaVersion(getJavaVersion(), project) + + configureMinSdkCheck(project) + val agpVersion: AndroidPluginVersion? = getAGPVersion(project) if (agpVersion != null) { checkAGPVersion(agpVersion, project) @@ -105,6 +132,54 @@ object DependencyVersionChecker { // KGP is not required, so don't log any warning if we can't find the version. } + private fun configureMinSdkCheck(project: Project) { + val androidComponents = + project.extensions.findByType(AndroidComponentsExtension::class.java) + + androidComponents?.onVariants( + androidComponents.selector().all() + ) { + val taskName = generateMinSdkCheckTaskName(it) + val minSdkCheckTask = + project.tasks.register(taskName) { + doLast { + val minSdkVersion = getMinSdkVersion(project, it) + try { + checkMinSdkVersion(minSdkVersion, project.rootDir.path, project.logger) + } catch (e: DependencyValidationException) { + project.extra.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true) + throw e + } + } + } + + project.afterEvaluate { + // Make assemble task depend on minSdkCheckTask for this variant. + project.tasks + .named(generateAssembleTaskName(it)) + .configure { + dependsOn(minSdkCheckTask) + } + } + } + } + + private fun generateAssembleTaskName(it: Variant) = "$ASSEMBLE_PREFIX${it.name.capitalize()}" + + private fun generateMinSdkCheckTaskName(it: Variant) = "${it.name.capitalize()}$MIN_SDK_CHECK_TASK_POSTFIX" + + private fun getMinSdkVersion( + project: Project, + it: Variant + ): MinSdkVersion { + val agpVersion: AndroidPluginVersion? = getAGPVersion(project) + return if (agpVersion != null && agpVersion.major >= 8 && agpVersion.minor >= 1) { + MinSdkVersion(it.name, it.minSdk.apiLevel) + } else { + MinSdkVersion(it.name, it.minSdkVersion.apiLevel) + } + } + // https://docs.gradle.org/current/kotlin-dsl/gradle/org.gradle.api.invocation/-gradle/index.html#-837060600%2FFunctions%2F-1793262594 @VisibleForTesting internal fun getGradleVersion(project: Project): Version { val untrimmedGradleVersion: String = project.gradle.gradleVersion @@ -177,6 +252,12 @@ object DependencyVersionChecker { "\nAlternatively, use the flag \"--android-skip-build-dependency-validation\"" + " to bypass this check.\n\nPotential fix: $potentialFix" + @VisibleForTesting + internal fun getFlavorSpecificMessage( + flavorName: String?, + dependencyName: String + ): String = dependencyName + (if (flavorName != null) " (flavor='$flavorName')" else "") + @VisibleForTesting internal fun checkGradleVersion( version: Version, project: Project @@ -280,6 +361,33 @@ object DependencyVersionChecker { project.logger.error(warnMessage) } } + + @VisibleForTesting internal fun checkMinSdkVersion( + minSdkVersion: MinSdkVersion, + projectDirectory: String, + logger: Logger + ) { + // For Android SDK, only the major version is relevant, no need to do a full version check. + if (minSdkVersion.version < errorMinSdkVersion) { + val errorMessage: String = + getErrorMessage( + getFlavorSpecificMessage(minSdkVersion.flavor, MIN_SDK_NAME), + minSdkVersion.version.toString(), + errorMinSdkVersion.toString(), + getPotentialSDKFix(projectDirectory) + ) + throw DependencyValidationException(errorMessage) + } else if (minSdkVersion.version < warnMinSdkVersion) { + val warnMessage: String = + getWarnMessage( + getFlavorSpecificMessage(minSdkVersion.flavor, MIN_SDK_NAME), + minSdkVersion.version.toString(), + warnMinSdkVersion.toString(), + getPotentialSDKFix(projectDirectory) + ) + logger.error(warnMessage) + } + } } // Helper class to parse the versions that are provided as plain strings (Gradle, Kotlin) and @@ -325,3 +433,14 @@ internal class Version( message: String? = null, cause: Throwable? = null ) : Exception(message, cause) + +/** + * Represents the minimum Android SDK version for a specific product flavor. + * + * @param flavor The product flavor name, or null for the default configuration. + * @param version The minimum Android SDK version (API level). + */ +@VisibleForTesting internal class MinSdkVersion( + val flavor: String, + val version: Int +) diff --git a/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt index 942d4b1e8f..0ad3930ea0 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt @@ -2,44 +2,56 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.Variant import com.flutter.gradle.DependencyVersionChecker.AGP_NAME import com.flutter.gradle.DependencyVersionChecker.GRADLE_NAME import com.flutter.gradle.DependencyVersionChecker.JAVA_NAME import com.flutter.gradle.DependencyVersionChecker.KGP_NAME +import com.flutter.gradle.DependencyVersionChecker.MIN_SDK_NAME import com.flutter.gradle.DependencyVersionChecker.OUT_OF_SUPPORT_RANGE_PROPERTY import com.flutter.gradle.DependencyVersionChecker.POTENTIAL_JAVA_FIX import com.flutter.gradle.DependencyVersionChecker.errorAGPVersion import com.flutter.gradle.DependencyVersionChecker.errorGradleVersion import com.flutter.gradle.DependencyVersionChecker.errorKGPVersion +import com.flutter.gradle.DependencyVersionChecker.errorMinSdkVersion import com.flutter.gradle.DependencyVersionChecker.getErrorMessage +import com.flutter.gradle.DependencyVersionChecker.getFlavorSpecificMessage import com.flutter.gradle.DependencyVersionChecker.getPotentialAGPFix import com.flutter.gradle.DependencyVersionChecker.getPotentialGradleFix import com.flutter.gradle.DependencyVersionChecker.getPotentialKGPFix +import com.flutter.gradle.DependencyVersionChecker.getPotentialSDKFix import com.flutter.gradle.DependencyVersionChecker.getWarnMessage import com.flutter.gradle.DependencyVersionChecker.warnAGPVersion import com.flutter.gradle.DependencyVersionChecker.warnGradleVersion import com.flutter.gradle.DependencyVersionChecker.warnJavaVersion import com.flutter.gradle.DependencyVersionChecker.warnKGPVersion +import com.flutter.gradle.DependencyVersionChecker.warnMinSdkVersion import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.slot import io.mockk.verify +import org.gradle.api.Action import org.gradle.api.JavaVersion import org.gradle.api.Project +import org.gradle.api.Task import org.gradle.api.logging.Logger import org.gradle.api.plugins.ExtraPropertiesExtension +import org.gradle.api.tasks.TaskContainer import org.gradle.internal.extensions.core.extra import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith -const val FAKE_PROJECT_ROOT_DIR = "/fake/root/dir" +private const val FAKE_PROJECT_ROOT_DIR = "/fake/root/dir" // The following values will need to be modified when the corresponding "warn$DepName" versions // are updated in DependencyVersionChecker.kt -const val SUPPORTED_GRADLE_VERSION: String = "7.4.2" -val SUPPORTED_JAVA_VERSION: JavaVersion = JavaVersion.VERSION_11 -val SUPPORTED_AGP_VERSION: AndroidPluginVersion = AndroidPluginVersion(7, 3, 1) -const val SUPPORTED_KGP_VERSION: String = "1.8.10" +private const val SUPPORTED_GRADLE_VERSION: String = "7.4.2" +private val SUPPORTED_JAVA_VERSION: JavaVersion = JavaVersion.VERSION_11 +private val SUPPORTED_AGP_VERSION: AndroidPluginVersion = AndroidPluginVersion(7, 3, 1) +private const val SUPPORTED_KGP_VERSION: String = "1.8.10" +private val SUPPORTED_SDK_VERSION: MinSdkVersion = MinSdkVersion("release", 30) class DependencyVersionCheckerTest { @Test @@ -209,6 +221,168 @@ class DependencyVersionCheckerTest { } verify(exactly = 0) { mockExtraPropertiesExtension.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true) } } + + @Test + fun `min SDK version in warn range results in warning logs`() { + val exampleWarnSDKVersion = 19 + val flavorName1 = "flavor1" + val flavorName2 = "flavor2" + val mockProject = + MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions( + minSdkVersions = + listOf( + MinSdkVersion(flavorName1, exampleWarnSDKVersion), + MinSdkVersion(flavorName2, exampleWarnSDKVersion) + ) + ) + + val mockExtraPropertiesExtension = mockProject.extra + every { + mockExtraPropertiesExtension.set( + OUT_OF_SUPPORT_RANGE_PROPERTY, + false + ) + } returns Unit + val mockLogger = mockProject.logger + every { mockLogger.error(any()) } returns Unit + + DependencyVersionChecker.checkDependencyVersions(mockProject) + verify { + mockLogger.error( + getWarnMessage( + getFlavorSpecificMessage(flavorName1, MIN_SDK_NAME), + exampleWarnSDKVersion.toString(), + warnMinSdkVersion.toString(), + getPotentialSDKFix(FAKE_PROJECT_ROOT_DIR) + ) + ) + mockLogger.error( + getWarnMessage( + getFlavorSpecificMessage(flavorName2, MIN_SDK_NAME), + exampleWarnSDKVersion.toString(), + warnMinSdkVersion.toString(), + getPotentialSDKFix(FAKE_PROJECT_ROOT_DIR) + ) + ) + } + verify(exactly = 0) { + mockExtraPropertiesExtension.set( + OUT_OF_SUPPORT_RANGE_PROPERTY, + true + ) + } + } + + @Test + fun `min SDK version in error range results in DependencyValidationException`() { + val exampleErrorSDKVersion = 0 + val flavorName = "flavor1" + val mockProject = + MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions( + minSdkVersions = + listOf( + MinSdkVersion(flavorName, exampleErrorSDKVersion) + ) + ) + + val mockExtraPropertiesExtension = mockProject.extra + val mockLogger = mockProject.logger + every { mockExtraPropertiesExtension.set(any(), any()) } returns Unit + every { mockLogger.error(any()) } returns Unit + + val dependencyValidationException = + assertFailsWith { + DependencyVersionChecker.checkDependencyVersions( + mockProject + ) + } + + assert( + dependencyValidationException.message == + getErrorMessage( + getFlavorSpecificMessage(flavorName, MIN_SDK_NAME), + exampleErrorSDKVersion.toString(), + errorMinSdkVersion.toString(), + getPotentialSDKFix(FAKE_PROJECT_ROOT_DIR) + ) + ) + verify(exactly = 1) { + mockExtraPropertiesExtension.set( + OUT_OF_SUPPORT_RANGE_PROPERTY, + true + ) + } + } + + @Test + fun `checkMinSdkVersion throws error when in error range for min SDK version`() { + val mockLogger = mockk() + val mockExtraPropertiesExtension = mockk() + val projectDir = "projectDir" + val flavor = "flavor" + val version = 0 + + every { mockExtraPropertiesExtension.set(any(), any()) } returns Unit + every { mockLogger.error(any()) } returns Unit + + val dependencyValidationException = + assertFailsWith { + DependencyVersionChecker.checkMinSdkVersion( + minSdkVersion = MinSdkVersion(flavor, version), + projectDirectory = projectDir, + logger = mockLogger + ) + } + + assertEquals( + dependencyValidationException.message, + "Error: Your project's minimum Android SDK (flavor='flavor') version (0) is lower than " + + "Flutter's minimum supported version of 1. Please upgrade your minimum Android SDK " + + "(flavor='flavor') version. \n" + + "Alternatively, use the flag \"--android-skip-build-dependency-validation\" to " + + "bypass this check.\n" + + "\n" + + "Potential fix: Your project's minimum Android SDK version is typically defined in " + + "the android block of the app-level `build.gradle(.kts)` file " + + "(projectDir/app/build.gradle(.kts))." + ) + } + + @Test + fun `checkMinSdkVersion logs warning when in warning range for min SDK version`() { + val mockLogger = mockk() + val mockExtraPropertiesExtension = mockk() + val projectDir = "projectDir" + val flavor = "flavor" + val version = 20 + + every { mockExtraPropertiesExtension.set(any(), any()) } returns Unit + every { mockLogger.error(any()) } returns Unit + + DependencyVersionChecker.checkMinSdkVersion( + minSdkVersion = MinSdkVersion(flavor, version), + projectDirectory = projectDir, + logger = mockLogger + ) + + val warningMessageSlot = slot() + verify { + mockLogger.error(capture(warningMessageSlot)) + } + + assertEquals( + warningMessageSlot.captured, + "Warning: Flutter support for your project's minimum Android SDK (flavor='flavor') " + + "version (20) will soon be dropped. Please upgrade your minimum Android SDK " + + "(flavor='flavor') version to a version of at least 21 soon.\n" + + "Alternatively, use the flag \"--android-skip-build-dependency-validation\" to " + + "bypass this check.\n" + + "\n" + + "Potential fix: Your project's minimum Android SDK version is typically defined in " + + "the android block of the app-level `build.gradle(.kts)` file " + + "(projectDir/app/build.gradle(.kts))." + ) + } } // There isn't a way to create a real org.gradle.api.Project object for testing unfortunately, so @@ -219,12 +393,13 @@ class DependencyVersionCheckerTest { // https://github.com/autonomousapps/dependency-analysis-gradle-plugin/tree/main/testkit // as a way to fill this gap in testing (combined with moving functionality to individual tasks // that can be tested independently). -object MockProjectFactory { +private object MockProjectFactory { fun createMockProjectWithSpecifiedDependencyVersions( javaVersion: JavaVersion = SUPPORTED_JAVA_VERSION, gradleVersion: String = SUPPORTED_GRADLE_VERSION, agpVersion: AndroidPluginVersion = SUPPORTED_AGP_VERSION, - kgpVersion: String = SUPPORTED_KGP_VERSION + kgpVersion: String = SUPPORTED_KGP_VERSION, + minSdkVersions: List = listOf(SUPPORTED_SDK_VERSION) ): Project { // Java mockkStatic(JavaVersion::class) @@ -254,6 +429,54 @@ object MockProjectFactory { // Project path every { mockProject.rootDir.path } returns FAKE_PROJECT_ROOT_DIR + // SDK + val actionSlot = slot>() + every { mockProject.afterEvaluate(capture(actionSlot)) } answers { + actionSlot.captured.execute(mockProject) + return@answers Unit + } + val onVariantsFnSlot = slot<(Variant) -> Unit>() + every { mockAndroidComponentsExtension.selector() } returns + mockk { + every { all() } returns mockk() + } + every { mockProject.tasks } returns + mockk { + val registerTaskSlot = slot>() + every { register(any(), capture(registerTaskSlot)) } answers registerAnswer@{ + registerTaskSlot.captured.execute( + mockk { + val doLastActionSlot = slot>() + every { doLast(capture(doLastActionSlot)) } answers doLastAnswer@{ + doLastActionSlot.captured.execute(mockk()) + return@doLastAnswer mockk() + } + } + ) + return@registerAnswer mockk() + } + + every { named(any()) } returns + mockk { + every { configure(any>()) } returns mockk() + } + } + every { + mockAndroidComponentsExtension.onVariants( + any(), + capture(onVariantsFnSlot) + ) + } answers { + minSdkVersions.forEach { + val variant = mockk() + every { variant.name } returns it.flavor + every { variant.minSdk } returns mockk { every { apiLevel } returns it.version } + every { variant.minSdkVersion } returns mockk { every { apiLevel } returns it.version } + onVariantsFnSlot.captured.invoke(variant) + } + return@answers Unit + } + return mockProject } }