From 79878e11cb45c6bccaaf8fda94c3e314be81ba38 Mon Sep 17 00:00:00 2001 From: reidbaker-agent Date: Wed, 22 Jul 2026 02:00:04 +0000 Subject: [PATCH 1/2] Register the Flutter compile task lazily from the variant API for app projects Phase P5, commit 1 of 2 (P5a), of the AGP public-API migration (flutter/flutter#180137, flutter/flutter#166550): - For application projects, compileFlutterBuild (FlutterTask) is now registered inside the consolidated androidComponents.onVariants block as a lazy TaskProvider, configured entirely from the public variant API: minSdk from Variant.minSdk.apiLevel, flavor from Variant.flavorName, and the Flutter build mode from buildModeFor(variant.buildType, variant.debuggable) so custom debuggable build types keep mapping to debug engine artifacts. Registration is gated by shouldConfigureFlutterTask on the computed assemble task name (new name-based overload), mirroring the legacy callback's gating. - addFlutterDeps is split: addFlutterDepsForApp (per-ABI versionCode, legacy assets copy into the merged-assets dir, processResources hook) looks the compile task up by name instead of registering it; addFlutterDepsForModule keeps the full legacy path for add-to-app module projects until that path migrates. The always-null packageAssets/isUsedAsSubproject dead code and the duplicated processResources hook in the application variant callback are removed. No behavior change intended for what gets built; the assets delivery mechanism changes in the next commit (P5b). Verification note: FGP unit tests could not run in this sandbox (network policy blocks dl.google.com); run 'gradle test' (both AGP axes) in CI. Revert-safe (with P5b) until P6 lands. --- .../gradle/src/main/kotlin/FlutterPlugin.kt | 304 +++++++++++++----- .../src/main/kotlin/FlutterPluginUtils.kt | 15 +- 2 files changed, 231 insertions(+), 88 deletions(-) diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt index a7880c247a210..1c4195c171b22 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt @@ -7,11 +7,11 @@ package com.flutter.gradle 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.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.android.build.gradle.tasks.ProcessAndroidResources import com.flutter.gradle.FlutterPluginConstants.PLATFORM_ABI_LIST import com.flutter.gradle.FlutterPluginUtils.readPropertiesIfExist import com.flutter.gradle.plugins.PluginHandler @@ -313,9 +313,32 @@ class FlutterPlugin : Plugin { // fails loudly rather than silently skipping libapp.so registration. val androidComponents = projectToAddTasksTo.extensions.getByType(AndroidComponentsExtension::class.java) val targetPlatformsList = targetPlatforms + val flutterPlugin = this + val isAppProject = FlutterPluginUtils.isFlutterAppProject(projectToAddTasksTo) androidComponents.onVariants { variant -> val capitalizeVariantName = FlutterPluginUtils.capitalize(variant.name) val compileTaskName = flutterCompileTaskName(variant.name) + + // For application projects, the Flutter compile task is registered here, lazily, + // from the public variant API. For add-to-app module (library) projects it is still + // registered by the legacy variant callback in addFlutterDepsForModule until that + // path migrates to the variant API + // (https://github.com/flutter/flutter/issues/166550). The gating mirrors the + // legacy callback's shouldConfigureFlutterTask check on the assemble task name. + if (isAppProject && + FlutterPluginUtils.shouldConfigureFlutterTask( + projectToAddTasksTo, + "assemble$capitalizeVariantName" + ) + ) { + registerFlutterCompileTask( + projectToAddTasksTo, + variant, + flutterPlugin, + targetPlatformsList + ) + } + val copyJniLibsTaskProvider: TaskProvider = projectToAddTasksTo.tasks.register( "copyJniLibs${FLUTTER_BUILD_PREFIX}$capitalizeVariantName", @@ -345,8 +368,6 @@ class FlutterPlugin : Plugin { ) } - val flutterPlugin = this - if (FlutterPluginUtils.isFlutterAppProject(projectToAddTasksTo)) { val appExtension = FlutterPluginUtils.getAndroidApplicationExtension(projectToAddTasksTo) configureAbis(projectToAddTasksTo, appExtension) @@ -362,23 +383,9 @@ class FlutterPlugin : Plugin { ) { return@configureEach } - val copyFlutterAssetsTask: Task = - addFlutterDeps(variant, flutterPlugin, targetPlatforms) - - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - val variantOutput: com.android.build.gradle.api.BaseVariantOutput = variant.outputs.first() - val processResources: ProcessAndroidResources = - try { - variantOutput.processResourcesProvider.get() - } catch (e: UnknownTaskException) { - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - variantOutput.processResources - } - processResources.dependsOn(copyFlutterAssetsTask) + // Registers the assets copy and hooks it into resource processing + // (addFlutterDepsForApp wires processResources itself). + addFlutterDepsForApp(variant, flutterPlugin) // 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`. @@ -486,7 +493,7 @@ class FlutterPlugin : Plugin { if (FlutterPluginUtils.buildModeFor(appProjectVariant.buildType) != variantBuildMode) { return@applicationVariantAll } - copyFlutterAssetsTask = copyFlutterAssetsTask ?: addFlutterDeps( + copyFlutterAssetsTask = copyFlutterAssetsTask ?: addFlutterDepsForModule( libraryVariant, flutterPlugin, targetPlatforms @@ -601,28 +608,17 @@ class FlutterPlugin : Plugin { } /** - * Finds a task by name, returning null if the task does not exist. + * Registers the [FlutterTask] (the `flutter assemble` invocation) for [variant], + * configured entirely from the public variant API. Application projects only; the + * add-to-app module path still registers its own compile task in + * [addFlutterDepsForModule]. */ - private fun findTaskOrNull( + private fun registerFlutterCompileTask( project: Project, - taskName: String - ): Task? = - try { - project.tasks.named(taskName).get() - } catch (ignored: UnknownTaskException) { - null - } - - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - private fun addFlutterDeps( - @Suppress("DEPRECATION") variant: com.android.build.gradle.api.BaseVariant, + variant: Variant, flutterPlugin: FlutterPlugin, targetPlatforms: List - ): Task { - // Shorthand - val project: Project = flutterPlugin.project!! - + ): TaskProvider { val fileSystemRootsValue: Array? = project .findProperty("filesystem-roots") @@ -654,6 +650,61 @@ class FlutterPlugin : Plugin { val validateDeferredComponentsValue: Boolean = project.findProperty("validate-deferred-components")?.toString()?.toBoolean() ?: true + // Variant-scope build-mode resolution uses the public debuggable flag so that + // custom debuggable build types (e.g. `staging`) map to the debug engine artifacts. + val variantBuildMode: String = + FlutterPluginUtils.buildModeFor(variant.buildType ?: variant.name, variant.debuggable) + val flavorValue: String? = variant.flavorName + val variantNameValue: String = variant.name + val minSdkVersionValue: Int = variant.minSdk.apiLevel + + return project.tasks.register(flutterCompileTaskName(variant.name), FlutterTask::class.java) { + flutterRoot = flutterPlugin.flutterRoot + flutterExecutable = flutterPlugin.flutterExecutable + buildMode = variantBuildMode + minSdkVersion = minSdkVersionValue + localEngine = flutterPlugin.localEngine + localEngineHost = flutterPlugin.localEngineHost + localEngineSrcPath = flutterPlugin.localEngineSrcPath + targetPath = FlutterPluginUtils.getFlutterTarget(project) + verbose = FlutterPluginUtils.isProjectVerbose(project) + fileSystemRoots = fileSystemRootsValue + fileSystemScheme = fileSystemSchemeValue + trackWidgetCreation = trackWidgetCreationValue + targetPlatformValues = targetPlatforms + sourceDir = FlutterPluginUtils.getFlutterSourceDirectory(project) + intermediateDir = + project.file( + project.layout.buildDirectory.dir("${FlutterPluginConstants.INTERMEDIATES_DIR}/flutter/$variantNameValue/") + ) + frontendServerStarterPath = frontendServerStarterPathValue + extraFrontEndOptions = extraFrontEndOptionsValue + extraGenSnapshotOptions = extraGenSnapshotOptionsValue + splitDebugInfo = splitDebugInfoValue + treeShakeIcons = treeShakeIconsOptionsValue + dartObfuscation = dartObfuscationValue + dartDefines = dartDefinesValue + performanceMeasurementFile = performanceMeasurementFileValue + codeSizeDirectory = codeSizeDirectoryValue + deferredComponents = deferredComponentsValue + validateDeferredComponents = validateDeferredComponentsValue + flavor = flavorValue + } + } + + // Legacy remainder of the application-project variant wiring. The Flutter compile + // task itself is registered from the variant API in registerFlutterCompileTask; the + // pieces below (per-ABI versionCode and the assets copy into the merged-assets + // directory) migrate in later phases. + // TODO(gmackall): Migrate to AGPs variant api. + // https://github.com/flutter/flutter/issues/166550 + private fun addFlutterDepsForApp( + @Suppress("DEPRECATION") variant: com.android.build.gradle.api.BaseVariant, + flutterPlugin: FlutterPlugin + ): Task { + // Shorthand + val project: Project = flutterPlugin.project!! + if (FlutterPluginUtils.shouldProjectSplitPerAbi(project)) { variant.outputs.forEach { output -> // need to force this as the API does not return the right thing for our use. @@ -678,24 +729,104 @@ class FlutterPlugin : Plugin { } } - // Build an AAR when this property is defined. - val isBuildingAar: Boolean = project.hasProperty("is-plugin") - // In add to app scenarios, a Gradle project contains a `:flutter` and `:app` project. - // `:flutter` is used as a subproject when these tasks exists and the build isn't building an AAR. - // TODO(gmackall): I think this is just always null? Which is great news! Consider removing. - val packageAssets: Task? = - findTaskOrNull( - project, - "package${FlutterPluginUtils.capitalize(variant.name)}Assets" - ) - val cleanPackageAssets: Task? = - findTaskOrNull( - project, - "cleanPackage${FlutterPluginUtils.capitalize(variant.name)}Assets" - ) + // The compile task was registered from the variant API (with matching + // shouldConfigureFlutterTask gating), so it is expected to exist here. + val flutterCompileTask: FlutterTask = + project.tasks + .named(flutterCompileTaskName(variant.name), FlutterTask::class.java) + .get() + val copyFlutterAssetsTaskProvider: TaskProvider = + project.tasks.register( + "copyFlutterAssets${FlutterPluginUtils.capitalize(variant.name)}", + Copy::class.java + ) { + dependsOn(flutterCompileTask) + with(flutterCompileTask.assets) + filePermissions { + user { + read = true + write = true + } + } + val mergeAssets = + try { + variant.mergeAssetsProvider.get() + } catch (e: IllegalStateException) { + // TODO(gmackall): Migrate to AGPs variant api. + // https://github.com/flutter/flutter/issues/166550 + @Suppress("DEPRECATION") + variant.mergeAssets + } + dependsOn(mergeAssets) + dependsOn("clean${FlutterPluginUtils.capitalize(mergeAssets.name)}") + mergeAssets.mustRunAfter("clean${FlutterPluginUtils.capitalize(mergeAssets.name)}") + into(mergeAssets.outputDir) + } + val copyFlutterAssetsTask: Task = copyFlutterAssetsTaskProvider.get() + // TODO(gmackall): Migrate to AGPs variant api. + // https://github.com/flutter/flutter/issues/166550 + @Suppress("DEPRECATION") + val variantOutput: com.android.build.gradle.api.BaseVariantOutput = variant.outputs.first() + val processResources = + try { + variantOutput.processResourcesProvider.get() + } catch (e: IllegalStateException) { + // TODO(gmackall): Migrate to AGPs variant api. + // https://github.com/flutter/flutter/issues/166550 + @Suppress("DEPRECATION") + variantOutput.processResources + } + processResources.dependsOn(copyFlutterAssetsTask) + // The following tasks use the output of copyFlutterAssetsTask, + // so it's necessary to declare it as an dependency since Gradle 8. + // See https://docs.gradle.org/8.1/userguide/validation_problems.html#implicit_dependency. + addCopyFlutterAssetsDependency(project, variant.name, copyFlutterAssetsTask) + return copyFlutterAssetsTask + } - val isUsedAsSubproject: Boolean = - packageAssets != null && cleanPackageAssets != null && !isBuildingAar + // 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). + // TODO(gmackall): Migrate to AGPs variant api. + // https://github.com/flutter/flutter/issues/166550 + private fun addFlutterDepsForModule( + @Suppress("DEPRECATION") variant: com.android.build.gradle.api.BaseVariant, + flutterPlugin: FlutterPlugin, + targetPlatforms: List + ): Task { + // Shorthand + val project: Project = flutterPlugin.project!! + + val fileSystemRootsValue: Array? = + project + .findProperty("filesystem-roots") + ?.toString() + ?.split("\\|") + ?.toTypedArray() + val fileSystemSchemeValue: String? = + project.findProperty("filesystem-scheme")?.toString() + val trackWidgetCreationValue: Boolean = + project.findProperty("track-widget-creation")?.toString()?.toBoolean() ?: true + val frontendServerStarterPathValue: String? = + project.findProperty("frontend-server-starter-path")?.toString() + val extraFrontEndOptionsValue: String? = + project.findProperty("extra-front-end-options")?.toString() + val extraGenSnapshotOptionsValue: String? = + project.findProperty("extra-gen-snapshot-options")?.toString() + val splitDebugInfoValue: String? = project.findProperty("split-debug-info")?.toString() + val dartObfuscationValue: Boolean = + project.findProperty("dart-obfuscation")?.toString()?.toBoolean() ?: false + val treeShakeIconsOptionsValue: Boolean = + project.findProperty("tree-shake-icons")?.toString()?.toBoolean() ?: false + val dartDefinesValue: String? = project.findProperty("dart-defines")?.toString() + val performanceMeasurementFileValue: String? = + project.findProperty("performance-measurement-file")?.toString() + val codeSizeDirectoryValue: String? = + project.findProperty("code-size-directory")?.toString() + val deferredComponentsValue: Boolean = + project.findProperty("deferred-components")?.toString()?.toBoolean() ?: false + val validateDeferredComponentsValue: Boolean = + project.findProperty("validate-deferred-components")?.toString()?.toBoolean() ?: true val variantBuildMode: String = FlutterPluginUtils.buildModeFor(variant.buildType) val flavorValue: String = variant.flavorName @@ -756,12 +887,6 @@ class FlutterPlugin : Plugin { write = true } } - if (isUsedAsSubproject) { - // TODO(gmackall): above is always false, can delete - dependsOn(packageAssets) - dependsOn(cleanPackageAssets) - into(packageAssets!!.outputs) - } val mergeAssets = try { variant.mergeAssetsProvider.get() @@ -777,41 +902,52 @@ class FlutterPlugin : Plugin { into(mergeAssets.outputDir) } val copyFlutterAssetsTask: Task = copyFlutterAssetsTaskProvider.get() - if (!isUsedAsSubproject) { - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - val variantOutput: com.android.build.gradle.api.BaseVariantOutput = variant.outputs.first() - val processResources = - try { - variantOutput.processResourcesProvider.get() - } catch (e: IllegalStateException) { - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - variantOutput.processResources - } - processResources.dependsOn(copyFlutterAssetsTask) - } + // TODO(gmackall): Migrate to AGPs variant api. + // https://github.com/flutter/flutter/issues/166550 + @Suppress("DEPRECATION") + val variantOutput: com.android.build.gradle.api.BaseVariantOutput = variant.outputs.first() + val processResources = + try { + variantOutput.processResourcesProvider.get() + } catch (e: IllegalStateException) { + // TODO(gmackall): Migrate to AGPs variant api. + // https://github.com/flutter/flutter/issues/166550 + @Suppress("DEPRECATION") + variantOutput.processResources + } + processResources.dependsOn(copyFlutterAssetsTask) // The following tasks use the output of copyFlutterAssetsTask, // so it's necessary to declare it as an dependency since Gradle 8. // See https://docs.gradle.org/8.1/userguide/validation_problems.html#implicit_dependency. + addCopyFlutterAssetsDependency(project, variant.name, copyFlutterAssetsTask) + return copyFlutterAssetsTask + } + + /** + * Wires the tasks that consume the output of `copyFlutterAssets` to depend on + * it explicitly, as required since Gradle 8. See + * https://docs.gradle.org/8.1/userguide/validation_problems.html#implicit_dependency. + */ + private fun addCopyFlutterAssetsDependency( + project: Project, + variantName: String, + copyFlutterAssetsTask: Task + ) { val tasksToCheck = listOf( - "compress${FlutterPluginUtils.capitalize(variant.name)}Assets", - "bundle${FlutterPluginUtils.capitalize(variant.name)}Aar", - "bundle${FlutterPluginUtils.capitalize(variant.name)}LocalLintAar" + "compress${FlutterPluginUtils.capitalize(variantName)}Assets", + "bundle${FlutterPluginUtils.capitalize(variantName)}Aar", + "bundle${FlutterPluginUtils.capitalize(variantName)}LocalLintAar" ) - tasksToCheck.forEach { taskTocheck -> + tasksToCheck.forEach { taskToCheck -> try { - project.tasks.named(taskTocheck).configure { + project.tasks.named(taskToCheck).configure { dependsOn(copyFlutterAssetsTask) } } catch (ignored: UnknownTaskException) { // ignored } } - return copyFlutterAssetsTask } } diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt index 9a7212815bdd6..66ce9967c04aa 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt @@ -358,6 +358,13 @@ object FlutterPluginUtils { internal fun shouldConfigureFlutterTask( project: Project, assembleTask: Task + ): Boolean = shouldConfigureFlutterTask(project, assembleTask.name) + + @JvmStatic + @JvmName("shouldConfigureFlutterTask") + internal fun shouldConfigureFlutterTask( + project: Project, + assembleTaskName: String ): Boolean { val cliTasksNames = project.gradle.startParameter.taskNames if (cliTasksNames.size != 1 || !cliTasksNames.first().contains("assemble")) { @@ -367,16 +374,16 @@ object FlutterPluginUtils { if (taskName == "assemble") { return true } - if (taskName == assembleTask.name) { + if (taskName == assembleTaskName) { return true } - if (taskName.endsWith("Release") && assembleTask.name.endsWith("Release")) { + if (taskName.endsWith("Release") && assembleTaskName.endsWith("Release")) { return true } - if (taskName.endsWith("Debug") && assembleTask.name.endsWith("Debug")) { + if (taskName.endsWith("Debug") && assembleTaskName.endsWith("Debug")) { return true } - if (taskName.endsWith("Profile") && assembleTask.name.endsWith("Profile")) { + if (taskName.endsWith("Profile") && assembleTaskName.endsWith("Profile")) { return true } return false From 2654136a8f5d4d7ba50bda5c20752205afde05b0 Mon Sep 17 00:00:00 2001 From: reidbaker-agent Date: Wed, 22 Jul 2026 02:03:46 +0000 Subject: [PATCH 2/2] Deliver Flutter assets as a generated assets source directory on the app path Phase P5, commit 2 of 2 (P5b), of the AGP public-API migration (flutter/flutter#180137, flutter/flutter#166550): - New CopyFlutterAssetsTask stages flutter_assets/** from the Flutter build output into its own output directory (modeled on CopyFlutterJniLibsTask, including the overlapping-outputs rationale), applying the user read+write file permissions the old Copy task set. - For application projects, copyFlutterAssets is registered in the consolidated onVariants block as a lazy TaskProvider and wired via variant.sources.assets.addGeneratedSourceDirectory. AGP now merges Flutter's assets like any other assets source; collisions with user assets resolve by source-set priority instead of the old post-merge overwrite (documented behavior change). A missing assets source set fails loudly instead of silently building an APK without Flutter assets. - The legacy app-path assets copy (into mergeAssets.outputDir), its processResources/cleanMergeAssets task-graph surgery, and the manual compressAssets dependsOn wiring are deleted for app projects; AGP owns those edges now. The application variant callback is reduced to the per-ABI versionCode override and the flutter-apk copy, both of which migrate in the next phase. The add-to-app module path is unchanged (still the full legacy copy) until it migrates. - copyFlutterAssets changes type from org.gradle.api.tasks.Copy to CopyFlutterAssetsTask and is now registered lazily (documented breaking change for build scripts that referenced it by type). - Tests: new CopyFlutterAssetsTaskTest executes the task against real files (staging layout, permission bits, non-asset exclusion, stale output cleanup). The FlutterPluginTest filePermissions test built on capturing the legacy Copy registration is superseded by it. Verification (CI): gradle unit tests both AGP axes; integration builddir/obfuscate/jni/print_build_variants/deferred_components_assets; add-to-app source smoke + flutter build aar; asset-staleness rebuild check; config-cache per baseline. Revert-safe until P6 lands. --- .../gradle/src/main/kotlin/FlutterPlugin.kt | 161 ++++++------- .../kotlin/tasks/CopyFlutterAssetsTask.kt | 67 ++++++ .../src/test/kotlin/FlutterPluginTest.kt | 212 ------------------ .../kotlin/tasks/CopyFlutterAssetsTaskTest.kt | 74 ++++++ 4 files changed, 209 insertions(+), 305 deletions(-) create mode 100644 packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterAssetsTask.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterAssetsTaskTest.kt diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt index 1c4195c171b22..34e93d64ffa79 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt @@ -15,6 +15,7 @@ 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.CopyFlutterAssetsTask import com.flutter.gradle.tasks.CopyFlutterJniLibsTask import com.flutter.gradle.tasks.FlutterTask import org.gradle.api.GradleException @@ -331,11 +332,40 @@ class FlutterPlugin : Plugin { "assemble$capitalizeVariantName" ) ) { - registerFlutterCompileTask( - projectToAddTasksTo, - variant, - flutterPlugin, - targetPlatformsList + val compileTaskProvider = + registerFlutterCompileTask( + projectToAddTasksTo, + variant, + flutterPlugin, + targetPlatformsList + ) + val copyFlutterAssetsTaskProvider: TaskProvider = + projectToAddTasksTo.tasks.register( + "copyFlutterAssets$capitalizeVariantName", + CopyFlutterAssetsTask::class.java + ) { + dependsOn(compileTaskProvider) + intermediateDir.set( + projectToAddTasksTo.layout.dir( + compileTaskProvider.map { requireNotNull(it.outputDirectory) } + ) + ) + } + // Flutter's assets are delivered as a generated assets source directory, so + // AGP merges and packages them like any other assets source. The assets + // source set is expected to exist for application variants; fail loudly + // rather than silently building an APK without Flutter assets. + val assetSources = + variant.sources.assets + ?: throw GradleException( + "Flutter could not register its generated assets for variant " + + "'${variant.name}' because the Android Gradle Plugin did not " + + "expose an assets source set for it. Please file an issue at " + + "https://github.com/flutter/flutter/issues." + ) + assetSources.addGeneratedSourceDirectory( + copyFlutterAssetsTaskProvider, + CopyFlutterAssetsTask::destinationDir ) } @@ -383,9 +413,10 @@ class FlutterPlugin : Plugin { ) { return@configureEach } - // Registers the assets copy and hooks it into resource processing - // (addFlutterDepsForApp wires processResources itself). - addFlutterDepsForApp(variant, flutterPlugin) + // 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`. @@ -692,96 +723,39 @@ class FlutterPlugin : Plugin { } } - // Legacy remainder of the application-project variant wiring. The Flutter compile - // task itself is registered from the variant API in registerFlutterCompileTask; the - // pieces below (per-ABI versionCode and the assets copy into the merged-assets - // directory) migrate in later phases. + // 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 addFlutterDepsForApp( + private fun configureLegacyAbiVersionCodeOverride( @Suppress("DEPRECATION") variant: com.android.build.gradle.api.BaseVariant, - flutterPlugin: FlutterPlugin - ): Task { - // Shorthand - val project: Project = flutterPlugin.project!! - - if (FlutterPluginUtils.shouldProjectSplitPerAbi(project)) { - 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 - ) - } - } + project: Project + ) { + if (!FlutterPluginUtils.shouldProjectSplitPerAbi(project)) { + return } - - // The compile task was registered from the variant API (with matching - // shouldConfigureFlutterTask gating), so it is expected to exist here. - val flutterCompileTask: FlutterTask = - project.tasks - .named(flutterCompileTaskName(variant.name), FlutterTask::class.java) - .get() - val copyFlutterAssetsTaskProvider: TaskProvider = - project.tasks.register( - "copyFlutterAssets${FlutterPluginUtils.capitalize(variant.name)}", - Copy::class.java - ) { - dependsOn(flutterCompileTask) - with(flutterCompileTask.assets) - filePermissions { - user { - read = true - write = true - } - } - val mergeAssets = - try { - variant.mergeAssetsProvider.get() - } catch (e: IllegalStateException) { - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - variant.mergeAssets - } - dependsOn(mergeAssets) - dependsOn("clean${FlutterPluginUtils.capitalize(mergeAssets.name)}") - mergeAssets.mustRunAfter("clean${FlutterPluginUtils.capitalize(mergeAssets.name)}") - into(mergeAssets.outputDir) - } - val copyFlutterAssetsTask: Task = copyFlutterAssetsTaskProvider.get() - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - val variantOutput: com.android.build.gradle.api.BaseVariantOutput = variant.outputs.first() - val processResources = - try { - variantOutput.processResourcesProvider.get() - } catch (e: IllegalStateException) { - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - variantOutput.processResources + 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 + ) } - processResources.dependsOn(copyFlutterAssetsTask) - // The following tasks use the output of copyFlutterAssetsTask, - // so it's necessary to declare it as an dependency since Gradle 8. - // See https://docs.gradle.org/8.1/userguide/validation_problems.html#implicit_dependency. - addCopyFlutterAssetsDependency(project, variant.name, copyFlutterAssetsTask) - return copyFlutterAssetsTask + } } // Add-to-app module (library) path. Still entirely on the legacy variant API; the @@ -902,6 +876,7 @@ class FlutterPlugin : Plugin { into(mergeAssets.outputDir) } val copyFlutterAssetsTask: Task = copyFlutterAssetsTaskProvider.get() + // TODO(gmackall): Migrate to AGPs variant api. // https://github.com/flutter/flutter/issues/166550 @Suppress("DEPRECATION") diff --git a/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterAssetsTask.kt b/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterAssetsTask.kt new file mode 100644 index 0000000000000..788db3006fd35 --- /dev/null +++ b/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterAssetsTask.kt @@ -0,0 +1,67 @@ +// 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 org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import javax.inject.Inject + +/** + * Stages the `flutter_assets` directory produced by the Flutter build into a dedicated + * [destinationDir], which is registered with the variant as a generated assets source + * directory (`variant.sources.assets.addGeneratedSourceDirectory`). AGP then merges it like + * any other assets source; collisions with user assets resolve by AGP source-set priority. + * (The pre-migration behavior was a copy into the merged-assets output after merging, which + * silently overwrote colliding user assets.) + * + * It deliberately writes to its own output directory rather than reusing the Flutter task's + * output directory, for the same overlapping-output reasons as [CopyFlutterJniLibsTask]. + */ +abstract class CopyFlutterAssetsTask : DefaultTask() { + /** + * The Flutter build output directory (the `flutter assemble` `--output` location). + * + * Optional for the same reason as [CopyFlutterJniLibsTask.intermediateDir]: absent when + * there is no Flutter compile task for the variant, in which case this task stages + * nothing. + */ + @get:Optional + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val intermediateDir: DirectoryProperty + + @get:OutputDirectory + abstract val destinationDir: DirectoryProperty + + @get:Inject + abstract val fileSystemOperations: FileSystemOperations + + @TaskAction + fun copy() { + fileSystemOperations.sync { + into(destinationDir) + if (intermediateDir.isPresent) { + from(intermediateDir) { + // Keeps the `flutter_assets/` prefix, so the staged layout matches what + // the pre-migration copy produced inside the merged-assets directory. + include(FlutterTaskHelper.FLUTTER_ASSETS_INCLUDE_DIRECTORY) + } + } + filePermissions { + user { + read = true + write = true + } + } + } + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginTest.kt index cad147bd862e8..30697cfa0d4f2 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginTest.kt @@ -9,25 +9,14 @@ import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.gradle.AbstractAppExtension import com.android.build.gradle.BaseExtension import com.android.build.gradle.api.AndroidSourceDirectorySet -import com.android.build.gradle.internal.core.InternalBaseVariant -import com.android.build.gradle.tasks.MergeSourceSetFolders -import com.android.build.gradle.tasks.ProcessAndroidResources -import com.flutter.gradle.tasks.FlutterTask import com.flutter.gradle.tasks.PrintTask import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject -import io.mockk.slot import io.mockk.verify -import org.gradle.api.Action import org.gradle.api.Project -import org.gradle.api.Task import org.gradle.api.file.Directory -import org.gradle.api.tasks.Copy -import org.gradle.api.tasks.TaskContainer -import org.gradle.api.tasks.TaskProvider import org.jetbrains.kotlin.gradle.plugin.extraProperties -import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.io.TempDir import java.nio.file.Path import kotlin.io.path.writeText @@ -134,207 +123,6 @@ class FlutterPluginTest { assertContains(registeredPrintTasks, "printNdkVersion") } - @Test - fun `copyFlutterAssets task sets filePermissions correctly`( - @TempDir tempDir: Path - ) { - val projectDir = tempDir.resolve("project-dir").resolve("android").resolve("app") - projectDir.toFile().mkdirs() - val settingsFile = projectDir.parent.resolve("settings.gradle") - settingsFile.writeText("empty for now") - val fakeFlutterSdkDir = tempDir.resolve("fake-flutter-sdk") - fakeFlutterSdkDir.toFile().mkdirs() - val fakeCacheDir = fakeFlutterSdkDir.resolve("bin").resolve("cache") - fakeCacheDir.toFile().mkdirs() - val fakeEngineStampFile = fakeCacheDir.resolve("engine.stamp") - fakeEngineStampFile.writeText(FAKE_ENGINE_STAMP) - val fakeEngineRealmFile = fakeCacheDir.resolve("engine.realm") - fakeEngineRealmFile.writeText(FAKE_ENGINE_REALM) - val project = mockk(relaxed = true) - val mockAbstractAppExtension = - mockk( - moreInterfaces = arrayOf(ApplicationExtension::class), - relaxed = true - ) - every { project.extensions.findByType(AbstractAppExtension::class.java) } returns mockAbstractAppExtension - every { project.extensions.getByType(AbstractAppExtension::class.java) } returns mockAbstractAppExtension - every { project.extensions.findByName("android") } returns mockAbstractAppExtension - val mockAndroidComponentsExtension = mockk>(relaxed = true) - every { project.extensions.getByType(AndroidComponentsExtension::class.java) } returns mockAndroidComponentsExtension - every { project.extensions.findByType(AndroidComponentsExtension::class.java) } returns mockAndroidComponentsExtension - val mockSelector = mockk(relaxed = true) - every { mockAndroidComponentsExtension.selector() } returns mockSelector - every { mockSelector.all() } returns mockSelector - every { mockSelector.withName(any()) } returns mockSelector - every { project.projectDir } returns projectDir.toFile() - every { project.findProperty("flutter.sdk") } returns fakeFlutterSdkDir.toString() - every { project.file(fakeFlutterSdkDir.toString()) } returns fakeFlutterSdkDir.toFile() - val flutterExtension = FlutterExtension() - every { project.extensions.create("flutter", any>()) } returns flutterExtension - every { project.extensions.findByType(FlutterExtension::class.java) } returns flutterExtension - val mockBaseExtension = mockk(relaxed = true) - val mockCommonExtension = mockk>(relaxed = true) - val mockDebugBuildType = mockk(relaxed = true) - val mockReleaseBuildType = mockk(relaxed = true) - - // Cast our multi-interface mock instead of creating a brand new one - val mockApplicationExtension = mockAbstractAppExtension as ApplicationExtension - - // Mock buildTypes on our new dual-purpose mock so AgpCommonExtensionWrapper can read them - every { mockApplicationExtension.buildTypes.getByName("debug") } returns mockDebugBuildType - every { mockApplicationExtension.buildTypes.getByName("release") } returns mockReleaseBuildType - - // Keep the CommonExtension mocks just in case other parts of the plugin look for it - every { mockCommonExtension.buildTypes.getByName("debug") } returns mockDebugBuildType - every { mockCommonExtension.buildTypes.getByName("release") } returns mockReleaseBuildType - - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { project.extensions.findByType(CommonExtension::class.java) } returns mockCommonExtension - - // Pass the dual-purpose mock for any ApplicationExtension lookups - every { project.extensions.findByType(ApplicationExtension::class.java) } returns mockApplicationExtension - every { project.extensions.getByType(ApplicationExtension::class.java) } returns mockApplicationExtension - - val mockApplicationDefaultConfig = - mockk( - moreInterfaces = arrayOf(ApplicationDefaultConfig::class), - relaxed = true - ) - every { mockApplicationExtension.defaultConfig } returns mockApplicationDefaultConfig - every { project.rootProject } returns project - every { project.state.failure as Throwable? } returns null - val mockDirectory = mockk(relaxed = true) - every { project.layout.buildDirectory.get() } returns mockDirectory - val mockAndroidSourceSet = mockk(relaxed = true) - val mockAndroidSourceDirectorySet = mockk(relaxed = true) - every { mockAndroidSourceSet.jniLibs.srcDir(any()) } returns mockAndroidSourceDirectorySet - every { mockAbstractAppExtension.sourceSets.getByName("main") } returns mockAndroidSourceSet - // mock return of NativePluginLoaderReflectionBridge.getPlugins - mockkObject(NativePluginLoaderReflectionBridge) - every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns - listOf() - // mock method calls that are invoked by the args to NativePluginLoaderReflectionBridge - every { project.extraProperties } returns mockk() - every { project.file(flutterExtension.source!!) } returns mockk() - // Set up the task container and our task capture - val taskContainer = mockk(relaxed = true) - every { project.tasks } returns taskContainer - val copyTaskActionCaptor = slot>() - val copyTask = mockk(relaxed = true) - val mockVariant = mockk(relaxed = true) - every { mockVariant.name } returns "debug" - every { mockVariant.buildType.name } returns "debug" - every { mockVariant.flavorName } returns "" - val mergedFlavor = mockk(relaxed = true) - every { mockVariant.mergedFlavor } returns mergedFlavor - val apiLevel = mockk(relaxed = true) - every { apiLevel.apiLevel } returns 21 - every { mergedFlavor.minSdkVersion } returns apiLevel - val variantOutput = mockk(relaxed = true) - val outputsIterator = mockk>() - every { outputsIterator.hasNext() } returns true andThen false - every { outputsIterator.next() } returns variantOutput - val variantOutputCollection = mockk>() - every { variantOutputCollection.iterator() } returns outputsIterator - every { mockVariant.outputs } returns variantOutputCollection - val processResourcesProvider = mockk>(relaxed = true) - every { processResourcesProvider.hint(ProcessAndroidResources::class).get() } returns mockk(relaxed = true) - every { variantOutput.processResourcesProvider } returns processResourcesProvider - val assembleTask = mockk(relaxed = true) - val assembleTaskProvider = mockk>(relaxed = true) - every { assembleTaskProvider.get() } returns assembleTask - every { mockVariant.assembleProvider } returns assembleTaskProvider - val variants = listOf(mockVariant) - val variantsIterator = mockk>() - every { variantsIterator.hasNext() } returns true andThen false - every { variantsIterator.next() } returns mockVariant - val variantCollection = mockk>() - every { mockAbstractAppExtension.applicationVariants } returns variantCollection - every { variantCollection.iterator() } returns variantsIterator - every { - variantCollection.configureEach(any>()) - } answers { - variants.forEach { firstArg>().execute(it) } - } - every { mockVariant.mergeAssetsProvider.hint(MergeSourceSetFolders::class).get() } returns - mockk(relaxed = true) - val flutterTask = mockk(relaxed = true) - val copySpec = mockk(relaxed = true) - every { - (flutterTask).assets - } returns copySpec - val flutterTaskProvider = mockk>(relaxed = true) - every { - flutterTaskProvider.hint(FlutterTask::class).get() - } returns flutterTask - every { - taskContainer.register( - match { it.contains("compileFlutterBuild") }, - any>(), - any() - ) - } answers { - flutterTaskProvider - } - // Actual task that should be captured to test if permissions have been set - val mockCopyTaskProvider = mockk>(relaxed = true) - every { mockCopyTaskProvider.hint(Copy::class).get() } returns copyTask - every { - taskContainer.register( - match { it.startsWith("copyFlutterAssets") }, - eq(Copy::class.java), - capture(copyTaskActionCaptor) - ) - } answers { - mockCopyTaskProvider - } - val mockJarTaskProvider = mockk>(relaxed = true) - every { mockJarTaskProvider.hint(org.gradle.api.tasks.bundling.Jar::class).get() } returns - mockk(relaxed = true) - every { - taskContainer.register( - match { it.contains("packJniLibs") }, - eq(org.gradle.api.tasks.bundling.Jar::class.java), - any() - ) - } answers { - mockJarTaskProvider - } - val mockTaskProvider = mockk>(relaxed = true) - every { mockTaskProvider.hint(Task::class).get() } returns mockk(relaxed = true) - every { - taskContainer.named(any()) - } returns mockTaskProvider - val flutterPlugin = FlutterPlugin() - flutterPlugin.apply(project) - - copyTaskActionCaptor.captured.execute(copyTask) - val filePermissionsActionCaptor = slot>() - verify { - copyTask.filePermissions(capture(filePermissionsActionCaptor)) - } - if (filePermissionsActionCaptor.isCaptured) { - val mockFilePermissionSet = mockk(relaxed = true) - filePermissionsActionCaptor.captured.execute(mockFilePermissionSet) - val userPermissionsActionCaptor = slot>() - verify { - mockFilePermissionSet.user(capture(userPermissionsActionCaptor)) - } - if (userPermissionsActionCaptor.isCaptured) { - val mockUserPermission = mockk(relaxed = true) - userPermissionsActionCaptor.captured.execute(mockUserPermission) - verify { - mockUserPermission.read = true - mockUserPermission.write = true - } - } else { - fail("User permissions configuration action was not captured") - } - } else { - fail("FilePermissions configuration action was not captured") - } - } - companion object { const val FAKE_ENGINE_STAMP = "901b0f1afe77c3555abee7b86a26aaa37f131379" const val FAKE_ENGINE_REALM = "made_up_realm" diff --git a/packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterAssetsTaskTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterAssetsTaskTest.kt new file mode 100644 index 0000000000000..fee4c2563ba84 --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterAssetsTaskTest.kt @@ -0,0 +1,74 @@ +// 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 org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CopyFlutterAssetsTaskTest { + @Test + fun `stages flutter_assets under the destination directory with user read and write permissions`( + @TempDir tempDir: Path + ) { + val project = ProjectBuilder.builder().withProjectDir(tempDir.resolve("project").toFile()).build() + val intermediateDir = tempDir.resolve("intermediate").toFile() + val assetFile = intermediateDir.resolve("flutter_assets/sub/asset.txt") + assetFile.parentFile.mkdirs() + assetFile.writeText("asset-bytes") + assetFile.setWritable(false) + // Files outside flutter_assets (e.g. app.so snapshots) must not be staged as assets. + val nonAssetFile = intermediateDir.resolve("arm64_v8a/app.so") + nonAssetFile.parentFile.mkdirs() + nonAssetFile.writeText("not-an-asset") + val destinationDir = tempDir.resolve("staged").toFile() + + val task = + project.tasks + .register("testCopyFlutterAssets", CopyFlutterAssetsTask::class.java) + .get() + .also { task -> + task.intermediateDir.set(intermediateDir) + task.destinationDir.set(destinationDir) + } + + task.copy() + + val stagedAsset = destinationDir.resolve("flutter_assets/sub/asset.txt") + assertTrue(stagedAsset.isFile, "expected $stagedAsset to be staged") + assertTrue(stagedAsset.canRead(), "staged asset should be user-readable") + assertTrue(stagedAsset.canWrite(), "staged asset should be user-writable") + assertFalse( + destinationDir.resolve("arm64_v8a/app.so").exists(), + "only flutter_assets content should be staged" + ) + } + + @Test + fun `clears the destination directory when there is no flutter build for the variant`( + @TempDir tempDir: Path + ) { + val project = ProjectBuilder.builder().withProjectDir(tempDir.resolve("project").toFile()).build() + val destinationDir = tempDir.resolve("staged").toFile() + val staleFile = destinationDir.resolve("flutter_assets/stale.txt") + staleFile.parentFile.mkdirs() + staleFile.writeText("stale") + + val task = + project.tasks + .register("testCopyFlutterAssets", CopyFlutterAssetsTask::class.java) + .get() + .also { task -> + task.destinationDir.set(destinationDir) + } + + task.copy() + + assertFalse(staleFile.exists(), "stale staged assets should be removed") + } +}