diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt index a7880c247a210..34e93d64ffa79 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt @@ -7,14 +7,15 @@ 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 +import com.flutter.gradle.tasks.CopyFlutterAssetsTask import com.flutter.gradle.tasks.CopyFlutterJniLibsTask import com.flutter.gradle.tasks.FlutterTask import org.gradle.api.GradleException @@ -313,9 +314,61 @@ 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" + ) + ) { + 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 + ) + } + val copyJniLibsTaskProvider: TaskProvider = projectToAddTasksTo.tasks.register( "copyJniLibs${FLUTTER_BUILD_PREFIX}$capitalizeVariantName", @@ -345,8 +398,6 @@ class FlutterPlugin : Plugin { ) } - val flutterPlugin = this - if (FlutterPluginUtils.isFlutterAppProject(projectToAddTasksTo)) { val appExtension = FlutterPluginUtils.getAndroidApplicationExtension(projectToAddTasksTo) configureAbis(projectToAddTasksTo, appExtension) @@ -362,23 +413,10 @@ class FlutterPlugin : Plugin { ) { return@configureEach } - val copyFlutterAssetsTask: Task = - addFlutterDeps(variant, flutterPlugin, targetPlatforms) - + // Per-ABI versionCode override; migrates to VariantOutput.versionCode. // 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) + 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`. @@ -486,7 +524,7 @@ class FlutterPlugin : Plugin { if (FlutterPluginUtils.buildModeFor(appProjectVariant.buildType) != variantBuildMode) { return@applicationVariantAll } - copyFlutterAssetsTask = copyFlutterAssetsTask ?: addFlutterDeps( + copyFlutterAssetsTask = copyFlutterAssetsTask ?: addFlutterDepsForModule( libraryVariant, flutterPlugin, targetPlatforms @@ -601,21 +639,131 @@ 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 + variant: Variant, + flutterPlugin: FlutterPlugin, + targetPlatforms: List + ): TaskProvider { + 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 + + // 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 } + } + // 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 addFlutterDeps( + 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). + // 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 @@ -654,49 +802,6 @@ class FlutterPlugin : Plugin { val validateDeferredComponentsValue: Boolean = project.findProperty("validate-deferred-components")?.toString()?.toBoolean() ?: true - 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 - ) - } - } - } - - // 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" - ) - - val isUsedAsSubproject: Boolean = - packageAssets != null && cleanPackageAssets != null && !isBuildingAar - val variantBuildMode: String = FlutterPluginUtils.buildModeFor(variant.buildType) val flavorValue: String = variant.flavorName val taskName: String = flutterCompileTaskName(variant.name) @@ -756,12 +861,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 +876,53 @@ 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 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") + } +}