@callstack/react-native-brownfield 5.0.2 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt +47 -4
  3. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeFragment.kt +23 -1
  4. package/gradle-plugin/brownfield/build.gradle.kts +7 -0
  5. package/gradle-plugin/brownfield/gradle/libs.versions.toml +2 -0
  6. package/gradle-plugin/brownfield/gradle.properties +1 -1
  7. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt +96 -0
  8. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt +10 -214
  9. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt +7 -0
  10. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +92 -1
  11. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt +12 -1
  12. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/{expo/utils/BrownfieldPrimitives.kt → shared/DependencyInfo.kt} +3 -8
  13. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt +22 -0
  14. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt +44 -0
  15. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt +181 -0
  16. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/TransitiveDependencyFiltering.kt +17 -0
  17. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/{expo/utils → shared}/VersionMediatingDependencySet.kt +1 -1
  18. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt +11 -0
  19. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt +171 -0
  20. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt +47 -0
  21. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/LegacyConsumerConfigurationTest.kt +110 -0
  22. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt +67 -0
  23. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjectorTest.kt +222 -0
  24. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/TransitiveDependencyFilteringTest.kt +37 -0
  25. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt +42 -0
  26. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt +11 -0
  27. package/ios/BrownfieldReactHostPreloader.h +30 -0
  28. package/ios/BrownfieldReactHostPreloader.m +113 -0
  29. package/ios/BrownfieldSurfaceRegistry.swift +93 -0
  30. package/ios/Expo/ExpoHostRuntime.swift +70 -8
  31. package/ios/JSBundleLoadObserver.swift +64 -17
  32. package/ios/ReactHostPreloading.swift +177 -0
  33. package/ios/ReactNativeBrownfield.swift +36 -1
  34. package/ios/Vanilla/ReactNativeHostRuntime.swift +28 -6
  35. package/ios/Views/ReactNativeView.swift +34 -5
  36. package/ios/Views/ReactNativeViewController.swift +120 -1
  37. package/lib/commonjs/expo-config-plugin/android/utils/constants.js +1 -1
  38. package/lib/commonjs/expo-config-plugin/template/android/ReactNativeHostManager.post55.kt +6 -1
  39. package/lib/module/expo-config-plugin/android/utils/constants.js +1 -1
  40. package/lib/module/expo-config-plugin/template/android/ReactNativeHostManager.post55.kt +6 -1
  41. package/lib/typescript/commonjs/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  42. package/lib/typescript/module/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  43. package/package.json +2 -2
  44. package/src/expo-config-plugin/android/utils/constants.ts +1 -1
  45. package/src/expo-config-plugin/template/android/ReactNativeHostManager.post55.kt +6 -1
@@ -3,6 +3,7 @@ package com.callstack.react.brownfield.plugin
3
3
  import com.android.build.api.variant.LibraryAndroidComponentsExtension
4
4
  import com.android.build.api.variant.LibraryVariant
5
5
  import com.callstack.react.brownfield.artifacts.ArtifactsResolver
6
+ import com.callstack.react.brownfield.artifacts.RncTransitiveDependencyDiscoverer
6
7
  import com.callstack.react.brownfield.expo.ExpoPublishingHelper
7
8
  import com.callstack.react.brownfield.expo.utils.ExpoGradleProjectProjection
8
9
  import com.callstack.react.brownfield.processors.AssetTaskProcessor
@@ -15,9 +16,13 @@ import com.callstack.react.brownfield.processors.VariantHelper
15
16
  import com.callstack.react.brownfield.processors.VariantPackagesProperty
16
17
  import com.callstack.react.brownfield.processors.VariantTaskProvider
17
18
  import com.callstack.react.brownfield.shared.BaseProject
19
+ import com.callstack.react.brownfield.shared.Constants
18
20
  import com.callstack.react.brownfield.shared.Constants.PROJECT_ID
19
21
  import com.callstack.react.brownfield.shared.Logging
22
+ import com.callstack.react.brownfield.shared.PublishingMetadataInjector
20
23
  import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo
24
+ import com.callstack.react.brownfield.shared.VersionMediatingDependencySet
25
+ import com.callstack.react.brownfield.shared.dropHardExcludedDependencies
21
26
  import com.callstack.react.brownfield.utils.AndroidArchiveLibrary
22
27
  import com.callstack.react.brownfield.utils.DirectoryManager
23
28
  import com.callstack.react.brownfield.utils.Extension
