@callstack/react-native-brownfield 6.0.1 → 6.1.0

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 (61) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/gradle-plugin/brownfield/build.gradle.kts +4 -0
  3. package/gradle-plugin/brownfield/gradle/libs.versions.toml +2 -0
  4. package/gradle-plugin/brownfield/gradle.properties +1 -1
  5. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +24 -7
  6. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt +4 -37
  7. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/JNILibsProcessor.kt +206 -104
  8. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/ResourceTaskProcessor.kt +201 -4
  9. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantHelper.kt +7 -0
  10. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantTaskProvider.kt +4 -0
  11. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleExclude.kt +31 -0
  12. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/MergeClassesTask.kt +2 -0
  13. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt +28 -3
  14. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt +14 -1
  15. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/TaskOutputs.kt +40 -0
  16. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt +14 -6
  17. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/HardExcludePredicateTest.kt +28 -0
  18. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/ConfigureProcessJniTaskTest.kt +60 -0
  19. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/CopyExplodedResTaskTest.kt +368 -0
  20. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/JniLibsPlanTest.kt +252 -0
  21. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/MergeClassesLocalJarsTest.kt +73 -0
  22. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/MetroResDirWiringTest.kt +39 -0
  23. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/ProcessAndCopyJniLibsTaskTest.kt +136 -0
  24. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/GradleExcludesTest.kt +81 -0
  25. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjectorTest.kt +56 -0
  26. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt +5 -0
  27. package/lib/commonjs/expo-config-plugin/android/utils/constants.js +1 -1
  28. package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
  29. package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
  30. package/lib/commonjs/expo-config-plugin/template/android/build.gradle.kts +1 -1
  31. package/lib/commonjs/index.js +1 -1
  32. package/lib/commonjs/index.js.map +1 -1
  33. package/lib/commonjs/scaffold/index.js +1 -1
  34. package/lib/commonjs/scaffold/index.js.map +1 -1
  35. package/lib/module/expo-config-plugin/android/utils/constants.js +1 -1
  36. package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
  37. package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
  38. package/lib/module/expo-config-plugin/template/android/build.gradle.kts +1 -1
  39. package/lib/module/index.js +1 -1
  40. package/lib/module/index.js.map +1 -1
  41. package/lib/module/scaffold/index.js +1 -1
  42. package/lib/module/scaffold/index.js.map +1 -1
  43. package/lib/typescript/commonjs/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  44. package/lib/typescript/commonjs/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts +6 -0
  46. package/lib/typescript/commonjs/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/src/scaffold/index.d.ts.map +1 -1
  49. package/lib/typescript/module/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  50. package/lib/typescript/module/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
  51. package/lib/typescript/module/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts +6 -0
  52. package/lib/typescript/module/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts.map +1 -1
  53. package/lib/typescript/module/src/index.d.ts.map +1 -1
  54. package/lib/typescript/module/src/scaffold/index.d.ts.map +1 -1
  55. package/package.json +2 -2
  56. package/src/expo-config-plugin/android/utils/constants.ts +1 -1
  57. package/src/expo-config-plugin/android/withAndroidModuleFiles.ts +42 -0
  58. package/src/expo-config-plugin/template/android/build.gradle.kts +1 -1
  59. package/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.ts +7 -0
  60. package/src/index.ts +4 -2
  61. package/src/scaffold/index.ts +1 -0
@@ -1,20 +1,39 @@
1
1
  package com.callstack.react.brownfield.processors
2
2
 
3
+ import com.android.build.api.dsl.LibraryExtension
4
+ import com.android.build.api.variant.LibraryAndroidComponentsExtension
3
5
  import com.android.build.api.variant.LibraryVariant
6
+ import com.android.ide.common.rendering.api.ResourceNamespace
7
+ import com.android.ide.common.resources.MergeConsumer
8
+ import com.android.ide.common.resources.ResourceMerger
9
+ import com.android.ide.common.resources.ResourceMergerItem
10
+ import com.android.ide.common.resources.ResourceSet
4
11
  import com.callstack.react.brownfield.shared.ExplodeAarTask
12
+ import com.callstack.react.brownfield.shared.GradleILogger
5
13
  import com.callstack.react.brownfield.utils.AndroidArchiveLibrary
14
+ import com.callstack.react.brownfield.utils.Extension
6
15
  import com.callstack.react.brownfield.utils.capitalized
16
+ import com.callstack.react.brownfield.utils.directoryGetter
7
17
  import org.gradle.api.DefaultTask
