@callstack/react-native-brownfield 6.0.1 → 6.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 (66) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt +12 -12
  3. package/android/src/main/java/com/callstack/reactnativebrownfield/RetryableOnce.kt +27 -0
  4. package/android/src/test/java/com/callstack/reactnativebrownfield/RetryableOnceTest.kt +37 -0
  5. package/gradle-plugin/brownfield/build.gradle.kts +4 -0
  6. package/gradle-plugin/brownfield/gradle/libs.versions.toml +2 -0
  7. package/gradle-plugin/brownfield/gradle.properties +1 -1
  8. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +24 -7
  9. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt +4 -37
  10. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/JNILibsProcessor.kt +206 -104
  11. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/ResourceTaskProcessor.kt +201 -4
  12. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantHelper.kt +7 -0
  13. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantTaskProvider.kt +4 -0
  14. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleExclude.kt +31 -0
  15. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/MergeClassesTask.kt +2 -0
  16. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt +28 -3
  17. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt +14 -1
  18. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/TaskOutputs.kt +40 -0
  19. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt +14 -6
  20. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/HardExcludePredicateTest.kt +28 -0
  21. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/ConfigureProcessJniTaskTest.kt +60 -0
  22. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/CopyExplodedResTaskTest.kt +368 -0
  23. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/JniLibsPlanTest.kt +252 -0
  24. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/MergeClassesLocalJarsTest.kt +73 -0
  25. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/MetroResDirWiringTest.kt +39 -0
  26. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/ProcessAndCopyJniLibsTaskTest.kt +136 -0
  27. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/GradleExcludesTest.kt +81 -0
  28. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjectorTest.kt +56 -0
  29. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt +5 -0
  30. package/ios/ReactNativeBrownfield.swift +3 -1
  31. package/ios/ReactNativeBrownfieldModule.mm +35 -7
  32. package/lib/commonjs/expo-config-plugin/android/utils/constants.js +1 -1
  33. package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
  34. package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
  35. package/lib/commonjs/expo-config-plugin/template/android/build.gradle.kts +1 -1
  36. package/lib/commonjs/index.js +1 -1
  37. package/lib/commonjs/index.js.map +1 -1
  38. package/lib/commonjs/scaffold/index.js +1 -1
  39. package/lib/commonjs/scaffold/index.js.map +1 -1
  40. package/lib/module/expo-config-plugin/android/utils/constants.js +1 -1
  41. package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
  42. package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
  43. package/lib/module/expo-config-plugin/template/android/build.gradle.kts +1 -1
  44. package/lib/module/index.js +1 -1
  45. package/lib/module/index.js.map +1 -1
  46. package/lib/module/scaffold/index.js +1 -1
  47. package/lib/module/scaffold/index.js.map +1 -1
  48. package/lib/typescript/commonjs/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  49. package/lib/typescript/commonjs/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts +6 -0
  51. package/lib/typescript/commonjs/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/src/scaffold/index.d.ts.map +1 -1
  54. package/lib/typescript/module/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  55. package/lib/typescript/module/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
  56. package/lib/typescript/module/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts +6 -0
  57. package/lib/typescript/module/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts.map +1 -1
  58. package/lib/typescript/module/src/index.d.ts.map +1 -1
  59. package/lib/typescript/module/src/scaffold/index.d.ts.map +1 -1
  60. package/package.json +2 -2
  61. package/src/expo-config-plugin/android/utils/constants.ts +1 -1
  62. package/src/expo-config-plugin/android/withAndroidModuleFiles.ts +42 -0
  63. package/src/expo-config-plugin/template/android/build.gradle.kts +1 -1
  64. package/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.ts +7 -0
  65. package/src/index.ts +4 -2
  66. package/src/scaffold/index.ts +1 -0
@@ -1,28 +1,27 @@
1
1
  package com.callstack.react.brownfield.processors
2
2
 
3
3
  import com.android.build.api.variant.LibraryVariant
