Enforce minSdk constraint for Android Flutter (#164251)

This commit adds validation for the Android SDK version within the
existing dependency version checker. It introduces a warning when the
SDK version is below a predefined threshold. The validation checks for
the `minSdk` property in the `android` block of the app's `build.gradle`
file. It will warn users if their `minSdk` version is out of Flutter's
supported
range and guide them on how to fix it.

Fixes https://github.com/flutter/flutter/issues/134570

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->


## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.


<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
ash-google 2025-03-04 18:40:12 -08:00 committed by GitHub
parent 0357b45337
commit b771b39813
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 349 additions and 7 deletions

View File

@ -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
)

View File

@ -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<DependencyValidationException> {
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<Logger>()
val mockExtraPropertiesExtension = mockk<ExtraPropertiesExtension>()
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<DependencyValidationException> {
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<Logger>()
val mockExtraPropertiesExtension = mockk<ExtraPropertiesExtension>()
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<String>()
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<MinSdkVersion> = 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<Action<Project>>()
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<TaskContainer> {
val registerTaskSlot = slot<Action<Task>>()
every { register(any(), capture(registerTaskSlot)) } answers registerAnswer@{
registerTaskSlot.captured.execute(
mockk {
val doLastActionSlot = slot<Action<Task>>()
every { doLast(capture(doLastActionSlot)) } answers doLastAnswer@{
doLastActionSlot.captured.execute(mockk())
return@doLastAnswer mockk()
}
}
)
return@registerAnswer mockk()
}
every { named(any<String>()) } returns
mockk {
every { configure(any<Action<Task>>()) } returns mockk()
}
}
every {
mockAndroidComponentsExtension.onVariants(
any(),
capture(onVariantsFnSlot)
)
} answers {
minSdkVersions.forEach {
val variant = mockk<Variant>()
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
}
}