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/.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/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..17323bf60 100644 --- a/apps/scripts/prepare-android-build-gradle-for-ci.ts +++ b/apps/scripts/prepare-android-build-gradle-for-ci.ts @@ -9,14 +9,13 @@ if (!projectDirName) { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const SNAPSHOT_VERSION = '2.0.0-alpha10-SNAPSHOT'; -const targetPath = path.resolve( - __dirname, - '..', - projectDirName, - 'android', - 'build.gradle' -); +const SNAPSHOT_VERSION = '2.0.0-alpha11-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) + ? projectDirName + : path.resolve(__dirname, '..', projectDirName); +const targetPath = path.resolve(projectDir, 'android', 'build.gradle'); function ensureMavenLocalInBuildscriptRepositories(contents: string): string { const buildscriptReposRegex = 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 f9dfcc74f..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, @@ -46,11 +47,47 @@ 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. + * + * 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 = + listOf( + File(subProj.projectDir, "src/main/AndroidManifest.xml"), + File(subProj.projectDir, "AndroidManifest.xml"), + ) + + 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 new file mode 100644 index 000000000..edd0e7547 --- /dev/null +++ b/gradle-plugins/react/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibraryNamespaceFallbackTest.kt @@ -0,0 +1,137 @@ +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 `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() + 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() } + } +} 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}")`; 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