4
- import com.callstack.react.brownfield.shared.Logging
5
4
  import com.callstack.react.brownfield.utils.AndroidArchiveLibrary
6
5
  import com.callstack.react.brownfield.utils.Extension
7
6
  import com.callstack.react.brownfield.utils.capitalized
8
7
  import org.gradle.api.DefaultTask
9
8
  import org.gradle.api.Project
9
+ import org.gradle.api.Task
10
10
  import org.gradle.api.file.ConfigurableFileCollection
11
11
  import org.gradle.api.file.DirectoryProperty
12
+ import org.gradle.api.file.RegularFileProperty
12
13
  import org.gradle.api.provider.ListProperty
13
- import org.gradle.api.provider.Property
14
14
  import org.gradle.api.tasks.CacheableTask
15
15
  import org.gradle.api.tasks.Input
16
- import org.gradle.api.tasks.InputDirectory
17
16
  import org.gradle.api.tasks.InputFiles
18
17
  import org.gradle.api.tasks.Optional
19
18
  import org.gradle.api.tasks.OutputDirectory
19
+ import org.gradle.api.tasks.OutputFile
20
20
  import org.gradle.api.tasks.PathSensitive
21
21
  import org.gradle.api.tasks.PathSensitivity
22
22
  import org.gradle.api.tasks.TaskAction
23
+ import org.gradle.api.tasks.TaskProvider
23
24
  import java.io.File
24
- import kotlin.collections.component1
25
- import kotlin.collections.component2
26
25
 
