Skip to content

Commit 5ee9bab

Browse files
[brownfield] Fix reading updates app.manifest and meta-data tags (expo#45655)
# Why `expo-updates` would fail to read app.manifest and meta-data tags when consuming it as a brownfield AAR, causing update to not start up properly on android # How Extended `expo-brownfield`'s gradle plugin (`ExpoBrownfieldSetupPlugin`) to forward the host `:app` module's build-time outputs into the published brownfield AAR's release variant. Two pieces are forwarded: 1. **`:app:mergeReleaseAssets` output** is copied into a generated source dir on the `:brownfield` library and registered as a release-variant asset source. Forwarding the *merged* assets output (rather than a specific generator task) is deliberate: it picks up everything AGP would bundle into the host APK's `assets/` — the RN JS bundle, expo-updates' `app.manifest`, expo-constants' `app.config`, and any future generated asset emitted by a host-side gradle plugin. The old `setupBundleDependencyForRelease` and the standalone `generated/assets/react/release` source dir are now subsumed by this. 2. **Every `<application>` `<meta-data>` entry** from `:app/src/main/AndroidManifest.xml` is forwarded into a generated release-variant manifest on the `:brownfield` library, which AGP merges into the consumer's manifest at AAR-consumption time. This covers expo-updates, expo-notifications, expo-dev-launcher, and any other config plugin that injects runtime configuration into the host manifest. The forwarder also resolves `@string/...` references against `:app/src/main/res/values/strings.xml` (inlining e.g. `expo_runtime_version` → `"1.0.0"`) so the AAR doesn't need to also forward `strings.xml`, and preserves `android:resource="..."` refs alongside `android:value="..."`. # Test Plan End-to-end on Android: 1. **Publish the brownfield AAR to Maven Local** ```bash cd apps/minimal-tester npx expo-brownfield build:android --repository MavenLocal --release ``` 2. **Verify the AAR carries the forwarded artifacts** ```bash AAR=~/.m2/repository/com/community/minimaltester/brownfield/1.0.0/brownfield-1.0.0.aar unzip -l "$AAR" | grep -E 'assets/|AndroidManifest' unzip -p "$AAR" AndroidManifest.xml unzip -p "$AAR" assets/app.manifest unzip -p "$AAR" assets/app.config ``` 3. **Run the brownfield consumer against the published AAR** ```bash cd apps/brownfield-tester/isolated/android ./gradlew assembleRelease ``` 4. **Confirm the consumer's merged manifest picked up the forwarded meta-data** ```bash grep -A1 expo.modules.updates \ apps/brownfield-tester/isolated/android/app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml ``` # Checklist <!-- Please check the appropriate items below if they apply to your diff. --> - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
1 parent dc733c9 commit 5ee9bab

3 files changed

Lines changed: 174 additions & 11 deletions

File tree

‎packages/expo-brownfield/CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
### 🐛 Bug fixes
1010

11+
- Fix reading updates app.manifest and meta-data tags ([#45655](https://github.com/expo/expo/pull/45655) by [@gabrieldonadel](https://github.com/gabrieldonadel))
12+
1113
### 💡 Others
1214

1315
## 56.0.5 — 2026-05-08

‎packages/expo-brownfield/gradle-plugins/brownfield/src/main/kotlin/expo/modules/plugin/ExpoBrownfieldSetupPlugin.kt‎

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ class ExpoBrownfieldSetupPlugin : Plugin<Project> {
1616
project.afterEvaluate { project ->
1717
setupSourceSets(project)
1818
setupCopyingAutolinking(project)
19-
setupBundleDependencyForRelease(project)
2019
setupCopyingNativeLibsForType(project, "Release")
2120
setupCopyingNativeLibsForType(project, "Debug")
21+
setupHostAppArtifactForwardingForRelease(project)
2222
wireDevLauncherTasks(project)
2323
}
2424
}
@@ -58,7 +58,7 @@ class ExpoBrownfieldSetupPlugin : Plugin<Project> {
5858

5959
libraryExtension.sourceSets.getByName("release").apply {
6060
jniLibs.srcDirs("libsRelease")
61-
assets.srcDirs("$appBuildDir/generated/assets/react/release")
61+
// release assets src dir is wired in setupHostAppArtifactForwardingForRelease
6262
res.srcDirs("$appBuildDir/generated/res/react/release")
6363
}
6464

@@ -120,18 +120,74 @@ class ExpoBrownfieldSetupPlugin : Plugin<Project> {
120120
}
121121

122122
/**
123-
* Setup the dependency of the bundle tasks.
123+
* Forward the host `:app` module's build-time outputs into the published brownfield AAR so the
124+
* runtime React Native + expo libraries inside the AAR find the configuration they need.
124125
*
125-
* Needed to include bundle and assets in the release variant.
126+
* Two pieces are forwarded:
126127
*
127-
* @param brownfieldProject The brownfield project to setup the dependency of the bundle tasks
128-
* for.
128+
* 1. `:app:mergeReleaseAssets` output (everything that AGP would bundle into the host APK's
129+
* `assets/`). This includes the RN JS bundle and hashed assets, expo-updates' `app.manifest`,
130+
* expo-constants' `app.config`, and any other generated asset emitted by a host-side gradle
131+
* plugin. Forwarding the merged output (rather than picking specific generator tasks) makes
132+
* this future-proof: any new expo library that emits an asset on the `:app` side is picked
133+
* up automatically. Transitive dep: `mergeReleaseAssets` requires `createBundleReleaseJsAndAssets`
134+
* so the old `setupBundleDependencyForRelease` is no longer needed.
135+
*
136+
* 2. Every `<application>` `<meta-data>` entry from `:app/src/main/AndroidManifest.xml`,
137+
* written into a generated release-variant manifest that AGP merges into the consumer.
138+
* Covers expo-updates' `EXPO_UPDATE_URL`, expo-notifications' default icon/color, and
139+
* anything else a config plugin injects.
140+
*
141+
* @param brownfieldProject The brownfield project.
129142
*/
130-
internal fun setupBundleDependencyForRelease(brownfieldProject: Project) {
143+
internal fun setupHostAppArtifactForwardingForRelease(brownfieldProject: Project) {
131144
val appProject = findAppProject(brownfieldProject)
145+
val mergeAssetsTask = appProject.tasks.findByName("mergeReleaseAssets") ?: run {
146+
brownfieldProject.logger.lifecycle(
147+
"brownfield: \":${appProject.name}:mergeReleaseAssets\" task not found; " +
148+
"skipping host-app asset forwarding."
149+
)
150+
return
151+
}
152+
153+
val libraryExtension = getLibraryExtension(brownfieldProject)
154+
val moduleBuildDir = brownfieldProject.layout.buildDirectory.get().asFile
155+
156+
val hostAssetsDir = File(moduleBuildDir, "generated/assets/hostApp/release")
157+
val copyHostAssetsTask =
158+
brownfieldProject.tasks.register("copyHostAppAssetsRelease", Copy::class.java) { task ->
159+
task.dependsOn(mergeAssetsTask)
160+
task.from(mergeAssetsTask.outputs.files)
161+
task.into(hostAssetsDir)
162+
}
163+
libraryExtension.sourceSets.getByName("release").assets.srcDirs(hostAssetsDir)
164+
165+
val hostManifestFile =
166+
File(moduleBuildDir, "generated/manifest/hostApp/release/AndroidManifest.xml")
167+
val appManifest = File(appProject.projectDir, "src/main/AndroidManifest.xml")
168+
val appStrings = File(appProject.projectDir, "src/main/res/values/strings.xml")
169+
170+
val generateHostManifestTask =
171+
brownfieldProject.tasks.register("generateBrownfieldHostAppManifestRelease") { task ->
172+
task.inputs.file(appManifest)
173+
if (appStrings.exists()) {
174+
task.inputs.file(appStrings)
175+
}
176+
task.outputs.file(hostManifestFile)
177+
task.doLast {
178+
hostManifestFile.parentFile.mkdirs()
179+
hostManifestFile.writeText(buildForwardedApplicationManifest(appManifest, appStrings))
180+
}
181+
}
182+
libraryExtension.sourceSets.getByName("release").manifest.srcFile(hostManifestFile)
183+
132184
brownfieldProject.tasks.named("preReleaseBuild").configure { task ->
133-
task.dependsOn(":${appProject.name}:createBundleReleaseJsAndAssets")
185+
task.dependsOn(copyHostAssetsTask)
186+
task.dependsOn(generateHostManifestTask)
134187
}
188+
brownfieldProject.tasks
189+
.matching { it.name == "processReleaseManifest" || it.name == "processReleaseMainManifest" }
190+
.configureEach { task -> task.dependsOn(generateHostManifestTask) }
135191
}
136192

137193
/**
@@ -266,7 +322,7 @@ class ExpoBrownfieldSetupPlugin : Plugin<Project> {
266322
/**
267323
* Add explicit dependency between the `sourceDebugJar` and `generateServiceApolloSources`
268324
* tasks in `expo-dev-launcher` project.
269-
*
325+
*
270326
* @param brownfieldProject The brownfield project
271327
*/
272328
private fun wireDevLauncherTasks(brownfieldProject: Project) {
@@ -275,7 +331,7 @@ class ExpoBrownfieldSetupPlugin : Plugin<Project> {
275331

276332
val sourceDebugTask = devLauncherProject.tasks.findByName("sourceDebugJar")
277333
val apolloSourcesTask = devLauncherProject.tasks.findByName("generateServiceApolloSources")
278-
334+
279335
if (sourceDebugTask == null || apolloSourcesTask == null) {
280336
brownfieldProject.logger.warn("WARNING: Application uses expo-dev-launcher but tasks: sourceDebugJar and generateServiceApolloSources")
281337
brownfieldProject.logger.warn("Skipping explicitly defining dependency between the tasks...")
@@ -284,7 +340,7 @@ class ExpoBrownfieldSetupPlugin : Plugin<Project> {
284340

285341
sourceDebugTask.dependsOn(apolloSourcesTask)
286342
} catch (e: GradleException) {
287-
// no-op
343+
// no-op
288344
}
289345
}
290346
}

‎packages/expo-brownfield/gradle-plugins/brownfield/src/main/kotlin/expo/modules/plugin/utils.kt‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
@file:JvmName("BrownfieldSetupUtilsKt")
2+
13
package expo.modules.plugin
24

5+
import java.io.File
6+
import javax.xml.parsers.DocumentBuilderFactory
37
import org.gradle.api.Project
8+
import org.w3c.dom.Element
49

510
/**
611
* Find the app project in the root project.
@@ -14,3 +19,103 @@ internal fun findAppProject(project: Project): Project {
1419
it.plugins.hasPlugin("com.android.application")
1520
} ?: throw IllegalStateException("App project not found in the root project")
1621
}
22+
23+
/**
24+
* Build a release-variant AndroidManifest.xml that forwards every `<application>`
25+
* `<meta-data>` entry from the expo app's manifest into the brownfield library's release
26+
* manifest, so AGP merges them into the consumer's manifest at AAR consumption time.
27+
*
28+
* This covers any expo library whose config plugin injects runtime configuration into
29+
* the expo app's AndroidManifest at `expo prebuild` time (expo-updates, expo-notifications,
30+
* etc.). Without forwarding, the brownfield library's runtime modules read empty meta-data
31+
* and silently disable themselves.
32+
*
33+
* Value forms supported:
34+
* - Literal `android:value="..."` (preserved as-is).
35+
* - String resource refs `android:value="@string/foo"` are resolved against the expo
36+
* app's `res/values/strings.xml` and inlined. The brownfield AAR cannot share those
37+
* strings with the consumer because resources are not currently forwarded.
38+
* - `android:resource="@drawable/foo"` (or any other resource ref) is preserved as a
39+
* `resource` attribute. Note: for the consumer to actually resolve it, the referenced
40+
* resource needs to ship in the brownfield AAR (drawable, color, etc.).
41+
*/
42+
fun buildForwardedApplicationManifest(appManifest: File, appStrings: File): String {
43+
val empty = """<?xml version="1.0" encoding="utf-8"?>
44+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
45+
"""
46+
if (!appManifest.exists()) return empty
47+
48+
val factory = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = false }
49+
val doc = factory.newDocumentBuilder().parse(appManifest)
50+
val applicationNodes = doc.getElementsByTagName("application")
51+
if (applicationNodes.length == 0) return empty
52+
val applicationEl = applicationNodes.item(0) as? Element ?: return empty
53+
54+
val strings = parseStringResources(appStrings)
55+
val children = applicationEl.childNodes
56+
57+
data class MetaEntry(val name: String, val attr: String, val value: String)
58+
val entries = mutableListOf<MetaEntry>()
59+
for (i in 0 until children.length) {
60+
val el = children.item(i) as? Element ?: continue
61+
if (el.tagName != "meta-data") continue
62+
val name = el.getAttribute("android:name")
63+
if (name.isNullOrEmpty()) continue
64+
val literalValue = el.getAttribute("android:value")
65+
val resourceRef = el.getAttribute("android:resource")
66+
when {
67+
!literalValue.isNullOrEmpty() ->
68+
entries.add(MetaEntry(name, "android:value", resolveResourceReference(literalValue, strings)))
69+
!resourceRef.isNullOrEmpty() ->
70+
entries.add(MetaEntry(name, "android:resource", resourceRef))
71+
}
72+
}
73+
if (entries.isEmpty()) return empty
74+
75+
return buildString {
76+
append("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n")
77+
append("<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n")
78+
append(" <application>\n")
79+
entries.forEach { entry ->
80+
append(" <meta-data android:name=\"")
81+
append(escapeXmlAttribute(entry.name))
82+
append("\" ")
83+
append(entry.attr)
84+
append("=\"")
85+
append(escapeXmlAttribute(entry.value))
86+
append("\" />\n")
87+
}
88+
append(" </application>\n")
89+
append("</manifest>\n")
90+
}
91+
}
92+
93+
private fun parseStringResources(file: File): Map<String, String> {
94+
if (!file.exists()) return emptyMap()
95+
val factory = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = false }
96+
val doc = factory.newDocumentBuilder().parse(file)
97+
val list = doc.getElementsByTagName("string")
98+
val result = mutableMapOf<String, String>()
99+
for (i in 0 until list.length) {
100+
val el = list.item(i) as? Element ?: continue
101+
val name = el.getAttribute("name") ?: continue
102+
if (name.isEmpty()) continue
103+
result[name] = el.textContent
104+
}
105+
return result
106+
}
107+
108+
private fun resolveResourceReference(raw: String, strings: Map<String, String>): String {
109+
if (raw.startsWith("@string/")) {
110+
val key = raw.removePrefix("@string/")
111+
return strings[key] ?: raw
112+
}
113+
return raw
114+
}
115+
116+
private fun escapeXmlAttribute(value: String): String =
117+
value.replace("&", "&amp;")
118+
.replace("<", "&lt;")
119+
.replace(">", "&gt;")
120+
.replace("\"", "&quot;")
121+
.replace("'", "&apos;")

0 commit comments

Comments
 (0)