From fd168e11926c9a1b7db91293814bc86c8330c867 Mon Sep 17 00:00:00 2001 From: Reid Baker <1063596+reidbaker@users.noreply.github.com> Date: Thu, 27 Mar 2025 18:53:08 +0000 Subject: [PATCH] Move app link settings task configuration to kotlin (#165819) - **Inital conversion and single test case** - **Task registered with name** - **Test can handle multiple variants** - **configuration for baseOutput setup** - **Test passing** - **formatting** - **Use constant with upppercase** - **Replace flutter.groovy version of addTasksForOutputsAppLinkSettings** Fixes #164393 Helpful commands: ` JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home/ ./gradlew test --info --tests "com.flutter.gradle.FlutterPluginUtilsTest"` from packages/flutter_tools/gradle `flutter test test/integration.shard/android_gradle_outputs_app_link_settings_test.dart` from packages/flutter_tools ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. --- .../gradle/src/main/groovy/flutter.groovy | 132 +------- .../src/main/kotlin/FlutterPluginConstants.kt | 3 + .../src/main/kotlin/FlutterPluginUtils.kt | 212 ++++++++++++ .../src/test/kotlin/FlutterPluginUtilsTest.kt | 317 ++++++++++++++---- 4 files changed, 469 insertions(+), 195 deletions(-) diff --git a/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy b/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy index 7d96fcb1c0..b988b58e3e 100644 --- a/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy +++ b/packages/flutter_tools/gradle/src/main/groovy/flutter.groovy @@ -5,22 +5,16 @@ import com.android.build.OutputFile import com.android.build.gradle.AbstractAppExtension -import com.android.tools.r8.P -import com.flutter.gradle.AppLinkSettings import com.android.build.gradle.api.BaseVariantOutput import com.android.build.gradle.tasks.PackageAndroidArtifact import com.android.build.gradle.tasks.ProcessAndroidResources import com.android.builder.model.BuildType import com.flutter.gradle.BaseApplicationNameHandler -import com.flutter.gradle.Deeplink import com.flutter.gradle.DependencyVersionChecker import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginConstants import com.flutter.gradle.FlutterTask import com.flutter.gradle.FlutterPluginUtils -import com.flutter.gradle.IntentFilterCheck -import com.flutter.gradle.VersionUtils -import groovy.xml.QName import org.gradle.api.file.Directory import java.nio.file.Paths @@ -40,7 +34,6 @@ import org.gradle.internal.os.OperatingSystem class FlutterPlugin implements Plugin { private final static String propLocalEngineRepo = "local-engine-repo" - private final static String propProcessResourcesProvider = "processResourcesProvider" /** * The name prefix for flutter builds. This is used to identify gradle tasks @@ -257,125 +250,6 @@ class FlutterPlugin implements Plugin { project.android.buildTypes.all(this.&addFlutterDependencies) } - // Add a task that can be called on Flutter projects that outputs app link related project - // settings into a json file. - // - // See https://developer.android.com/training/app-links/ for more information about app link. - // - // The json will be saved in path stored in outputPath parameter. - // - // An example json: - // { - // applicationId: "com.example.app", - // deeplinks: [ - // {"scheme":"http", "host":"example.com", "path":".*"}, - // {"scheme":"https","host":"example.com","path":".*"} - // ] - // } - // - // The output file is parsed and used by devtool. - private static void addTasksForOutputsAppLinkSettings(Project project) { - AbstractAppExtension android = (AbstractAppExtension) project.extensions.findByName("android") - android.applicationVariants.configureEach { variant -> - // Warning: The name of this task is used by AndroidBuilder.outputsAppLinkSettings - project.tasks.register("output${variant.name.capitalize()}AppLinkSettings") { - description "stores app links settings for the given build variant of this Android project into a json file." - variant.outputs.configureEach { output -> - // Deeplinks are defined in AndroidManifest.xml and is only available after - // `processResourcesProvider`. - Object processResources = output.hasProperty(propProcessResourcesProvider) ? - output.processResourcesProvider.get() : output.processResources - dependsOn processResources.name - } - doLast { - AppLinkSettings appLinkSettings = new AppLinkSettings(variant.applicationId) - variant.outputs.configureEach { output -> - Object processResources = output.hasProperty(propProcessResourcesProvider) ? - output.processResourcesProvider.get() : output.processResources - Node manifest = new XmlParser().parse(processResources.manifestFile) - manifest.application.activity.each { activity -> - activity."meta-data".each { metadata -> - boolean nameAttribute = metadata.attributes().find { it.key == 'android:name' }?.value == 'flutter_deeplinking_enabled' - boolean valueAttribute = metadata.attributes().find { it.key == 'android:value' }?.value == 'true' - if (nameAttribute && valueAttribute) { - appLinkSettings.deeplinkingFlagEnabled = true - } - } - activity."intent-filter".each { appLinkIntent -> - // Print out the host attributes in data tags. - Set schemes = [] as Set - Set hosts = [] as Set - Set paths = [] as Set - IntentFilterCheck intentFilterCheck = new IntentFilterCheck() - - if (appLinkIntent.attributes().find { it.key == 'android:autoVerify' }?.value == 'true') { - intentFilterCheck.hasAutoVerify = true - } - appLinkIntent.'action'.each { action -> - if (action.attributes().find { it.key == 'android:name' }?.value == 'android.intent.action.VIEW') { - intentFilterCheck.hasActionView = true - } - } - appLinkIntent.'category'.each { category -> - if (category.attributes().find { it.key == 'android:name' }?.value == 'android.intent.category.DEFAULT') { - intentFilterCheck.hasDefaultCategory = true - } - if (category.attributes().find { it.key == 'android:name' }?.value == 'android.intent.category.BROWSABLE') { - intentFilterCheck.hasBrowsableCategory = true - } - } - appLinkIntent.data.each { data -> - data.attributes().each { entry -> - if (entry.key instanceof QName) { - switch (entry.key.getLocalPart()) { - case "scheme": - schemes.add(entry.value) - break - case "host": - hosts.add(entry.value) - break - case "pathAdvancedPattern": - case "pathPattern": - case "path": - paths.add(entry.value) - break - case "pathPrefix": - paths.add("${entry.value}.*") - break - case "pathSuffix": - paths.add(".*${entry.value}") - break - } - } - } - } - if (!hosts.isEmpty() || !paths.isEmpty()) { - if (schemes.isEmpty()) { - schemes.add(null) - } - if (hosts.isEmpty()) { - hosts.add(null) - } - if (paths.isEmpty()) { - paths.add('.*') - } - schemes.each { scheme -> - hosts.each { host -> - paths.each { path -> - appLinkSettings.deeplinks.add(new Deeplink(scheme, host, path, intentFilterCheck)) - } - } - } - } - } - } - } - new File(project.getProperty("outputPath")).write(appLinkSettings.toJson().toString()) - } - } - } - } - /** * Adds the dependencies required by the Flutter project. * This includes: @@ -575,7 +449,7 @@ class FlutterPlugin implements Plugin { FlutterPluginUtils.addTaskForJavaVersion(project) if (FlutterPluginUtils.isFlutterAppProject(project)) { FlutterPluginUtils.addTaskForPrintBuildVariants(project) - addTasksForOutputsAppLinkSettings(project) + FlutterPluginUtils.addTasksForOutputsAppLinkSettings(project) } List targetPlatforms = FlutterPluginUtils.getTargetPlatforms(project) def addFlutterDeps = { variant -> @@ -718,7 +592,7 @@ class FlutterPlugin implements Plugin { Task copyFlutterAssetsTask = copyFlutterAssetsTaskProvider.get() if (!isUsedAsSubproject) { def variantOutput = variant.outputs.first() - def processResources = variantOutput.hasProperty(propProcessResourcesProvider) ? + def processResources = variantOutput.hasProperty(FlutterPluginConstants.PROP_PROCESS_RESOURCES_PROVIDER) ? variantOutput.processResourcesProvider.get() : variantOutput.processResources processResources.dependsOn(copyFlutterAssetsTask) } @@ -749,7 +623,7 @@ class FlutterPlugin implements Plugin { } Task copyFlutterAssetsTask = addFlutterDeps(variant) BaseVariantOutput variantOutput = variant.outputs.first() - ProcessAndroidResources processResources = variantOutput.hasProperty(propProcessResourcesProvider) ? + ProcessAndroidResources processResources = variantOutput.hasProperty(FlutterPluginConstants.PROP_PROCESS_RESOURCES_PROVIDER) ? variantOutput.processResourcesProvider.get() : variantOutput.processResources processResources.dependsOn(copyFlutterAssetsTask) diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginConstants.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginConstants.kt index eefcd25be7..2c21aada82 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginConstants.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginConstants.kt @@ -3,6 +3,9 @@ package com.flutter.gradle // TODO(gmackall): this should be collapsed back into the core FlutterPlugin once the Groovy to // kotlin conversion is complete. object FlutterPluginConstants { + // Strings that define project properties + const val PROP_PROCESS_RESOURCES_PROVIDER = "processResourcesProvider" + /** The platforms that can be passed to the `--Ptarget-platform` flag. */ private const val PLATFORM_ARM32 = "android-arm" private const val PLATFORM_ARM64 = "android-arm64" diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt index 2e473f6345..dfef3a72f4 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt @@ -2,8 +2,13 @@ package com.flutter.gradle import com.android.build.gradle.AbstractAppExtension import com.android.build.gradle.BaseExtension +import com.android.build.gradle.api.ApplicationVariant +import com.android.build.gradle.api.BaseVariantOutput +import com.android.build.gradle.tasks.ProcessAndroidResources import com.android.builder.model.BuildType import groovy.lang.Closure +import groovy.util.Node +import groovy.util.XmlParser import org.gradle.api.GradleException import org.gradle.api.JavaVersion import org.gradle.api.Project @@ -18,6 +23,10 @@ import java.util.Properties * A collection of static utility functions used by the Flutter Gradle Plugin. */ object FlutterPluginUtils { + private const val MANIFEST_NAME_KEY = "android:name" + private const val MANIFEST_VALUE_KEY = "android:value" + private const val MANIFEST_VALUE_TRUE = "true" + // Gradle properties. These must correspond to the values used in // flutter/packages/flutter_tools/lib/src/android/gradle.dart, and therefore it is not // recommended to use these const values in tests. @@ -399,6 +408,11 @@ object FlutterPluginUtils { return project.extensions.findByType(BaseExtension::class.java)!! } + // TODO: Use find by type and AbstractAppExtension instead. Or delete in favor of getAndroidExtension. + // see https://github.com/flutter/flutter/issues/165882 + private fun getAndroidAppExtensionOrNull(project: Project): AbstractAppExtension? = + project.extensions.findByName("android") as? AbstractAppExtension + /** * Expected format of getAndroidExtension(project).compileSdkVersion is a string of the form * `android-` followed by either the numeric version, e.g. `android-35`, or a preview version, @@ -849,6 +863,204 @@ object FlutterPluginUtils { } } } + + private fun findProcessResources(baseVariantOutput: BaseVariantOutput): ProcessAndroidResources = + baseVariantOutput.processResourcesProvider?.get() ?: baseVariantOutput.processResources + + /** + * Adds required tasks for the AppLinkSettings feature. + * + * Should only be called if the build target is an app, as opposed to an aar/module. + * + * Add a task that can be called on Flutter projects that outputs app link related project + * settings into a json file. + * See https://developer.android.com/training/app-links/ for more information about app link. + * The json will be saved in path stored in outputPath parameter. + * + * An example json: + * { + * applicationId: "com.example.app", + * deeplinks: [ + * {"scheme":"http", "host":"example.com", "path":".*"}, + * {"scheme":"https","host":"example.com","path":".*"} + * ] + * } + * The output file is parsed and used by devtool. + */ + @JvmStatic + @JvmName("addTasksForOutputsAppLinkSettings") + internal fun addTasksForOutputsAppLinkSettings(project: Project) { + // Integration test for AppLinkSettings task defined in + // flutter/flutter/packages/flutter_tools/test/integration.shard/android_gradle_outputs_app_link_settings_test.dart + val android = getAndroidAppExtensionOrNull(project) + if (android == null) { + project.logger.info("addTasksForOutputsAppLinkSettings called on project without android extension.") + return + } + android.applicationVariants.configureEach { + val variant = this + project.tasks.register("output${FlutterPluginUtils.capitalize(variant.name)}AppLinkSettings") { + val task: Task = this + task.description = + "stores app links settings for the given build variant of this Android project into a json file." + variant.outputs.configureEach { + val baseVariantOutput: BaseVariantOutput = this + // Deeplinks are defined in AndroidManifest.xml and is only available after + // processResourcesProvider. + dependsOn(findProcessResources(baseVariantOutput)) + } + doLast { + // We are configuring the same object before a doLast and in a doLast. + // without a clear reason why. That is not good. + variant.outputs.configureEach { + val appLinkSettings = createAppLinkSettings(variant, this) + File(project.property("outputPath").toString()).writeText( + appLinkSettings.toJson().toString() + ) + } + } + } + } + } + + /** + * Extracts app deeplink information from the Android manifest file of a variant then returns + * an AppLinkSettings object. + * + * @param BaseVariantOutput The output of a specific build variant (e.g., debug, release). + * @param variant The application variant being processed. + */ + private fun createAppLinkSettings( + variant: ApplicationVariant, + baseVariantOutput: BaseVariantOutput + ): AppLinkSettings { + val appLinkSettings = AppLinkSettings(variant.applicationId) + // TODO https://github.com/flutter/flutter/issues/165881 + // Use import groovy.xml.XmlParser instead. + // XmlParser is not namespace aware because it makes querying nodes cumbersome. + val manifest: Node = + XmlParser(false, false).parse(findProcessResources(baseVariantOutput).manifestFile) + // The groovy.xml.XmlParser import would use getProperty like + // manifest.getProperty("application").let { applicationNode -> ... + val applicationNode: Node? = + manifest.children().find { node -> + node is Node && node.name() == "application" + } as Node? + if (applicationNode == null) { + return appLinkSettings + } + val activities: List = + applicationNode.children().filterIsInstance().filter { item -> + item.name() == "activity" + } + + activities.forEach { activity -> + val metaDataItems: List = + activity.children().filterIsInstance().filter { metaItem -> + metaItem.name() == "meta-data" + } + metaDataItems.forEach { metaDataItem -> + val nameAttribute: Boolean = + metaDataItem.attribute(MANIFEST_NAME_KEY) == "flutter_deeplinking_enabled" + val valueAttribute: Boolean = + metaDataItem.attribute(MANIFEST_VALUE_KEY) == MANIFEST_VALUE_TRUE + if (nameAttribute && valueAttribute) { + appLinkSettings.deeplinkingFlagEnabled = true + } + } + val intentFilterItems: List = + activity.children().filterIsInstance().filter { filterItem -> + filterItem.name() == "intent-filter" + } + intentFilterItems.forEach { appLinkIntent -> + // Print out the host attributes in data tags. + val schemes: MutableSet = mutableSetOf() + val hosts: MutableSet = mutableSetOf() + val paths: MutableSet = mutableSetOf() + val intentFilterCheck = IntentFilterCheck() + if (appLinkIntent.attribute("android:autoVerify") == MANIFEST_VALUE_TRUE) { + intentFilterCheck.hasAutoVerify = true + } + + val actionItems: List = + appLinkIntent.children().filterIsInstance().filter { item -> + item.name() == "action" + } + // Any action item causes intentFilterCheck to always be true + // and we keep looping instead of exiting out early. + // TODO: Exit out early per intent filter action view. + actionItems.forEach { action -> + if (action.attribute(MANIFEST_NAME_KEY) == "android.intent.action.VIEW") { + intentFilterCheck.hasActionView = true + } + } + val categoryItems: List = + appLinkIntent.children().filterIsInstance().filter { item -> + item.name() == "category" + } + categoryItems.forEach { category -> + // TODO: Exit out early per intent filter default category. + if (category.attribute(MANIFEST_NAME_KEY) == "android.intent.category.DEFAULT") { + intentFilterCheck.hasDefaultCategory = true + } + // TODO: Exit out early per intent filter browsable category. + if (category.attribute(MANIFEST_NAME_KEY) == "android.intent.category.BROWSABLE") { + intentFilterCheck.hasBrowsableCategory = + true + } + } + val dataItems: List = + appLinkIntent.children().filterIsInstance().filter { item -> + item.name() == "data" + } + dataItems.forEach { data -> + data.attributes().forEach { entry -> + when (entry.key) { + "android:scheme" -> schemes.add(entry.value.toString()) + "android:host" -> hosts.add(entry.value.toString()) + // All path patterns add to paths. + "android:pathAdvancedPattern" -> + paths.add( + entry.value.toString() + ) + + "android:pathPattern" -> paths.add(entry.value.toString()) + "android:path" -> paths.add(entry.value.toString()) + "android:pathPrefix" -> paths.add(entry.value.toString() + ".*") + "android:pathSuffix" -> paths.add(".*" + entry.value.toString()) + } + } + } + if (hosts.isNotEmpty() || paths.isNotEmpty()) { + if (schemes.isEmpty()) { + schemes.add(null) + } + if (hosts.isEmpty()) { + hosts.add(null) + } + if (paths.isEmpty()) { + paths.add(".*") + } + // Sets are not ordered this could produce a bug. + schemes.forEach { scheme -> + hosts.forEach { host -> + paths.forEach { path -> + appLinkSettings.deeplinks.add( + Deeplink( + scheme, + host, + path, + intentFilterCheck + ) + ) + } + } + } + } + } + } + return appLinkSettings + } } private data class PluginVersionPair( diff --git a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt index dcad62a7ea..d12b9080d6 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt @@ -2,8 +2,11 @@ package com.flutter.gradle import com.android.build.gradle.AbstractAppExtension import com.android.build.gradle.BaseExtension +import com.android.build.gradle.api.ApplicationVariant +import com.android.build.gradle.api.BaseVariantOutput import com.android.build.gradle.internal.dsl.CmakeOptions import com.android.build.gradle.internal.dsl.DefaultConfig +import com.android.build.gradle.tasks.ProcessAndroidResources import com.android.builder.model.BuildType import io.mockk.called import io.mockk.every @@ -11,6 +14,8 @@ import io.mockk.mockk import io.mockk.slot import io.mockk.verify import org.gradle.api.Action +import org.gradle.api.DomainObjectCollection +import org.gradle.api.DomainObjectSet import org.gradle.api.GradleException import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Project @@ -18,6 +23,8 @@ import org.gradle.api.Task import org.gradle.api.UnknownTaskException import org.gradle.api.artifacts.dsl.DependencyHandler import org.gradle.api.logging.Logger +import org.gradle.api.tasks.TaskContainer +import org.gradle.api.tasks.TaskProvider import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.io.TempDir import java.io.File @@ -31,6 +38,116 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class FlutterPluginUtilsTest { + companion object { + val exampleEngineVersion = "1.0.0-e0676b47c7550ecdc0f0c4fa759201449b2c5f23" + + val devDependency: Map = + mapOf( + Pair("name", "grays_fun_dev_dependency"), + Pair( + "path", + "/Users/someuser/.pub-cache/hosted/pub.dev/grays_fun_dev_dependency-1.1.1/" + ), + Pair("native_build", true), + Pair("dependencies", emptyList()), + Pair("dev_dependency", true) + ) + + val cameraDependency: Map = + mapOf( + Pair("name", "camera_android_camerax"), + Pair( + "path", + "/Users/someuser/.pub-cache/hosted/pub.dev/camera_android_camerax-0.6.14+1/" + ), + Pair("native_build", true), + Pair("dependencies", emptyList()), + Pair("dev_dependency", false) + ) + + val flutterPluginAndroidLifecycleDependency: Map = + mapOf( + Pair("name", "flutter_plugin_android_lifecycle"), + Pair( + "path", + "/Users/someuser/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.27/" + ), + Pair("native_build", true), + Pair("dependencies", emptyList()), + Pair("dev_dependency", false) + ) + + val pluginListWithoutDevDependency: List> = + listOf( + cameraDependency, + flutterPluginAndroidLifecycleDependency, + mapOf( + Pair("name", "in_app_purchase_android"), + Pair( + "path", + "/Users/someuser/.pub-cache/hosted/pub.dev/in_app_purchase_android-0.4.0+1/" + ), + Pair("native_build", true), + Pair("dependencies", emptyList()), + Pair("dev_dependency", false) + ) + ) + + val pluginListWithDevDependency: List> = + listOf( + cameraDependency, + flutterPluginAndroidLifecycleDependency, + devDependency, + mapOf( + Pair("name", "in_app_purchase_android"), + Pair( + "path", + "/Users/someuser/.pub-cache/hosted/pub.dev/in_app_purchase_android-0.4.0+1/" + ), + Pair("native_build", true), + Pair("dependencies", emptyList()), + Pair("dev_dependency", false) + ) + ) + val manifestText = + """ + + + + + + + + + + + + + + + + + + + + + + + + + """.trimIndent() + } + // toCamelCase @Test fun `toCamelCase converts a list of strings to camel case`() { @@ -1109,76 +1226,144 @@ class FlutterPluginUtilsTest { } } - companion object { - val exampleEngineVersion = "1.0.0-e0676b47c7550ecdc0f0c4fa759201449b2c5f23" + @Test + fun addTasksForOutputsAppLinkSettingsActual( + @TempDir tempDir: Path + ) { + val variants: MutableList = mutableListOf() + val registerTaskList = mutableListOf() + val descriptionSlot = slot() + // vars so variables can be overridden below. + var mockLogger = mockk() + var variantWithLinks = mockk() - val devDependency: Map = - mapOf( - Pair("name", "grays_fun_dev_dependency"), - Pair( - "path", - "/Users/someuser/.pub-cache/hosted/pub.dev/grays_fun_dev_dependency-1.1.1/" - ), - Pair("native_build", true), - Pair("dependencies", emptyList()), - Pair("dev_dependency", true) - ) + val mockProject = + mockk { + every { logger } returns + mockk { + mockLogger = this + every { info(any()) } returns Unit + every { warn(any()) } returns Unit + } + every { extensions.findByName("android") } returns + mockk { + val variant1 = + mockk { + every { name } returns "one" + every { applicationId } returns "com.example.FlutterActivity1" + } + variants.add(variant1) + mockk { + variantWithLinks = this + every { name } returns "two" + every { applicationId } returns "com.example.FlutterActivity2" + } + variants.add(variantWithLinks) + // Capture the "action" that needs to be run for each variant. + val actionSlot = slot>() + every { applicationVariants } returns + mockk> { + every { configureEach(capture(actionSlot)) } answers { + // Execute the action for each variant. + variants.forEach { variant -> + actionSlot.captured.execute(variant) + } + } + } + } - val cameraDependency: Map = - mapOf( - Pair("name", "camera_android_camerax"), - Pair( - "path", - "/Users/someuser/.pub-cache/hosted/pub.dev/camera_android_camerax-0.6.14+1/" - ), - Pair("native_build", true), - Pair("dependencies", emptyList()), - Pair("dev_dependency", false) - ) + val registerTaskSlot = slot>() + every { tasks } returns + mockk { + val registerTaskNameSlot = slot() + every { register(capture(registerTaskNameSlot), capture(registerTaskSlot)) } answers registerAnswer@{ + val mockRegisterTask = + mockk { + every { name } returns registerTaskNameSlot.captured + every { description = capture(descriptionSlot) } returns Unit + every { dependsOn(any()) } returns mockk() + val doLastActionSlot = slot>() + every { doLast(capture(doLastActionSlot)) } answers doLastAnswer@{ + // We need to capture the task as well + doLastActionSlot.captured.execute(mockk()) + return@doLastAnswer mockk() + } + } + registerTaskList.add(mockRegisterTask) + registerTaskSlot.captured.execute(mockRegisterTask) + return@registerAnswer mockk() + } - val flutterPluginAndroidLifecycleDependency: Map = - mapOf( - Pair("name", "flutter_plugin_android_lifecycle"), - Pair( - "path", - "/Users/someuser/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.27/" - ), - Pair("native_build", true), - Pair("dependencies", emptyList()), - Pair("dev_dependency", false) - ) + every { named(any()) } returns + mockk { + every { configure(any>()) } returns mockk() + } + } + } - val pluginListWithoutDevDependency: List> = - listOf( - cameraDependency, - flutterPluginAndroidLifecycleDependency, - mapOf( - Pair("name", "in_app_purchase_android"), - Pair( - "path", - "/Users/someuser/.pub-cache/hosted/pub.dev/in_app_purchase_android-0.4.0+1/" - ), - Pair("native_build", true), - Pair("dependencies", emptyList()), - Pair("dev_dependency", false) - ) - ) + variants.forEach { variant -> + val testOutputs: DomainObjectCollection = mockk>() + val baseVariantSlot = slot>() + val baseVariantOutput = mockk() + // Create a real file in a temp directory. + val manifest = + tempDir + .resolve("${tempDir.toAbsolutePath()}/AndroidManifest.xml") + .toFile() + manifest.writeText(manifestText) + val mockProcessResourcesProvider = mockk>() + val mockProcessResources = mockk() + every { mockProcessResourcesProvider.hint(ProcessAndroidResources::class).get() } returns mockProcessResources + every { baseVariantOutput.processResourcesProvider } returns mockProcessResourcesProvider + // Fallback processing. + every { mockProcessResources.manifestFile } returns manifest - val pluginListWithDevDependency: List> = - listOf( - cameraDependency, - flutterPluginAndroidLifecycleDependency, - devDependency, - mapOf( - Pair("name", "in_app_purchase_android"), - Pair( - "path", - "/Users/someuser/.pub-cache/hosted/pub.dev/in_app_purchase_android-0.4.0+1/" - ), - Pair("native_build", true), - Pair("dependencies", emptyList()), - Pair("dev_dependency", false) - ) - ) + every { testOutputs.configureEach(capture(baseVariantSlot)) } answers { + // Execute the action for each output. + baseVariantSlot.captured.execute(baseVariantOutput) + } + every { variant.outputs } returns testOutputs + } + val outputFile = + tempDir + .resolve("${tempDir.toAbsolutePath()}/app-link-settings-build-variant.json") + .toFile() + every { mockProject.property("outputPath") } returns outputFile + + FlutterPluginUtils.addTasksForOutputsAppLinkSettings(mockProject) + + verify(exactly = 0) { mockLogger.info(any()) } + assert(descriptionSlot.captured.contains("stores app links settings for the given build variant")) + assertEquals(variants.size, registerTaskList.size) + for (i in 0 until variants.size) { + assertEquals("output${FlutterPluginUtils.capitalize(variants[i].name)}AppLinkSettings", registerTaskList[i].name) + verify(exactly = 1) { registerTaskList[i].dependsOn(any()) } + } + // Output assertions are minimal which ensures code is running but is not exhaustive testing. + // Integration test for more exhaustive behavior is defined in + // flutter/flutter/packages/flutter_tools/test/integration.shard/android_gradle_outputs_app_link_settings_test.dart + val outputFileText = outputFile.readText() + // Only variant2 since that one has app links. + assertContains(outputFileText, variantWithLinks.applicationId) + // Host. + assertContains(outputFileText, "deeplink.flutter.dev") + // pathPrefix used in variant2 combined with prefix logic. + assertContains(outputFileText, "some.prefix.*") + // Deep linking + assertContains(outputFileText, "deeplinkingFlagEnabled\":true") + } + + @Test + fun addTasksForOutputsAppLinkSettingsNoAndroid( + @TempDir tempDir: Path + ) { + val mockProject = mockk() + val mockLogger = mockk() + every { mockProject.logger } returns mockLogger + every { mockLogger.info(any()) } returns Unit + every { mockProject.extensions.findByName("android") } returns null + + FlutterPluginUtils.addTasksForOutputsAppLinkSettings(mockProject) + verify(exactly = 1) { mockLogger.info("addTasksForOutputsAppLinkSettings called on project without android extension.") } } }