27
26
  /**
28
27
  * Below contains a list of dynamic libs which we should not embed
@@ -37,126 +36,246 @@ val IGNORE_EMBEDDED_LIBS =
37
36
  "libreactnative.so",
38
37
  )
39
38
 
39
+ private val ABIS = listOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
40
+
41
+ private const val APP_MODULES_LIB = "libappmodules.so"
42
+
43
+ internal enum class SkipReason { IGNORED, NOT_EMBEDDED }
44
+
45
+ /**
46
+ * @property copies source file for each `<abi>/<name>` that goes into the AAR
47
+ * @property unmatchedDynamicLibs dynamicLibs entries that match no file in the app module's output
48
+ * @property skipped every lib name that no ABI copies, with why
49
+ * @property ignoredDynamicLibs dynamicLibs entries dropped because an ignore entry matches them
50
+ * @property excludedAbis ABIs the embedded AARs ship but the app module did not build
51
+ */
52
+ internal data class JniLibsPlan(
53
+ val copies: Map<String, File>,
54
+ val unmatchedDynamicLibs: Set<String>,
55
+ val skipped: Map<String, SkipReason>,
56
+ val ignoredDynamicLibs: Set<String> = emptySet(),
57
+ val excludedAbis: Set<String> = emptySet(),
58
+ )
59
+
60
+ private fun isAppOwned(name: String) = name == APP_MODULES_LIB || (name.startsWith("libreact_codegen_") && name.endsWith(".so"))
61
+
62
+ private fun isIgnored(
63
+ name: String,
64
+ ignoredLibs: List<String>,
65
+ ) = ignoredLibs.any { entry -> name == entry || name.contains(entry) }
66
+
67
+ private fun libNames(dir: File?): List<String> = dir?.listFiles()?.filter { it.isFile }?.map { it.name }.orEmpty()
68
+
69
+ /**
70
+ * Picks the .so files for the AAR: the app module's own libs, the libs the embedded libraries
71
+ * ship, and [dynamicLibs], minus [ignoredLibs]. Libs that only come from Maven dependencies stay
72
+ * out, because the host app gets them through the published POM.
73
+ */
74
+ internal fun planJniLibs(
75
+ aarJniDirs: List<File>,
76
+ appNativeLibsDir: File?,
77
+ ignoredLibs: List<String>,
78
+ dynamicLibs: List<String>,
79
+ ): JniLibsPlan {
80
+ val copies = linkedMapOf<String, File>()
81
+ val matchedDynamicLibs = mutableSetOf<String>()
82
+ val candidates = mutableMapOf<String, SkipReason>()
83
+ val ignoredDynamicLibs = mutableSetOf<String>()
84
+
85
+ // An ABI folder without libappmodules.so crashes on devices that pick it, so follow the app
86
+ // module's ABIs (it may use abiFilters) and only fall back to all of them when it built none.
87
+ val appAbis = ABIS.filter { abi -> appNativeLibsDir?.let { File(it, abi).isDirectory } == true }
88
+ val abis = appAbis.ifEmpty { ABIS }
89
+ val excludedAbis = ABIS.filter { abi -> abi !in abis && aarJniDirs.any { File(it, abi).isDirectory } }.toSet()
90
+
91
+ for (abi in abis) {
92
+ val appAbiDir = appNativeLibsDir?.let { File(it, abi) }
93
+ val appNames = libNames(appAbiDir).toSet()
94
+
95
+ val embedded = linkedMapOf<String, File>()
96
+ aarJniDirs.forEach { jniDir ->
97
+ val abiDir = File(jniDir, abi)
98
+ libNames(abiDir).forEach { name -> embedded.putIfAbsent(name, File(abiDir, name)) }
99
+ }
100
+ val extra = dynamicLibs.filter { it in appNames }
101
+ matchedDynamicLibs += extra
102
+ ignoredDynamicLibs += extra.filter { isIgnored(it, ignoredLibs) }
103
+
104
+ val wanted = appNames.filter(::isAppOwned) + (embedded.keys + extra).filterNot { isIgnored(it, ignoredLibs) }
105
+ wanted.forEach { name ->
106
+ copies["$abi/$name"] = appAbiDir?.let { File(it, name) }?.takeIf { it.isFile } ?: embedded.getValue(name)
107
+ }
108
+
109
+ (appNames + embedded.keys).filterNot { it in wanted }.forEach { name ->
110
+ val reason = if (isIgnored(name, ignoredLibs)) SkipReason.IGNORED else SkipReason.NOT_EMBEDDED
111
+ // IGNORED wins: once a name is ignored in one ABI it is ignored everywhere.
112
+ if (candidates[name] != SkipReason.IGNORED) candidates[name] = reason
113
+ }
114
+ }
115
+
116
+ val copiedNames = copies.keys.map { it.substringAfter('/') }.toSet()
117
+ return JniLibsPlan(
118
+ copies = copies,
119
+ unmatchedDynamicLibs = dynamicLibs.toSet() - matchedDynamicLibs,
120
+ skipped = candidates.filterKeys { it !in copiedNames }.toSortedMap(),
121
+ ignoredDynamicLibs = ignoredDynamicLibs,
122
+ excludedAbis = excludedAbis,
123
+ )
124
+ }
125
+
126
+ internal fun planWarnings(plan: JniLibsPlan): List<String> =
127
+ plan.unmatchedDynamicLibs.map { entry ->
128
+ "Brownfield: dynamicLibs entry '$entry' matches no .so file in the app module's native output."
129
+ } +
130
+ plan.ignoredDynamicLibs.map { name ->
131
+ "Brownfield: dynamicLibs entry '$name' is not in the AAR because an ignore entry matches it. " +
132
+ "Remove that entry from ignoreEmbeddedLibs to embed it."
133
+ }
134
+
135
+ /**
136
+ * One line naming the libs no embedded library ships, or null when there are none. Ignored libs
137
+ * are left out: they are skipped on purpose and would only add noise on every build.
138
+ */
139
+ internal fun skippedSummary(
140
+ plan: JniLibsPlan,
141
+ reportFile: File?,
142
+ ): String? {
143
+ val names = plan.skipped.filterValues { it == SkipReason.NOT_EMBEDDED }.keys.sorted()
144
+ if (names.isEmpty()) return null
145
+ val details = reportFile?.let { " Details: ${it.absolutePath}" }.orEmpty()
146
+ return "Brownfield: left ${names.size} .so file(s) out of the AAR because no embedded library ships them: " +
147
+ "${names.joinToString(", ")}. If one comes from a Maven dependency, the host app gets it through the POM. " +
148
+ "If not (for example, your app module builds or ships it), add it to reactBrownfield.dynamicLibs.$details"
149
+ }
150
+
151
+ internal data class AppNativeLibsSource(val relativeDir: String, val producerTaskName: String)
152
+
153
+ internal fun appNativeLibsSource(
154
+ variantName: String,
155
+ useStrippedSoFiles: Boolean,
156
+ ): AppNativeLibsSource {
157
+ val capitalized = variantName.capitalized()
158
+ return if (useStrippedSoFiles) {
159
+ val task = "strip${capitalized}DebugSymbols"
160
+ AppNativeLibsSource("intermediates/stripped_native_libs/$variantName/$task/out/lib", task)
161
+ } else {
162
+ val task = "merge${capitalized}NativeLibs"
163
+ AppNativeLibsSource("intermediates/merged_native_libs/$variantName/$task/out/lib", task)
164
+ }
165
+ }
166
+
40
167
  @CacheableTask
