diff --git a/firebase-dataconnect/firebase-dataconnect.gradle.kts b/firebase-dataconnect/firebase-dataconnect.gradle.kts index add13046cb9..6369c0a4b19 100644 --- a/firebase-dataconnect/firebase-dataconnect.gradle.kts +++ b/firebase-dataconnect/firebase-dataconnect.gradle.kts @@ -25,6 +25,7 @@ plugins { id("copy-google-services") alias(libs.plugins.kotlinx.serialization) id("com.google.firebase.dataconnect.gradle.plugin") apply false + id("com.google.firebase.dataconnect.sharedtest") } firebaseLibrary { diff --git a/firebase-dataconnect/gradleplugin/plugin/build.gradle.kts b/firebase-dataconnect/gradleplugin/plugin/build.gradle.kts index b63e4ca4733..78f7d554fe1 100644 --- a/firebase-dataconnect/gradleplugin/plugin/build.gradle.kts +++ b/firebase-dataconnect/gradleplugin/plugin/build.gradle.kts @@ -38,6 +38,11 @@ gradlePlugin { id = "com.google.firebase.dataconnect.gradle.plugin" implementationClass = "com.google.firebase.dataconnect.gradle.plugin.DataConnectGradlePlugin" } + create("sharedTest") { + id = "com.google.firebase.dataconnect.sharedtest" + implementationClass = + "com.google.firebase.dataconnect.gradle.sharedtest.SharedWithAndroidTestPlugin" + } } } diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/sharedtest/CopySharedWithAndroidTestFiles.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/sharedtest/CopySharedWithAndroidTestFiles.kt new file mode 100644 index 00000000000..11f39f42994 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/sharedtest/CopySharedWithAndroidTestFiles.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.sharedtest + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.logging.Logger +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import java.io.File + +/** + * A Gradle task that copies Kotlin source files annotated with `@file:SharedWithAndroidTest` from + * an input directory to an output directory. + * + * This driving motivation behind creating this task is to share test utilities or fixtures between + * unit tests and Android instrumentation tests by copying the annotated files into the appropriate + * source set before compilation. + */ +abstract class CopySharedWithAndroidTestFiles : DefaultTask() { + + @get:Internal abstract val inputBaseDirectory: DirectoryProperty + + @get:InputDirectory abstract val inputDirectory: DirectoryProperty + + @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun run() { + val inputBaseDirectory: File = inputBaseDirectory.get().asFile + val inputDirectory: File = inputDirectory.get().asFile + val outputDirectory: File = outputDirectory.get().asFile + + logger.info("inputBaseDirectory={}", inputBaseDirectory.absolutePath) + logger.info("inputDirectory={}", inputDirectory.absolutePath) + logger.info("outputDirectory={}", outputDirectory.absolutePath) + + copyAnnotatedFiles( + srcBaseDir = inputBaseDirectory, + srcDir = inputDirectory, + destDir = outputDirectory, + taskPath = path, + logger = logger, + ) + } +} + +/** + * Recursively searches through [srcDir] for Kotlin source files (`.kt`) that contain the target + * annotation and copies them to [destDir], maintaining their relative directory structure. + * + * @param srcBaseDir The root directory to use when reporting file system paths in the generated + * files, rather than their absolute paths, to avoid defeating build caches; this **MUST** be a + * parent directory of [srcDir]. + * @param srcDir The root directory to search for annotated Kotlin files. + * @param destDir The root directory where annotated files should be copied. + * @param taskPath The full Gradle path of the task performing the copy. + * @param logger The Gradle logger to use for logging the copy operations. + */ +private fun copyAnnotatedFiles(srcBaseDir: File, srcDir: File, destDir: File, taskPath: String, logger: Logger) { + if (!srcDir.exists()) { + logger.info("source directory was not found; no files copied") + return + } + if (!srcDir.isDirectory) { + logger.info("source directory is not a directory; no files copied") + return + } + + val srcFilesToCopy = srcDir + .walk() + .filter { it.isFile && it.extension == "kt" && it.hasALineEqualToOneOf(sharedWithAndroidTestAnnotationLines) } + .toList() + + logger.info( + "Found {} files in {} that have a line equal to one of: {}", + srcFilesToCopy.size, + srcDir, + sharedWithAndroidTestAnnotationLines.joinToString { "\"$it\"" } + ) + + if (srcFilesToCopy.isEmpty()) { + logger.info("No files found to copy; no files copied") + return + } + + srcFilesToCopy.forEachIndexed { fileIndex, srcFile -> + val relativePath = srcFile.relativeTo(srcDir) + val destFile = File(destDir, relativePath.path) + logger.info("Copying file {}/{}: {} to {}", (fileIndex+1), srcFilesToCopy.size, srcFile, destFile) + + destFile.parentFile?.mkdirs() + + destFile.printWriter().use { + val srcFileRelative = srcFile.absoluteFile.relativeTo(srcBaseDir.absoluteFile) + it.println("// Generated from: $srcFileRelative") + it.println("// Generated by Gradle task: $taskPath") + it.println("//") + it.println("// WARNING: This file is generated!") + it.println("// Any changes made to this file will eventually be overwritten by the build.") + it.println("// If changes are desired, make them in the original file instead.") + it.println() + + srcFile.useLines { lines -> + lines.forEach { line -> + it.println(line) + } + } + } + } +} + +private val sharedWithAndroidTestAnnotationLines = listOf( + "@file:SharedWithAndroidTest", + "@file:com.google.firebase.dataconnect.testutil.SharedWithAndroidTest", +) + +private fun File.hasALineEqualToOneOf(desiredLines: Collection): Boolean = + useLines { lines -> + lines.any { + it.trim() in desiredLines + } + } diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/sharedtest/SharedWithAndroidTestPlugin.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/sharedtest/SharedWithAndroidTestPlugin.kt new file mode 100644 index 00000000000..ddb35be1b90 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/sharedtest/SharedWithAndroidTestPlugin.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.gradle.sharedtest + +import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.HasAndroidTest +import com.android.build.api.variant.Variant +import java.util.Locale +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.ExtensionContainer +import org.gradle.api.tasks.TaskContainer +import org.gradle.kotlin.dsl.register + +/** + * A Gradle plugin that, when applied, makes all files in `src/test/kotlin` that are annotated with + * `@file:SharedWithAndroidTest` available to code in `src/androidTest/kotlin`, enabling test + * utilities written for unit tests to also be available to integration tests. + * + * This is achieved by adding a "code generation" step to the `androidTest` target which simply + * copies the appropriately-annotated files into the "generated code" directory. + * + * To apply this plugin to an Android application or library, simply register the plugin alongside + * other Gradle plugins in `build.gradle.kts`: + * + * ``` + * plugins { + * // other plugins + * id("com.google.firebase.dataconnect.sharedtest") + * } + * ``` + */ +@Suppress("unused") +abstract class SharedWithAndroidTestPlugin : Plugin { + override fun apply(project: Project) = applyPlugin(project.extensions, project.tasks) +} + +private fun applyPlugin(extensions: ExtensionContainer, tasks: TaskContainer) { + val androidComponents = extensions.getByType(AndroidComponentsExtension::class.java) + androidComponents.onVariants { variant -> handleVariant(variant, tasks) } +} + +private fun handleVariant(variant: Variant, tasks: TaskContainer) { + val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return + val variantNameTitleCase = variant.name.replaceFirstChar { it.titlecase(Locale.US) } + + val task = + tasks.register( + "copy${variantNameTitleCase}SharedWithAndroidTestFiles" + ) { + val projectDirectory = project.layout.projectDirectory + inputBaseDirectory.set(projectDirectory) + inputDirectory.set(projectDirectory.dir("src/test/kotlin")) + } + + androidTest.sources.java!!.addGeneratedSourceDirectory( + task, + CopySharedWithAndroidTestFiles::outputDirectory + ) +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/SharedWithAndroidTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/SharedWithAndroidTest.kt new file mode 100644 index 00000000000..28e8bd57c77 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/SharedWithAndroidTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:SharedWithAndroidTest + +package com.google.firebase.dataconnect.testutil + +/** + * Annotation used to mark files that should be shared with the androidTest source set. + * + * This annotation is processed by the CopySharedWithAndroidTestFiles Gradle task. For performance + * and simplicity, the task performs a rudimentary string-based check on the file's contents. To be + * recognized, a line in the source file must, when trimmed, exactly equal either: + * - `@file:SharedWithAndroidTest` + * - `@file:com.google.firebase.dataconnect.testutil.SharedWithAndroidTest` + * + * Notably, "grouped syntax" like `@file:[JvmName("MyFile") SharedWithAndroidTest]` is NOT supported + * and will not be recognized by the task. + */ +@Target(AnnotationTarget.FILE) +@Retention(AnnotationRetention.SOURCE) +internal annotation class SharedWithAndroidTest