diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactExtension.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactExtension.kt index 2704dd9fc217..be43b18535ff 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactExtension.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactExtension.kt @@ -7,6 +7,7 @@ package com.facebook.react +import com.facebook.react.model.ModelAutolinkingDependenciesPlatformAndroidJson import com.facebook.react.utils.JsonUtils import com.facebook.react.utils.projectPathToLibraryName import java.io.File @@ -201,19 +202,29 @@ abstract class ReactExtension @Inject constructor(val project: Project) { ?.filterNot { it.platforms?.android?.isPureCxxDependency == true } ?.forEach { deps -> val nameCleansed = deps.nameCleansed - val dependencyConfiguration = deps.platforms?.android?.dependencyConfiguration - val buildTypes = deps.platforms?.android?.buildTypes ?: emptyList() - if (buildTypes.isEmpty()) { - result.add((dependencyConfiguration ?: "implementation") to ":$nameCleansed") - } else { - buildTypes.forEach { buildType -> - result.add( - (dependencyConfiguration ?: "${buildType}Implementation") to ":$nameCleansed", - ) - } + resolveGradleConfigurations(checkNotNull(deps.platforms?.android)).forEach { + configuration -> + result.add(configuration to ":$nameCleansed") } } return result } + + /** + * Resolves the Gradle configuration names a dependency is added to, honoring + * `dependencyConfiguration` overrides and `buildTypes` scoping. + */ + internal fun resolveGradleConfigurations( + platformAndroid: ModelAutolinkingDependenciesPlatformAndroidJson + ): List { + val dependencyConfiguration = platformAndroid.dependencyConfiguration + // Declared non-null in the model, but JSON deserialization can leave it unset. + val buildTypes: List? = platformAndroid.buildTypes + return if (buildTypes.isNullOrEmpty()) { + listOf(dependencyConfiguration ?: "implementation") + } else { + buildTypes.map { dependencyConfiguration ?: "${it}Implementation" } + } + } } } diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt index 5924836d2245..4be0e7476628 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt @@ -40,6 +40,7 @@ import kotlin.system.exitProcess import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task +import org.gradle.api.artifacts.Configuration import org.gradle.api.file.Directory import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider @@ -349,8 +350,11 @@ class ReactPlugin : Plugin { extension: ReactExtension, rootExtension: PrivateReactExtension, ) { + // This folder intentionally doesn't reuse the previous generated/autolinking/src/main/java + // path: PackageList.java used to be generated there, so stale outputs from older React Native + // versions must never become part of the sources again. val generatedAutolinkingJavaDir: Provider = - project.layout.buildDirectory.dir("generated/autolinking/src/main/java") + project.layout.buildDirectory.dir("generated/autolinking/src/common/java") val generatedAutolinkingJniDir: Provider = project.layout.buildDirectory.dir("generated/autolinking/src/main/jni") val generatedPureCxxSourceDir: Provider = @@ -373,14 +377,14 @@ class ReactPlugin : Plugin { // We add a task called generateAutolinkingPackageList to do not clash with the existing task // called generatePackageList. This can to be renamed once we unlink the rn <-> cli - // dependency. + // dependency. This is only a lifecycle task aggregating the per-variant instances + // registered inside onVariants below: PackageList.java is generated per variant so that + // dependencies restricted to specific build types are only listed in the variants that + // have them on the compile classpath. val generatePackageListTask = - project.tasks.register( - "generateAutolinkingPackageList", - GeneratePackageListTask::class.java, - ) { task -> - task.autolinkInputFile.set(rootGeneratedAutolinkingFile) - task.generatedOutputDirectory.set(generatedAutolinkingJavaDir) + project.tasks.register("generateAutolinkingPackageList") { task -> + task.group = "react" + task.description = "Generates the PackageList.java file for every variant" } // We add a task called generateReactNativeEntryPoint to generate the React Native entry point. @@ -418,13 +422,33 @@ class ReactPlugin : Plugin { .named("preBuild", Task::class.java) .dependsOn(generatePackageListTask, generateEntryPointTask) - // We tell Android Gradle Plugin that inside /build/generated/autolinking/src/main/java there - // are sources to be compiled as well. + // We tell Android Gradle Plugin that inside /build/generated/autolinking/src/common/java + // there are sources to be compiled as well. The entry point file is variant-agnostic and + // stays there, while the per-variant PackageList.java lives in its own source directory, + // added only to the variant it was generated for. project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java).apply { onVariants(selector().all()) { variant -> variant.sources.java?.addStaticSourceDirectory( generatedAutolinkingJavaDir.get().asFile.absolutePath, ) + + val variantGeneratedAutolinkingJavaDir = + project.layout.buildDirectory.dir("generated/autolinking/src/${variant.name}/java") + val generateVariantPackageListTask = + project.tasks.register( + "generateAutolinkingPackageList${variant.name.replaceFirstChar(Char::uppercaseChar)}", + GeneratePackageListTask::class.java, + ) { task -> + task.autolinkInputFile.set(rootGeneratedAutolinkingFile) + task.generatedOutputDirectory.set(variantGeneratedAutolinkingJavaDir) + project.configurations.findByName("${variant.name}CompileClasspath")?.let { + task.applicableConfigurations.set(compileClasspathConfigurationNames(it)) + } + } + generatePackageListTask.configure { it.dependsOn(generateVariantPackageListTask) } + variant.sources.java?.addStaticSourceDirectory( + variantGeneratedAutolinkingJavaDir.get().asFile.absolutePath, + ) } } } @@ -497,4 +521,24 @@ class ReactPlugin : Plugin { .map { char -> if (char.isLetterOrDigit()) char.toString() else "_${char.code}_" } .joinToString("") .replaceFirstChar { char -> char.titlecase() } + + /** + * Returns the names of [configuration] plus every configuration it transitively extends from, + * i.e. the Gradle configurations whose dependencies land on this compile classpath. + */ + private fun compileClasspathConfigurationNames(configuration: Configuration): Set { + val names = mutableSetOf() + val queue = ArrayDeque() + queue.add(configuration) + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + names.add(current.name) + current.extendsFrom.forEach { parent -> + if (parent.name !in names) { + queue.add(parent) + } + } + } + return names + } } diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/GeneratePackageListTask.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/GeneratePackageListTask.kt index 3bc2386901a7..060510fdb654 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/GeneratePackageListTask.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/GeneratePackageListTask.kt @@ -7,6 +7,7 @@ package com.facebook.react.tasks +import com.facebook.react.ReactExtension import com.facebook.react.model.ModelAutolinkingConfigJson import com.facebook.react.model.ModelAutolinkingDependenciesPlatformAndroidJson import com.facebook.react.utils.JsonUtils @@ -14,7 +15,10 @@ import java.io.File import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.SetProperty +import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction @@ -28,6 +32,16 @@ abstract class GeneratePackageListTask : DefaultTask() { @get:OutputDirectory abstract val generatedOutputDirectory: DirectoryProperty + /** + * The names of the Gradle configurations on the compile classpath of the variant this task is + * generating for: the variant's `CompileClasspath` configuration plus everything it + * transitively extends from (e.g. `implementation`, `api`, `debugImplementation`, + * `Implementation`). A dependency is only listed when at least one of the configurations + * it resolves to is present in this set, so the generated file only references packages that are + * actually on the variant's compile classpath. When unset, all dependencies are included. + */ + @get:Optional @get:Input abstract val applicableConfigurations: SetProperty + @TaskAction fun taskAction() { val model = @@ -48,7 +62,7 @@ abstract class GeneratePackageListTask : DefaultTask() { "RNGP - Autolinking: Could not find project.android.packageName in react-native config output! Could not autolink packages without this field.", ) - val androidPackages = filterAndroidPackages(model) + val androidPackages = filterAndroidPackages(model, applicableConfigurations.orNull) val packageClassInstance = composePackageInstance(packageName, androidPackages) val generatedFileContents = composeFileContent(packageClassInstance) @@ -106,12 +120,24 @@ abstract class GeneratePackageListTask : DefaultTask() { internal fun filterAndroidPackages( model: ModelAutolinkingConfigJson?, + applicableConfigurations: Set? = null, ): Map { val packages = model?.dependencies?.values ?: emptyList() return packages .filter { it.platforms?.android != null } // The pure C++ dependencies won't have a .java/.kt file to import .filterNot { it.platforms?.android?.isPureCxxDependency == true } + // Each dependency resolves to one or more Gradle configurations (e.g. `implementation`, + // `debugImplementation` or a `dependencyConfiguration` override). It is only listed for + // a variant whose compile classpath contains one of those configurations — otherwise + // the generated file would reference a package that is not on the classpath. + .filter { + applicableConfigurations == null || + ReactExtension.resolveGradleConfigurations(checkNotNull(it.platforms?.android)).any { + configuration -> + configuration in applicableConfigurations + } + } .associate { it.name to checkNotNull(it.platforms?.android) } } diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GeneratePackageListTaskTest.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GeneratePackageListTaskTest.kt index 261ca070d906..409388a3b8d5 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GeneratePackageListTaskTest.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GeneratePackageListTaskTest.kt @@ -213,6 +213,145 @@ class GeneratePackageListTaskTest { .isEqualTo(emptyMap()) } + @Test + fun filterAndroidPackages_withBuildTypeDependency_returnsItOnMatchingClassPath() { + val task = createTestTask() + val android = + ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory/android", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = listOf("debug"), + ) + val model = autolinkingModelWithAndroid(android) + + assertThat(task.filterAndroidPackages(model, DEBUG_COMPILE_CLASSPATH)["a-dependency"]) + .isEqualTo(android) + // A `buildTypes` dependency is not on the compile classpath of unrelated variants. + assertThat(task.filterAndroidPackages(model, RELEASE_COMPILE_CLASSPATH)) + .isEqualTo(emptyMap()) + assertThat(task.filterAndroidPackages(model, DEBUG_OPTIMIZED_COMPILE_CLASSPATH)) + .isEqualTo(emptyMap()) + } + + @Test + fun filterAndroidPackages_withMultipleBuildTypes_returnsItForEachOne() { + val task = createTestTask() + val android = + ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory/android", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = listOf("debug", "staging"), + ) + val model = autolinkingModelWithAndroid(android) + + assertThat(task.filterAndroidPackages(model, DEBUG_COMPILE_CLASSPATH)["a-dependency"]) + .isEqualTo(android) + assertThat( + task + .filterAndroidPackages(model, setOf("stagingImplementation", "implementation"))[ + "a-dependency"] + ) + .isEqualTo(android) + assertThat(task.filterAndroidPackages(model, RELEASE_COMPILE_CLASSPATH)) + .isEqualTo(emptyMap()) + } + + @Test + fun filterAndroidPackages_withSharedDependency_returnsItForEveryVariant() { + val task = createTestTask() + val android = + ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory/android", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + ) + val model = autolinkingModelWithAndroid(android) + + for (classpath in + listOf( + DEBUG_COMPILE_CLASSPATH, + RELEASE_COMPILE_CLASSPATH, + DEBUG_OPTIMIZED_COMPILE_CLASSPATH, + )) { + assertThat(task.filterAndroidPackages(model, classpath)["a-dependency"]).isEqualTo(android) + } + // An unset applicableConfigurations keeps the legacy include-all behavior. + assertThat(task.filterAndroidPackages(model, null)["a-dependency"]).isEqualTo(android) + assertThat(task.filterAndroidPackages(model)["a-dependency"]).isEqualTo(android) + } + + @Test + fun filterAndroidPackages_withDependencyConfigurationOverride_matchesClasspath() { + val task = createTestTask() + // `dependencyConfiguration` scopes the dependency just like `buildTypes` does: + // it lands only on the compile classpath of the matching variant. + val android = + ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory/android", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + dependencyConfiguration = "debugImplementation", + ) + val model = autolinkingModelWithAndroid(android) + + assertThat(task.filterAndroidPackages(model, DEBUG_COMPILE_CLASSPATH)["a-dependency"]) + .isEqualTo(android) + assertThat(task.filterAndroidPackages(model, RELEASE_COMPILE_CLASSPATH)) + .isEqualTo(emptyMap()) + assertThat(task.filterAndroidPackages(model, DEBUG_OPTIMIZED_COMPILE_CLASSPATH)) + .isEqualTo(emptyMap()) + } + + @Test + fun filterAndroidPackages_withDependencyConfigurationOverridingBuildTypes_isListedEverywhere() { + val task = createTestTask() + // When `dependencyConfiguration` is set it fully replaces the `${buildType}Implementation` + // derivation, so the dependency is on every variant's compile classpath. + val android = + ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory/android", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = listOf("debug"), + dependencyConfiguration = "implementation", + ) + val model = autolinkingModelWithAndroid(android) + + for (classpath in + listOf( + DEBUG_COMPILE_CLASSPATH, + RELEASE_COMPILE_CLASSPATH, + DEBUG_OPTIMIZED_COMPILE_CLASSPATH, + )) { + assertThat(task.filterAndroidPackages(model, classpath)["a-dependency"]).isEqualTo(android) + } + } + + @Test + fun filterAndroidPackages_withConfigurationOutsideCompileClasspath_isNotListed() { + val task = createTestTask() + // A dependency in a configuration that is not on any variant's compile classpath (e.g. + // `kapt`, `runtimeOnly`, `testImplementation`) cannot be referenced by PackageList. + val android = + ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory/android", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + dependencyConfiguration = "runtimeOnly", + ) + val model = autolinkingModelWithAndroid(android) + + assertThat(task.filterAndroidPackages(model, DEBUG_COMPILE_CLASSPATH)) + .isEqualTo(emptyMap()) + assertThat(task.filterAndroidPackages(model, RELEASE_COMPILE_CLASSPATH)) + .isEqualTo(emptyMap()) + } + @Test fun composeFileContent_withNoPackages_returnsValidFile() { val task = createTestTask() @@ -389,4 +528,62 @@ class GeneratePackageListTaskTest { cmakeListsPath = "./another/directory/CMakeLists.txt", ), ) + + private fun autolinkingModelWithAndroid( + android: ModelAutolinkingDependenciesPlatformAndroidJson + ): ModelAutolinkingConfigJson = + ModelAutolinkingConfigJson( + reactNativeVersion = "1000.0.0", + dependencies = + mapOf( + "a-dependency" to + ModelAutolinkingDependenciesJson( + root = "./a/directory", + name = "a-dependency", + platforms = ModelAutolinkingDependenciesPlatformJson(android = android), + ) + ), + project = null, + ) + + private companion object { + // The configuration names AGP wires into each variant's `CompileClasspath` + // (transitively), as observed on a real app build. + val DEBUG_COMPILE_CLASSPATH = + setOf( + "debugCompileClasspath", + "debugCompileOnly", + "debugImplementation", + "debugCompileOnlyApi", + "debugApi", + "compileOnly", + "implementation", + "compileOnlyApi", + "api", + ) + val RELEASE_COMPILE_CLASSPATH = + setOf( + "releaseCompileClasspath", + "releaseCompileOnly", + "releaseImplementation", + "releaseCompileOnlyApi", + "releaseApi", + "compileOnly", + "implementation", + "compileOnlyApi", + "api", + ) + val DEBUG_OPTIMIZED_COMPILE_CLASSPATH = + setOf( + "debugOptimizedCompileClasspath", + "debugOptimizedCompileOnly", + "debugOptimizedImplementation", + "debugOptimizedCompileOnlyApi", + "debugOptimizedApi", + "compileOnly", + "implementation", + "compileOnlyApi", + "api", + ) + } }