diff --git a/.changeset/aar-merge-res-values.md b/.changeset/aar-merge-res-values.md new file mode 100644 index 000000000..2daddd9e4 --- /dev/null +++ b/.changeset/aar-merge-res-values.md @@ -0,0 +1,5 @@ +--- +'@callstack/react-native-brownfield': patch +--- + +fix: keep the resources of every library embedded in the Android AAR; previously only one library's `res/values` survived and host apps failed at resource linking. As in a regular app, the module's own `res` and `resValue`s win over a library's, and when two libraries define the same resource the first one wins (previously the last one won for files such as drawables) diff --git a/apps/RNApp/android/build.gradle b/apps/RNApp/android/build.gradle index f26a88522..f255767fd 100644 --- a/apps/RNApp/android/build.gradle +++ b/apps/RNApp/android/build.gradle @@ -16,7 +16,7 @@ buildscript { classpath("com.android.tools.build:gradle") classpath("com.facebook.react:react-native-gradle-plugin") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") - classpath("com.callstack.react:brownfield-gradle-plugin:2.0.0-alpha11-SNAPSHOT") + classpath("com.callstack.react:brownfield-gradle-plugin:2.0.0-alpha13-SNAPSHOT") } } diff --git a/apps/scripts/prepare-android-build-gradle-for-ci.ts b/apps/scripts/prepare-android-build-gradle-for-ci.ts index 17323bf60..09e84d35e 100644 --- a/apps/scripts/prepare-android-build-gradle-for-ci.ts +++ b/apps/scripts/prepare-android-build-gradle-for-ci.ts @@ -9,7 +9,7 @@ if (!projectDirName) { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const SNAPSHOT_VERSION = '2.0.0-alpha11-SNAPSHOT'; +const SNAPSHOT_VERSION = '2.0.0-alpha13-SNAPSHOT'; // Accepts a repo-relative app dir (e.g. "ExpoApp58") or an absolute path (e.g. // the scaffold-smoke app created outside the repo). const projectDir = path.isAbsolute(projectDirName) diff --git a/gradle-plugins/react/brownfield/build.gradle.kts b/gradle-plugins/react/brownfield/build.gradle.kts index 9c38a6b5d..07ed97308 100644 --- a/gradle-plugins/react/brownfield/build.gradle.kts +++ b/gradle-plugins/react/brownfield/build.gradle.kts @@ -113,10 +113,14 @@ dependencies { // minimum Gradle version) for every app that applies this plugin. compileOnly(libs.agp) compileOnly(libs.common) + compileOnly(libs.sdk.common) + compileOnly(libs.layoutlib.api) implementation(libs.asm.commons) implementation(libs.versioncompare) testImplementation(libs.agp) testImplementation(libs.common) + testImplementation(libs.sdk.common) + testImplementation(libs.layoutlib.api) testImplementation(libs.junit.jupiter) testImplementation(gradleTestKit()) testImplementation(kotlin("test")) diff --git a/gradle-plugins/react/brownfield/gradle.properties b/gradle-plugins/react/brownfield/gradle.properties index 60afacf51..659d435e2 100644 --- a/gradle-plugins/react/brownfield/gradle.properties +++ b/gradle-plugins/react/brownfield/gradle.properties @@ -1,6 +1,6 @@ PROJECT_ID=com.callstack.react.brownfield ARTIFACT_ID=brownfield-gradle-plugin -VERSION=2.0.0-alpha11 +VERSION=2.0.0-alpha13 GROUP=com.callstack.react IMPLEMENTATION_CLASS=com.callstack.react.brownfield.plugin.RNBrownfieldPlugin diff --git a/gradle-plugins/react/brownfield/gradle/libs.versions.toml b/gradle-plugins/react/brownfield/gradle/libs.versions.toml index befe551dc..800069472 100644 --- a/gradle-plugins/react/brownfield/gradle/libs.versions.toml +++ b/gradle-plugins/react/brownfield/gradle/libs.versions.toml @@ -19,6 +19,8 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } [libraries] agp = { module = "com.android.tools.build:gradle", name = "agp", version.ref = "agp" } common = { module = "com.android.tools:common", version.ref = "common" } +sdk-common = { module = "com.android.tools:sdk-common", version.ref = "common" } +layoutlib-api = { module = "com.android.tools.layoutlib:layoutlib-api", version.ref = "common" } asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm-commons" } versioncompare = { module = "io.github.g00fy2:versioncompare", version.ref = "versioncompare" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/ResourceTaskProcessor.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/ResourceTaskProcessor.kt index 620aef6da..fd2d1bc7e 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/ResourceTaskProcessor.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/ResourceTaskProcessor.kt @@ -1,20 +1,37 @@ package com.callstack.react.brownfield.processors +import com.android.build.api.dsl.LibraryExtension +import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryVariant +import com.android.ide.common.rendering.api.ResourceNamespace +import com.android.ide.common.resources.MergeConsumer +import com.android.ide.common.resources.ResourceMerger +import com.android.ide.common.resources.ResourceMergerItem +import com.android.ide.common.resources.ResourceSet import com.callstack.react.brownfield.shared.ExplodeAarTask +import com.callstack.react.brownfield.shared.GradleILogger import com.callstack.react.brownfield.utils.AndroidArchiveLibrary import com.callstack.react.brownfield.utils.capitalized import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider +import org.w3c.dom.Element +import org.w3c.dom.Node +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult @CacheableTask abstract class CopyExplodedResTask : DefaultTask() { @@ -22,23 +39,168 @@ abstract class CopyExplodedResTask : DefaultTask() { @get:PathSensitive(PathSensitivity.RELATIVE) abstract val inputResDirs: ConfigurableFileCollection + /** The module's own resources, which win over every library's, as an app's do over its dependencies. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val moduleResDirs: ConfigurableFileCollection + + /** `type/name` of the module's `resValue`s, which also win over every library's. */ + @get:Input + abstract val moduleResValues: ListProperty + + /** The first library wins on a duplicate, so the order is part of the input. */ + @get:Input + abstract val libraryOrder: ListProperty + @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + /** + * Every exploded AAR keeps its values in `res/values/values.xml`, so libraries can't simply be + * copied over each other (#490), and keeping a resource twice in one dir fails the build (#125). + * AGP's own merger picks the winners, with the precedence it uses in an app: the module's own + * resources, then the first library. + */ @TaskAction fun run() { val out = outputDirectory.get().asFile out.deleteRecursively() out.mkdirs() - inputResDirs.forEach { resDir -> - if (resDir.exists()) { - resDir.copyRecursively(out, overwrite = true) - } + val iLogger = GradleILogger(logger) + val moduleDirs = moduleResDirs.filter { it.exists() }.toList() + // 0 keeps every versioned resource. AGP applies the module's minSdk when it merges this dir. + val merger = ResourceMerger(0) + // Lowest priority first. + inputResDirs.filter { it.exists() }.reversed().forEachIndexed { index, resDir -> + merger.addDataSet(resourceSet("library$index", resDir, isLibrary = true, iLogger)) + } + // One set per dir: two module dirs may override each other, like main and a flavor. + moduleDirs.forEachIndexed { index, resDir -> + merger.addDataSet(resourceSet("module$index", resDir, isLibrary = false, iLogger)) + } + val writer = LibraryResWriter(moduleDirs, moduleResValues.get().toSet()) + merger.mergeData(writer, false) + writer.messages.forEach { logger.warn("Brownfield: $it") } + writer.writeTo(out) + } + + private fun resourceSet( + name: String, + resDir: File, + isLibrary: Boolean, + iLogger: GradleILogger, + ) = ResourceSet(name, ResourceNamespace.RES_AUTO, if (isLibrary) name else null, false, null).apply { + addSource(resDir) + loadFromFiles(iLogger) + } +} + +/** + * Writes the merged library resources that the module doesn't define itself. AGP ranks this generated + * dir above the module's own res, so a library copy of a module resource would win there. + */ +private class LibraryResWriter( + private val moduleDirs: List, + private val moduleResValues: Set, +) : MergeConsumer { + private val files = mutableListOf>() + private val values = sortedMapOf>() + private val moduleAttrs = mutableMapOf>() + private val styleableAttrWinners = mutableMapOf>() + val messages = mutableListOf() + + override fun start(factory: DocumentBuilderFactory) = Unit + + override fun end() = Unit + + override fun removeItem( + removedItem: ResourceMergerItem, + replacedBy: ResourceMergerItem?, + ) = Unit + + override fun ignoreItemInMerge(item: ResourceMergerItem) = false + + override fun addItem(item: ResourceMergerItem) { + val folder = valuesFolder(item.qualifiers) + val source = item.sourceFile?.file + if (source != null && moduleDirs.any { source.startsWith(it) }) { + if (item.type.getName() == "attr") moduleAttrs.getOrPut(folder) { mutableSetOf() }.add(item.name) + return + } + if (item.qualifiers.isEmpty() && "${item.type.getName()}/${item.name}" in moduleResValues) return + // An attr declared inside a styleable. The merger picked this one, so it becomes the definition. + if (item.ignoredFromDiskMerge) { + styleableAttrWinners.getOrPut(folder) { mutableListOf() }.add(item.value) + return + } + if (item.isFileBased && source != null) { + files.add("${source.parentFile.name}/${source.name}" to source) + } else { + values.getOrPut(folder) { mutableListOf() }.add(item.value) } } + + fun writeTo(out: File) { + files.forEach { (path, file) -> file.copyTo(File(out, path)) } + val transformer = TransformerFactory.newInstance().newTransformer() + (values.keys + styleableAttrWinners.keys).toSortedSet().forEach { folder -> + val nodes = values[folder].orEmpty() + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() + val root = document.createElement("resources") + document.appendChild(root) + nodes.forEach { root.appendChild(document.importNode(it, true)) } + styleableAttrWinners[folder].orEmpty().forEach { root.appendChild(document.importNode(it, true)) } + keepOneAttrDefinition(root, folder, moduleAttrs[folder].orEmpty()) + val target = File(out, "$folder/$folder.xml") + target.parentFile.mkdirs() + transformer.transform(DOMSource(document), StreamResult(target)) + } + } + + /** + * AGP also registers an attr with a `format` (or enum/flag values) inside a styleable as its own + * `attr/`, so one dir may define it only once. The merger's winner is written as a top-level + * attr, and every styleable copy, or one of an attr the module defines, becomes a plain reference. + */ + private fun keepOneAttrDefinition( + root: Element, + folder: String, + moduleAttrs: Set, + ) { + val defined = root.childElements().filter { it.tagName == "attr" }.associateBy { it.getAttribute("name") } + root.childElements() + .filter { it.tagName == "declare-styleable" } + .flatMap { it.childElements() } + .filter { it.tagName == "attr" && !it.getAttribute("name").startsWith("android:") } + .filter { definition(it).isNotEmpty() } + .forEach { attr -> + val name = attr.getAttribute("name") + val winner = defined[name] + if (winner == null && name !in moduleAttrs) return@forEach + if (winner != null && definition(winner) != definition(attr)) { + messages.add("$folder: attr/$name is defined differently by two libraries, keeping the first") + } + attr.removeAttribute("format") + attr.childElements().forEach { attr.removeChild(it) } + } + } + + /** Format and enum/flag values of an attr; empty for a plain reference. */ + private fun definition(attr: Element): String = + attr.getAttribute("format") + + attr.childElements().joinToString { "${it.tagName}:${it.getAttribute("name")}=${it.getAttribute("value")}" } + + private fun valuesFolder(qualifiers: String) = if (qualifiers.isEmpty()) "values" else "values-$qualifiers" + + private fun Element.childElements(): List { + val children = childNodes + return (0 until children.length).map { children.item(it) }.filterIsInstance() + } } object ResourceTaskProcessor { + private const val RES_VALUES_OFF_BY_DEFAULT_SINCE_AGP = 9 + fun process( project: Project, variant: LibraryVariant, @@ -47,12 +209,20 @@ object ResourceTaskProcessor { ) { val capitalized = variant.name.capitalized() val resDirs = aarLibraries.map { it.getResDir() } + val buildDir = project.layout.buildDirectory.get().asFile val copyResTask = project.tasks.register( "copyExplodedResFor$capitalized", CopyExplodedResTask::class.java, ) { task -> task.inputResDirs.setFrom(resDirs) + if (resValuesEnabled(project)) { + task.moduleResValues.set(variant.resValues.map { values -> values.keys.map { "${it.type}/${it.name}" } }) + } else { + task.moduleResValues.set(emptyList()) + } + variant.sources.res?.static?.let { static -> task.moduleResDirs.from(static.map { layers -> layers.flatten() }) } + task.libraryOrder.set(aarLibraries.map { it.getExplodedAarRootDir().relativeTo(buildDir).invariantSeparatorsPath }) task.dependsOn(explodeTask) } @@ -61,4 +231,12 @@ object ResourceTaskProcessor { CopyExplodedResTask::outputDirectory, ) } + + /** AGP warns on every read of `variant.resValues` while the feature is off, the default from AGP 9 on. */ + private fun resValuesEnabled(project: Project): Boolean { + val explicit = project.extensions.getByType(LibraryExtension::class.java).buildFeatures.resValues + val default = project.providers.gradleProperty("android.defaults.buildfeatures.resvalues").orNull?.toBooleanStrictOrNull() + val agpMajor = project.extensions.getByType(LibraryAndroidComponentsExtension::class.java).pluginVersion.major + return explicit ?: default ?: (agpMajor < RES_VALUES_OFF_BY_DEFAULT_SINCE_AGP) + } } diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt index f67c455b7..a26764420 100644 --- a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt @@ -7,6 +7,7 @@ import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import java.io.File +import kotlin.test.assertFalse import kotlin.test.assertTrue /** @@ -39,12 +40,19 @@ class AgpCompatibilityMatrixTest { // `--dry-run` still runs configuration in full — variant callbacks, source set wiring, task // graph construction — which is where every AGP API the plugin touches is exercised. It just // skips the task actions, which would need a real RN app to produce anything meaningful. - GradleRunner.create() - .withProjectDir(projectDir) - .withPluginClasspath(pluginClasspathFor(agpVersion)) - .withArguments("assembleDebug", "--dry-run", "--stacktrace") - .forwardOutput() - .build() + val result = + GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath(pluginClasspathFor(agpVersion)) + .withArguments("assembleDebug", "--dry-run", "--stacktrace") + .forwardOutput() + .build() + + // AGP 9 turns resValues off by default and warns on every read of variant.resValues. + assertFalse( + result.output.contains("Accessing value resValues"), + "the plugin reads variant.resValues although the feature is disabled", + ) } @Test diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/CopyExplodedResTaskTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/CopyExplodedResTaskTest.kt new file mode 100644 index 000000000..0ba41e92b --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/processors/CopyExplodedResTaskTest.kt @@ -0,0 +1,352 @@ +package com.callstack.react.brownfield.processors + +import com.android.ide.common.rendering.api.ResourceNamespace +import com.android.ide.common.resources.ResourceSet +import com.android.utils.StdLogger +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.w3c.dom.Element +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Every exploded AAR keeps its values in `res/values/values.xml`, so libraries share that path. + * See #490 (values lost) and #125 (duplicate resources failing the build). + */ +class CopyExplodedResTaskTest { + @TempDir + lateinit var tempDir: File + + @Test + fun `keeps the values of every library`() { + val bootsplash = + libraryRes( + "react-native-bootsplash", + """""", + ) + val brownfield = + libraryRes( + "callstack_react-native-brownfield", + """ReactNativeBrownfield""", + ) + + val out = runCopy(bootsplash, brownfield) + + val names = valuesElements(out).map { it.key() } + assertTrue("attr/bootSplashBackground" in names, "bootsplash values were dropped: $names") + assertTrue("string/app_name" in names, "brownfield values were dropped: $names") + } + + @Test + fun `a name defined by two libraries ends up once`() { + val video = libraryRes("react-native-video", """#FF0000""") + val messaging = + libraryRes("react-native-firebase_messaging", """#E53935""") + + val out = runCopy(video, messaging) + + val reds = valuesElements(out).filter { it.key() == "color/red" } + assertEquals(1, reds.size, "duplicate resources would fail the build: ${reds.map { it.textContent }}") + assertEquals("#FF0000", reds.single().textContent, "the first library should win, like in an app") + } + + @Test + fun `merges the attrs of a styleable defined by two libraries`() { + val first = + libraryRes( + "lib-a", + """""", + ) + val second = + libraryRes( + "lib-b", + """""", + ) + + val out = runCopy(first, second) + + val styleables = valuesElements(out).filter { it.key() == "declare-styleable/Shared" } + assertEquals(1, styleables.size) + // aapt2 sorts a styleable's attrs itself, so their order doesn't matter. + val attrs = styleables.single().childElements().map { it.getAttribute("name") } + assertEquals(setOf("aColor", "bSize"), attrs.toSet()) + } + + @Test + fun `defines an attr once when another library declares it inside a styleable`() { + val first = libraryRes("lib-a", """""") + // One library can't define it twice itself, its own build would fail. + val second = libraryRes("lib-b", """""") + val third = libraryRes("lib-c", """""") + + val out = runCopy(first, second, third) + + val elements = valuesElements(out) + val definitions = + elements.filter { it.key() == "attr/tint" } + + elements.filter { it.tagName == "declare-styleable" }.flatMap { it.childElements() } + .filter { it.getAttribute("name") == "tint" && it.hasAttribute("format") } + assertEquals(1, definitions.size, "attr/tint defined more than once would fail the build") + val styleables = elements.filter { it.tagName == "declare-styleable" }.map { it.getAttribute("name") } + assertEquals(listOf("Badge", "Chip"), styleables.sorted()) + } + + @Test + fun `keeps an attr definition that only the second of two same-name styleables has`() { + val first = libraryRes("lib-a", """""") + val second = + libraryRes("lib-b", """""") + + val out = runCopy(first, second) + + val elements = valuesElements(out) + val definitions = + (elements + elements.filter { it.tagName == "declare-styleable" }.flatMap { it.childElements() }) + .filter { it.tagName == "attr" && it.getAttribute("name") == "x" && it.hasAttribute("format") } + assertEquals(listOf("color"), definitions.map { it.getAttribute("format") }) + } + + @Test + fun `keeps one file when two libraries ship the same resource with different extensions`() { + val first = libraryRes("lib-a", "") + val second = libraryRes("lib-b", "") + File(first, "drawable").mkdirs() + File(first, "drawable/ic_close.png").writeBytes(byteArrayOf(1)) + File(second, "drawable").mkdirs() + File(second, "drawable/ic_close.xml").writeText("") + + val out = runCopy(first, second) + + assertEquals(listOf("ic_close.png"), File(out, "drawable").list().orEmpty().toList()) + } + + @Test + fun `keeps namespace declarations of merged styleable attrs`() { + val first = libraryRes("lib-a", """""") + val second = File(tempDir, "exploded-aar/lib-b/res") + File(second, "values").mkdirs() + File(second, "values/values.xml").writeText( + """ + + + + + """.trimIndent(), + ) + + val out = runCopy(first, second) + + val factory = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } + File(out, "values").listFiles().orEmpty().forEach { factory.newDocumentBuilder().parse(it) } + } + + @Test + fun `lets the module's own resources win over a library's`() { + val module = libraryRes("module", """MyBrownfieldModule""") + File(module, "drawable").mkdirs() + File(module, "drawable/logo.png").writeBytes(byteArrayOf(1)) + val brownfield = + libraryRes( + "callstack_react-native-brownfield", + """ReactNativeBrownfieldx""", + ) + File(brownfield, "drawable").mkdirs() + File(brownfield, "drawable/logo.xml").writeText("") + + val out = runCopy(brownfield, moduleResDirs = listOf(module)) + + assertEquals(listOf("string/other"), valuesElements(out).map { it.key() }) + assertTrue(!File(out, "drawable/logo.xml").exists(), "the library's drawable overrides the module's") + } + + @Test + fun `matches the module's qualifiers with the normalized ones of an AAR`() { + val module = File(tempDir, "module/res") + File(module, "values-night").mkdirs() + File(module, "values-night/colors.xml").writeText(resources("""#000000""")) + val lib = libraryRes("lib-a", "") + File(lib, "values-night-v8").mkdirs() + File(lib, "values-night-v8/values-night-v8.xml").writeText(resources("""#FFFFFF""")) + + val out = runCopy(lib, moduleResDirs = listOf(module)) + + assertEquals(emptyList(), valuesElements(out, "values-night-v8").map { it.key() }) + } + + @Test + fun `reads module values that declare entities in a DOCTYPE`() { + val module = File(tempDir, "module/res") + File(module, "values").mkdirs() + File(module, "values/strings.xml").writeText( + """ + + ]> + + &product; + + """.trimIndent(), + ) + val brownfield = libraryRes("brownfield", """ReactNativeBrownfield""") + + val out = runCopy(brownfield, moduleResDirs = listOf(module)) + + assertEquals(emptyList(), valuesElements(out).map { it.key() }) + } + + @Test + fun `lets the module's resValues win over a library's`() { + val brownfield = libraryRes("brownfield", """ReactNativeBrownfield""") + + val out = runCopy(brownfield, moduleResValues = listOf("string/app_name")) + + assertEquals(emptyList(), valuesElements(out).map { it.key() }) + } + + @Test + fun `keeps namespaced values from an AGP-written AAR`() { + val lib = File(tempDir, "exploded-aar/lib-a/res") + File(lib, "values").mkdirs() + File(lib, "values/values.xml").writeText( + """ + + + Hi %s + + """.trimIndent(), + ) + + val out = runCopy(lib) + + val factory = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } + val file = File(out, "values").listFiles().orEmpty().single() + val g = factory.newDocumentBuilder().parse(file).getElementsByTagNameNS("urn:oasis:names:tc:xliff:document:1.2", "g") + assertEquals(1, g.length) + } + + @Test + fun `keeps the module's definition of an attr a library declares inside a styleable`() { + val module = File(tempDir, "module/res") + File(module, "values").mkdirs() + File(module, "values/attrs.xml").writeText(resources("""""")) + val lib = + libraryRes("lib-a", """""") + + val out = runCopy(lib, moduleResDirs = listOf(module)) + + val attrs = valuesElements(out).filter { it.tagName == "declare-styleable" }.flatMap { it.childElements() } + assertEquals(listOf(""), attrs.map { it.getAttribute("format") }, "the library would override the module's attr format") + } + + @Test + fun `keeps the first library's format of an attr two libraries declare in different styleables`() { + listOf("A" to "B", "B" to "A", "Badge" to "Chip", "Chip" to "Badge").forEach { (firstStyleable, secondStyleable) -> + tempDir.listFiles()?.forEach { it.deleteRecursively() } + val first = + libraryRes("lib-a", """""") + val second = + libraryRes( + "lib-b", + """""", + ) + + val out = runCopy(first, second) + + val elements = valuesElements(out) + val formats = + (elements + elements.filter { it.tagName == "declare-styleable" }.flatMap { it.childElements() }) + .filter { it.tagName == "attr" && it.getAttribute("name") == "x" && it.hasAttribute("format") } + .map { it.getAttribute("format") } + assertEquals(listOf("color"), formats, "styleables $firstStyleable and $secondStyleable") + } + } + + @Test + fun `keeps the same name in different qualifiers`() { + val lib = libraryRes("lib-a", """#000000""") + File(lib, "values-night").mkdirs() + File(lib, "values-night/values-night.xml").writeText(resources("""#FFFFFF""")) + + val out = runCopy(lib) + + assertEquals(listOf("#000000"), valuesElements(out, "values").map { it.textContent }) + // Written with the implied version qualifier, as in an AAR. + assertEquals(listOf("#FFFFFF"), valuesElements(out, "values-night-v8").map { it.textContent }) + } + + @Test + fun `keeps the first library's file for other resource types`() { + val first = libraryRes("lib-a", "") + val second = libraryRes("lib-b", "") + listOf(first to "a", second to "b").forEach { (resDir, marker) -> + File(resDir, "drawable").mkdirs() + File(resDir, "drawable/icon.xml").writeText("") + } + + val out = runCopy(first, second) + + assertTrue(File(out, "drawable/icon.xml").readText().contains("\"a\"")) + } + + private fun libraryRes( + libraryName: String, + valuesBody: String, + ): File { + val resDir = File(tempDir, "exploded-aar/$libraryName/res") + File(resDir, "values").mkdirs() + File(resDir, "values/values.xml").writeText(resources(valuesBody)) + return resDir + } + + private fun resources(body: String) = + """ + + + $body + + """.trimIndent() + + private fun runCopy( + vararg resDirs: File, + moduleResDirs: List = emptyList(), + moduleResValues: List = emptyList(), + ): File { + val project: Project = ProjectBuilder.builder().withProjectDir(File(tempDir, "project")).build() + val out = File(tempDir, "out") + val task = project.tasks.register("copyExplodedResForTest", CopyExplodedResTask::class.java).get() + task.inputResDirs.setFrom(resDirs.toList()) + task.moduleResDirs.setFrom(moduleResDirs) + task.moduleResValues.set(moduleResValues) + task.outputDirectory.set(out) + + task.run() + + // AGP loads the generated dir as one resource set, which fails on any duplicate (#125). + ResourceSet("generated", ResourceNamespace.RES_AUTO, null, true, null).apply { + addSource(out) + loadFromFiles(StdLogger(StdLogger.Level.WARNING)) + } + return out + } + + /** Top-level resources of every file in one qualifier directory, one entry per definition. */ + private fun valuesElements( + resDir: File, + qualifierDir: String = "values", + ): List { + val files = File(resDir, qualifierDir).listFiles { f -> f.extension == "xml" }.orEmpty() + return files.flatMap { file -> + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file).documentElement.childElements() + } + } + + private fun Element.key() = "$tagName/${getAttribute("name")}" + + private fun Element.childElements(): List { + val children = childNodes + return (0 until children.length).map { children.item(it) }.filterIsInstance() + } +} diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts index 03ba1ee0c..6f116765b 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/__tests__/gradleHelpers.test.ts @@ -16,7 +16,7 @@ describe('gradleHelpers', () => { }`; expect(modifyRootBuildGradle(contents)).toContain( - 'classpath("com.callstack.react:brownfield-gradle-plugin:2.0.0-alpha11")' + 'classpath("com.callstack.react:brownfield-gradle-plugin:2.0.0-alpha13")' ); }); diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts index 0c67be0cb..59cf7e2b9 100644 --- a/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts +++ b/packages/react-native-brownfield/src/expo-config-plugin/android/utils/constants.ts @@ -1,2 +1,2 @@ -export const BROWNFIELD_PLUGIN_VERSION = '2.0.0-alpha11'; +export const BROWNFIELD_PLUGIN_VERSION = '2.0.0-alpha13'; export const brownfieldGradlePluginDependency = `classpath("com.callstack.react:brownfield-gradle-plugin:${BROWNFIELD_PLUGIN_VERSION}")`;