8
18
  import org.gradle.api.Project
9
19
  import org.gradle.api.file.ConfigurableFileCollection
10
20
  import org.gradle.api.file.DirectoryProperty
21
+ import org.gradle.api.provider.ListProperty
11
22
  import org.gradle.api.tasks.CacheableTask
23
+ import org.gradle.api.tasks.Input
12
24
  import org.gradle.api.tasks.InputFiles
13
25
  import org.gradle.api.tasks.OutputDirectory
14
26
  import org.gradle.api.tasks.PathSensitive
15
27
  import org.gradle.api.tasks.PathSensitivity
16
28
  import org.gradle.api.tasks.TaskAction
17
29
  import org.gradle.api.tasks.TaskProvider
30
+ import org.w3c.dom.Element
31
+ import org.w3c.dom.Node
32
+ import java.io.File
33
+ import javax.xml.parsers.DocumentBuilderFactory
34
+ import javax.xml.transform.TransformerFactory
35
+ import javax.xml.transform.dom.DOMSource
36
+ import javax.xml.transform.stream.StreamResult
18
37
 
19
38
  @CacheableTask
20
39
  abstract class CopyExplodedResTask : DefaultTask() {
@@ -22,23 +41,183 @@ abstract class CopyExplodedResTask : DefaultTask() {
22
41
  @get:PathSensitive(PathSensitivity.RELATIVE)
23
42
  abstract val inputResDirs: ConfigurableFileCollection
24
43
 
44
+ /** The module's own resources, which win over every library's, as an app's do over its dependencies. */
45
+ @get:InputFiles
46
+ @get:PathSensitive(PathSensitivity.RELATIVE)
47
+ abstract val moduleResDirs: ConfigurableFileCollection
48
+
49
+ /** `type/name` of the module's `resValue`s, which also win over every library's. */
50
+ @get:Input
51
+ abstract val moduleResValues: ListProperty<String>
52
+
53
+ /** The first library wins on a duplicate, so the order is part of the input. */
54
+ @get:Input
55
+ abstract val libraryOrder: ListProperty<String>
56
+
25
57
  @get:OutputDirectory
26
58
  abstract val outputDirectory: DirectoryProperty
27
59
 
60
+ /**
61
+ * Every exploded AAR keeps its values in `res/values/values.xml`, so libraries can't simply be
62
+ * copied over each other (#490), and keeping a resource twice in one dir fails the build (#125).
63
+ * AGP's own merger picks the winners, with the precedence it uses in an app: the module's own
64
+ * resources, then the first library.
65
+ */
28
66
  @TaskAction
29
67
  fun run() {
30
68
  val out = outputDirectory.get().asFile
31
69
  out.deleteRecursively()
32
70
  out.mkdirs()
33
- inputResDirs.forEach { resDir ->
34
- if (resDir.exists()) {
35
- resDir.copyRecursively(out, overwrite = true)
36
- }
71
+ val iLogger = GradleILogger(logger)
72
+ val moduleDirs = moduleResDirs.filter { it.exists() }.toList()
73
+ // 0 keeps every versioned resource. AGP applies the module's minSdk when it merges this dir.
74
+ val merger = ResourceMerger(0)
75
+ // Lowest priority first.
76
+ inputResDirs.filter { it.exists() }.reversed().forEachIndexed { index, resDir ->
77
+ merger.addDataSet(resourceSet("library$index", resDir, isLibrary = true, iLogger))
78
+ }
79
+ // One set per dir: two module dirs may override each other, like main and a flavor.
80
+ moduleDirs.forEachIndexed { index, resDir ->
81
+ merger.addDataSet(resourceSet("module$index", resDir, isLibrary = false, iLogger))
82
+ }
83
+ val writer = LibraryResWriter(moduleDirs, moduleResValues.get().toSet())
84
+ merger.mergeData(writer, false)
85
+ writer.messages.forEach { logger.warn("Brownfield: $it") }
86
+ writer.writeTo(out)
87
+ }
88
+
89
+ private fun resourceSet(
90
+ name: String,
91
+ resDir: File,
92
+ isLibrary: Boolean,
93
+ iLogger: GradleILogger,
94
+ ) = ResourceSet(name, ResourceNamespace.RES_AUTO, if (isLibrary) name else null, false, null).apply {
95
+ addSource(resDir)
96
+ loadFromFiles(iLogger)
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Writes the merged library resources that the module doesn't define itself. AGP ranks this generated
102
+ * dir above the module's own res, so a library copy of a module resource would win there.
103
+ */
104
+ private class LibraryResWriter(
105
+ private val moduleDirs: List<File>,
106
+ private val moduleResValues: Set<String>,
107
+ ) : MergeConsumer<ResourceMergerItem> {
108
+ private val files = mutableListOf<Pair<String, File>>()
109
+ private val values = sortedMapOf<String, MutableList<Node>>()
110
+ private val moduleAttrs = mutableMapOf<String, MutableSet<String>>()
111
+ private val styleableAttrWinners = mutableMapOf<String, MutableList<Node>>()
112
+ val messages = mutableListOf<String>()
113
+
114
+ override fun start(factory: DocumentBuilderFactory) = Unit
115
+
116
+ override fun end() = Unit
117
+
118
+ override fun removeItem(
119
+ removedItem: ResourceMergerItem,
120
+ replacedBy: ResourceMergerItem?,
121
+ ) = Unit
122
+
123
+ override fun ignoreItemInMerge(item: ResourceMergerItem) = false
124
+
125
+ override fun addItem(item: ResourceMergerItem) {
126
+ val folder = valuesFolder(item.qualifiers)
127
+ val source = item.sourceFile?.file
128
+ if (source != null && moduleDirs.any { source.startsWith(it) }) {
129
+ if (item.type.getName() == "attr") moduleAttrs.getOrPut(folder) { mutableSetOf() }.add(item.name)
130
+ return
37
131
  }
132
+ if (item.qualifiers.isEmpty() && "${item.type.getName()}/${item.name}" in moduleResValues) return
133
+ // An attr declared inside a styleable. The merger picked this one, so it becomes the definition.
134
+ if (item.ignoredFromDiskMerge) {
135
+ styleableAttrWinners.getOrPut(folder) { mutableListOf() }.add(item.value)
136
+ return
137
+ }
138
+ if (item.isFileBased && source != null) {
139
+ files.add("${source.parentFile.name}/${source.name}" to source)
140
+ } else {
141
+ values.getOrPut(folder) { mutableListOf() }.add(item.value)
142
+ }
143
+ }
144
+
145
+ fun writeTo(out: File) {
146
+ files.forEach { (path, file) -> file.copyTo(File(out, path)) }
147
+ val transformer = TransformerFactory.newInstance().newTransformer()
148
+ (values.keys + styleableAttrWinners.keys).toSortedSet().forEach { folder ->
149
+ val nodes = values[folder].orEmpty()
150
+ val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()
151
+ val root = document.createElement("resources")
152
+ document.appendChild(root)
153
+ nodes.forEach { root.appendChild(document.importNode(it, true)) }
154
+ styleableAttrWinners[folder].orEmpty().forEach { root.appendChild(document.importNode(it, true)) }
155
+ keepOneAttrDefinition(root, folder, moduleAttrs[folder].orEmpty())
156
+ val target = File(out, "$folder/$folder.xml")
157
+ target.parentFile.mkdirs()
158
+ transformer.transform(DOMSource(document), StreamResult(target))
159
+ }
160
+ }
161
+
162
+ /**
163
+ * AGP also registers an attr with a `format` (or enum/flag values) inside a styleable as its own
164
+ * `attr/<name>`, so one dir may define it only once. The merger's winner is written as a top-level
165
+ * attr, and every styleable copy, or one of an attr the module defines, becomes a plain reference.
166
+ */
167
+ private fun keepOneAttrDefinition(
168
+ root: Element,
169
+ folder: String,
170
+ moduleAttrs: Set<String>,
171
+ ) {
172
+ val defined = root.childElements().filter { it.tagName == "attr" }.associateBy { it.getAttribute("name") }
173
+ root.childElements()
174
+ .filter { it.tagName == "declare-styleable" }
175
+ .flatMap { it.childElements() }
176
+ .filter { it.tagName == "attr" && !it.getAttribute("name").startsWith("android:") }
177
+ .filter { definition(it).isNotEmpty() }
178
+ .forEach { attr ->
179
+ val name = attr.getAttribute("name")
180
+ val winner = defined[name]
181
+ if (winner == null && name !in moduleAttrs) return@forEach
182
+ if (winner != null && definition(winner) != definition(attr)) {
183
+ messages.add("$folder: attr/$name is defined differently by two libraries, keeping the first")
184
+ }
185
+ attr.removeAttribute("format")
186
+ attr.childElements().forEach { attr.removeChild(it) }
187
+ }
188
+ }
189
+
190
+ /** Format and enum/flag values of an attr; empty for a plain reference. */
191
+ private fun definition(attr: Element): String =
192
+ attr.getAttribute("format") +
193
+ attr.childElements().joinToString { "${it.tagName}:${it.getAttribute("name")}=${it.getAttribute("value")}" }
194
+
195
+ private fun valuesFolder(qualifiers: String) = if (qualifiers.isEmpty()) "values" else "values-$qualifiers"
196
+
197
+ private fun Element.childElements(): List<Element> {
198
+ val children = childNodes
199
+ return (0 until children.length).map { children.item(it) }.filterIsInstance<Element>()
38
200
  }
39
201
  }
40
202
 
203
+ /**
204
+ * Some libraries commit the drawables Metro generates for their JS assets to their own `res`, and
205
+ * the app's bundle writes the same files to `generated/res/react`. Both end up as generated res of
206
+ * this module, where AGP fails on the duplicates, so Metro's copy is treated as the module's own and
207
+ * wins. Reading the bundle task's output also orders the copy after it, and keys the cache on it.
208
+ */
209
+ internal fun wireMetroResDir(
210
+ task: CopyExplodedResTask,
211
+ appProject: Project,
212
+ bundleTaskName: String,
213
+ ) {
214
+ val getter = directoryGetter(appProject, bundleTaskName, "getResourcesDir")
215
+ task.moduleResDirs.from(appProject.tasks.named(bundleTaskName).flatMap { getter.invoke(it) as DirectoryProperty })
216
+ }
217
+
41
218
  object ResourceTaskProcessor {
219
+ private const val RES_VALUES_OFF_BY_DEFAULT_SINCE_AGP = 9
220
+
42
221
  fun process(
43
222
  project: Project,
44
223
  variant: LibraryVariant,
@@ -47,12 +226,22 @@ object ResourceTaskProcessor {
47
226
  ) {
48
227
  val capitalized = variant.name.capitalized()
49
228
  val resDirs = aarLibraries.map { it.getResDir() }
229
+ val buildDir = project.layout.buildDirectory.get().asFile
230
+ val appProject = project.rootProject.project(project.extensions.getByType(Extension::class.java).appProjectName)
50
231
  val copyResTask =
51
232
  project.tasks.register(
52
233
  "copyExplodedResFor$capitalized",
53
234
  CopyExplodedResTask::class.java,
54
235
  ) { task ->
55
236
  task.inputResDirs.setFrom(resDirs)
237
+ if (resValuesEnabled(project)) {
238
+ task.moduleResValues.set(variant.resValues.map { values -> values.keys.map { "${it.type}/${it.name}" } })
239
+ } else {
240
+ task.moduleResValues.set(emptyList())
241
+ }
242
+ variant.sources.res?.static?.let { static -> task.moduleResDirs.from(static.map { layers -> layers.flatten() }) }
243
+ task.libraryOrder.set(aarLibraries.map { it.getExplodedAarRootDir().relativeTo(buildDir).invariantSeparatorsPath })
244
+ wireMetroResDir(task, appProject, VariantHelper.getMetroBundleTaskName(variant))
56
245
  task.dependsOn(explodeTask)
57
246
  }
58
247
 
@@ -61,4 +250,12 @@ object ResourceTaskProcessor {
61
250
  CopyExplodedResTask::outputDirectory,
62
251
  )
63
252
  }
253
+
254
+ /** AGP warns on every read of `variant.resValues` while the feature is off, the default from AGP 9 on. */
255
+ private fun resValuesEnabled(project: Project): Boolean {
256
+ val explicit = project.extensions.getByType(LibraryExtension::class.java).buildFeatures.resValues
257
+ val default = project.providers.gradleProperty("android.defaults.buildfeatures.resvalues").orNull?.toBooleanStrictOrNull()
258
+ val agpMajor = project.extensions.getByType(LibraryAndroidComponentsExtension::class.java).pluginVersion.major
259
+ return explicit ?: default ?: (agpMajor < RES_VALUES_OFF_BY_DEFAULT_SINCE_AGP)
260
+ }
64
261
  }
@@ -1,5 +1,6 @@
1
1
  package com.callstack.react.brownfield.processors
2
2
 
3
+ import com.android.build.api.variant.LibraryVariant
3
4
  import com.callstack.react.brownfield.utils.capitalized
4
5
  import org.gradle.api.Project
5
6
  import org.gradle.api.Task
@@ -39,6 +40,12 @@ object VariantHelper {
39
40
  }
40
41
  }
41
42
 
43
+ /** RNGP's `createBundle<Variant>JsAndAssets` task in the app project for [variant]. */
44
+ fun getMetroBundleTaskName(variant: LibraryVariant): String {
45
+ val bundledAssetsVariantName = getBundledAssetsVariantName(variant.name, variant.buildType, variant.debuggable)
46
+ return "createBundle${bundledAssetsVariantName.capitalized()}JsAndAssets"
47
+ }
48
+
42
49
  fun getExpoUpdatesResourcesTaskName(variantName: String): String {
43
50
  return "create${variantName.capitalized()}UpdatesResources"
44
51
  }
@@ -66,6 +66,10 @@ class VariantTaskProvider(val project: Project) {
66
66
  it.dependsOn(VariantHelper.getJavaCompileTask(project, capitalizedVariantName))
67
67
  it.variantName.set(variantName)
68
68
  it.inputClassesJars.from(aarLibraries.map { aarLibrary -> aarLibrary.getClassesJarFile() })
69
+ // libs/*.jar of each library (#489)
70
+ it.inputClassesJars.from(
71
+ aarLibraries.map { aarLibrary -> project.fileTree(aarLibrary.getLibsDir()) { tree -> tree.include("*.jar") } },
72
+ )
69
73
  it.outputDir.set(DirectoryManager.getMergeClassDirectory(variantName))
70
74
  }
71
75
  }
@@ -0,0 +1,31 @@
1
+ package com.callstack.react.brownfield.shared
2
+
3
+ import org.gradle.api.Project
4
+
5
+ /**
6
+ * The variant-independent configurations that feed what the host app gets. A variant's own
7
+ * (`debugRuntimeClasspath`) would leak into every variant, since the published list is shared, and a
8
+ * tool's (lint, detekt) may exclude things the app still needs.
9
+ */
10
+ private val PUBLISHED_CONFIG_NAMES = TRANSITIVE_DEPENDENCY_CONFIG_NAMES + "embed"
11
+
12
+ /**
13
+ * The configuration-level excludes that keep a dependency out of the brownfield module's build.
14
+ * The plugin reads what embedded libraries declare, not what Gradle resolves, so without these an
15
+ * excluded dependency would still be published and the host app would resolve it. `:app`'s rules
16
+ * are not read: they describe the standalone RN app, which may drop things the host needs.
17
+ */
18
+ internal fun gradleExcludes(project: Project): List<GradleExclude> =
19
+ project.configurations
20
+ .filter { it.name in PUBLISHED_CONFIG_NAMES }
21
+ .flatMap { it.excludeRules }
22
+ .map { GradleExclude(it.group, it.module) }
23
+ .distinct()
24
+
25
+ /** One exclude rule. Gradle types both parts as non-null, but a rule without one leaves it null. */
26
+ internal data class GradleExclude(val group: String?, val module: String?) {
27
+ fun matches(
28
+ groupId: String,
29
+ artifactId: String,
30
+ ) = (group == null || group == groupId) && (module == null || module == artifactId)
31
+ }
@@ -54,6 +54,8 @@ abstract class MergeClassesTask : DefaultTask() {
54
54
  copyTask.from(mergedOutputDir)
55
55
  copyTask.into(javacDir)
56
56
  copyTask.exclude("META-INF/")
57
+ // Root module descriptor of a library's local jar, not a class of the AAR.
58
+ copyTask.exclude("module-info.class")
57
59
  }
58
60
 
59
61
  project.copy { copyTask ->
@@ -19,6 +19,7 @@ import org.gradle.api.publish.tasks.GenerateModuleMetadata
19
19
  internal fun mutatePomDependenciesNode(
20
20
  dependenciesNode: Node,
21
21
  dependencies: VersionMediatingDependencySet,
22
+ excludes: List<GradleExclude> = emptyList(),
22
23
  shouldExclude: (groupId: String, artifactId: String) -> Boolean,
23
24
  ) {
24
25
  dependenciesNode.children()
@@ -49,6 +50,21 @@ internal fun mutatePomDependenciesNode(
49
50
  childTags.forEach { (tagName, tagValue) ->
50
51
  newDepNode.appendNode(tagName, tagValue)
51
52
  }
53
+ appendExclusions(newDepNode, excludes)
54
+ }
55
+ }
56
+ }
57
+
58
+ private fun appendExclusions(
59
+ dependencyNode: Node,
60
+ excludes: List<GradleExclude>,
61
+ ) {
62
+ if (excludes.isEmpty()) return
63
+ val exclusionsNode = dependencyNode.appendNode("exclusions")
64
+ excludes.forEach { exclude ->
65
+ exclusionsNode.appendNode("exclusion").apply {
66
+ appendNode("groupId", exclude.group ?: "*")
67
+ appendNode("artifactId", exclude.module ?: "*")
52
68
  }
53
69
  }
54
70
  }
@@ -69,6 +85,7 @@ internal fun resolveOrCreateDependenciesNode(root: Node): Node =
69
85
  internal fun mutateModuleJsonVariants(
70
86
  json: Map<*, *>,
71
87
  dependencies: VersionMediatingDependencySet,
88
+ excludes: List<GradleExclude> = emptyList(),
72
89
  shouldExclude: (groupId: String, artifactId: String) -> Boolean,
73
90
  ) {
74
91
  @Suppress("UNCHECKED_CAST")
@@ -98,6 +115,9 @@ internal fun mutateModuleJsonVariants(
98
115
  dependencyToAdd.version?.let { version ->
99
116
  put("version", mapOf("requires" to version))
100
117
  }
118
+ if (excludes.isNotEmpty()) {
119
+ put("excludes", excludes.map { mapOf("group" to (it.group ?: "*"), "module" to (it.module ?: "*")) })
120
+ }
101
121
  },
102
122
  )
103
123
  }
@@ -116,6 +136,7 @@ internal fun mutateModuleJsonVariants(
116
136
  class PublishingMetadataInjector(private val project: Project) {
117
137
  private var dependencies = VersionMediatingDependencySet()
118
138
  private var shouldExclude: (groupId: String, artifactId: String) -> Boolean = { _, _ -> false }
139
+ private var excludes: List<GradleExclude> = emptyList()
119
140
  private var enabled = false
120
141
 
121
142
  init {
@@ -124,12 +145,16 @@ class PublishingMetadataInjector(private val project: Project) {
124
145
  }
125
146
 
126
147
  /** Supplies the dependencies to inject and enables injection on the hooks registered in [init]. */
127
- fun configure(
148
+ internal fun configure(
128
149
  dependencies: VersionMediatingDependencySet,
129
150
  shouldExclude: (groupId: String, artifactId: String) -> Boolean,
151
+ excludes: List<GradleExclude>,
130
152
  ) {
131
153
  this.dependencies = dependencies
132
154
  this.shouldExclude = shouldExclude
155
+ // Gradle writes the module's excludes onto the dependencies it declares; these are injected
156
+ // later, so they need them too, or an excluded library could still arrive through one.
157
+ this.excludes = excludes
133
158
  this.enabled = true
134
159
  }
135
160
 
@@ -148,7 +173,7 @@ class PublishingMetadataInjector(private val project: Project) {
148
173
  if (!moduleFile.exists()) return@forEach
149
174
 
150
175
  val json = moduleFile.inputStream().use { JsonSlurper().parse(it) as Map<*, *> }
151
- mutateModuleJsonVariants(json, dependencies, shouldExclude)
176
+ mutateModuleJsonVariants(json, dependencies, excludes, shouldExclude)
152
177
  moduleFile.writer().use {
153
178
  it.write(JsonOutput.prettyPrint(JsonOutput.toJson(json)))
154
179
  }
@@ -172,7 +197,7 @@ class PublishingMetadataInjector(private val project: Project) {
172
197
 
173
198
  val root = it.asNode()
174
199
  val dependenciesNode = resolveOrCreateDependenciesNode(root)
175
- mutatePomDependenciesNode(dependenciesNode, dependencies, shouldExclude)
200
+ mutatePomDependenciesNode(dependenciesNode, dependencies, excludes, shouldExclude)
176
201
  }
177
202
  }
178
203
  }
@@ -14,7 +14,9 @@ open class Extension {
14
14
  var appProjectName = "app"
15
15
 
16
16
  /**
17
- * Whether to use stripped .so files.
17
+ * Whether to take the .so files from the app module's stripped or unstripped native output. It
18
+ * does not change which .so files are embedded. AGP still strips the AAR's native libs when it
19
+ * packages the library.
18
20
  *
19
21
  * Default is `true`.
20
22
  */
@@ -54,6 +56,17 @@ open class Extension {
54
56
  */
55
57
  var ignoreEmbeddedLibs = listOf<String>()
56
58
 
59
+ /**
60
+ * Exact .so file names, taken from the app module's native output, to embed in the AAR on top
61
+ * of the app's own libs and the embedded libraries' libs. Use it for a .so the host app cannot
62
+ * get any other way: one the app module builds or ships itself, or one from a dependency the
63
+ * published AAR does not declare.
64
+ *
65
+ * Provide in this format:
66
+ * listOf("libmmkv.so")
67
+ */
68
+ var dynamicLibs = listOf<String>()
69
+
57
70
  /**
58
71
  * Whether to publish embedded native modules' third-party dependencies into the generated POM
59
72
  * and Gradle Module Metadata, so consuming apps resolve them instead of declaring them by hand.
@@ -0,0 +1,40 @@
1
+ package com.callstack.react.brownfield.utils
2
+
3
+ import org.gradle.api.GradleException
4
+ import org.gradle.api.Project
5
+ import org.gradle.api.file.DirectoryProperty
6
+ import java.lang.reflect.Method
7
+
8
+ /**
9
+ * Finds `taskName`'s `getterName()` that returns a `DirectoryProperty`. Resolving the task by name
10
+ * (rather than importing its class) keeps this plugin from depending on RNGP's/expo-updates'
11
+ * internal task types. If the task, or the property, isn't there, this fails the build with a
12
+ * clear message instead of silently producing an empty or stale directory.
13
+ */
14
+ internal fun directoryGetter(
15
+ appProject: Project,
16
+ taskName: String,
17
+ getterName: String,
18
+ ): Method {
19
+ val task =
20
+ appProject.tasks.findByName(taskName)
21
+ ?: throw GradleException(
22
+ "brownfield: expected task '$taskName' was not found in project " +
23
+ "'${appProject.path}'. Check that the plugin producing it is applied there.",
24
+ )
25
+
26
+ val getter =
27
+ runCatching { task.javaClass.getMethod(getterName) }.getOrElse {
28
+ throw GradleException(
29
+ "brownfield: task '$taskName' (${task.javaClass.name}) does not expose " +
30
+ "'$getterName()'. The plugin that registers it most likely changed its " +
31
+ "API; the brownfield Gradle plugin needs updating to match.",
32
+ it,
33
+ )
34
+ }
35
+
36
+ check(DirectoryProperty::class.java.isAssignableFrom(getter.returnType)) {
37
+ "brownfield: '$taskName.$getterName()' no longer returns a DirectoryProperty."
38
+ }
39
+ return getter
40
+ }
@@ -7,6 +7,7 @@ import org.junit.jupiter.api.io.TempDir
7
7
  import org.junit.jupiter.params.ParameterizedTest
8
8
  import org.junit.jupiter.params.provider.MethodSource
9
9
  import java.io.File
10
+ import kotlin.test.assertFalse
10
11
  import kotlin.test.assertTrue
11
12
 
12
13
  /**
@@ -39,12 +40,19 @@ class AgpCompatibilityMatrixTest {
39
40
  // `--dry-run` still runs configuration in full — variant callbacks, source set wiring, task
40
41
  // graph construction — which is where every AGP API the plugin touches is exercised. It just
41
42
  // skips the task actions, which would need a real RN app to produce anything meaningful.
42
- GradleRunner.create()
43
- .withProjectDir(projectDir)
44
- .withPluginClasspath(pluginClasspathFor(agpVersion))
45
- .withArguments("assembleDebug", "--dry-run", "--stacktrace")
46
- .forwardOutput()
47
- .build()
43
+ val result =
44
+ GradleRunner.create()
45
+ .withProjectDir(projectDir)
46
+ .withPluginClasspath(pluginClasspathFor(agpVersion))
47
+ .withArguments("assembleDebug", "--dry-run", "--stacktrace")
48
+ .forwardOutput()
49
+ .build()
50
+
51
+ // AGP 9 turns resValues off by default and warns on every read of variant.resValues.
52
+ assertFalse(
53
+ result.output.contains("Accessing value resValues"),
54
+ "the plugin reads variant.resValues although the feature is disabled",
55
+ )
48
56
  }
49
57
 
50
58
  @Test
@@ -0,0 +1,28 @@
1
+ package com.callstack.react.brownfield.plugin
2
+
3
+ import com.callstack.react.brownfield.shared.gradleExcludes
4
+ import org.gradle.testfixtures.ProjectBuilder
5
+ import org.junit.jupiter.api.Test
6
+ import org.junit.jupiter.api.io.TempDir
7
+ import java.io.File
8
+ import kotlin.test.assertFalse
9
+ import kotlin.test.assertTrue
10
+
11
+ class HardExcludePredicateTest {
12
+ @TempDir
13
+ lateinit var tempDir: File
14
+
15
+ @Test
16
+ fun `never publishes what the brownfield module excludes, or its own subprojects`() {
17
+ val root = ProjectBuilder.builder().withName("MyApp").withProjectDir(tempDir).build()
18
+ val lib = ProjectBuilder.builder().withName("brownfieldlib").withParent(root).build()
19
+ lib.configurations.create("implementation")
20
+ lib.configurations.all { it.exclude(mapOf("group" to "com.caverock", "module" to "androidsvg")) }
21
+
22
+ val excluded = hardExcludePredicate(lib, null, emptyList(), emptyList(), gradleExcludes(lib))
23
+
24
+ assertTrue(excluded("com.caverock", "androidsvg"))
25
+ assertTrue(excluded("MyApp", "react-native-screens"))
26
+ assertFalse(excluded("androidx.core", "core-ktx"))
27
+ }
28
+ }
@@ -0,0 +1,60 @@
1
+ package com.callstack.react.brownfield.processors
2
+
3
+ import com.callstack.react.brownfield.utils.Extension
4
+ import org.gradle.testfixtures.ProjectBuilder
5
+ import org.junit.jupiter.api.Test
6
+ import org.junit.jupiter.api.io.TempDir
7
+ import java.io.File
8
+ import kotlin.test.assertEquals
9
+ import kotlin.test.assertTrue
10
+
11
+ class ConfigureProcessJniTaskTest {
12
+ @TempDir
13
+ lateinit var tempDir: File
14
+
15
+ private fun configure(useStripped: Boolean): Pair<ProcessAndCopyJniLibsTask, File> {
16
+ val root = ProjectBuilder.builder().withProjectDir(tempDir).build()
17
+ val app = ProjectBuilder.builder().withName("app").withParent(root).build()
18
+ val lib = ProjectBuilder.builder().withName("BrownfieldLib").withParent(root).build()
19
+ val ext =
20
+ Extension().apply {
21
+ useStrippedSoFiles = useStripped
22
+ ignoreEmbeddedLibs = listOf("libdatadog")
23
+ dynamicLibs = listOf("libmmkv.so")
24
+ }
25
+ val explode = lib.tasks.register("explodeDevReleaseAar")
26
+ val task = lib.tasks.register("processJni", ProcessAndCopyJniLibsTask::class.java).get()
27
+
28
+ configureProcessJniTask(task, lib, app, ext, "devRelease", listOf(File(tempDir, "jni")), explode)
29
+ return task to app.layout.buildDirectory.get().asFile
30
+ }
31
+
32
+ @Test
33
+ fun `wires the stripped source, settings and report path`() {
34
+ val (task, appBuild) = configure(useStripped = true)
35
+
36
+ assertEquals(
37
+ File(appBuild, "intermediates/stripped_native_libs/devRelease/stripDevReleaseDebugSymbols/out/lib"),
38
+ task.appNativeLibsDir.get().asFile,
39
+ )
40
+ assertTrue(":app:stripDevReleaseDebugSymbols" in task.dependsOn, task.dependsOn.toString())
41
+ assertTrue(":BrownfieldLib:generateCodegenSchemaFromJavaScript" in task.dependsOn, task.dependsOn.toString())
42
+ assertEquals(listOf("libdatadog"), task.extraIgnoredLibs.get())
43
+ assertEquals(listOf("libmmkv.so"), task.dynamicLibs.get())
44
+ assertTrue(
45
+ task.skippedReportFile.get().asFile.invariantSeparatorsPath
46
+ .endsWith("BrownfieldLib/build/reports/brownfield/devRelease/skipped-so.txt"),
47
+ )
48
+ }
49
+
50
+ @Test
51
+ fun `wires the unstripped source`() {
52
+ val (task, appBuild) = configure(useStripped = false)
53
+
54
+ assertEquals(
55
+ File(appBuild, "intermediates/merged_native_libs/devRelease/mergeDevReleaseNativeLibs/out/lib"),
56
+ task.appNativeLibsDir.get().asFile,
57
+ )
58
+ assertTrue(":app:mergeDevReleaseNativeLibs" in task.dependsOn, task.dependsOn.toString())
59
+ }
60
+ }