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 1589905bb415f..5a828aef49313 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 @@ -76,7 +76,27 @@ projects. That opt-out dies with AGP 10. it is seeded with the merged value; set `abiOffset * 1000 + current`, 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)_. + - *Spike result:* read-then-set implemented in P6 (`VariantOutput.versionCode.orNull` + read at onVariants time, then `set(abiOffset * 1000 + base)`); the sandbox could not + execute builds, so the split-per-abi × flavor-defined-versionCode apkanalyzer check in + CI is the confirming gate. The `finalizeDsl` snapshot fallback remains unimplemented. + - *Behavioral shift for custom `onVariants` build scripts:* Under modern AGP 9 + `androidComponents.onVariants`, plugins applied at line 25 of `build.gradle.kts` + execute their callbacks before app-level `onVariants` blocks at line 70 (FIFO + callback ordering). Therefore, when building `--split-per-abi`, FGP seeds + `output.versionCode` (`abiOffset * 1000 + base`) before app-level callbacks run. + Any custom app-level `onVariants` block that reads `output.versionCode.get()` will + observe the offset value rather than the base `versionCode`. For standard apps, no + change is needed. Even if an app script transforms `versionCode` (e.g. via + multiplication or addition), monotonic ABI ordering (`armeabi-v7a` < `arm64-v8a` < + `x86_64`) and Google Play Store uniqueness are preserved. If an app requires an exact + literal numeric formula on base versionCodes, it can subtract `abiOffset` before its + transformation and re-add it afterwards. + - *flutter-apk copy outputs:* the copy task declares individual predictable + `@OutputFiles` (from target platforms × flavor × build mode) instead of the shared + `outputs/flutter-apk` directory, because a shared `@OutputDirectory` would overlap + between variants by construction. A runtime warning reports produced names outside + the predicted set. 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 diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt index 34e93d64ffa79..03f3be2ac8d20 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt @@ -4,17 +4,19 @@ package com.flutter.gradle +import com.android.build.api.artifact.SingleArtifact import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.BuildType import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.ApplicationVariant +import com.android.build.api.variant.FilterConfiguration import com.android.build.api.variant.Variant import com.android.build.gradle.AbstractAppExtension import com.android.build.gradle.LibraryExtension -import com.android.build.gradle.api.ApkVariant -import com.android.build.gradle.tasks.PackageAndroidArtifact import com.flutter.gradle.FlutterPluginConstants.PLATFORM_ABI_LIST import com.flutter.gradle.FlutterPluginUtils.readPropertiesIfExist import com.flutter.gradle.plugins.PluginHandler +import com.flutter.gradle.tasks.CopyFlutterApksTask import com.flutter.gradle.tasks.CopyFlutterAssetsTask import com.flutter.gradle.tasks.CopyFlutterJniLibsTask import com.flutter.gradle.tasks.FlutterTask @@ -23,7 +25,6 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task import org.gradle.api.UnknownTaskException -import org.gradle.api.file.Directory import org.gradle.api.tasks.Copy import org.gradle.api.tasks.TaskProvider import org.gradle.internal.os.OperatingSystem @@ -367,6 +368,72 @@ class FlutterPlugin : Plugin { copyFlutterAssetsTaskProvider, CopyFlutterAssetsTask::destinationDir ) + + // Per-ABI versionCode for --split-per-abi builds: read the merged versionCode + // AGP seeded on each variant output and offset it by the ABI constant. + // (Read-then-set with a plain get, not a self-referential lazy map.) + val appliedAbiVersionCodes = mutableMapOf() + if (FlutterPluginUtils.shouldProjectSplitPerAbi(projectToAddTasksTo) && + !FlutterPluginUtils.shouldForceVersionCodeIgnoringAbi(projectToAddTasksTo) + ) { + (variant as? ApplicationVariant)?.outputs?.forEach { output -> + val abi: String? = + output.filters + .find { it.filterType == FilterConfiguration.FilterType.ABI } + ?.identifier + val abiVersionCode: Int? = FlutterPluginConstants.ABI_VERSION[abi] + val baseVersionCode: Int? = output.versionCode.orNull + if (abi != null && abiVersionCode != null && baseVersionCode != null) { + val newVersionCode = abiVersionCode * 1000 + baseVersionCode + output.versionCode.set(newVersionCode) + appliedAbiVersionCodes[abi] = newVersionCode + } + } + } + + // Copy the output APKs into a known location, so `flutter run` or + // `flutter build apk` can discover them. By default, this is + // `/build/app/outputs/flutter-apk/.apk`, where the + // filename is `app<-abi>?<-flavor-name>?-.apk` (unchanged from + // the pre-migration assemble.doLast copy). + val variantBuildModeName = + FlutterPluginUtils.buildModeFor(variant.buildType ?: variant.name, variant.debuggable) + val flutterApkDir = + projectToAddTasksTo.layout.buildDirectory.dir("outputs/flutter-apk") + val apkOutputFileNames = + flutterApkOutputFileNames(projectToAddTasksTo, variant.flavorName, variantBuildModeName) + val copyFlutterApksTaskProvider: TaskProvider = + projectToAddTasksTo.tasks.register( + "copyFlutterApks$capitalizeVariantName", + CopyFlutterApksTask::class.java + ) { + apkDirectory.set(variant.artifacts.get(SingleArtifact.APK)) + builtArtifactsLoader.set(variant.artifacts.getBuiltArtifactsLoader()) + buildModeName.set(variantBuildModeName) + flavorName.set(variant.flavorName ?: "") + destinationDir.set(flutterApkDir) + outputApks.from(apkOutputFileNames.map { name -> flutterApkDir.map { dir -> dir.file(name) } }) + expectedVersionCodes.set(appliedAbiVersionCodes) + } + // The flutter-apk copy must run whenever the variant is assembled. The + // assemble task does not exist yet at variant-API time, so match it by name, + // and assert after evaluation that it was found: a silent no-match would mean + // `flutter run`/`flutter build apk` no longer find their APKs. + val assembleTaskName = "assemble$capitalizeVariantName" + projectToAddTasksTo.tasks + .matching { it.name == assembleTaskName } + .configureEach { finalizedBy(copyFlutterApksTaskProvider) } + projectToAddTasksTo.gradle.projectsEvaluated { + if (projectToAddTasksTo.tasks.findByName(assembleTaskName) == null) { + throw GradleException( + "Flutter expected the Android Gradle Plugin to create the " + + "'$assembleTaskName' task, but it does not exist, so the " + + "flutter-apk copy for variant '${variant.name}' could not be " + + "attached. Please file an issue at " + + "https://github.com/flutter/flutter/issues." + ) + } + } } val copyJniLibsTaskProvider: TaskProvider = @@ -401,63 +468,6 @@ class FlutterPlugin : Plugin { if (FlutterPluginUtils.isFlutterAppProject(projectToAddTasksTo)) { val appExtension = FlutterPluginUtils.getAndroidApplicationExtension(projectToAddTasksTo) configureAbis(projectToAddTasksTo, appExtension) - val android: AbstractAppExtension = - projectToAddTasksTo.extensions.findByName("android") as AbstractAppExtension - android.applicationVariants.configureEach { - val variant = this - val assembleTask = variant.assembleProvider.get() - if (!FlutterPluginUtils.shouldConfigureFlutterTask( - projectToAddTasksTo, - assembleTask - ) - ) { - return@configureEach - } - // Per-ABI versionCode override; migrates to VariantOutput.versionCode. - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - configureLegacyAbiVersionCodeOverride(variant, projectToAddTasksTo) - - // Copy the output APKs into a known location, so `flutter run` or `flutter build apk` - // can discover them. By default, this is `/build/app/outputs/flutter-apk/.apk`. - // - // The filename consists of `app<-abi>?<-flavor-name>?-.apk`. - // Where: - // * `abi` can be `armeabi-v7a|arm64-v8a|x86_64` only if the flag `split-per-abi` is set. - // * `flavor-name` is the flavor used to build the app in lower case if the assemble task is called. - // * `build-mode` can be `release|debug|profile`. - variant.outputs.forEach { output -> - assembleTask.doLast { - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - output as com.android.build.gradle.api.ApkVariantOutput - val packageApplicationProvider: PackageAndroidArtifact = - variant.packageApplicationProvider.get() - val outputDirectory: Directory = - packageApplicationProvider.outputDirectory.get() - val outputDirectoryStr: String = outputDirectory.toString() - var filename = "app" - - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - val abi = output.getFilter(com.android.build.VariantOutput.FilterType.ABI) - if (abi != null && abi.isNotEmpty()) { - filename += "-$abi" - } - if (variant.flavorName != null && variant.flavorName.isNotEmpty()) { - filename += "-${FlutterPluginUtils.lowercase(variant.flavorName)}" - } - filename += "-${FlutterPluginUtils.buildModeFor(variant.buildType)}" - projectToAddTasksTo.copy { - from(File("$outputDirectoryStr/${output.outputFileName}")) - into(projectToAddTasksTo.layout.buildDirectory.dir("outputs/flutter-apk")) - rename { "$filename.apk" } - } - } - } - } getPluginHandler(projectToAddTasksTo).configurePlugins(engineVersion!!) FlutterPluginUtils.detectLowCompileSdkVersionOrNdkVersion( projectToAddTasksTo, @@ -579,6 +589,28 @@ class FlutterPlugin : Plugin { private fun flutterCompileTaskName(variantName: String): String = FlutterPluginUtils.toCamelCase(listOf("compile", FLUTTER_BUILD_PREFIX, variantName)) + /** + * The flutter-apk file names a variant build produces, predictable at configuration + * time: one per target ABI for `--split-per-abi` builds (universal APKs are disabled + * for those, see [configureAbis]), or the single fat-APK name otherwise. + */ + private fun flutterApkOutputFileNames( + project: Project, + flavorName: String?, + buildModeName: String + ): List { + val flavorPart = + if (flavorName.isNullOrEmpty()) "" else "-${FlutterPluginUtils.lowercase(flavorName)}" + return if (FlutterPluginUtils.shouldProjectSplitPerAbi(project)) { + FlutterPluginUtils.getTargetPlatforms(project).map { platform -> + val abi = FlutterPluginConstants.PLATFORM_ARCH_MAP[platform] + "app-$abi$flavorPart-$buildModeName.apk" + } + } else { + listOf("app$flavorPart-$buildModeName.apk") + } + } + /** * Configures flutter default abi support respecting flutter command line flags. */ @@ -723,41 +755,6 @@ class FlutterPlugin : Plugin { } } - // Per-ABI versionCode override for --split-per-abi builds. Last legacy-variant-API - // consumer on the application path besides the flutter-apk copy; both migrate to the - // variant API (VariantOutput.versionCode / SingleArtifact.APK) in the next phase. - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - private fun configureLegacyAbiVersionCodeOverride( - @Suppress("DEPRECATION") variant: com.android.build.gradle.api.BaseVariant, - project: Project - ) { - if (!FlutterPluginUtils.shouldProjectSplitPerAbi(project)) { - return - } - variant.outputs.forEach { output -> - // need to force this as the API does not return the right thing for our use. - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - output as com.android.build.gradle.api.ApkVariantOutput - val versionCodeIfPresent: Int? = if (variant is ApkVariant) variant.versionCode else null - - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - val filterIdentifier: String? = - output.getFilter(com.android.build.VariantOutput.FilterType.ABI) - val abiVersionCode: Int? = FlutterPluginConstants.ABI_VERSION[filterIdentifier] - if (abiVersionCode != null && !FlutterPluginUtils.shouldForceVersionCodeIgnoringAbi(project)) { - output.versionCodeOverride = abiVersionCode * 1000 + ( - versionCodeIfPresent - ?: variant.mergedFlavor.versionCode as Int - ) - } - } - } - // Add-to-app module (library) path. Still entirely on the legacy variant API; the // whole path is rewired to the variant API when add-to-app migrates // (https://github.com/flutter/flutter/issues/166550). diff --git a/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterApksTask.kt b/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterApksTask.kt new file mode 100644 index 0000000000000..13bfe28431bdf --- /dev/null +++ b/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterApksTask.kt @@ -0,0 +1,138 @@ +// 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.tasks + +import com.android.build.api.variant.BuiltArtifact +import com.android.build.api.variant.BuiltArtifactsLoader +import com.android.build.api.variant.FilterConfiguration +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import javax.inject.Inject + +/** + * Copies the APKs produced for a variant ([apkDirectory], the `SingleArtifact.APK` directory) + * into the flutter-apk output directory, under the names the Flutter tool expects: + * `app[-abi][-flavor]-.apk`. + * + * Replaces the pre-migration `assemble.doLast` copy. Because every variant copies + * into the same flutter-apk directory, this task declares its individual [outputApks] (which + * are predictable at configuration time) rather than the shared directory, keeping the task + * UP-TO-DATE-capable without overlapping outputs between variants. + */ +abstract class CopyFlutterApksTask : DefaultTask() { + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val apkDirectory: DirectoryProperty + + @get:Internal + abstract val builtArtifactsLoader: Property + + /** The Flutter build mode segment of the file name ("debug", "profile" or "release"). */ + @get:Input + abstract val buildModeName: Property + + /** The variant's flavor name; empty when the project has no flavors. */ + @get:Input + abstract val flavorName: Property + + /** + * The files this task produces, computed at configuration time from the target platforms + * (for `--split-per-abi` builds) or the single universal APK name. + */ + @get:OutputFiles + abstract val outputApks: ConfigurableFileCollection + + /** + * The destination directory. Not an output directory: it is shared between the per-variant + * copies, so the tracked outputs are the individual [outputApks]. + */ + @get:Internal + abstract val destinationDir: DirectoryProperty + + /** + * The per-ABI versionCodes Flutter configured on the variant outputs, keyed by ABI + * identifier. Only set for `--split-per-abi` builds; used to warn when something mutated + * the versionCode after Flutter configured it. + */ + @get:Optional + @get:Input + abstract val expectedVersionCodes: MapProperty + + @get:Inject + abstract val fileSystemOperations: FileSystemOperations + + @TaskAction + fun copyApks() { + val builtArtifacts = + builtArtifactsLoader.get().load(apkDirectory.get()) + ?: throw GradleException( + "Flutter could not read the built APK metadata from " + + "${apkDirectory.get()}. The flutter-apk copy cannot run; please file " + + "an issue at https://github.com/flutter/flutter/issues." + ) + val expectedNames = outputApks.files.map { it.name }.toSet() + builtArtifacts.elements.forEach { artifact -> + val filename = apkFileNameFor(artifact) + if (filename !in expectedNames) { + logger.warn( + "Flutter staged APK '$filename' which was not among the expected output " + + "names $expectedNames; incremental builds may re-run this copy." + ) + } + warnOnVersionCodeDivergence(artifact) + fileSystemOperations.copy { + from(artifact.outputFile) + into(destinationDir) + rename { filename } + } + } + } + + private fun abiFor(artifact: BuiltArtifact): String? = + artifact.filters + .find { it.filterType == FilterConfiguration.FilterType.ABI } + ?.identifier + + /** Mirrors the pre-migration name: `app[-abi][-flavor]-.apk`. */ + private fun apkFileNameFor(artifact: BuiltArtifact): String { + var filename = "app" + val abi = abiFor(artifact) + if (!abi.isNullOrEmpty()) { + filename += "-$abi" + } + if (flavorName.get().isNotEmpty()) { + filename += "-${flavorName.get().lowercase()}" + } + filename += "-${buildModeName.get()}" + return "$filename.apk" + } + + private fun warnOnVersionCodeDivergence(artifact: BuiltArtifact) { + val expected: Int = expectedVersionCodes.getOrElse(emptyMap())[abiFor(artifact)] ?: return + val actual: Int? = artifact.versionCode + if (actual != expected) { + logger.warn( + "Warning: the versionCode of ${artifact.outputFile} is $actual, but Flutter " + + "configured $expected for this ABI. Something modified the versionCode " + + "after Flutter did (for example an afterEvaluate block); with the Android " + + "Gradle Plugin's variant API, versionCode should be set through " + + "androidComponents.onVariants instead." + ) + } + } +} diff --git a/packages/flutter_tools/test/integration.shard/flutter_build_apk_split_per_abi_test.dart b/packages/flutter_tools/test/integration.shard/flutter_build_apk_split_per_abi_test.dart index c1a272bef7987..686b22504dc1c 100644 --- a/packages/flutter_tools/test/integration.shard/flutter_build_apk_split_per_abi_test.dart +++ b/packages/flutter_tools/test/integration.shard/flutter_build_apk_split_per_abi_test.dart @@ -177,8 +177,9 @@ Future _assertSplitPerAbiVersionCodes( ); final int actual = actualVersionCodes[abi]!; - final int expected = - (abiIndex * 1000) + ((buildNumber ?? 1) * (usingCustomAppGradleFile ? 10000 : 1)); + final int expected = usingCustomAppGradleFile + ? ((abiIndex * 1000) + (buildNumber ?? 1)) * 10000 + : (abiIndex * 1000) + (buildNumber ?? 1); expect( actual, expected,