41
168
  abstract class ProcessAndCopyJniLibsTask : DefaultTask() {
42
169
  @get:InputFiles
43
170
  @get:PathSensitive(PathSensitivity.RELATIVE)
44
171
  abstract val aarJniDirs: ConfigurableFileCollection
45
172
 
46
- @get:InputDirectory
173
+ // @InputFiles rather than @InputDirectory: a variant without native code has no such dir,
174
+ // and @InputDirectory would fail validation before the task can run.
175
+ @get:InputFiles
47
176
  @get:Optional
48
177
  @get:PathSensitive(PathSensitivity.RELATIVE)
49
- abstract val strippedLibsDir: DirectoryProperty
178
+ abstract val appNativeLibsDir: DirectoryProperty
50
179
 
51
180
  @get:Input
52
- abstract val useStrippedSoFiles: Property<Boolean>
181
+ abstract val extraIgnoredLibs: ListProperty<String>
53
182
 
54
183
  @get:Input
55
- abstract val extraIgnoredLibs: ListProperty<String>
184
+ abstract val dynamicLibs: ListProperty<String>
56
185
 
57
186
  @get:OutputDirectory
58
187
  abstract val outputDirectory: DirectoryProperty
59
188
 
189
+ // A declared output, so it is restored on cache hits when nothing is logged.
190
+ @get:OutputFile
191
+ abstract val skippedReportFile: RegularFileProperty
192
+
60
193
  @TaskAction
61
194
  fun execute() {
62
195
  val outDir = outputDirectory.get().asFile
63
- // Clear out old data to ensure clean, reproducible builds
64
196
  outDir.deleteRecursively()
65
197
  outDir.mkdirs()
66
198
 
67
- val existingJNILibs =
68
- listOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
69
- .associateWith { mutableListOf<String>() }
70
- .toMutableMap()
71
-
72
- // 1. Scan and process duplicate libs from AAR paths
73
- aarJniDirs.forEach { jniDir ->
74
- if (jniDir.exists()) {
75
- processNestedLibs(jniDir.listFiles(), existingJNILibs)
76
-
77
- // If not using stripped files, copy the unstripped ones directly to our output
78
- if (!useStrippedSoFiles.get()) {
79
- jniDir.copyRecursively(outDir, overwrite = true)
80
- }
81
- }
199
+ val appDir = appNativeLibsDir.orNull?.asFile
200
+ if (appDir != null && !appDir.exists()) {
201
+ logger.warn(
202
+ "Brownfield: $appDir does not exist, so libappmodules.so and libreact_codegen_*.so " +
203
+ "were not found and are not in the AAR.",
204
+ )
82
205
  }
83
206
 
84
- // 2. If using stripped files, copy targeted files from the stripped directory
85
- if (useStrippedSoFiles.get() && strippedLibsDir.isPresent) {
86
- copyStrippedSoLibs(outDir, existingJNILibs)
87
- }
88
- }
207
+ val plan =
208
+ planJniLibs(
209
+ aarJniDirs.files.toList(),
210
+ appDir,
211
+ IGNORE_EMBEDDED_LIBS + extraIgnoredLibs.getOrElse(emptyList()),
212
+ dynamicLibs.getOrElse(emptyList()),
213
+ )
89
214
 
90
- private fun copyStrippedSoLibs(
91
- outDir: File,
92
- existingJNILibs: MutableMap<String, MutableList<String>>,
93
- ) {
94
- val fromDir = strippedLibsDir.get().asFile
95
- if (!fromDir.exists()) return
215
+ plan.copies.forEach { (path, source) -> source.copyTo(File(outDir, path), overwrite = true) }
96
216
 
97
- existingJNILibs.keys.forEach { arch ->
98
- copyArchStrippedSoLibs(fromDir, outDir, arch)
217
+ writeReport(plan)
218
+ val reportFile = skippedReportFile.get().asFile
219
+ skippedSummary(plan, reportFile)?.let { logger.lifecycle(it) }
220
+ plan.skipped.forEach { (name, reason) -> logger.info("Brownfield: skipped $name ($reason)") }
221
+ planWarnings(plan).forEach { logger.warn(it) }
222
+ if (plan.excludedAbis.isNotEmpty()) {
223
+ logger.info("Brownfield: left out ABIs the app module did not build: ${plan.excludedAbis.sorted().joinToString(", ")}")
99
224
  }
100
225
  }
101
226
 
102
- private fun copyArchStrippedSoLibs(
103
- fromDir: File,
104
- outDir: File,
105
- arch: String,
106
- ) {
107
- val sourceArchDir = File(fromDir, arch)
108
- if (!sourceArchDir.exists()) return
109
-
110
- val destArchDir = File(outDir, arch).apply { mkdirs() }
111
- sourceArchDir.listFiles()?.forEach { file ->
112
- copyIfAllowed(file, destArchDir)
113
- }
227
+ private fun writeReport(plan: JniLibsPlan) {
228
+ val reportFile = skippedReportFile.get().asFile
229
+ reportFile.parentFile.mkdirs()
230
+ val lines =
231
+ if (plan.skipped.isEmpty()) {
232
+ listOf("No .so files were left out.")
233
+ } else {
234
+ listOfNotNull(skippedSummary(plan, null)) + plan.skipped.map { (name, reason) -> "$name: $reason" }
235
+ }
236
+ reportFile.writeText(lines.joinToString("\n", postfix = "\n"))
114
237
  }
238
+ }
115
239
 
116
- private fun copyIfAllowed(
117
- file: File,
118
- destArchDir: File,
119
- ) {
120
- val name = file.name
121
- if (!matchesAllowedLib(name)) return
240
+ internal fun wireAarJniDirs(
241
+ task: ProcessAndCopyJniLibsTask,
242
+ jniDirs: List<File>,
243
+ explodeTask: TaskProvider<out Task>,
244
+ ) {
245
+ // The dirs are plain paths, so Gradle can't infer who fills them; without this the task can
246
+ // run before the AARs are unpacked and drop every embedded lib.
247
+ task.aarJniDirs.setFrom(jniDirs)
248
+ task.aarJniDirs.builtBy(explodeTask)
249
+ }
122
250
 
123
- try {
124
- file.copyTo(File(destArchDir, name), overwrite = true)
125
- } catch (e: java.io.IOException) {
126
- Logging.error("Failed to copy $name: ${e.message}", e.cause)
127
- }
128
- }
251
+ internal fun configureProcessJniTask(
252
+ task: ProcessAndCopyJniLibsTask,
253
+ project: Project,
254
+ appProject: Project,
255
+ projectExt: Extension,
256
+ variantName: String,
257
+ jniDirs: List<File>,
258
+ explodeTask: TaskProvider<out Task>,
259
+ ) {
260
+ val source = appNativeLibsSource(variantName, projectExt.useStrippedSoFiles)
129
261
 
130
- private fun matchesAllowedLib(name: String): Boolean {
131
- val isCodegenLib = name.startsWith("libreact_codegen_") && name.endsWith(".so")
132
- val ignoredLibs = IGNORE_EMBEDDED_LIBS + extraIgnoredLibs.getOrElse(emptyList())
133
- val isIgnoredLib = ignoredLibs.any { lib -> name == lib || name.contains(lib) }
134
- return name == "libappmodules.so" || isCodegenLib || !isIgnoredLib
135
- }
262
+ task.extraIgnoredLibs.set(projectExt.ignoreEmbeddedLibs)
263
+ task.dynamicLibs.set(projectExt.dynamicLibs)
264
+ wireAarJniDirs(task, jniDirs, explodeTask)
265
+ task.appNativeLibsDir.set(appProject.layout.buildDirectory.dir(source.relativeDir))
266
+ task.skippedReportFile.set(project.layout.buildDirectory.file("reports/brownfield/$variantName/skipped-so.txt"))
136
267
 
137
- private fun processNestedLibs(
138
- files: Array<File>?,
139
- existingJNILibs: MutableMap<String, MutableList<String>>,
140
- ) {
141
- files?.forEach { folder ->
142
- val libFiles = folder.listFiles() ?: return@forEach
143
- val libList = existingJNILibs[folder.name] ?: return@forEach
144
-
145
- libFiles.forEach { file ->
146
- if (file.name in libList) {
147
- file.delete()
148
- } else {
149
- libList.add(file.name)
150
- }
151
- }
152
- }
153
- }
268
+ task.dependsOn(
269
+ "${appProject.path}:${source.producerTaskName}",
270
+ "${project.path}:generateCodegenSchemaFromJavaScript",
271
+ )
154
272
  }
155
273
 
156
274
  class JNILibsProcessor(val project: Project) {
157
275
  fun processJniLibs(
158
276
  aarLibraries: Collection<AndroidArchiveLibrary>,
159
277
  variant: LibraryVariant,
278
+ explodeTask: TaskProvider<out Task>,
160
279
  ) {
161
280
  val variantName = variant.name
162
281
  val capitalizedVariantName = variantName.capitalized()
@@ -165,25 +284,8 @@ class JNILibsProcessor(val project: Project) {
165
284
 
166
285
  val processJniTask =
167
286
  project.tasks.register("processCustomJniLibsFor$capitalizedVariantName", ProcessAndCopyJniLibsTask::class.java) {
168
- it.useStrippedSoFiles.set(projectExt.useStrippedSoFiles)
169
- it.extraIgnoredLibs.set(projectExt.ignoreEmbeddedLibs)
170
-
171
- val jniDirs =
172
- aarLibraries.map { aarLib ->
173
- aarLib.getJniDir()
174
- }
175
-
176
- it.aarJniDirs.setFrom(jniDirs)
177
-
178
- val fromDirProvider =
179
- appProject.layout.buildDirectory.dir(
180
- "intermediates/stripped_native_libs/$variantName/strip${capitalizedVariantName}DebugSymbols/out/lib",
181
- )
182
- it.strippedLibsDir.set(fromDirProvider)
183
-
184
- val stripTaskPath = ":${appProject.name}:strip${capitalizedVariantName}DebugSymbols"
185
- val codegenTaskPath = ":${project.name}:generateCodegenSchemaFromJavaScript"
186
- it.dependsOn(stripTaskPath, codegenTaskPath)
287
+ val jniDirs = aarLibraries.map { aarLib -> aarLib.getJniDir() }
288
+ configureProcessJniTask(it, project, appProject, projectExt, variantName, jniDirs, explodeTask)
187
289
  }
188
290
 
189
291
  variant.sources.jniLibs?.addGeneratedSourceDirectory(
@@ -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
  }