diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 229ba5dc..bc0c9acc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,6 +143,7 @@ jobs: needs.filter.outputs.expo56 == 'true' || needs.filter.outputs.androidapp == 'true' || needs.filter.outputs.packages == 'true' || + needs.filter.outputs.gradle-plugins == 'true' || needs.filter.outputs.ci == 'true' ) && (needs.build-lint.result == 'success' || needs.build-lint.result == 'skipped') @@ -172,6 +173,7 @@ jobs: needs.filter.outputs.expo56 == 'true' || needs.filter.outputs.androidapp == 'true' || needs.filter.outputs.packages == 'true' || + needs.filter.outputs.gradle-plugins == 'true' || needs.filter.outputs.ci == 'true' ) && (needs.build-lint.result == 'success' || needs.build-lint.result == 'skipped') && @@ -202,6 +204,7 @@ jobs: needs.filter.outputs.expo57 == 'true' || needs.filter.outputs.androidapp == 'true' || needs.filter.outputs.packages == 'true' || + needs.filter.outputs.gradle-plugins == 'true' || needs.filter.outputs.ci == 'true' ) && (needs.build-lint.result == 'success' || needs.build-lint.result == 'skipped') @@ -231,6 +234,7 @@ jobs: needs.filter.outputs.expo57 == 'true' || needs.filter.outputs.androidapp == 'true' || needs.filter.outputs.packages == 'true' || + needs.filter.outputs.gradle-plugins == 'true' || needs.filter.outputs.ci == 'true' ) && (needs.build-lint.result == 'success' || needs.build-lint.result == 'skipped') && diff --git a/.github/workflows/gradle-plugin-lint.yml b/.github/workflows/gradle-plugin-lint.yml index 81298b6e..f0d2de0c 100644 --- a/.github/workflows/gradle-plugin-lint.yml +++ b/.github/workflows/gradle-plugin-lint.yml @@ -29,3 +29,7 @@ jobs: - name: Run KtLint working-directory: gradle-plugins/react/brownfield run: ./gradlew ktlintCheck --no-daemon --stacktrace + + - name: Run Unit Tests + working-directory: gradle-plugins/react/brownfield + run: ./gradlew test --no-daemon --stacktrace diff --git a/.gitignore b/.gitignore index a05d1915..5eac6ba7 100644 --- a/.gitignore +++ b/.gitignore @@ -91,4 +91,7 @@ packages/react-native-brownfield/ios/swiftpm/.build/ # skillgym .skillgym-results/ +# internal process artifacts (should never be part of the PR diff) +.superpowers/ + .cursor diff --git a/apps/RNApp/android/BrownfieldLib/build.gradle.kts b/apps/RNApp/android/BrownfieldLib/build.gradle.kts index e876cc24..35f566c0 100644 --- a/apps/RNApp/android/BrownfieldLib/build.gradle.kts +++ b/apps/RNApp/android/BrownfieldLib/build.gradle.kts @@ -1,6 +1,3 @@ -import groovy.json.JsonOutput -import groovy.json.JsonSlurper - plugins { id("com.android.library") id("org.jetbrains.kotlin.android") @@ -18,22 +15,6 @@ publishing { afterEvaluate { from(components.getByName("default")) } - - pom { - withXml { - /** - * As a result of `from(components.getByName("default"))` all of the project - * dependencies are added to `pom.xml` file. We do not need the react-native - * third party dependencies to be a part of it as we embed those dependencies. - */ - val dependenciesNode = - (asNode().get("dependencies") as groovy.util.NodeList).first() as groovy.util.Node - dependenciesNode.children() - .filterIsInstance() - .filter { (it.get("groupId") as groovy.util.NodeList).text() == rootProject.name } - .forEach { dependenciesNode.remove(it) } - } - } } } @@ -42,33 +23,14 @@ publishing { } } -val moduleBuildDir: Directory = layout.buildDirectory.get() - -/** - * As a result of `from(components.getByName("default"))` all of the project - * dependencies are added to `module.json` file. We do not need the react-native - * third party dependencies to be a part of it as we embed those dependencies. - */ -tasks.register("removeDependenciesFromModuleFile") { - doLast { - file("$moduleBuildDir/publications/mavenAar/module.json").run { - val json = inputStream().use { JsonSlurper().parse(it) as Map } - (json["variants"] as? List>)?.forEach { variant -> - (variant["dependencies"] as? MutableList>)?.removeAll { it["group"] == rootProject.name } - } - writer().use { it.write(JsonOutput.prettyPrint(JsonOutput.toJson(json))) } - } - } -} - -tasks.named("generateMetadataFileForMavenAarPublication") { - finalizedBy("removeDependenciesFromModuleFile") -} - react { autolinkLibrariesWithApp() } +reactBrownfield { + includeTransitiveDependencies = true +} + android { namespace = "com.rnapp.brownfieldlib" compileSdk = 37 diff --git a/docs/docs/docs/getting-started/android.mdx b/docs/docs/docs/getting-started/android.mdx index 0f55091c..87913e45 100644 --- a/docs/docs/docs/getting-started/android.mdx +++ b/docs/docs/docs/getting-started/android.mdx @@ -312,7 +312,11 @@ publishing { mavenLocal() } } +``` + +Skip this task-registration block entirely if you plan to use the `includeTransitiveDependencies` option described below. +```kotlin val moduleBuildDir: Directory = layout.buildDirectory.get() tasks.register("removeDependenciesFromModuleFile") { @@ -332,6 +336,18 @@ tasks.named("generateMetadataFileForMavenAarPublication") { } ``` +> **Transitive dependencies:** the task-registration block above strips embedded-module entries from the generated POM by hand. If you don't need your embedded modules' own third-party dependencies to be resolvable by the app consuming this AAR, that manual block is all you need — skip the rest of this note. +> +> If you *do* want that (e.g. your embedded native modules pull in AndroidX libraries the consuming app should get automatically via Maven), set `includeTransitiveDependencies = true` in this module's `reactBrownfield { }` block instead of hand-rolling the JSON-manipulation task above — the plugin now performs the equivalent removal *and* injects your modules' real dependencies for you: +> +> ```kotlin +> reactBrownfield { +> includeTransitiveDependencies = true +> } +> ``` +> +> Do **not** combine this option with a hand-written `tasks.register("removeDependenciesFromModuleFile")` in the same module — the plugin registers a task with that exact name once the option is enabled, and Gradle throws `task 'removeDependenciesFromModuleFile' already exists` if both are present. + ## 7. Create a Brownfield Configuration Create `brownfield.config.json` in your project root: diff --git a/gradle-plugins/react/brownfield/build.gradle.kts b/gradle-plugins/react/brownfield/build.gradle.kts index ad2a04a7..b73354d7 100644 --- a/gradle-plugins/react/brownfield/build.gradle.kts +++ b/gradle-plugins/react/brownfield/build.gradle.kts @@ -108,6 +108,13 @@ dependencies { implementation(libs.common) implementation(libs.asm.commons) implementation(libs.versioncompare) + testImplementation(libs.junit.jupiter) + testImplementation(gradleTestKit()) + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() } tasks.named("detekt").configure { diff --git a/gradle-plugins/react/brownfield/gradle/libs.versions.toml b/gradle-plugins/react/brownfield/gradle/libs.versions.toml index b1b59971..74ecb054 100644 --- a/gradle-plugins/react/brownfield/gradle/libs.versions.toml +++ b/gradle-plugins/react/brownfield/gradle/libs.versions.toml @@ -6,6 +6,7 @@ agp = "8.5.2" common = "31.2.2" # do not bump it for now, as it throws an error for incompatible AGP used asm-commons = "9.7" versioncompare = "1.5.0" +junit = "5.11.4" [plugins] kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinJvm" } @@ -17,3 +18,4 @@ agp = { module = "com.android.tools.build:gradle", name = "agp", version.ref = " common = { module = "com.android.tools:common", version.ref = "common" } asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm-commons" } versioncompare = { module = "io.github.g00fy2:versioncompare", version.ref = "versioncompare" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt new file mode 100644 index 00000000..5e188153 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt @@ -0,0 +1,61 @@ +package com.callstack.react.brownfield.artifacts + +import com.callstack.react.brownfield.shared.Logging +import com.callstack.react.brownfield.shared.TRANSITIVE_DEPENDENCY_CONFIG_NAMES +import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo +import com.callstack.react.brownfield.shared.VersionMediatingDependencySet +import com.callstack.react.brownfield.shared.collectPublishableGradleDependencies +import org.gradle.api.Project + +/** + * Discovers the real third-party (non-project) dependencies of the native module projects + * embedded into the fat AAR, for publication into the AAR's own POM/module metadata. + * Mirrors ExpoPublishingHelper.appendExpoTransitiveDependenciesFromGradle for the RNC-CLI + * ("vanilla") path. + */ +class RncTransitiveDependencyDiscoverer(private val project: Project) { + fun discover(artifacts: List): VersionMediatingDependencySet { + val discovered = VersionMediatingDependencySet() + + artifacts + .filter { it.isExpoPublishDependency != true } + .forEach { artifact -> discoverFromArtifact(artifact, discovered) } + + return discovered + } + + private fun discoverFromArtifact( + artifact: UnresolvedArtifactInfo, + discovered: VersionMediatingDependencySet, + ) { + val moduleProject = project.rootProject.findProject(":${artifact.moduleName}") + if (moduleProject == null) { + Logging.log( + "WARNING: Could not discover transitive dependencies for embedded module " + + "'${artifact.moduleName}' - no Gradle project found at " + + "':${artifact.moduleName}' in the root project", + ) + return + } + + collectPublishableGradleDependencies(moduleProject) + .filterNot { isAlreadyDeclaredByConsumer(it.groupId, it.artifactId) } + .forEach { discovered.add(it) } + } + + /** + * Injection-time dedup only — NOT the removal predicate passed to + * PublishingMetadataInjector. Prevents double-declaring a coordinate the consumer + * project (e.g. BrownfieldLib) already declares explicitly itself. + */ + private fun isAlreadyDeclaredByConsumer( + groupId: String, + artifactId: String, + ): Boolean { + return TRANSITIVE_DEPENDENCY_CONFIG_NAMES.any { configName -> + project.configurations.findByName(configName)?.dependencies?.any { + it.group == groupId && it.name == artifactId + } ?: false + } + } +} diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt index 72c4288b..c81c85f6 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt @@ -1,20 +1,15 @@ package com.callstack.react.brownfield.expo import com.android.utils.forEach -import com.callstack.react.brownfield.expo.utils.DependencyInfo import com.callstack.react.brownfield.expo.utils.ExpoGradleProjectProjection import com.callstack.react.brownfield.expo.utils.LocalMavenUtils -import com.callstack.react.brownfield.expo.utils.VersionMediatingDependencySet import com.callstack.react.brownfield.expo.utils.asExpoGradleProjectProjection import com.callstack.react.brownfield.shared.Constants +import com.callstack.react.brownfield.shared.DependencyInfo import com.callstack.react.brownfield.shared.Logging -import groovy.json.JsonOutput -import groovy.json.JsonSlurper -import groovy.util.NodeList +import com.callstack.react.brownfield.shared.VersionMediatingDependencySet +import com.callstack.react.brownfield.shared.collectPublishableGradleDependencies import org.gradle.api.Project -import org.gradle.api.publish.PublishingExtension -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.api.publish.tasks.GenerateModuleMetadata import org.w3c.dom.Node import java.io.File import javax.xml.parsers.DocumentBuilderFactory @@ -55,13 +50,10 @@ open class ExpoPublishingHelper(val brownfieldAppProject: Project) { ) } - reconfigurePOM(expoTransitiveDependencies) - reconfigureGradleModuleJSON(expoTransitiveDependencies) - return discoverableExpoProjects } - protected fun shouldExcludeDependency( + internal fun shouldExcludeDependency( groupId: String, artifactId: String, ): Boolean { @@ -78,174 +70,6 @@ open class ExpoPublishingHelper(val brownfieldAppProject: Project) { return (isRootProjectArtifact || isExpoArtifact) } - /** - * Modifies the generated Gradle Module Metadata file to inject Expo transitive dependencies. - * @param discoveredExpoTransitiveDependencies Set of DependencyInfo - * representing Expo transitive dependencies to add. - */ - @Suppress("LongMethod") - protected fun reconfigureGradleModuleJSON(discoveredExpoTransitiveDependencies: VersionMediatingDependencySet) { - val removeDependenciesFromModuleFileTask = - brownfieldAppProject.tasks.register("removeDependenciesFromModuleFile") - removeDependenciesFromModuleFileTask.configure { task -> - task.doLast { - val moduleBuildDir = brownfieldAppProject.layout.buildDirectory.get() - - File("$moduleBuildDir/publications/mavenAar/module.json").run { - val json = inputStream().use { JsonSlurper().parse(it) as Map<*, *> } - - discoveredExpoTransitiveDependencies.forEach { dependencyToAdd -> - @Suppress("UNCHECKED_CAST") - (json["variants"] as? List>)?.forEach { variant -> - Logging.log( - "Injecting dependency to Gradle module JSON for variant " + - "'${variant["name"]}': ${dependencyToAdd.groupId}:" + - "${dependencyToAdd.artifactId}:${dependencyToAdd.version}", - ) - - (variant["dependencies"] as? MutableList>)?.add( - mutableMapOf( - "group" to dependencyToAdd.groupId, - "module" to dependencyToAdd.artifactId, - ).apply { - dependencyToAdd.version?.let { version -> - put( - "version", - mapOf( - "requires" to version, - ), - ) - } - }, - ) - } - } - - @Suppress("UNCHECKED_CAST") - (json["variants"] as? List>)?.forEach { variant -> - (variant["dependencies"] as? MutableList>)?.removeAll { - val group = it["group"] as String - val module = it["module"] as String - - val shouldBeExcluded = - shouldExcludeDependency( - groupId = group, - artifactId = module, - ) - - if (shouldBeExcluded) { - Logging.log( - "Removing excluded dependency from Gradle module JSON: $group:$module", - ) - } - - shouldBeExcluded - } - - writer().use { - it.write( - JsonOutput.prettyPrint( - JsonOutput.toJson( - json, - ), - ), - ) - } - } - } - } - } - - brownfieldAppProject.tasks.withType(GenerateModuleMetadata::class.java) - .configureEach { - it.finalizedBy(removeDependenciesFromModuleFileTask.get()) - } - } - - /** - * Modifies the generated Maven POM file to inject Expo transitive dependencies. - * @param discoveredExpoTransitiveDependencies Set of DependencyInfo - * representing Expo transitive dependencies to add. - */ - @Suppress("LongMethod") - protected fun reconfigurePOM(discoveredExpoTransitiveDependencies: VersionMediatingDependencySet) { - brownfieldAppProject.pluginManager.withPlugin("maven-publish") { - brownfieldAppProject.extensions.configure(PublishingExtension::class.java) { publishing -> - publishing.publications.withType(MavenPublication::class.java) - .configureEach { pub -> - Logging.log( - "Configuring POM for publication '${pub.name}' to include Expo transitive dependencies", - ) - - pub.pom.withXml { - val root = it.asNode() - - // below: obtains a view of the node(s) - // inside the POM XML; in practice, there should be only one such node - val dependenciesNodeList = - root.get("dependencies") as NodeList - val dependenciesNode = - dependenciesNodeList.first() as groovy.util.Node - - // below: inject the discovered Expo transitive dependencies - // into the POM's node - discoveredExpoTransitiveDependencies.forEach { dependencyToAdd -> - Logging.log( - "Injecting dependency to POM: ${dependencyToAdd.groupId}:" + - "${dependencyToAdd.artifactId}:${dependencyToAdd.version}", - ) - - val childTags = - mutableMapOf( - "groupId" to dependencyToAdd.groupId, - "artifactId" to dependencyToAdd.artifactId, - "scope" to dependencyToAdd.scope, - "optional" to dependencyToAdd.optional.toString(), - ) - - if (dependencyToAdd.version?.isNotBlank() == true) { - childTags["version"] = dependencyToAdd.version - } - - dependenciesNode.appendNode("dependency").let { newDepNode -> - childTags.forEach { (tagName, tagValue) -> - newDepNode.appendNode(tagName, tagValue) - } - } - } - - // below: filter out dependencies that should be excluded - dependenciesNode.children() - .filterIsInstance() - .filter { dependency -> - val groupId = - (dependency["groupId"] as NodeList).text() - val artifactId = - (dependency["artifactId"] as NodeList).text() - - val shouldBeExcluded = - shouldExcludeDependency( - groupId = groupId, - artifactId = artifactId, - ) - - if (shouldBeExcluded) { - Logging.log( - "Removing excluded dependency from POM: $groupId:$artifactId", - ) - } - - shouldBeExcluded - } - .forEach { dependency -> - dependenciesNode.remove(dependency) - } - } - } - } - } - } - fun discoverAllExpoTransitiveDependencies(expoProjects: Iterable): VersionMediatingDependencySet { var discoveredExpoTransitiveDependencies = VersionMediatingDependencySet() expoProjects.forEach { expoProj -> @@ -368,24 +192,7 @@ open class ExpoPublishingHelper(val brownfieldAppProject: Project) { pkgProject: Project, dependencies: VersionMediatingDependencySet, ) { - /** - * Not accounting for variant specific configurations as Expo packages are not - * using it. Should we face any issues/needs to account for it, we can do it here. - */ - listOf("implementation", "api", "runtime").forEach { - val configuration = pkgProject.configurations.findByName(it) - configuration?.dependencies?.forEach { dep -> - if (dep.group != null) { - dependencies.add( - DependencyInfo.fromGradleDep( - groupId = dep.group!!, - artifactId = dep.name, - version = dep.version, - ), - ) - } - } - } + dependencies.addAll(collectPublishableGradleDependencies(pkgProject)) } /** diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt new file mode 100644 index 00000000..10ef2ae2 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt @@ -0,0 +1,7 @@ +package com.callstack.react.brownfield.expo.utils + +data class BrownfieldPublishingInfo( + val groupId: String, + val artifactId: String, + val version: String, +) diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt index a408f586..4ef96b4d 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt @@ -3,6 +3,7 @@ package com.callstack.react.brownfield.plugin import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryVariant import com.callstack.react.brownfield.artifacts.ArtifactsResolver +import com.callstack.react.brownfield.artifacts.RncTransitiveDependencyDiscoverer import com.callstack.react.brownfield.expo.ExpoPublishingHelper import com.callstack.react.brownfield.expo.utils.ExpoGradleProjectProjection import com.callstack.react.brownfield.processors.AssetTaskProcessor @@ -17,7 +18,9 @@ import com.callstack.react.brownfield.processors.VariantTaskProvider import com.callstack.react.brownfield.shared.BaseProject import com.callstack.react.brownfield.shared.Constants.PROJECT_ID import com.callstack.react.brownfield.shared.Logging +import com.callstack.react.brownfield.shared.PublishingMetadataInjector import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo +import com.callstack.react.brownfield.shared.VersionMediatingDependencySet import com.callstack.react.brownfield.utils.AndroidArchiveLibrary import com.callstack.react.brownfield.utils.DirectoryManager import com.callstack.react.brownfield.utils.Extension @@ -54,8 +57,9 @@ class RNBrownfieldPlugin : Plugin { } var expoProjects = listOf() + var expoPublishingHelper: ExpoPublishingHelper? = null if (this.isExpoProject) { - val expoPublishingHelper = ExpoPublishingHelper(brownfieldAppProject = project) + expoPublishingHelper = ExpoPublishingHelper(brownfieldAppProject = project) expoProjects = expoPublishingHelper.configure() } @@ -65,6 +69,49 @@ class RNBrownfieldPlugin : Plugin { val artifactsResolver = ArtifactsResolver(project, isExpoProject) val artifacts = artifactsResolver.processDefaultDependencies(expoProjects) + /** + * Discovers and publishes transitive (third-party) dependencies of embedded + * native modules into this project's POM/Gradle Module Metadata, so a consuming + * native app resolves them automatically. Deferred to afterEvaluate: `extension` + * is created eagerly in initializers() above, before the build script's own + * `reactBrownfield { }` block has configured it — reading `extension.includeTransitiveDependencies` + * any earlier than this would always observe its default `false`. + */ + project.afterEvaluate { + val transitiveDeps = VersionMediatingDependencySet() + + if (isExpoProject && expoPublishingHelper != null) { + val expoTransitiveDeps = expoPublishingHelper.discoverAllExpoTransitiveDependencies(expoProjects) + Logging.log("Merged ${expoTransitiveDeps.size} transitive dependencies discovered from Expo") + transitiveDeps.addAll(expoTransitiveDeps) + } + if (extension.includeTransitiveDependencies) { + val rncTransitiveDeps = RncTransitiveDependencyDiscoverer(project).discover(artifacts) + Logging.log("Merged ${rncTransitiveDeps.size} transitive dependencies discovered by the RNC discoverer") + transitiveDeps.addAll(rncTransitiveDeps) + } + + if (isExpoProject || extension.includeTransitiveDependencies) { + Logging.log( + "Total of ${transitiveDeps.size} unique transitive dependencies merged for POM/module.json injection", + ) + + val removalPredicate: (String, String) -> Boolean = { groupId, artifactId -> + (expoPublishingHelper?.shouldExcludeDependency(groupId, artifactId) ?: (groupId == project.rootProject.name)) || + artifacts.any { it.moduleGroup == groupId && it.moduleName == artifactId } + } + + val injector = PublishingMetadataInjector(project) + injector.reconfigurePOM(transitiveDeps, removalPredicate) + injector.reconfigureGradleModuleJSON(transitiveDeps, removalPredicate) + Logging.log("PublishingMetadataInjector ran: injected merged transitive dependencies into POM and Gradle Module Metadata") + } else { + Logging.log( + "PublishingMetadataInjector skipped: project is not an Expo project and includeTransitiveDependencies is disabled", + ) + } + } + val variantTaskProvider = VariantTaskProvider(project) val androidComponents = project.extensions.getByType(LibraryAndroidComponentsExtension::class.java) diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt index d6a5537f..b7c82f29 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt @@ -1,6 +1,5 @@ package com.callstack.react.brownfield.shared -import com.callstack.react.brownfield.expo.utils.DependencyInfo import com.callstack.react.brownfield.utils.StringMatcher /** diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPrimitives.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyInfo.kt similarity index 77% rename from gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPrimitives.kt rename to gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyInfo.kt index e051a820..351fe758 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPrimitives.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyInfo.kt @@ -1,10 +1,4 @@ -package com.callstack.react.brownfield.expo.utils - -data class BrownfieldPublishingInfo( - val groupId: String, - val artifactId: String, - val version: String, -) +package com.callstack.react.brownfield.shared data class DependencyInfo( val groupId: String, diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt new file mode 100644 index 00000000..8af425c6 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt @@ -0,0 +1,16 @@ +package com.callstack.react.brownfield.shared + +/** + * Whether [dependency] is safe to publish as-is in a POM/Gradle Module Metadata dependency + * entry. Rejects dynamic (`+`) versions and missing/blank versions — both produce either a + * non-reproducible resolution for consumers or (for a blank version) a `` node + * with no version at all, which is exactly the shape of problem the pre-existing + * `kotlin-build-tools-impl` entry in [Constants.BROWNFIELD_EXPO_TRANSITIVE_DEPS_ARTIFACTS_BLACKLIST] + * exists to work around on the Expo side. + */ +fun isPublishableCoordinate(dependency: DependencyInfo): Boolean { + val version = dependency.version + if (version.isNullOrBlank()) return false + if (version.contains("+")) return false + return true +} diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt new file mode 100644 index 00000000..1a15f4a9 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt @@ -0,0 +1,39 @@ +package com.callstack.react.brownfield.shared + +import org.gradle.api.Project +import org.gradle.api.internal.artifacts.dependencies.DefaultProjectDependency + +/** + * Configuration names inspected when discovering a Gradle project's direct external + * dependencies for publication into a consumer's POM/Gradle Module Metadata. + * + * Not variant/flavor-aware: only these plain configuration names are inspected, not e.g. + * `releaseImplementation`. Fine for Expo packages (confirmed not to use flavor-specific + * configurations); an RNC-CLI native module that does use them will have those dependencies + * missed here. Extend this list if that turns out to matter in practice. + */ +val TRANSITIVE_DEPENDENCY_CONFIG_NAMES = listOf("implementation", "api", "runtimeOnly") + +/** + * Collects [project]'s direct external (non-project) dependencies declared on + * [TRANSITIVE_DEPENDENCY_CONFIG_NAMES], dropping any coordinate [isPublishableCoordinate] + * rejects. Shared by the Expo Gradle-fallback and RNC-CLI transitive-dependency discovery + * paths so both get the same publishability guarantees. + */ +fun collectPublishableGradleDependencies(project: Project): List { + val result = mutableListOf() + + TRANSITIVE_DEPENDENCY_CONFIG_NAMES.forEach { configName -> + val configuration = project.configurations.findByName(configName) ?: return@forEach + + configuration.dependencies.forEach { dependency -> + if (dependency is DefaultProjectDependency) return@forEach + val group = dependency.group ?: return@forEach + + val info = DependencyInfo.fromGradleDep(group, dependency.name, dependency.version) + if (isPublishableCoordinate(info)) result.add(info) + } + } + + return result +} diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt new file mode 100644 index 00000000..90f08fa8 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt @@ -0,0 +1,121 @@ +package com.callstack.react.brownfield.shared + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import groovy.util.NodeList +import org.gradle.api.Project +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.publish.tasks.GenerateModuleMetadata +import java.io.File + +/** + * Injects a resolved set of transitive dependencies into the generated Maven POM and + * Gradle Module Metadata (`module.json`) for every `MavenPublication` on [project], and + * removes any existing entry (from the base publication or previously injected) that + * [shouldExclude] matches. Used identically by the Expo and RNC-CLI transitive-dependency + * paths. + */ +class PublishingMetadataInjector(private val project: Project) { + @Suppress("LongMethod") + fun reconfigureGradleModuleJSON( + dependencies: VersionMediatingDependencySet, + shouldExclude: (groupId: String, artifactId: String) -> Boolean, + ) { + val removeDependenciesFromModuleFileTask = + project.tasks.register("removeDependenciesFromModuleFile") + removeDependenciesFromModuleFileTask.configure { task -> + task.doLast { + val moduleBuildDir = project.layout.buildDirectory.get() + + File("$moduleBuildDir/publications/mavenAar/module.json").run { + val json = inputStream().use { JsonSlurper().parse(it) as Map<*, *> } + + dependencies.forEach { dependencyToAdd -> + @Suppress("UNCHECKED_CAST") + (json["variants"] as? List>)?.forEach { variant -> + (variant["dependencies"] as? MutableList>)?.add( + mutableMapOf( + "group" to dependencyToAdd.groupId, + "module" to dependencyToAdd.artifactId, + ).apply { + dependencyToAdd.version?.let { version -> + put("version", mapOf("requires" to version)) + } + }, + ) + } + } + + @Suppress("UNCHECKED_CAST") + (json["variants"] as? List>)?.forEach { variant -> + (variant["dependencies"] as? MutableList>)?.removeAll { + val group = it["group"] as String + val module = it["module"] as String + shouldExclude(group, module) + } + + writer().use { + it.write(JsonOutput.prettyPrint(JsonOutput.toJson(json))) + } + } + } + } + } + + project.tasks.withType(GenerateModuleMetadata::class.java) + .configureEach { + it.finalizedBy(removeDependenciesFromModuleFileTask.get()) + } + } + + @Suppress("LongMethod") + fun reconfigurePOM( + dependencies: VersionMediatingDependencySet, + shouldExclude: (groupId: String, artifactId: String) -> Boolean, + ) { + project.pluginManager.withPlugin("maven-publish") { + project.extensions.configure(PublishingExtension::class.java) { publishing -> + publishing.publications.withType(MavenPublication::class.java) + .configureEach { pub -> + pub.pom.withXml { + val root = it.asNode() + val dependenciesNodeList = root.get("dependencies") as NodeList + val dependenciesNode = dependenciesNodeList.first() as groovy.util.Node + + dependencies.forEach { dependencyToAdd -> + val childTags = + mutableMapOf( + "groupId" to dependencyToAdd.groupId, + "artifactId" to dependencyToAdd.artifactId, + "scope" to dependencyToAdd.scope, + "optional" to dependencyToAdd.optional.toString(), + ) + + if (dependencyToAdd.version?.isNotBlank() == true) { + childTags["version"] = dependencyToAdd.version + } + + dependenciesNode.appendNode("dependency").let { newDepNode -> + childTags.forEach { (tagName, tagValue) -> + newDepNode.appendNode(tagName, tagValue) + } + } + } + + dependenciesNode.children() + .filterIsInstance() + .filter { dependency -> + val groupId = (dependency["groupId"] as NodeList).text() + val artifactId = (dependency["artifactId"] as NodeList).text() + shouldExclude(groupId, artifactId) + } + .forEach { dependency -> + dependenciesNode.remove(dependency) + } + } + } + } + } + } +} diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/VersionMediatingDependencySet.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySet.kt similarity index 98% rename from gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/VersionMediatingDependencySet.kt rename to gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySet.kt index b7dc2bc7..c8677784 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/VersionMediatingDependencySet.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySet.kt @@ -1,4 +1,4 @@ -package com.callstack.react.brownfield.expo.utils +package com.callstack.react.brownfield.shared import io.github.g00fy2.versioncompare.Version diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt index 5bebbfac..524edba4 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt @@ -53,4 +53,15 @@ open class Extension { * listOf("libdatadog-ndk.so") */ var ignoreEmbeddedLibs = listOf() + + /** + * Whether to discover and publish the transitive (third-party) dependencies of + * embedded native modules into the generated POM and Gradle Module Metadata, so a + * consuming native app resolves them automatically via Maven/Gradle instead of having + * to declare them by hand. + * + * Default is `false`. Expo projects already get equivalent behavior unconditionally; + * this option only affects non-Expo (RNC CLI) projects. + */ + var includeTransitiveDependencies = false } diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt new file mode 100644 index 00000000..484bbcb8 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt @@ -0,0 +1,63 @@ +package com.callstack.react.brownfield.artifacts + +import com.callstack.react.brownfield.shared.DependencyInfo +import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RncTransitiveDependencyDiscovererTest { + @Test + fun `discovers a module's direct external dependencies, skipping project deps and non-publishable coordinates`() { + val root = ProjectBuilder.builder().build() + val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build() + val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build() + val siblingProject = ProjectBuilder.builder().withParent(root).withName("some-other-module").build() + + embeddedModule.configurations.create("implementation") + embeddedModule.configurations.create("api") + embeddedModule.configurations.create("runtimeOnly") + + embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.7.1") + embeddedModule.dependencies.add("api", "com.facebook.react:hermes-android:0.87.0") + embeddedModule.dependencies.add("implementation", "com.facebook.react:react-native:+") + embeddedModule.dependencies.add("runtimeOnly", "androidx.annotation:annotation:1.9.1") + embeddedModule.dependencies.add( + "implementation", + embeddedModule.dependencies.project(mapOf("path" to siblingProject.path)), + ) + + consumer.configurations.create("api") + consumer.dependencies.add("api", "com.facebook.react:hermes-android:0.87.0") + + val artifacts = + listOf( + UnresolvedArtifactInfo( + moduleGroup = root.name, + moduleName = "react-native-fake-module", + moduleVersion = "unspecified", + file = null, + isExpoPublishDependency = false, + ), + ) + + val discovered = RncTransitiveDependencyDiscoverer(consumer).discover(artifacts) + + assertEquals(2, discovered.size) + assertTrue( + discovered.contains( + DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false), + ), + ) + assertTrue( + discovered.contains( + DependencyInfo("androidx.annotation", "annotation", "1.9.1", "compile", false), + ), + ) + assertFalse(discovered.contains(DependencyInfo("com.facebook.react", "hermes-android", "0.87.0", "compile", false))) + assertFalse(discovered.contains(DependencyInfo("com.facebook.react", "react-native", "+", "compile", false))) + assertFalse(discovered.contains(DependencyInfo(root.name, "some-other-module", "unspecified", "compile", false))) + } +} diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt new file mode 100644 index 00000000..3e03884a --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt @@ -0,0 +1,41 @@ +package com.callstack.react.brownfield.expo + +import com.callstack.react.brownfield.shared.DependencyInfo +import com.callstack.react.brownfield.shared.VersionMediatingDependencySet +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import kotlin.test.assertTrue + +class ExpoPublishingHelperGradleFallbackTest { + @Test + fun `Gradle-fallback discovery picks up runtimeOnly dependencies`() { + val root = ProjectBuilder.builder().build() + val expoPkgProject = ProjectBuilder.builder().withParent(root).withName("expo-fake-module").build() + + expoPkgProject.configurations.create("implementation") + expoPkgProject.configurations.create("api") + expoPkgProject.configurations.create("runtimeOnly") + expoPkgProject.dependencies.add("runtimeOnly", "androidx.annotation:annotation:1.9.1") + + val helper = + object : ExpoPublishingHelper(brownfieldAppProject = root) { + fun exposedAppend( + pkgProject: Project, + deps: VersionMediatingDependencySet, + ) { + appendExpoTransitiveDependenciesFromGradle(pkgProject, deps) + } + } + + val discovered = VersionMediatingDependencySet() + helper.exposedAppend(expoPkgProject, discovered) + + assertTrue( + discovered.contains( + DependencyInfo("androidx.annotation", "annotation", "1.9.1", "compile", false), + ), + "expected the runtimeOnly dependency to be discovered via the Gradle fallback path", + ) + } +} diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt new file mode 100644 index 00000000..4eadc4a0 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt @@ -0,0 +1,37 @@ +package com.callstack.react.brownfield.shared + +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DependencyPublishabilityTest { + @Test + fun `rejects a fully dynamic version`() { + val dep = DependencyInfo("com.facebook.react", "react-native", "+", "compile", false) + assertFalse(isPublishableCoordinate(dep)) + } + + @Test + fun `rejects a partially dynamic version`() { + val dep = DependencyInfo("androidx.core", "core-ktx", "1.+", "compile", false) + assertFalse(isPublishableCoordinate(dep)) + } + + @Test + fun `rejects a null version`() { + val dep = DependencyInfo("com.facebook.react", "react-android", null, "compile", false) + assertFalse(isPublishableCoordinate(dep)) + } + + @Test + fun `rejects a blank version`() { + val dep = DependencyInfo("com.facebook.react", "react-android", " ", "compile", false) + assertFalse(isPublishableCoordinate(dep)) + } + + @Test + fun `accepts a normal pinned version`() { + val dep = DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false) + assertTrue(isPublishableCoordinate(dep)) + } +} diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt new file mode 100644 index 00000000..927f8dc5 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt @@ -0,0 +1,42 @@ +package com.callstack.react.brownfield.shared + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class VersionMediatingDependencySetTest { + @Test + fun `keeps the higher version when the same coordinate is added twice`() { + val set = VersionMediatingDependencySet() + + set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.6.0", "compile", false)) + set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false)) + + assertEquals(1, set.size) + assertTrue(set.contains(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))) + } + + @Test + fun `does not downgrade when a lower version is added second`() { + val set = VersionMediatingDependencySet() + + set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false)) + set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.6.0", "compile", false)) + + val kept = set.first { it.groupId == "androidx.appcompat" } + assertEquals("1.7.1", kept.version) + } + + @Test + fun `removeAll removes matching entries and returns them`() { + val set = VersionMediatingDependencySet() + set.add(DependencyInfo("host.exp.exponent", "expo", "1.0.0", "compile", false)) + set.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false)) + + val removed = set.removeAll { it.groupId == "host.exp.exponent" } + + assertEquals(1, removed.size) + assertEquals(1, set.size) + assertTrue(set.contains(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))) + } +} diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt new file mode 100644 index 00000000..92fb3776 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt @@ -0,0 +1,11 @@ +package com.callstack.react.brownfield.utils + +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse + +class ExtensionTest { + @Test + fun `includeTransitiveDependencies defaults to false`() { + assertFalse(Extension().includeTransitiveDependencies) + } +}