@@ -54,8 +59,9 @@ class RNBrownfieldPlugin : Plugin<Project> {
54
59
  }
55
60
 
56
61
  var expoProjects = listOf<ExpoGradleProjectProjection>()
62
+ var expoPublishingHelper: ExpoPublishingHelper? = null
57
63
  if (this.isExpoProject) {
58
- val expoPublishingHelper = ExpoPublishingHelper(brownfieldAppProject = project)
64
+ expoPublishingHelper = ExpoPublishingHelper(brownfieldAppProject = project)
59
65
  expoProjects = expoPublishingHelper.configure()
60
66
  }
61
67
 
@@ -65,6 +71,18 @@ class RNBrownfieldPlugin : Plugin<Project> {
65
71
  val artifactsResolver = ArtifactsResolver(project, isExpoProject)
66
72
  val artifacts = artifactsResolver.processDefaultDependencies(expoProjects)
67
73
 
74
+ // Registered eagerly: Gradle disallows registering tasks from afterEvaluate. Only the
75
+ // dependency set supplied below has to wait.
76
+ val injector = PublishingMetadataInjector(project)
77
+
78
+ // whenReady, not afterEvaluate: afterEvaluate guarantees only that *this* project is
79
+ // configured, not the embedded module projects we read dependencies from — theirs were
80
+ // empirically still empty at that point. whenReady fires once the whole task graph is
81
+ // resolved (so every embedded module is configured) but before any task runs.
82
+ project.gradle.taskGraph.whenReady {
83
+ configureTransitiveDependencyInjection(project, injector, expoPublishingHelper, expoProjects, artifacts)
84
+ }
85
+
68
86
  val variantTaskProvider = VariantTaskProvider(project)
69
87
 
70
88
  val androidComponents = project.extensions.getByType(LibraryAndroidComponentsExtension::class.java)
@@ -77,6 +95,79 @@ class RNBrownfieldPlugin : Plugin<Project> {
77
95
  const val EXPO_PROJECT_LOCATOR = ":expo"
78
96
  }
79
97
 
98
+ @Suppress("LongMethod")
99
+ private fun configureTransitiveDependencyInjection(
100
+ project: Project,
101
+ injector: PublishingMetadataInjector,
102
+ expoPublishingHelper: ExpoPublishingHelper?,
103
+ expoProjects: List<ExpoGradleProjectProjection>,
104
+ artifacts: List<UnresolvedArtifactInfo>,
105
+ ) {
106
+ val transitiveDeps = VersionMediatingDependencySet()
107
+ var rncSupersededCoordinates: Set<Pair<String, String>> = emptySet()
108
+
109
+ if (isExpoProject && expoPublishingHelper != null) {
110
+ val expoTransitiveDeps = expoPublishingHelper.discoverAllExpoTransitiveDependencies(expoProjects)
111
+ Logging.log("Merged ${expoTransitiveDeps.size} transitive dependencies discovered from Expo")
112
+ expoTransitiveDeps.forEach {
113
+ Logging.log(
114
+ "(*) dependency ${it.groupId}:${it.artifactId}:${it.version} (scope: ${it.scope}, " +
115
+ "${if (it.optional) "optional" else "required"})",
116
+ )
117
+ }
118
+ transitiveDeps.addAll(expoTransitiveDeps)
119
+ }
120
+ // Never on Expo: the path above already covers every embedded module there, and the RNC
121
+ // discoverer doesn't filter by the Expo blacklist, so running both leaks Expo coordinates.
122
+ if (!isExpoProject && extension.experimentalIncludeTransitiveDependencies) {
123
+ val rncDiscovery = RncTransitiveDependencyDiscoverer(project).discover(artifacts)
124
+ Logging.log(
125
+ "Merged ${rncDiscovery.dependencies.size} transitive dependencies discovered by the RNC discoverer",
126
+ )
127
+ transitiveDeps.addAll(rncDiscovery.dependencies)
128
+ rncSupersededCoordinates = rncDiscovery.supersededCoordinates
129
+ }
130
+
131
+ if (isExpoProject || extension.experimentalIncludeTransitiveDependencies) {
132
+ // Coordinates that must never be published: they're embedded in the AAR, not
133
+ // resolvable from Maven. Distinct from "superseded" coordinates, which ARE re-injected
134
+ // (replacing a stale entry) and so must not be dropped here.
135
+ val hardExcludePredicate: (String, String) -> Boolean = { groupId, artifactId ->
136
+ (expoPublishingHelper?.shouldExcludeDependency(groupId, artifactId) ?: (groupId == project.rootProject.name)) ||
137
+ artifacts.any { it.moduleGroup == groupId && it.moduleName == artifactId }
138
+ }
139
+ dropHardExcludedDependencies(transitiveDeps, hardExcludePredicate)
140
+
141
+ Logging.log(
142
+ "Total of ${transitiveDeps.size} unique transitive dependencies merged for POM/module.json injection",
143
+ )
144
+
145
+ val removalPredicate: (String, String) -> Boolean = { groupId, artifactId ->
146
+ hardExcludePredicate(groupId, artifactId) || rncSupersededCoordinates.contains(groupId to artifactId)
147
+ }
148
+
149
+ injector.configure(transitiveDeps, removalPredicate)
150
+ Logging.log("PublishingMetadataInjector ran: injected merged transitive dependencies into POM and Gradle Module Metadata")
151
+
152
+ // Informational only — throwing would recreate the upgrade break that the namespaced
153
+ // task name exists to prevent. `names` avoids realizing the task from whenReady.
154
+ if (project.tasks.names.contains(Constants.LEGACY_CONSUMER_MODULE_METADATA_TASK_NAME)) {
155
+ Logging.log(
156
+ "NOTE: this project still registers a hand-written " +
157
+ "'${Constants.LEGACY_CONSUMER_MODULE_METADATA_TASK_NAME}' task. The plugin now performs " +
158
+ "that removal itself (plus transitive-dependency injection) via " +
159
+ "'${Constants.MODULE_METADATA_POST_PROCESS_TASK_NAME}'. The hand-written task is " +
160
+ "redundant and can be deleted; leaving it in place is harmless.",
161
+ )
162
+ }
163
+ } else {
164
+ Logging.log(
165
+ "PublishingMetadataInjector skipped: project is not an Expo project and " +
166
+ "experimentalIncludeTransitiveDependencies is disabled",
167
+ )
168
+ }
169
+ }
170
+
80
171
  private fun initializers() {
81
172
  RClassTransformer.project = project
82
173
  Logging.project = project
@@ -1,6 +1,5 @@
1
1
  package com.callstack.react.brownfield.shared
2
2
 
3
- import com.callstack.react.brownfield.expo.utils.DependencyInfo
4
3
  import com.callstack.react.brownfield.utils.StringMatcher
5
4
 
6
5
  /**
@@ -40,6 +39,18 @@ object Constants {
40
39
 
41
40
  const val INTERMEDIATES_TEMP_DIR = PLUGIN_NAME
42
41
 
42
+ /**
43
+ * Plugin-owned task that post-processes generated Gradle Module Metadata.
44
+ *
45
+ * Never rename to the bare [LEGACY_CONSUMER_MODULE_METADATA_TASK_NAME]: the plugin registers
46
+ * during `apply()`, before the consumer's script body, so claiming that name breaks every
47
+ * consumer still following the setup docs with "a task with that name already exists".
48
+ */
49
+ const val MODULE_METADATA_POST_PROCESS_TASK_NAME = "brownfieldRemoveDependenciesFromModuleFile"
50
+
51
+ /** The name the setup docs tell consumers to register themselves; must stay free for them. */
52
+ const val LEGACY_CONSUMER_MODULE_METADATA_TASK_NAME = "removeDependenciesFromModuleFile"
53
+
43
54
  val BROWNFIELD_EXPO_TRANSITIVE_DEPS_WHITELISTED_MODULES_FOR_DISCOVERY =
44
55
  setOf("expo-modules-core", "expo-constants", "expo", "expo-updates")
45
56
  val BROWNFIELD_EXPO_TRANSITIVE_DEPS_ARTIFACTS_BLACKLIST =
@@ -1,10 +1,4 @@
1
- package com.callstack.react.brownfield.expo.utils
2
-
3
- data class BrownfieldPublishingInfo(
4
- val groupId: String,
5
- val artifactId: String,
6
- val version: String,
7
- )
1
+ package com.callstack.react.brownfield.shared
8
2
 
9
3
  data class DependencyInfo(
10
4
  val groupId: String,
@@ -18,12 +12,13 @@ data class DependencyInfo(
18
12
  groupId: String,
19
13
  artifactId: String,
20
14
  version: String?,
15
+ scope: String = "compile",
21
16
  ): DependencyInfo {
22
17
  return DependencyInfo(
23
18
  groupId = groupId,
24
19
  artifactId = artifactId,
25
20
  version = version,
26
- scope = "compile",
21
+ scope = scope,
27
22
  optional = false,
28
23
  )
29
24
  }
@@ -0,0 +1,22 @@
1
+ package com.callstack.react.brownfield.shared
2
+
3
+ /** Matches Maven/Gradle version-range syntax, e.g. `[1.0,2.0)`, `(,2.0]`, `[1.0,)`. */
4
+ private val VERSION_RANGE_REGEX = Regex("^[\\[(].*,.*[\\])]$")
5
+
6
+ /** Reserved Gradle/Maven dynamic-version keywords that resolve to whatever is newest at build time. */
7
+ private val DYNAMIC_VERSION_KEYWORDS = setOf("latest.release", "latest.integration", "latest", "release")
8
+
9
+ /**
10
+ * Whether [dependency] is safe to publish as-is. Rejects dynamic versions (`+`, `latest.*`, ranges)
11
+ * and blank ones: the former resolve non-reproducibly for consumers, the latter emit a
12
+ * `<dependency>` with no version at all.
13
+ */
14
+ fun isPublishableCoordinate(dependency: DependencyInfo): Boolean {
15
+ val version = dependency.version
16
+ if (version.isNullOrBlank()) return false
17
+ val trimmed = version.trim()
18
+ if (trimmed.contains("+")) return false
19
+ if (trimmed.lowercase() in DYNAMIC_VERSION_KEYWORDS) return false
20
+ if (VERSION_RANGE_REGEX.matches(trimmed)) return false
21
+ return true
22
+ }
@@ -0,0 +1,44 @@
1
+ package com.callstack.react.brownfield.shared
2
+
3
+ import org.gradle.api.Project
4
+ import org.gradle.api.internal.artifacts.dependencies.DefaultProjectDependency
5
+
6
+ /**
7
+ * Configurations inspected when discovering a project's direct external dependencies.
8
+ *
9
+ * Not flavor-aware — `releaseImplementation` and friends are missed. Fine for Expo packages, which
10
+ * don't use them; extend if an RNC-CLI native module turns out to.
11
+ */
12
+ val TRANSITIVE_DEPENDENCY_CONFIG_NAMES = listOf("implementation", "api", "runtimeOnly")
13
+
14
+ /** Maven scope per configuration; `runtimeOnly` maps to `runtime` to keep it off the compile classpath. */
15
+ private val CONFIG_NAME_TO_SCOPE =
16
+ mapOf(
17
+ "implementation" to "compile",
18
+ "api" to "compile",
19
+ "runtimeOnly" to "runtime",
20
+ )
21
+
22
+ /**
23
+ * [project]'s direct external (non-project) dependencies on [TRANSITIVE_DEPENDENCY_CONFIG_NAMES],
24
+ * minus anything [isPublishableCoordinate] rejects. Shared by the Expo fallback and RNC paths so
25
+ * both get the same guarantees.
26
+ */
27
+ fun collectPublishableGradleDependencies(project: Project): List<DependencyInfo> {
28
+ val result = mutableListOf<DependencyInfo>()
29
+
30
+ TRANSITIVE_DEPENDENCY_CONFIG_NAMES.forEach { configName ->
31
+ val configuration = project.configurations.findByName(configName) ?: return@forEach
32
+ val scope = CONFIG_NAME_TO_SCOPE.getValue(configName)
33
+
34
+ configuration.dependencies.forEach { dependency ->
35
+ if (dependency is DefaultProjectDependency) return@forEach
36
+ val group = dependency.group ?: return@forEach
37
+
38
+ val info = DependencyInfo.fromGradleDep(group, dependency.name, dependency.version, scope)
39
+ if (isPublishableCoordinate(info)) result.add(info)
40
+ }
41
+ }
42
+
43
+ return result
44
+ }
@@ -0,0 +1,181 @@
1
+ package com.callstack.react.brownfield.shared
2
+
3
+ import groovy.json.JsonOutput
4
+ import groovy.json.JsonSlurper
5
+ import groovy.util.Node
6
+ import groovy.util.NodeList
7
+ import org.gradle.api.Project
8
+ import org.gradle.api.publish.PublishingExtension
9
+ import org.gradle.api.publish.maven.MavenPublication
10
+ import org.gradle.api.publish.tasks.GenerateModuleMetadata
11
+
12
+ /**
13
+ * Removes every `<dependency>` matching [shouldExclude] from [dependenciesNode], then appends one
14
+ * per entry in [dependencies].
15
+ *
16
+ * Removal must run before appending: [shouldExclude] matches on coordinate alone, so afterwards it
17
+ * couldn't tell a stale superseded entry from the one just appended for it, and would drop both.
18
+ */
19
+ internal fun mutatePomDependenciesNode(
20
+ dependenciesNode: Node,
21
+ dependencies: VersionMediatingDependencySet,
22
+ shouldExclude: (groupId: String, artifactId: String) -> Boolean,
23
+ ) {
24
+ dependenciesNode.children()
25
+ .filterIsInstance<Node>()
26
+ .filter { dependency ->
27
+ val groupId = (dependency["groupId"] as NodeList).text()
28
+ val artifactId = (dependency["artifactId"] as NodeList).text()
29
+ shouldExclude(groupId, artifactId)
30
+ }
31
+ .forEach { dependency ->
32
+ dependenciesNode.remove(dependency)
33
+ }
34
+
35
+ dependencies.forEach { dependencyToAdd ->
36
+ val childTags =
37
+ mutableMapOf(
38
+ "groupId" to dependencyToAdd.groupId,
39
+ "artifactId" to dependencyToAdd.artifactId,
40
+ "scope" to dependencyToAdd.scope,
41
+ "optional" to dependencyToAdd.optional.toString(),
42
+ )
43
+
44
+ if (dependencyToAdd.version?.isNotBlank() == true) {
45
+ childTags["version"] = dependencyToAdd.version
46
+ }
47
+
48
+ dependenciesNode.appendNode("dependency").let { newDepNode ->
49
+ childTags.forEach { (tagName, tagValue) ->
50
+ newDepNode.appendNode(tagName, tagValue)
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The POM's `<dependencies>` element, created if absent — a publication with no dependencies has
58
+ * none, and `first()` throws on it. Extracted so tests exercise this code rather than
59
+ * reimplementing the fallback and passing regardless of what production does.
60
+ */
61
+ internal fun resolveOrCreateDependenciesNode(root: Node): Node =
62
+ (root.get("dependencies") as NodeList).firstOrNull() as? Node
63
+ ?: root.appendNode("dependencies")
64
+
65
+ /**
66
+ * The `module.json` equivalent of [mutatePomDependenciesNode], applied to every variant's
67
+ * `dependencies` list. Same removal-before-append requirement, for the same reason.
68
+ */
69
+ internal fun mutateModuleJsonVariants(
70
+ json: Map<*, *>,
71
+ dependencies: VersionMediatingDependencySet,
72
+ shouldExclude: (groupId: String, artifactId: String) -> Boolean,
73
+ ) {
74
+ @Suppress("UNCHECKED_CAST")
75
+ (json["variants"] as? List<MutableMap<String, Any>>)?.forEach { variant ->
76
+ (variant["dependencies"] as? MutableList<Map<String, Any>>)?.removeAll {
77
+ val group = it["group"] as String
78
+ val module = it["module"] as String
79
+ shouldExclude(group, module)
80
+ }
81
+ }
82
+
83
+ dependencies.forEach { dependencyToAdd ->
84
+ @Suppress("UNCHECKED_CAST")
85
+ (json["variants"] as? List<MutableMap<String, Any>>)?.forEach { variant ->
86
+ // Known gap: every dependency lands in every variant, so runtime-scoped ones are
87
+ // compile-visible to Gradle consumers. A fix must key on provenance, NOT on
88
+ // `scope`, which means different things per path: on the Expo path "runtime" is what
89
+ // Gradle emits for `implementation` (73 of 84 deps across the upstream Expo POMs), on
90
+ // the RNC path it means `runtimeOnly`. Filtering on it would strip react-android and
91
+ // appcompat from Expo consumers. Needs a `fromRuntimeOnlyConfiguration` flag on
92
+ // DependencyInfo, and its own PR.
93
+ (variant["dependencies"] as? MutableList<MutableMap<String, Any>>)?.add(
94
+ mutableMapOf<String, Any>(
95
+ "group" to dependencyToAdd.groupId,
96
+ "module" to dependencyToAdd.artifactId,
97
+ ).apply {
98
+ dependencyToAdd.version?.let { version ->
99
+ put("version", mapOf("requires" to version))
100
+ }
101
+ },
102
+ )
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Injects transitive dependencies into the generated Maven POM and Gradle Module Metadata for
109
+ * every `MavenPublication` on [project], removing any existing entry the exclusion predicate
110
+ * matches. Used identically by the Expo and RNC-CLI paths.
111
+ *
112
+ * Hooks are registered eagerly on construction, since Gradle disallows registering tasks from
113
+ * `afterEvaluate`. The dependency set isn't known until then, so [configure] supplies it later;
114
+ * until it's called, injection is disabled and the hooks are no-ops.
115
+ */
116
+ class PublishingMetadataInjector(private val project: Project) {
117
+ private var dependencies = VersionMediatingDependencySet()
118
+ private var shouldExclude: (groupId: String, artifactId: String) -> Boolean = { _, _ -> false }
119
+ private var enabled = false
120
+
121
+ init {
122
+ registerModuleJsonInjectors()
123
+ registerPomInjector()
124
+ }
125
+
126
+ /** Supplies the dependencies to inject and enables injection on the hooks registered in [init]. */
127
+ fun configure(
128
+ dependencies: VersionMediatingDependencySet,
129
+ shouldExclude: (groupId: String, artifactId: String) -> Boolean,
130
+ ) {
131
+ this.dependencies = dependencies
132
+ this.shouldExclude = shouldExclude
133
+ this.enabled = true
134
+ }
135
+
136
+ private fun registerModuleJsonInjectors() {
137
+ // One task, not one per publication registered from a GenerateModuleMetadata
138
+ // configureEach — that crashes with "DefaultTaskContainer#register(String) ... cannot be
139
+ // executed in the current context". Walking each task's outputFile at execution time also
140
+ // avoids hardcoding a publication name. Name is namespaced: see Constants.
141
+ val removeDependenciesFromModuleFileTask =
142
+ project.tasks.register(Constants.MODULE_METADATA_POST_PROCESS_TASK_NAME)
143
+ removeDependenciesFromModuleFileTask.configure { task ->
144
+ task.onlyIf { enabled }
145
+ task.doLast {
146
+ project.tasks.withType(GenerateModuleMetadata::class.java).forEach { metadataTask ->
147
+ val moduleFile = metadataTask.outputFile.get().asFile
148
+ if (!moduleFile.exists()) return@forEach
149
+
150
+ val json = moduleFile.inputStream().use { JsonSlurper().parse(it) as Map<*, *> }
151
+ mutateModuleJsonVariants(json, dependencies, shouldExclude)
152
+ moduleFile.writer().use {
153
+ it.write(JsonOutput.prettyPrint(JsonOutput.toJson(json)))
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ // Finalizing with an already-registered task is safe here, unlike registering one.
160
+ project.tasks.withType(GenerateModuleMetadata::class.java).configureEach { metadataTask ->
161
+ metadataTask.finalizedBy(removeDependenciesFromModuleFileTask)
162
+ }
163
+ }
164
+
165
+ private fun registerPomInjector() {
166
+ project.pluginManager.withPlugin("maven-publish") {
167
+ project.extensions.configure(PublishingExtension::class.java) { publishing ->
168
+ publishing.publications.withType(MavenPublication::class.java)
169
+ .configureEach { pub ->
170
+ pub.pom.withXml {
171
+ if (!enabled) return@withXml
172
+
173
+ val root = it.asNode()
174
+ val dependenciesNode = resolveOrCreateDependenciesNode(root)
175
+ mutatePomDependenciesNode(dependenciesNode, dependencies, shouldExclude)
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,17 @@
1
+ package com.callstack.react.brownfield.shared
2
+
3
+ /**
4
+ * Drops coordinates matching [hardExcludePredicate] — ones that are embedded in the AAR and so
5
+ * would never resolve from Maven. A discovery path can surface these without knowing it: the RNC
6
+ * discoverer has no Expo awareness, so an embedded module depending on an Expo module coordinate
7
+ * would otherwise be published as an ordinary dependency.
8
+ *
9
+ * [hardExcludePredicate] must never include superseded coordinates — those are meant to be
10
+ * re-injected with the mediated version, and dropping them here removes them entirely.
11
+ */
12
+ internal fun dropHardExcludedDependencies(
13
+ transitiveDeps: VersionMediatingDependencySet,
14
+ hardExcludePredicate: (groupId: String, artifactId: String) -> Boolean,
15
+ ): VersionMediatingDependencySet {
16
+ return transitiveDeps.filter { !hardExcludePredicate(it.groupId, it.artifactId) }
17
+ }
@@ -1,4 +1,4 @@
1
- package com.callstack.react.brownfield.expo.utils
1
+ package com.callstack.react.brownfield.shared
2
2
 
3
3
  import io.github.g00fy2.versioncompare.Version
4
4
 
@@ -53,4 +53,15 @@ open class Extension {
53
53
  * listOf("libdatadog-ndk.so")
54
54
  */
55
55
  var ignoreEmbeddedLibs = listOf<String>()
56
+
57
+ /**
58
+ * Whether to publish embedded native modules' third-party dependencies into the generated POM
59
+ * and Gradle Module Metadata, so consuming apps resolve them instead of declaring them by hand.
60
+ *
61
+ * Experimental; behavior may still change. Applies to non-Expo (RNC CLI) projects only — Expo
62
+ * gets this unconditionally. Set to `false` to hand-declare them instead.
63
+ *
64
+ * Default is `true`.
65
+ */
66
+ var experimentalIncludeTransitiveDependencies = true
56
67
  }
@@ -0,0 +1,171 @@
1
+ package com.callstack.react.brownfield.artifacts
2
+
3
+ import com.callstack.react.brownfield.shared.DependencyInfo
4
+ import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo
5
+ import org.gradle.testfixtures.ProjectBuilder
6
+ import org.junit.jupiter.api.Test
7
+ import kotlin.test.assertEquals
8
+ import kotlin.test.assertFalse
9
+ import kotlin.test.assertTrue
10
+
11
+ class RncTransitiveDependencyDiscovererTest {
12
+ private fun defaultArtifacts(root: org.gradle.api.Project) =
13
+ listOf(
14
+ UnresolvedArtifactInfo(
15
+ moduleGroup = root.name,
16
+ moduleName = "react-native-fake-module",
17
+ moduleVersion = "unspecified",
18
+ file = null,
19
+ isExpoPublishDependency = false,
20
+ ),
21
+ )
22
+
23
+ @Test
24
+ fun `a dynamic consumer-declared version never becomes a mediation candidate`() {
25
+ // Module-side versions are validated by collectPublishableGradleDependencies; the
26
+ // consumer side must be validated too, or `1.+` can win mediation and be published.
27
+ val root = ProjectBuilder.builder().build()
28
+ val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
29
+ val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
30
+
31
+ embeddedModule.configurations.create("implementation")
32
+ embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.0.0")
33
+
34
+ consumer.configurations.create("implementation")
35
+ consumer.dependencies.add("implementation", "androidx.appcompat:appcompat:1.+")
36
+
37
+ val result = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root))
38
+
39
+ val appcompat = result.dependencies.first { it.groupId == "androidx.appcompat" }
40
+ assertEquals("1.0.0", appcompat.version, "the dynamic consumer version must not win mediation")
41
+ assertFalse(result.dependencies.any { it.version == "1.+" }, "'1.+' must never reach the published set")
42
+ }
43
+
44
+ @Test
45
+ fun `a versionless consumer declaration still supersedes, so the coordinate is not duplicated`() {
46
+ // Declared via BOM/platform, so `.version` is null. A version-only lookup returns null
47
+ // here, never marks the coordinate superseded, and leaves two entries for it in the POM.
48
+ val root = ProjectBuilder.builder().build()
49
+ val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
50
+ val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
51
+
52
+ embeddedModule.configurations.create("implementation")
53
+ embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.7.1")
54
+
55
+ consumer.configurations.create("implementation")
56
+ consumer.dependencies.add("implementation", "androidx.appcompat:appcompat")
57
+
58
+ val result = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root))
59
+
60
+ assertTrue(
61
+ result.supersededCoordinates.contains("androidx.appcompat" to "appcompat"),
62
+ "a declared-but-versionless coordinate must still be superseded",
63
+ )
64
+ assertEquals("1.7.1", result.dependencies.first { it.groupId == "androidx.appcompat" }.version)
65
+ }
66
+
67
+ @Test
68
+ fun `discovers a module's direct external dependencies, skipping project deps and non-publishable coordinates`() {
69
+ val root = ProjectBuilder.builder().build()
70
+ val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
71
+ val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
72
+ val siblingProject = ProjectBuilder.builder().withParent(root).withName("some-other-module").build()
73
+
74
+ embeddedModule.configurations.create("implementation")
75
+ embeddedModule.configurations.create("api")
76
+ embeddedModule.configurations.create("runtimeOnly")
77
+
78
+ embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.7.1")
79
+ embeddedModule.dependencies.add("implementation", "com.facebook.react:react-native:+")
80
+ embeddedModule.dependencies.add("runtimeOnly", "androidx.annotation:annotation:1.9.1")
81
+ embeddedModule.dependencies.add(
82
+ "implementation",
83
+ embeddedModule.dependencies.project(mapOf("path" to siblingProject.path)),
84
+ )
85
+
86
+ consumer.configurations.create("api")
87
+
88
+ val result = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root))
89
+ val discovered = result.dependencies
90
+
91
+ assertEquals(2, discovered.size)
92
+ assertTrue(
93
+ discovered.contains(
94
+ DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false),
95
+ ),
96
+ )
97
+ assertTrue(
98
+ discovered.contains(
99
+ DependencyInfo("androidx.annotation", "annotation", "1.9.1", "runtime", false),
100
+ ),
101
+ )
102
+ assertFalse(discovered.contains(DependencyInfo("com.facebook.react", "react-native", "+", "compile", false)))
103
+ assertFalse(discovered.contains(DependencyInfo(root.name, "some-other-module", "unspecified", "compile", false)))
104
+ assertTrue(result.supersededCoordinates.isEmpty())
105
+ }
106
+
107
+ @Test
108
+ fun `publishes a runtimeOnly dependency under Maven runtime scope, not compile`() {
109
+ val root = ProjectBuilder.builder().build()
110
+ val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
111
+ val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
112
+
113
+ embeddedModule.configurations.create("implementation")
114
+ embeddedModule.configurations.create("api")
115
+ embeddedModule.configurations.create("runtimeOnly")
116
+ embeddedModule.dependencies.add("runtimeOnly", "androidx.annotation:annotation:1.9.1")
117
+
118
+ val discovered = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root)).dependencies
119
+
120
+ val annotationDep = discovered.first { it.groupId == "androidx.annotation" }
121
+ assertEquals("runtime", annotationDep.scope)
122
+ }
123
+
124
+ @Test
125
+ fun `a module's higher version requirement wins over, and supersedes, the consumer's own lower declaration`() {
126
+ val root = ProjectBuilder.builder().build()
127
+ val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
128
+ val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
129
+
130
+ embeddedModule.configurations.create("implementation")
131
+ embeddedModule.configurations.create("api")
132
+ embeddedModule.configurations.create("runtimeOnly")
133
+ embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.7.1")
134
+
135
+ consumer.configurations.create("api")
136
+ consumer.dependencies.add("api", "androidx.appcompat:appcompat:1.6.0")
137
+
138
+ val result = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root))
139
+
140
+ val appcompatDep = result.dependencies.first { it.groupId == "androidx.appcompat" }
141
+ assertEquals("1.7.1", appcompatDep.version, "the module's higher requirement must win mediation")
142
+ assertTrue(
143
+ result.supersededCoordinates.contains("androidx.appcompat" to "appcompat"),
144
+ "the consumer's stale lower-version entry must be marked for replacement",
145
+ )
146
+ }
147
+
148
+ @Test
149
+ fun `does not regress or supersede when the consumer's own version already satisfies the module`() {
150
+ val root = ProjectBuilder.builder().build()
151
+ val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
152
+ val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
153
+
154
+ embeddedModule.configurations.create("implementation")
155
+ embeddedModule.configurations.create("api")
156
+ embeddedModule.configurations.create("runtimeOnly")
157
+ embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.6.0")
158
+
159
+ consumer.configurations.create("api")
160
+ consumer.dependencies.add("api", "androidx.appcompat:appcompat:1.7.1")
161
+
162
+ val result = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root))
163
+
164
+ val appcompatDep = result.dependencies.first { it.groupId == "androidx.appcompat" }
165
+ assertEquals("1.7.1", appcompatDep.version, "the consumer's already-higher version must be preserved, not downgraded")
166
+ assertTrue(
167
+ result.supersededCoordinates.contains("androidx.appcompat" to "appcompat"),
168
+ "still marked superseded so the injector re-declares it at the mediated (unchanged) version instead of duplicating it",
169
+ )
170
+ }
171
+ }