expo-brownfield 57.0.6 → 57.0.7

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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,12 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 57.0.7 — 2026-07-22
14
+
15
+ ### 🎉 New features
16
+
17
+ - [android] Add support for publishing a single fused AAR via AGP Fused Library ([#47921](https://github.com/expo/expo/pull/45878) by [@gabrieldonadel](https://github.com/gabrieldonadel))
18
+
13
19
  ## 57.0.6 — 2026-07-17
14
20
 
15
21
  _This version does not introduce any user-facing changes._
@@ -4,7 +4,7 @@ plugins {
4
4
  }
5
5
 
6
6
  group = 'expo.modules.brownfield'
7
- version = '57.0.6'
7
+ version = '57.0.7'
8
8
 
9
9
  expoModule {
10
10
  canBePublished false
@@ -14,7 +14,7 @@ android {
14
14
  namespace "expo.modules.brownfield"
15
15
  defaultConfig {
16
16
  versionCode 1
17
- versionName '57.0.6'
17
+ versionName '57.0.7'
18
18
  }
19
19
  }
20
20
 
@@ -17,8 +17,9 @@ const buildAndroid = async (command: Command) => {
17
17
  }
18
18
  printAndroidConfig(config);
19
19
 
20
+ const extraGradleArgs = config.fused ? ['-Pbrownfield.fused=true'] : [];
20
21
  for (const task of config.tasks) {
21
- await runTask(task, config.verbose, config.dryRun);
22
+ await runTask(task, config.verbose, config.dryRun, extraGradleArgs);
22
23
  }
23
24
  };
24
25
 
package/cli/src/index.ts CHANGED
@@ -16,6 +16,7 @@ program
16
16
  .option('-d, --debug', 'build debug variant')
17
17
  .option('-r, --release', 'build release variant')
18
18
  .option('-a, --all', 'build both debug and release variants')
19
+ .option('--fused', 'publish a single fat AAR per variant via AGP Fused Library')
19
20
  .option('--verbose', 'forward all output to the terminal')
20
21
  .option('-l, --library <library>', 'name of the brownfield library')
21
22
  .option('-t, --task <task...>', 'publishing task to be run (multiple can be passed)')
@@ -7,9 +7,21 @@ import CLIError from './error';
7
7
  import { withSpinner } from './spinner';
8
8
  import type { AndroidConfig, BuildVariant } from './types';
9
9
 
10
- export const buildPublishingTask = (variant: BuildVariant, repository: string): string => {
11
- const repositoryName = repository === 'MavenLocal' ? repository : `${repository}Repository`;
12
- return `publishBrownfield${variant}PublicationTo${repositoryName}`;
10
+ export const buildPublishingTask = (
11
+ variant: BuildVariant,
12
+ repository: string,
13
+ fusedOpts: { fused: boolean; library: string } = { fused: false, library: '' }
14
+ ): string => {
15
+ const repositoryName =
16
+ repository.toLowerCase() === 'mavenlocal' ? 'MavenLocal' : `${repository}Repository`;
17
+ const task = `publishBrownfield${variant}PublicationTo${repositoryName}`;
18
+ // In `--fused` mode, route the task to the matching sibling subproject:
19
+ // `:<lib>-fused-release` for Release, `:<lib>-fused-debug` for Debug.
20
+ if (fusedOpts.fused) {
21
+ const siblingSuffix = variant === 'Debug' ? 'debug' : 'release';
22
+ return `:${fusedOpts.library}-fused-${siblingSuffix}:${task}`;
23
+ }
24
+ return task;
13
25
  };
14
26
 
15
27
  export const findBrownfieldLibrary = (): string | undefined => {
@@ -59,6 +71,7 @@ export const printAndroidConfig = (config: AndroidConfig) => {
59
71
  console.log(chalk.bold('Resolved build configuration'));
60
72
  console.log(` - Build variant: ${chalk.blue(config.variant)}`);
61
73
  console.log(` - Library: ${chalk.blue(config.library)}`);
74
+ console.log(` - Fused: ${chalk.blue(config.fused)}`);
62
75
  console.log(` - Verbose: ${chalk.blue(config.verbose)}`);
63
76
  console.log(` - Dry run: ${chalk.blue(config.dryRun)}`);
64
77
  console.log(` - Tasks:`);
@@ -93,15 +106,33 @@ export const processTasks = (stdout: string): string[] => {
93
106
  );
94
107
  };
95
108
 
96
- export const runTask = async (task: string, verbose: boolean, dryRun: boolean) => {
109
+ export const runTask = async (
110
+ task: string,
111
+ verbose: boolean,
112
+ dryRun: boolean,
113
+ extraGradleArgs: string[] = []
114
+ ) => {
115
+ // Fused-shaped tasks (e.g. passed manually via -t without --fused) must still
116
+ // activate fused mode in Gradle: without `-Pbrownfield.fused=true` the fused
117
+ // sibling subprojects are inert (no publications) and the conditional AGP
118
+ // force-bump in the root build.gradle never applies, so the build would fail
119
+ // mid-execution under the version catalog's AGP.
120
+ const fusedProperty = '-Pbrownfield.fused=true';
121
+ const isFusedTask = /(?:^|:)[^:\s]+-fused-(?:release|debug):/.test(task);
122
+ const perTaskArgs =
123
+ isFusedTask && !extraGradleArgs.includes(fusedProperty)
124
+ ? [...extraGradleArgs, fusedProperty]
125
+ : extraGradleArgs;
126
+
127
+ const args = [task, ...perTaskArgs];
97
128
  if (dryRun) {
98
- console.log(`./gradlew ${task}`);
129
+ console.log(`./gradlew ${args.join(' ')}`);
99
130
  return;
100
131
  }
101
132
 
102
133
  return withSpinner({
103
134
  operation: () =>
104
- spawnAsync('./gradlew', [task], {
135
+ spawnAsync('./gradlew', args, {
105
136
  cwd: path.join(process.cwd(), 'android'),
106
137
  stdio: verbose ? 'inherit' : 'pipe',
107
138
  }),
@@ -1,4 +1,5 @@
1
1
  import type { OptionValues } from 'commander';
2
+ import { getConfig } from 'expo/config';
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
5
 
@@ -16,12 +17,15 @@ import type {
16
17
  import { HOST_PROVIDED_FRAMEWORKS_KEY } from '../../../shared/build';
17
18
 
18
19
  export const resolveBuildConfigAndroid = (options: OptionValues): AndroidConfig => {
19
- const variant = resolveVariant(options);
20
+ const fused = !!options.fused;
21
+ const variant: BuildVariant = resolveVariant(options);
22
+ const library = resolveLibrary(options);
20
23
  return {
21
24
  ...resolveCommonConfig(options),
22
- library: resolveLibrary(options),
23
- tasks: resolveTaskArray(options, variant),
25
+ library,
26
+ tasks: resolveTaskArray(options, variant, { fused, library }),
24
27
  variant,
28
+ fused,
25
29
  };
26
30
  };
27
31
 
@@ -157,15 +161,66 @@ const resolveLibrary = (options: OptionValues): string => {
157
161
  return options.library || findBrownfieldLibrary();
158
162
  };
159
163
 
160
- const resolveTaskArray = (options: OptionValues, variant: BuildVariant): string[] => {
164
+ const resolveTaskArray = (
165
+ options: OptionValues,
166
+ variant: BuildVariant,
167
+ fusedOpts: { fused: boolean; library: string }
168
+ ): string[] => {
161
169
  const tasks: string[] = options.task ?? [];
162
- const repoTasks = (options.repository ?? []).map((repo: string) =>
163
- buildPublishingTask(variant, repo)
170
+ let repositories: string[] = options.repository ?? [];
171
+ if (tasks.length === 0 && repositories.length === 0) {
172
+ repositories = resolveLocalRepositoriesFromAppConfig(!!options.verbose);
173
+ if (repositories.length > 0) {
174
+ console.info(
175
+ `No --repo or --task specified; defaulting to local repositories from the app config: ${repositories.join(', ')}`
176
+ );
177
+ }
178
+ }
179
+ // In `--fused` mode, `--all` expands to separate Debug + Release task
180
+ // invocations against the matching sibling subprojects.
181
+ const variantsForRepoTasks: BuildVariant[] =
182
+ fusedOpts.fused && variant === 'All' ? ['Debug', 'Release'] : [variant];
183
+ const repoTasks = repositories.flatMap((repo) =>
184
+ variantsForRepoTasks.map((v) => buildPublishingTask(v, repo, fusedOpts))
164
185
  );
165
186
 
166
187
  return Array.from(new Set([...tasks, ...repoTasks]));
167
188
  };
168
189
 
190
+ const resolveLocalRepositoriesFromAppConfig = (verbose: boolean): string[] => {
191
+ let publishing: unknown;
192
+ try {
193
+ const { exp } = getConfig(process.cwd(), { skipSDKVersionRequirement: true });
194
+ const plugin = exp.plugins?.find(
195
+ (entry): entry is [string, any] => Array.isArray(entry) && entry[0] === 'expo-brownfield'
196
+ );
197
+ publishing = plugin?.[1]?.android?.publishing;
198
+ } catch (error) {
199
+ // App config could not be evaluated here — fall through to the plugin's
200
+ // default publishing target below.
201
+ if (verbose) {
202
+ console.warn(
203
+ `Could not read \`android.publishing\` from the app config, falling back to MavenLocal: ${
204
+ error instanceof Error ? error.message : String(error)
205
+ }`
206
+ );
207
+ }
208
+ }
209
+
210
+ const entries =
211
+ Array.isArray(publishing) && publishing.length > 0 ? publishing : [{ type: 'localMaven' }];
212
+ const repositories = entries.flatMap((entry) => {
213
+ if (entry?.type === 'localMaven') {
214
+ return ['MavenLocal'];
215
+ }
216
+ if (entry?.type === 'localDirectory' && typeof entry?.name === 'string') {
217
+ return [entry.name];
218
+ }
219
+ return [];
220
+ });
221
+ return Array.from(new Set(repositories));
222
+ };
223
+
169
224
  const resolveVariant = (options: OptionValues): BuildVariant => {
170
225
  let variant: BuildVariant = 'All';
171
226
  if (options.release && !options.debug) {
@@ -20,7 +20,8 @@ type ErrorType =
20
20
  class CLIError {
21
21
  private static readonly errorSymbol: string = '✖';
22
22
  private static readonly errorMessages: Record<ErrorType, string> = {
23
- 'android-task-repo': 'At least one task or repository must be specified',
23
+ 'android-task-repo':
24
+ 'At least one task or repository must be specified. The app config declares no local Maven repositories to default to, and remote repositories are never published to implicitly — pass `--repo <name>` or `-t <gradle task>` explicitly.',
24
25
  'android-directory-not-found': 'Cannot find `android` directory in the project',
25
26
  'android-library-unknown-error': 'Unknown error occurred while finding brownfield library',
26
27
  'android-library-not-found': 'Could not find brownfield library in the project',
@@ -17,14 +17,24 @@ export const validatePrebuild = async (
17
17
 
18
18
  if (!checkPrebuild(platform)) {
19
19
  console.info(`${chalk.yellow(`⚠ Prebuild for platform: ${platform} is missing`)}`);
20
- const response = await prompts({
21
- type: 'confirm',
22
- name: 'shouldRunPrebuild',
23
- message: 'Do you want to run the prebuild now?',
24
- initial: false,
25
- });
26
20
 
27
- if (response.shouldRunPrebuild) {
21
+ let shouldRunPrebuild: boolean;
22
+ if (isInteractive()) {
23
+ const response = await prompts({
24
+ type: 'confirm',
25
+ name: 'shouldRunPrebuild',
26
+ message: 'Do you want to run the prebuild now?',
27
+ initial: false,
28
+ });
29
+ shouldRunPrebuild = !!response.shouldRunPrebuild;
30
+ } else {
31
+ console.info(
32
+ `Non-interactive shell detected; running \`npx expo prebuild --platform ${platform}\` automatically`
33
+ );
34
+ shouldRunPrebuild = true;
35
+ }
36
+
37
+ if (shouldRunPrebuild) {
28
38
  await withSpinner({
29
39
  operation: () => spawnAsync('npx', ['expo', 'prebuild', '--platform', platform]),
30
40
  loaderMessage: `Running 'npx expo prebuild' for platform: ${platform}...`,
@@ -109,3 +119,7 @@ const checkPrebuild = (platform: Platform): boolean => {
109
119
  const nativeDirectory = path.join(process.cwd(), platform);
110
120
  return fs.existsSync(nativeDirectory);
111
121
  };
122
+
123
+ const isInteractive = (): boolean => {
124
+ return !!process.stdin.isTTY && !!process.stdout.isTTY;
125
+ };
@@ -20,6 +20,7 @@ export interface AndroidConfig extends CommonConfig {
20
20
  library: string;
21
21
  tasks: string[];
22
22
  variant: BuildVariant;
23
+ fused: boolean;
23
24
  }
24
25
 
25
26
  export interface PackageConfiguration {
@@ -12,6 +12,7 @@ class ExpoBrownfieldSetupPlugin : Plugin<Project> {
12
12
  override fun apply(project: Project) {
13
13
  project.evaluationDependsOn(":expo")
14
14
  setupDependencySubstitution(project)
15
+ setupFusedModeStripping(project)
15
16
 
16
17
  project.afterEvaluate { project ->
17
18
  setupSourceSets(project)
@@ -23,6 +24,61 @@ class ExpoBrownfieldSetupPlugin : Plugin<Project> {
23
24
  }
24
25
  }
25
26
 
27
+ /**
28
+ * In `--fused` builds, strips entries from the autolinking-generated
29
+ * `ExpoModulesPackageList.kt` by package prefix, via
30
+ * `-Pbrownfield.fused.strip-packages=expo.modules.foo.,expo.modules.bar.`.
31
+ *
32
+ * This is the companion escape hatch to `-Pbrownfield.fused.skip=<project,...>`
33
+ * in the fused sibling's build script: a module excluded from the fat AAR still
34
+ * has its `Package` class referenced by `ExpoModulesPackageList.<clinit>`, which
35
+ * would crash the host with `NoClassDefFoundError` at startup — stripping the
36
+ * matching entries here keeps the package list consistent with the AAR contents.
37
+ * Inert unless both properties are set.
38
+ */
39
+ private fun setupFusedModeStripping(brownfieldProject: Project) {
40
+ if (brownfieldProject.findProperty("brownfield.fused") != "true") return
41
+
42
+ val expoProject = brownfieldProject.rootProject.findProject(":expo") ?: return
43
+
44
+ val stripPrefixes =
45
+ (brownfieldProject.findProperty("brownfield.fused.strip-packages") as? String)
46
+ ?.split(',')
47
+ ?.map { it.trim() }
48
+ ?.filter { it.isNotEmpty() }
49
+ ?.toSet()
50
+ ?: emptySet()
51
+
52
+ if (stripPrefixes.isEmpty()) return
53
+
54
+ // `:expo` is already evaluated (forced by `evaluationDependsOn(":expo")` at the
55
+ // top of `apply`), so we read the task directly — no afterEvaluate needed.
56
+ val task = expoProject.tasks.findByName("generatePackagesList") ?: return
57
+ task.doLast {
58
+ val outputFile =
59
+ expoProject.layout.buildDirectory
60
+ .file("generated/expo/src/main/java/expo/modules/ExpoModulesPackageList.kt")
61
+ .get()
62
+ .asFile
63
+ if (!outputFile.exists()) {
64
+ expoProject.logger.warn(
65
+ "brownfield.fused: ExpoModulesPackageList.kt not found at ${outputFile.path}; nothing to strip"
66
+ )
67
+ return@doLast
68
+ }
69
+ val original = outputFile.readText()
70
+ val stripped =
71
+ original
72
+ .lineSequence()
73
+ .filter { line -> stripPrefixes.none { prefix -> line.contains(prefix) } }
74
+ .joinToString("\n")
75
+ outputFile.writeText(stripped)
76
+ expoProject.logger.lifecycle(
77
+ "brownfield.fused: stripped ExpoModulesPackageList entries matching ${stripPrefixes.joinToString(", ")}"
78
+ )
79
+ }
80
+ }
81
+
26
82
  /**
27
83
  * Setup the dependency substitution for `react-android` and `hermes-android`.
28
84
  *
@@ -14,7 +14,10 @@ dependencies {
14
14
  implementation("expo.modules:expo-autolinking-plugin-shared")
15
15
  implementation("org.json:json:20250517")
16
16
  implementation(gradleApi())
17
- compileOnly("com.android.tools.build:gradle:8.5.0")
17
+ // Matches FUSED_AGP_VERSION in the config plugin — the newest AGP the plugin can
18
+ // run against (forced in fused mode). Compiling against a newer major than the
19
+ // runtime AGP risks NoSuchMethodError on changed APIs.
20
+ compileOnly("com.android.tools.build:gradle:8.13.0")
18
21
  }
19
22
 
20
23
  java {
@@ -13,6 +13,14 @@ import org.gradle.api.tasks.TaskProvider
13
13
  */
14
14
  internal fun setupPrebuiltsCopying(rootProject: Project) {
15
15
  rootProject.afterEvaluate {
16
+ // In `--fused` mode the user is publishing a single fat AAR via Fused Library that
17
+ // already merges every Expo module's classes/resources/jni inside it — the
18
+ // per-module mavenLocal/remote re-publish loop below would emit redundant
19
+ // coordinates next to the fat AAR. Skip.
20
+ if (rootProject.findProperty("brownfield.fused") == "true") {
21
+ return@afterEvaluate
22
+ }
23
+
16
24
  val configExtension = getConfigExtension(rootProject)
17
25
 
18
26
  if (configExtension.publications.isEmpty) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-brownfield",
3
- "version": "57.0.6",
3
+ "version": "57.0.7",
4
4
  "description": "Toolkit and APIs for adding brownfield setup to Expo projects",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -41,7 +41,7 @@
41
41
  "chalk": "^4.1.2",
42
42
  "commander": "^14.0.3",
43
43
  "diff": "^5.2.0",
44
- "expo-build-properties": "~57.0.6",
44
+ "expo-build-properties": "~57.0.7",
45
45
  "expo-manifests": "~57.0.1",
46
46
  "ora": "^5.4.1",
47
47
  "prompts": "^2.4.2"
@@ -53,15 +53,15 @@
53
53
  "@types/prompts": "^2.0.6",
54
54
  "@types/react": "~19.2.0",
55
55
  "glob": "^13.0.6",
56
- "expo": "57.0.7",
57
- "expo-module-scripts": "56.0.3",
58
- "create-expo": "5.0.0"
56
+ "expo": "57.0.8",
57
+ "create-expo": "5.0.0",
58
+ "expo-module-scripts": "56.0.3"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "expo": "*",
62
62
  "react": "*"
63
63
  },
64
- "gitHead": "0dac79c8469fa00b76fb8f402c47ed61f3d6317e",
64
+ "gitHead": "a4789f1e53353f4929b0baddcfe5a7c622b99c71",
65
65
  "scripts": {
66
66
  "build": "expo-module build",
67
67
  "build:shared": "expo-module build shared",
@@ -14,10 +14,63 @@ const withGradlePropertiesPlugin: ConfigPlugin = (config) => {
14
14
  }
15
15
  }
16
16
 
17
+ // Opt into AGP's Fused Library Preview. The `.publicationOnly=false` flag lets
18
+ // `include(project(...))` resolve sibling subprojects directly (no mavenLocal
19
+ // round-trip). No-op in non-fused mode.
20
+ const hasFusedOptIn = config.modResults.some(
21
+ (item) =>
22
+ item.type === 'property' && item.key === 'android.experimental.fusedLibrarySupport'
23
+ );
24
+ const hasFusedPubFlag = config.modResults.some(
25
+ (item) =>
26
+ item.type === 'property' &&
27
+ item.key === 'android.experimental.fusedLibrarySupport.publicationOnly'
28
+ );
29
+ if (!hasFusedOptIn || !hasFusedPubFlag) {
30
+ config.modResults = [
31
+ ...config.modResults,
32
+ ...getFusedLibrarySupportConfiguration(hasFusedOptIn, hasFusedPubFlag),
33
+ ];
34
+ }
35
+
17
36
  return config;
18
37
  });
19
38
  };
20
39
 
40
+ const getFusedLibrarySupportConfiguration = (
41
+ hasFusedOptIn: boolean,
42
+ hasFusedPubFlag: boolean
43
+ ): AndroidConfig.Properties.PropertiesItem[] => {
44
+ const items: AndroidConfig.Properties.PropertiesItem[] = [];
45
+ if (!hasFusedOptIn) {
46
+ items.push(
47
+ {
48
+ type: 'comment',
49
+ value: "Acknowledge AGP Fused Library Preview status (required to apply the plugin)",
50
+ },
51
+ {
52
+ type: 'property',
53
+ key: 'android.experimental.fusedLibrarySupport',
54
+ value: 'true',
55
+ }
56
+ );
57
+ }
58
+ if (!hasFusedPubFlag) {
59
+ items.push(
60
+ {
61
+ type: 'comment',
62
+ value: 'Allow `com.android.fused-library` to include sibling project deps directly',
63
+ },
64
+ {
65
+ type: 'property',
66
+ key: 'android.experimental.fusedLibrarySupport.publicationOnly',
67
+ value: 'false',
68
+ }
69
+ );
70
+ }
71
+ return items;
72
+ };
73
+
21
74
  const getDevMenuReleaseConfiguration = (): AndroidConfig.Properties.PropertiesItem[] => {
22
75
  return [
23
76
  {
@@ -7,27 +7,63 @@ const EXPO_APPLY_STATEMENT = 'apply plugin: "expo-root-project"';
7
7
  const PLUGIN_CLASSPATH = 'expo.modules:publish';
8
8
  const PLUGIN_NAME = 'expo-brownfield-publish';
9
9
 
10
+ // AGP 8.12.0's Fused Library plugin's `rewriteClasses` task uses an ASM API version
11
+ // below 9 at the call site and can't read `PermittedSubclasses` bytecode attributes
12
+ // emitted for every Kotlin `sealed class`. AGP 8.13.0+ raises the hardcoded API
13
+ // version. Bump only when the user opts into fused mode via `--fused` (which passes
14
+ // `-Pbrownfield.fused=true`), so default builds keep using the version catalog's AGP.
15
+ const FUSED_AGP_VERSION = '8.13.0';
16
+ const FUSED_AGP_MARKER = 'brownfield.fused';
17
+
10
18
  const withProjectBuildGradlePlugin: ConfigPlugin<PluginConfig> = (config, pluginConfig) => {
11
19
  return withProjectBuildGradle(config, (config) => {
12
- if (config.modResults.contents.includes(PLUGIN_CLASSPATH)) {
13
- return config;
20
+ let lines = config.modResults.contents.split('\n');
21
+
22
+ if (!config.modResults.contents.includes(FUSED_AGP_MARKER)) {
23
+ lines = addFusedAgpResolutionStrategy(lines);
24
+ }
25
+
26
+ if (!config.modResults.contents.includes(PLUGIN_CLASSPATH)) {
27
+ lines = addPluginClasspathStatement(lines);
28
+ lines = addApplyStatement(lines);
29
+ lines = addPublicationConfiguration(
30
+ lines,
31
+ pluginConfig.publishing,
32
+ pluginConfig.projectRoot,
33
+ pluginConfig.libraryName
34
+ );
14
35
  }
15
36
 
16
- let lines = config.modResults.contents.split('\n');
17
- lines = addPluginClasspathStatement(lines);
18
- lines = addApplyStatement(lines);
19
- lines = addPublicationConfiguration(
20
- lines,
21
- pluginConfig.publishing,
22
- pluginConfig.projectRoot,
23
- pluginConfig.libraryName
24
- );
25
37
  config.modResults.contents = lines.join('\n');
26
38
 
27
39
  return config;
28
40
  });
29
41
  };
30
42
 
43
+ const addFusedAgpResolutionStrategy = (lines: string[]): string[] => {
44
+ // Appended as a sibling top-level `buildscript { ... }` block. Gradle merges multiple
45
+ // top-level buildscript blocks, so this composes with the project's existing one
46
+ // without clobbering it. The `findProperty` check makes the override conditional on
47
+ // `-Pbrownfield.fused=true` (set by `expo-brownfield build:android --fused`); without
48
+ // the flag the resolution-strategy block never runs and AGP stays at the catalog
49
+ // version.
50
+ const block = [
51
+ '',
52
+ 'buildscript {',
53
+ ' // expo-brownfield: bump AGP only when `--fused` is active (CLI passes',
54
+ ` // -P${FUSED_AGP_MARKER}=true). AGP 8.12.0's Fused Library can't read Kotlin`,
55
+ ' // sealed-class bytecode (PermittedSubclasses requires ASM API >= 9, fixed',
56
+ ` // in AGP ${FUSED_AGP_VERSION}).`,
57
+ ` if (findProperty('${FUSED_AGP_MARKER}') == 'true') {`,
58
+ ' configurations.classpath {',
59
+ ` resolutionStrategy.force 'com.android.tools.build:gradle:${FUSED_AGP_VERSION}'`,
60
+ ' }',
61
+ ' }',
62
+ '}',
63
+ ];
64
+ return [...lines, ...block];
65
+ };
66
+
31
67
  const addPluginClasspathStatement = (lines: string[]): string[] => {
32
68
  const statement = ` classpath('${PLUGIN_CLASSPATH}')`;
33
69
  const lastClasspathIndex = lines.findLastIndex((line) => line.includes('classpath('));
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
 
4
4
  import { applyPatchToFile, checkPlugin, mkdir } from '../../common';
5
5
  import type { PluginConfig } from '../types';
6
- import { createFileFromTemplate } from '../utils';
6
+ import { createFileFromTemplate, createFileFromTemplateAs } from '../utils';
7
7
 
8
8
  const withProjectFilesPlugin: ConfigPlugin<PluginConfig> = (config, pluginConfig) => {
9
9
  return withAndroidManifest(config, (config) => {
@@ -15,9 +15,20 @@ const withProjectFilesPlugin: ConfigPlugin<PluginConfig> = (config, pluginConfig
15
15
  const brownfieldMainPath = path.join(brownfieldPath, 'src/main/');
16
16
  const brownfieldSourcesPath = path.join(brownfieldMainPath, pluginConfig.packagePath);
17
17
 
18
+ const fusedReleasePath = path.join(
19
+ pluginConfig.projectRoot,
20
+ `android/${pluginConfig.libraryName}-fused-release`
21
+ );
22
+ const fusedDebugPath = path.join(
23
+ pluginConfig.projectRoot,
24
+ `android/${pluginConfig.libraryName}-fused-debug`
25
+ );
26
+
18
27
  // Create directory for the brownfield library sources
19
28
  // (and all intermediate directories)
20
29
  mkdir(brownfieldSourcesPath, true);
30
+ mkdir(fusedReleasePath, true);
31
+ mkdir(fusedDebugPath, true);
21
32
 
22
33
  // Add files from templates to the brownfield library:
23
34
  // - AndroidManifest.xml
@@ -49,6 +60,27 @@ const withProjectFilesPlugin: ConfigPlugin<PluginConfig> = (config, pluginConfig
49
60
  createFileFromTemplate('proguard-rules.pro', brownfieldPath);
50
61
  createFileFromTemplate('consumer-rules.pro', brownfieldPath);
51
62
 
63
+ // Emit the fused siblings' build.gradle.kts files. Same template at
64
+ // packages/expo-brownfield/plugin/templates/android/fused/build.gradle.kts —
65
+ // `fusedVariant` is the only difference: substituted as "release" or "debug",
66
+ // it drives the namespace suffix, publication name, and which build type the
67
+ // sibling resolves and fuses.
68
+ const fusedTemplate = path.join('fused', 'build.gradle.kts');
69
+ const fusedBaseVars = {
70
+ packageId: pluginConfig.package,
71
+ groupId: pluginConfig.group,
72
+ version: pluginConfig.version,
73
+ libraryName: pluginConfig.libraryName,
74
+ };
75
+ createFileFromTemplateAs(fusedTemplate, fusedReleasePath, 'build.gradle.kts', {
76
+ ...fusedBaseVars,
77
+ fusedVariant: 'release',
78
+ });
79
+ createFileFromTemplateAs(fusedTemplate, fusedDebugPath, 'build.gradle.kts', {
80
+ ...fusedBaseVars,
81
+ fusedVariant: 'debug',
82
+ });
83
+
52
84
  // Adjust ReactNativeHostManager and BrownfieldActivity to initialize dev menu
53
85
  if (checkPlugin(config, 'expo-dev-menu')) {
54
86
  applyPatchToFile(
@@ -4,14 +4,32 @@ import type { PluginConfig } from '../types';
4
4
 
5
5
  const withSettingsGradlePlugin: ConfigPlugin<PluginConfig> = (config, pluginConfig) => {
6
6
  return withSettingsGradle(config, (config) => {
7
- config.modResults.contents += getBrownfieldIncludeStatement(pluginConfig.libraryName);
8
- config.modResults.contents += getBrownfieldPluginIncludeStatement();
7
+ // Append line-by-line so re-running prebuild without --clean (or after
8
+ // upgrading from a version without the fused siblings) adds only what's
9
+ // missing instead of duplicating the whole block.
10
+ for (const line of getBrownfieldIncludeStatements(pluginConfig.libraryName)) {
11
+ if (!config.modResults.contents.includes(line)) {
12
+ config.modResults.contents += `${line}\n`;
13
+ }
14
+ }
15
+ if (!config.modResults.contents.includes('includeBuild(brownfieldPluginsPath)')) {
16
+ config.modResults.contents += getBrownfieldPluginIncludeStatement();
17
+ }
9
18
  return config;
10
19
  });
11
20
  };
12
21
 
13
- const getBrownfieldIncludeStatement = (libraryName: string) => {
14
- return `include ':${libraryName}'\n`;
22
+ // Two fused sibling Gradle subprojects (sourceless, applies `com.android.fused-library`)
23
+ // are always emitted — one per variant — so each can be targeted on demand by
24
+ // `expo-brownfield build:android --fused --release` / `--debug` / `--all`. Their
25
+ // build scripts are inert unless the CLI passes `-Pbrownfield.fused=true`, so in
26
+ // default mode they register no publications and resolve nothing.
27
+ const getBrownfieldIncludeStatements = (libraryName: string) => {
28
+ return [
29
+ `include ':${libraryName}'`,
30
+ `include ':${libraryName}-fused-release'`,
31
+ `include ':${libraryName}-fused-debug'`,
32
+ ];
15
33
  };
16
34
 
17
35
  const getBrownfieldPluginIncludeStatement = () => {
@@ -15,6 +15,11 @@ const interpolateVariables = (str: string, variables: Record<string, unknown>):
15
15
  let match = variableRegex.exec(str);
16
16
  while (match) {
17
17
  const variable = match[0].slice(3, -2);
18
+ if (!(variable in variables)) {
19
+ throw new Error(
20
+ `The expo-brownfield template variable "${variable}" has no value, so the generated file would contain the literal string "undefined" and fail to build. This usually means the plugin passed an incomplete variable set for this template — report this at https://github.com/expo/expo/issues.`
21
+ );
22
+ }
18
23
  str = str.replace(match[0], String(variables[variable]));
19
24
  match = variableRegex.exec(str);
20
25
  }