From fcfd082f0bf49a0e99d974539faf850799c8e62e Mon Sep 17 00:00:00 2001 From: Hur Ali Date: Thu, 15 Oct 2026 12:58:13 +0500 Subject: [PATCH 1/3] fix: namespace for libraries which define with legacy convention --- .../brownfield/utils/AndroidArchiveLibrary.kt | 27 ++++- ...roidArchiveLibraryNamespaceFallbackTest.kt | 101 ++++++++++++++++++ 2 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibraryNamespaceFallbackTest.kt diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibrary.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibrary.kt index f9dfcc74f..1a53fbc13 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibrary.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibrary.kt @@ -46,11 +46,30 @@ class AndroidArchiveLibrary( val regex = Regex("""namespace\s*=?\s*["']([^"']+)["']""") val match = regex.find(text) - val namespace = - match?.groupValues?.get(1) - ?: error("No namespace found in ${buildFile.path}") + return match?.groupValues?.get(1) + ?: getNameSpaceFromAndroidManifest(subProj) + ?: error( + "No namespace found in ${buildFile.path}, and no package attribute found in " + + "AndroidManifest.xml for ${artifact.moduleName}", + ) + } - return namespace + /** + * Falls back to the `package` attribute of the dependency's `AndroidManifest.xml` when the + * module declares no `namespace` in its `build.gradle`. The Android Gradle plugin itself + * derives the namespace this way for such libraries, so they build fine as a regular + * dependency but trip up [getNameSpaceFromBuildGradle]'s `build.gradle`-only regex. + */ + private fun getNameSpaceFromAndroidManifest(subProj: Project): String? { + val candidateManifests = + listOf( + File(subProj.projectDir, "src/main/AndroidManifest.xml"), + File(subProj.projectDir, "AndroidManifest.xml"), + ) + val manifestFile = candidateManifests.firstOrNull { it.exists() } ?: return null + + val regex = Regex("""package\s*=\s*["']([^"']+)["']""") + return regex.find(manifestFile.readText())?.groupValues?.get(1) } fun getManifestFile() = File(getExplodedAarRootDir(), "AndroidManifest.xml") diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibraryNamespaceFallbackTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibraryNamespaceFallbackTest.kt new file mode 100644 index 000000000..1abbcb59a --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibraryNamespaceFallbackTest.kt @@ -0,0 +1,101 @@ +package com.callstack.react.brownfield.utils + +import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AndroidArchiveLibraryNamespaceFallbackTest { + private fun artifactFor(moduleName: String) = + UnresolvedArtifactInfo( + moduleGroup = "com.example", + moduleName = moduleName, + moduleVersion = "1.0.0", + file = null, + isExpoPublishDependency = false, + ) + + @Test + fun `falls back to the AndroidManifest package when build-gradle declares no namespace`() { + val root = ProjectBuilder.builder().build() + val subProject = + ProjectBuilder.builder() + .withParent(root) + .withName("react-native-launch-arguments") + .build() + + // No `namespace` declaration, mirroring libraries whose build.gradle predates AGP namespaces. + subProject.projectDir.mkdirs() + subProject.buildFile.writeText( + """ + apply plugin: 'com.android.library' + """.trimIndent(), + ) + subProject.projectDir.resolve("src/main").mkdirs() + subProject.projectDir.resolve("src/main/AndroidManifest.xml").writeText( + """ + + + """.trimIndent(), + ) + + val library = AndroidArchiveLibrary(root, artifactFor("react-native-launch-arguments"), "devRelease") + + assertEquals("com.reactnativelauncharguments", library.getPackageName()) + } + + @Test + fun `prefers the namespace declared in build-gradle over the manifest package`() { + val root = ProjectBuilder.builder().build() + val subProject = + ProjectBuilder.builder() + .withParent(root) + .withName("react-native-screens") + .build() + + subProject.projectDir.mkdirs() + subProject.buildFile.writeText( + """ + apply plugin: 'com.android.library' + android { + namespace = "com.swmansion.rnscreens" + } + """.trimIndent(), + ) + subProject.projectDir.resolve("src/main").mkdirs() + subProject.projectDir.resolve("src/main/AndroidManifest.xml").writeText( + """ + + + """.trimIndent(), + ) + + val library = AndroidArchiveLibrary(root, artifactFor("react-native-screens"), "devRelease") + + assertEquals("com.swmansion.rnscreens", library.getPackageName()) + } + + @Test + fun `errors when neither build-gradle nor the manifest declare a namespace`() { + val root = ProjectBuilder.builder().build() + val subProject = + ProjectBuilder.builder() + .withParent(root) + .withName("no-namespace-anywhere") + .build() + + subProject.projectDir.mkdirs() + subProject.buildFile.writeText( + """ + apply plugin: 'com.android.library' + """.trimIndent(), + ) + + val library = AndroidArchiveLibrary(root, artifactFor("no-namespace-anywhere"), "devRelease") + + assertFailsWith { library.getPackageName() } + } +} From e4a336e2e4ce63f165807b466cb10b6aa554259e Mon Sep 17 00:00:00 2001 From: hurali97 Date: Tue, 20 Oct 2026 01:21:27 -0700 Subject: [PATCH 2/3] fix: code review --- .../legacy-manifest-namespace-fallback.md | 5 +++ apps/RNApp/android/build.gradle | 2 +- .../prepare-android-build-gradle-for-ci.ts | 2 +- .../react/brownfield/gradle.properties | 2 +- .../brownfield/utils/AndroidArchiveLibrary.kt | 24 +++++++++++-- ...roidArchiveLibraryNamespaceFallbackTest.kt | 36 +++++++++++++++++++ .../android/__tests__/gradleHelpers.test.ts | 2 +- .../android/utils/constants.ts | 2 +- 8 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 .changeset/legacy-manifest-namespace-fallback.md diff --git a/.changeset/legacy-manifest-namespace-fallback.md b/.changeset/legacy-manifest-namespace-fallback.md new file mode 100644 index 000000000..326b2a39d --- /dev/null +++ b/.changeset/legacy-manifest-namespace-fallback.md @@ -0,0 +1,5 @@ +--- +'@callstack/react-native-brownfield': patch +--- + +fix: resolve the namespace from AndroidManifest.xml `package` for libraries without a `namespace` in build.gradle diff --git a/apps/RNApp/android/build.gradle b/apps/RNApp/android/build.gradle index c9e5efcc3..f26a88522 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-alpha10-SNAPSHOT") + classpath("com.callstack.react:brownfield-gradle-plugin:2.0.0-alpha11-SNAPSHOT") } } diff --git a/apps/scripts/prepare-android-build-gradle-for-ci.ts b/apps/scripts/prepare-android-build-gradle-for-ci.ts index d3f983d80..5be315323 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-alpha10-SNAPSHOT'; +const SNAPSHOT_VERSION = '2.0.0-alpha11-SNAPSHOT'; const targetPath = path.resolve( __dirname, '..', diff --git a/gradle-plugins/react/brownfield/gradle.properties b/gradle-plugins/react/brownfield/gradle.properties index 5b0eab2f9..60afacf51 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-alpha10 +VERSION=2.0.0-alpha11 GROUP=com.callstack.react IMPLEMENTATION_CLASS=com.callstack.react.brownfield.plugin.RNBrownfieldPlugin diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibrary.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibrary.kt index 1a53fbc13..2b8c4e83e 100644 --- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibrary.kt +++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibrary.kt @@ -3,6 +3,7 @@ package com.callstack.react.brownfield.utils import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo import org.gradle.api.Project import java.io.File +import javax.xml.parsers.DocumentBuilderFactory class AndroidArchiveLibrary( private val project: Project, @@ -59,6 +60,10 @@ class AndroidArchiveLibrary( * module declares no `namespace` in its `build.gradle`. The Android Gradle plugin itself * derives the namespace this way for such libraries, so they build fine as a regular * dependency but trip up [getNameSpaceFromBuildGradle]'s `build.gradle`-only regex. + * + * The manifest is parsed as XML and only the root `` element's `package` + * attribute is read, so comments or child attributes such as `android:targetPackage` + * cannot be mistaken for the package name. */ private fun getNameSpaceFromAndroidManifest(subProj: Project): String? { val candidateManifests = @@ -66,10 +71,23 @@ class AndroidArchiveLibrary( File(subProj.projectDir, "src/main/AndroidManifest.xml"), File(subProj.projectDir, "AndroidManifest.xml"), ) - val manifestFile = candidateManifests.firstOrNull { it.exists() } ?: return null - val regex = Regex("""package\s*=\s*["']([^"']+)["']""") - return regex.find(manifestFile.readText())?.groupValues?.get(1) + return candidateManifests + .filter { it.exists() } + .firstNotNullOfOrNull { manifestFile -> + runCatching { + val document = + DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeature("http://xml.org/sax/features/external-general-entities", false) + setFeature("http://xml.org/sax/features/external-parameter-entities", false) + }.newDocumentBuilder().parse(manifestFile) + document.documentElement + ?.getAttribute("package") + ?.takeIf { it.isNotBlank() } + }.getOrNull() + } } fun getManifestFile() = File(getExplodedAarRootDir(), "AndroidManifest.xml") diff --git a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibraryNamespaceFallbackTest.kt b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibraryNamespaceFallbackTest.kt index 1abbcb59a..edd0e7547 100644 --- a/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibraryNamespaceFallbackTest.kt +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibraryNamespaceFallbackTest.kt @@ -78,6 +78,42 @@ class AndroidArchiveLibraryNamespaceFallbackTest { assertEquals("com.swmansion.rnscreens", library.getPackageName()) } + @Test + fun `reads the package from the manifest root element and ignores comments and child attributes`() { + val root = ProjectBuilder.builder().build() + val subProject = + ProjectBuilder.builder() + .withParent(root) + .withName("react-native-legacy-manifest") + .build() + + subProject.projectDir.mkdirs() + subProject.buildFile.writeText( + """ + apply plugin: 'com.android.library' + """.trimIndent(), + ) + subProject.projectDir.resolve("src/main").mkdirs() + subProject.projectDir.resolve("src/main/AndroidManifest.xml").writeText( + """ + + + + + + + """.trimIndent(), + ) + + val library = AndroidArchiveLibrary(root, artifactFor("react-native-legacy-manifest"), "devRelease") + + assertEquals("com.reactnativelegacymanifest", library.getPackageName()) + } + @Test fun `errors when neither build-gradle nor the manifest declare a namespace`() { val root = ProjectBuilder.builder().build() 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 9100d096f..03ba1ee0c 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-alpha10")' + 'classpath("com.callstack.react:brownfield-gradle-plugin:2.0.0-alpha11")' ); }); 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 c4637a05a..0c67be0cb 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-alpha10'; +export const BROWNFIELD_PLUGIN_VERSION = '2.0.0-alpha11'; export const brownfieldGradlePluginDependency = `classpath("com.callstack.react:brownfield-gradle-plugin:${BROWNFIELD_PLUGIN_VERSION}")`; From bec6c79f105abde571999d85b0c2dcf804f45fb1 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Tue, 20 Oct 2026 03:17:40 -0700 Subject: [PATCH 3/3] fix: ci --- .github/workflows/ci.yml | 3 +++ apps/scripts/prepare-android-build-gradle-for-ci.ts | 13 ++++++------- scripts/ci-scaffold-smoke-android.sh | 11 +++++++++++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1b5d77cb..2773da4a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,9 @@ jobs: - name: Build packages run: yarn build + - name: Publish Brownfield Gradle Plugin to Maven Local + run: yarn run brownfield:plugin:publish:local + - name: Scaffold a fresh RN CLI app and assemble the packaging module run: bash ./scripts/ci-scaffold-smoke-android.sh diff --git a/apps/scripts/prepare-android-build-gradle-for-ci.ts b/apps/scripts/prepare-android-build-gradle-for-ci.ts index 5be315323..17323bf60 100644 --- a/apps/scripts/prepare-android-build-gradle-for-ci.ts +++ b/apps/scripts/prepare-android-build-gradle-for-ci.ts @@ -10,13 +10,12 @@ if (!projectDirName) { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const SNAPSHOT_VERSION = '2.0.0-alpha11-SNAPSHOT'; -const targetPath = path.resolve( - __dirname, - '..', - projectDirName, - 'android', - 'build.gradle' -); +// 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) + ? projectDirName + : path.resolve(__dirname, '..', projectDirName); +const targetPath = path.resolve(projectDir, 'android', 'build.gradle'); function ensureMavenLocalInBuildscriptRepositories(contents: string): string { const buildscriptReposRegex = diff --git a/scripts/ci-scaffold-smoke-android.sh b/scripts/ci-scaffold-smoke-android.sh index 0ed199be1..d607c3bed 100644 --- a/scripts/ci-scaffold-smoke-android.sh +++ b/scripts/ci-scaffold-smoke-android.sh @@ -54,6 +54,17 @@ grep -qF "brownfield-gradle-plugin:$PLUGIN_VERSION" "$GRADLE_DIR/build.gradle" | grep -q "include ':brownfieldlib'" "$GRADLE_DIR/settings.gradle" || { echo "settings.gradle lacks ':brownfieldlib'"; exit 1; } echo "::endgroup::" +echo "::group::Patch root build.gradle for CI snapshot plugin" +# The Gradle plugin under test is not published to Maven Central yet. Like the +# road-test CI, resolve it from Maven Local as -SNAPSHOT (the previous +# step publishes it with `yarn brownfield:plugin:publish:local`). The script +# also adds mavenLocal() to the buildscript repositories. +node --experimental-strip-types --no-warnings \ + "$REPO_ROOT/apps/scripts/prepare-android-build-gradle-for-ci.ts" "$WORKDIR/$APP_NAME" +grep -q "mavenLocal()" "$GRADLE_DIR/build.gradle" || { echo "root build.gradle lacks mavenLocal()"; exit 1; } +grep -q "brownfield-gradle-plugin:.*-SNAPSHOT" "$GRADLE_DIR/build.gradle" || { echo "root build.gradle lacks the SNAPSHOT Gradle plugin classpath"; exit 1; } +echo "::endgroup::" + echo "::group::Assemble Android packaging module" # The freshly generated app pins its own ndkVersion in buildscript ext; install # it if missing (CI runners carry a different preinstalled set). No-op locally