@symbiote-native/expo-modules-link 0.4.0 → 0.5.0

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/README.md CHANGED
@@ -19,12 +19,17 @@ project more than once.
19
19
  npm install @symbiote-native/expo-modules-link
20
20
  ```
21
21
 
22
+ Scaffolding or extending a SymbioteNative app? `npx @symbiote-native/cli new --expo-modules` (or
23
+ `add --expo-modules` in an existing app) installs and wires this for you — see
24
+ [`@symbiote-native/cli`](../cli). Picking any individual Expo-backed package (`--battery`,
25
+ `--sensors`, ...) pulls this in automatically too.
26
+
22
27
  Then wire the `postinstall` hook so every install re-runs the linker:
23
28
 
24
29
  ```json
25
30
  {
26
31
  "dependencies": {
27
- "@symbiote-native/expo-modules-link": "^0.1.0"
32
+ "@symbiote-native/expo-modules-link": "^0.4.0"
28
33
  },
29
34
  "scripts": {
30
35
  "postinstall": "symbiote-expo-link"
@@ -141,6 +146,18 @@ That makes overriding a description free:
141
146
  When your wording and the package's default disagree, the run prints a one-line notice naming
142
147
  both. It keeps your file as-is - the point is that drift is visible, not that it's corrected.
143
148
 
149
+ ## iOS Info.plist array keys
150
+
151
+ `UIBackgroundModes`, `BGTaskSchedulerPermittedIdentifiers` - anything Info.plist expects as an
152
+ `<array>` rather than a `<string>` - is a different shape from a permission string and can't reuse
153
+ `infoPlistKeys`. A package declares it under `ios.infoPlistArrayKeys` instead:
154
+ `{ "UIBackgroundModes": ["audio"] }`. Several packages can want the SAME key (`task-manager`,
155
+ `background-fetch`, `location`, and `audio` all touch `UIBackgroundModes`) - every package's items
156
+ merge into one array instead of one package's write clobbering another's, and an item already
157
+ present (packaged or hand-added) is never duplicated or reordered. A key the app doesn't have yet
158
+ gets a whole new `<key>` + `<array>` block in the outer `<dict>`; a key it already has only gets
159
+ the missing items appended, same additive/no-generated-region policy as `infoPlistKeys` above.
160
+
144
161
  ## Android manifest attributes
145
162
 
146
163
  `android.manifestApplicationAttributes` is additive-only for the same reason Info.plist is, plus
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@symbiote-native/expo-modules-link",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Native-module registration for apps using expo-modules-core wrapper packages (sensors, local-auth, haptics, ...). One app-level postinstall scans node_modules for every installed package's native-link.json and regenerates the app's app/build.gradle and MainApplication.kt registration blocks, so a new native module never needs a hand-edit.",
5
+ "keywords": [
6
+ "react-native",
7
+ "symbiote-native",
8
+ "expo-modules-core",
9
+ "autolinking",
10
+ "codegen",
11
+ "postinstall",
12
+ "android",
13
+ "ios"
14
+ ],
5
15
  "license": "MIT",
6
16
  "repository": {
7
17
  "type": "git",
@@ -18,7 +28,10 @@
18
28
  "module": "src/index.cjs",
19
29
  "types": "src/index.d.ts",
20
30
  "exports": {
21
- ".": "./src/index.cjs"
31
+ ".": {
32
+ "types": "./src/index.d.ts",
33
+ "default": "./src/index.cjs"
34
+ }
22
35
  },
23
36
  "bin": {
24
37
  "symbiote-expo-link": "./bin/postinstall.cjs"
package/src/index.cjs CHANGED
@@ -16,6 +16,10 @@ const REGION_NOTE = 'generated by symbiote-expo-link - do not edit, regenerated
16
16
 
17
17
  const REACT_ANDROID_ANCHOR = 'implementation("com.facebook.react:react-android")';
18
18
  const MODULES_MAP_ANCHOR = 'override fun getModulesMap(): Map<Class<out Module>, String?> = mapOf(';
19
+ // Unlike MODULES-MAP, no `getServices()` stub exists in a hand-written ExpoModulesProvider
20
+ // (AppContext defaults it to `emptyList()`) - the whole method is generated content, anchored
21
+ // right after the class opens.
22
+ const MODULES_PROVIDER_CLASS_ANCHOR = ': ModulesProvider {';
19
23
 
20
24
  const MANIFEST_FILENAME = 'native-link.json';
21
25
 
@@ -180,10 +184,16 @@ function patchBuildGradle(appRoot, entries) {
180
184
  return;
181
185
  }
182
186
 
183
- const projectNames = entries
187
+ const primaryProjectNames = entries
184
188
  .filter((entry) => entry.manifest.android && entry.manifest.android.gradleProjectName)
185
189
  .map((entry) => entry.manifest.android.gradleProjectName);
186
- const deps = [...new Set(projectNames)].sort().map((name) => `implementation project(':${name}')`);
190
+ // A `services` entry can back onto a different native package than the one declaring it (e.g.
191
+ // task-manager's native-link.json lists expo-constants' ConstantsService) - that package's
192
+ // Gradle subproject needs including too, same as any primary gradleProjectName.
193
+ const serviceProjectNames = androidServicesOf(entries).map((service) => service.gradleProjectName);
194
+ const deps = [...new Set([...primaryProjectNames, ...serviceProjectNames])]
195
+ .sort()
196
+ .map((name) => `implementation project(':${name}')`);
187
197
 
188
198
  const content = fs.readFileSync(gradlePath, 'utf8');
189
199
  const next = writeRegion({
@@ -204,6 +214,12 @@ function androidModulesOf(entries) {
204
214
  );
205
215
  }
206
216
 
217
+ function androidServicesOf(entries) {
218
+ return entries.flatMap((entry) =>
219
+ entry.manifest.android && Array.isArray(entry.manifest.android.services) ? entry.manifest.android.services : [],
220
+ );
221
+ }
222
+
207
223
  function patchMainApplication(appRoot, entries) {
208
224
  const filePath = findMainApplicationFile(appRoot);
209
225
  if (!filePath) {
@@ -212,8 +228,18 @@ function patchMainApplication(appRoot, entries) {
212
228
  }
213
229
 
214
230
  const modules = androidModulesOf(entries);
215
- const imports = [...new Set(modules.map((module) => `import ${module.importPath}`))].sort();
231
+ const services = androidServicesOf(entries);
232
+ const imports = [
233
+ ...new Set([
234
+ ...modules.map((module) => `import ${module.importPath}`),
235
+ ...services.map((service) => `import ${service.importPath}`),
236
+ // Only getServices()'s return type needs this import, but it's unconditional because the
237
+ // method itself is always regenerated below (see the SERVICES region), signature and all.
238
+ 'import expo.modules.kotlin.services.Service',
239
+ ]),
240
+ ].sort();
216
241
  const mapEntries = [...new Set(modules.map((module) => `${module.className}::class.java to "${module.nativeName}",`))].sort();
242
+ const serviceEntries = [...new Set(services.map((service) => ` ${service.className}::class.java,`))].sort();
217
243
 
218
244
  const content = fs.readFileSync(filePath, 'utf8');
219
245
 
@@ -236,7 +262,7 @@ function patchMainApplication(appRoot, entries) {
236
262
  });
237
263
  if (withImports === null) return;
238
264
 
239
- const next = writeRegion({
265
+ const withModules = writeRegion({
240
266
  filePath,
241
267
  content: withImports,
242
268
  name: 'MODULES-MAP',
@@ -245,6 +271,20 @@ function patchMainApplication(appRoot, entries) {
245
271
  findAnchorIndex: (lines) => lines.findIndex((line) => line.includes(MODULES_MAP_ANCHOR)),
246
272
  anchorLabel: 'no `getModulesMap()` declaration to anchor below',
247
273
  });
274
+ if (withModules === null) return;
275
+
276
+ // Unlike MODULES-MAP, there's no pre-existing `getServices()` stub to anchor entries into, so
277
+ // the whole method (header + entries + closing paren) is the region body, generated right after
278
+ // the class opens (before getModulesMap()).
279
+ const next = writeRegion({
280
+ filePath,
281
+ content: withModules,
282
+ name: 'SERVICES',
283
+ indent: ' ',
284
+ bodyLines: ['override fun getServices(): List<Class<out Service>> = listOf(', ...serviceEntries, ')'],
285
+ findAnchorIndex: (lines) => lines.findIndex((line) => line.includes(MODULES_PROVIDER_CLASS_ANCHOR)),
286
+ anchorLabel: 'no `ExpoModulesProvider : ModulesProvider` class declaration to anchor below',
287
+ });
248
288
  applyIfChanged(filePath, content, next);
249
289
  }
250
290
 
@@ -259,11 +299,11 @@ function escapeXmlAttribute(value) {
259
299
  return escapeXml(value).replace(/"/g, '&quot;');
260
300
  }
261
301
 
262
- // The bounds of the `<application ...>` opening tag. Scanning for the first unquoted ">" rather
263
- // than matching a regex: RN's own template puts "${usesCleartextTraffic}" in an attribute, and
264
- // a manifest is free to put a ">" inside one.
265
- function findApplicationTag(content) {
266
- const match = /<application(?=[\s>/])/.exec(content);
302
+ // The bounds of a `<tagName ...>` opening tag. Scanning for the first unquoted ">" rather than
303
+ // matching a regex: RN's own template puts "${usesCleartextTraffic}" in an attribute, and a
304
+ // manifest is free to put a ">" inside one.
305
+ function findTag(content, tagName) {
306
+ const match = new RegExp(`<${tagName}(?=[\\s>/])`).exec(content);
267
307
  if (!match) return null;
268
308
 
269
309
  let quote = null;
@@ -279,6 +319,44 @@ function findApplicationTag(content) {
279
319
  return null;
280
320
  }
281
321
 
322
+ function findApplicationTag(content) {
323
+ return findTag(content, 'application');
324
+ }
325
+
326
+ // Same tag-boundary scan as findTag, but returns every match: <activity> can appear more than
327
+ // once, and only the one named MainActivity matters here.
328
+ function findAllTags(content, tagName) {
329
+ const tags = [];
330
+ const re = new RegExp(`<${tagName}(?=[\\s>/])`, 'g');
331
+ let match = re.exec(content);
332
+ while (match) {
333
+ let quote = null;
334
+ for (let i = match.index; i < content.length; i += 1) {
335
+ const char = content[i];
336
+ if (quote !== null) {
337
+ if (char === quote) quote = null;
338
+ continue;
339
+ }
340
+ if (char === '"' || char === "'") quote = char;
341
+ else if (char === '>') {
342
+ tags.push({ start: match.index, end: i });
343
+ re.lastIndex = i + 1;
344
+ break;
345
+ }
346
+ }
347
+ match = re.exec(content);
348
+ }
349
+ return tags;
350
+ }
351
+
352
+ function findMainActivityTag(content) {
353
+ for (const tag of findAllTags(content, 'activity')) {
354
+ const name = readXmlAttribute(content.slice(tag.start, tag.end), 'android:name');
355
+ if (name && /(^|\.)MainActivity$/.test(name)) return tag;
356
+ }
357
+ return null;
358
+ }
359
+
282
360
  function readXmlAttribute(tagText, name) {
283
361
  const match = new RegExp(`\\s${name}\\s*=\\s*"([^"]*)"`).exec(tagText);
284
362
  return match ? match[1] : null;
@@ -347,6 +425,188 @@ function patchAndroidManifest(appRoot, entries) {
347
425
  applyIfChanged(manifestPath, content, next);
348
426
  }
349
427
 
428
+ // Sibling of patchAndroidManifest, but MERGES into MainActivity's existing android:configChanges
429
+ // value instead of skipping when the attribute is present - every RN template already ships it
430
+ // with a base value, so "already present" can't be the idempotency signal here.
431
+
432
+ // Why this shape exists: packages/localization's plugin defaults allowDynamicLocaleChangesAndroid
433
+ // to true, appending locale|layoutDirection so onConfigurationChanged fires instead of the
434
+ // Activity restarting on locale change (protects useLocales() Android reactivity).
435
+ function patchMainActivityConfigChanges(appRoot, entries) {
436
+ const wanted = new Set();
437
+ for (const entry of entries) {
438
+ const values = entry.manifest.android && entry.manifest.android.mainActivityConfigChanges;
439
+ if (!values) continue;
440
+ for (const value of values) wanted.add(value);
441
+ }
442
+ if (wanted.size === 0) return;
443
+
444
+ const manifestPath = path.join(appRoot, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
445
+ if (!fs.existsSync(manifestPath)) {
446
+ dlog('AndroidManifest.xml not found, skipping MainActivity configChanges');
447
+ return;
448
+ }
449
+
450
+ const content = fs.readFileSync(manifestPath, 'utf8');
451
+ const tag = findMainActivityTag(content);
452
+ if (!tag) {
453
+ warn(`${manifestPath} has no MainActivity <activity> element, skipping configChanges`);
454
+ return;
455
+ }
456
+
457
+ const tagText = content.slice(tag.start, tag.end);
458
+ const existingValue = readXmlAttribute(tagText, 'android:configChanges');
459
+ const existingTokens = existingValue
460
+ ? existingValue.split('|').map((token) => token.trim()).filter(Boolean)
461
+ : [];
462
+ const existingSet = new Set(existingTokens);
463
+ const missing = [...wanted].filter((token) => !existingSet.has(token)).sort();
464
+ if (missing.length === 0) return;
465
+
466
+ const merged = [...existingTokens, ...missing].join('|');
467
+ let next;
468
+ if (existingValue !== null) {
469
+ const valueMatch = /android:configChanges\s*=\s*"/.exec(tagText);
470
+ const valueStart = valueMatch.index + valueMatch[0].length;
471
+ const valueEnd = tagText.indexOf('"', valueStart);
472
+ const nextTagText = tagText.slice(0, valueStart) + merged + tagText.slice(valueEnd);
473
+ next = content.slice(0, tag.start) + nextTagText + content.slice(tag.end);
474
+ } else {
475
+ const lastNewline = tagText.lastIndexOf('\n');
476
+ const separator = lastNewline === -1 ? ' ' : `\n${/^[ \t]*/.exec(tagText.slice(lastNewline + 1))[0]}`;
477
+ const selfClosing = content[tag.end - 1] === '/';
478
+ const insertAt = selfClosing ? tag.end - 1 : tag.end;
479
+ // A self-closing tag's own trailing space (before the `/`) would otherwise double up with
480
+ // `separator`'s leading space; put exactly one back between the new attribute and `/>`.
481
+ const before = content.slice(0, insertAt).replace(/[ \t]+$/, '');
482
+ const trailer = selfClosing ? ' ' : '';
483
+ next = `${before}${separator}android:configChanges="${merged}"${trailer}${content.slice(insertAt)}`;
484
+ }
485
+
486
+ applyIfChanged(manifestPath, content, next);
487
+ }
488
+
489
+ function readManifestPermissions(content) {
490
+ return new Set([...content.matchAll(/<uses-permission\s[^>]*android:name="([^"]+)"/g)].map((match) => match[1]));
491
+ }
492
+
493
+ // Sibling of patchAndroidManifest for root-level `<uses-permission>` elements rather than
494
+ // `<application>` attributes. Same additive policy: an existing permission, however it got there,
495
+ // is left alone - this only ever ADDS a missing one, right after the `<manifest>` tag opens.
496
+ //
497
+ // Every entry here is something the PACKAGE'S OWN AndroidManifest.xml deliberately does NOT
498
+ // carry, so autolinking's ordinary merge never adds it - typically because requesting it triggers
499
+ // Play Console policy review for every consumer (FOREGROUND_SERVICE_LOCATION,
500
+ // FOREGROUND_SERVICE_MICROPHONE, ACCESS_BACKGROUND_LOCATION, ...), so a package only lists one
501
+ // here when it wants EVERY consumer to request it unconditionally - see each package's own
502
+ // native-link.json and README for which permissions it deliberately leaves for the app to add by
503
+ // hand instead (matching whatever upstream's own config-plugin defaults to `false`).
504
+ function patchAndroidManifestPermissions(appRoot, entries) {
505
+ const wanted = new Set();
506
+ for (const entry of entries) {
507
+ const permissions = entry.manifest.android && entry.manifest.android.manifestPermissions;
508
+ if (!permissions) continue;
509
+ for (const permission of permissions) wanted.add(permission);
510
+ }
511
+ if (wanted.size === 0) return;
512
+
513
+ const manifestPath = path.join(appRoot, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
514
+ if (!fs.existsSync(manifestPath)) {
515
+ dlog('AndroidManifest.xml not found, skipping permissions');
516
+ return;
517
+ }
518
+
519
+ const content = fs.readFileSync(manifestPath, 'utf8');
520
+ const existing = readManifestPermissions(content);
521
+ const missing = [...wanted].filter((permission) => !existing.has(permission)).sort();
522
+ if (missing.length === 0) return;
523
+
524
+ const tag = findTag(content, 'manifest');
525
+ if (!tag) {
526
+ warn(`${manifestPath} has no <manifest> element, skipping permissions`);
527
+ return;
528
+ }
529
+
530
+ const lines = missing.map((permission) => ` <uses-permission android:name="${permission}" />`).join('\n');
531
+ const next = `${content.slice(0, tag.end + 1)}\n${lines}${content.slice(tag.end + 1)}`;
532
+
533
+ applyIfChanged(manifestPath, content, next);
534
+ }
535
+
536
+ function readManifestServiceNames(content) {
537
+ return new Set([...content.matchAll(/<service\s[^>]*android:name="([^"]+)"/g)].map((match) => match[1]));
538
+ }
539
+
540
+ function buildServiceXml(service, indent) {
541
+ const attrs = [
542
+ `android:name="${escapeXmlAttribute(service.name)}"`,
543
+ `android:exported="${service.exported === true ? 'true' : 'false'}"`,
544
+ ];
545
+ if (service.foregroundServiceType) {
546
+ attrs.push(`android:foregroundServiceType="${escapeXmlAttribute(service.foregroundServiceType)}"`);
547
+ }
548
+ const openTag = `${indent}<service ${attrs.join(' ')}`;
549
+ if (!service.intentFilterActions || service.intentFilterActions.length === 0) {
550
+ return `${openTag} />`;
551
+ }
552
+ const actions = service.intentFilterActions
553
+ .map((action) => `${indent} <action android:name="${escapeXmlAttribute(action)}" />`)
554
+ .join('\n');
555
+ return `${openTag}>\n${indent} <intent-filter>\n${actions}\n${indent} </intent-filter>\n${indent}</service>`;
556
+ }
557
+
558
+ // Sibling of patchAndroidManifestPermissions for a whole `<service>` ELEMENT rather than one
559
+ // `<uses-permission>` string — needed because a foreground service's native class is often not
560
+ // declared in the package's own AndroidManifest.xml either (unlike location's LocationTaskService,
561
+ // which ships in expo-location's own manifest and merges automatically): upstream's config-plugin
562
+ // adds it dynamically at `expo prebuild` time (e.g. expo-audio's AudioControlsService /
563
+ // AudioRecordingService, see plugin/src/withAudio.ts), which this project's static manifest-merge
564
+ // approach has no equivalent of until a package lists it here. Additive by `android:name`
565
+ // presence, same idempotency policy as every other patcher in this file.
566
+ function patchAndroidManifestServices(appRoot, entries) {
567
+ const wanted = new Map();
568
+ for (const entry of entries) {
569
+ const services = entry.manifest.android && entry.manifest.android.manifestServices;
570
+ if (!services) continue;
571
+ for (const service of services) {
572
+ if (!wanted.has(service.name)) wanted.set(service.name, service);
573
+ }
574
+ }
575
+ if (wanted.size === 0) return;
576
+
577
+ const manifestPath = path.join(appRoot, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
578
+ if (!fs.existsSync(manifestPath)) {
579
+ dlog('AndroidManifest.xml not found, skipping services');
580
+ return;
581
+ }
582
+
583
+ const content = fs.readFileSync(manifestPath, 'utf8');
584
+ const existing = readManifestServiceNames(content);
585
+ const missing = [...wanted.values()]
586
+ .filter((service) => !existing.has(service.name))
587
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
588
+ if (missing.length === 0) return;
589
+
590
+ const tag = findApplicationTag(content);
591
+ if (!tag) {
592
+ warn(`${manifestPath} has no <application> element, skipping services`);
593
+ return;
594
+ }
595
+ if (content[tag.end - 1] === '/') {
596
+ warn(`${manifestPath}'s <application> is self-closing, skipping services`);
597
+ return;
598
+ }
599
+
600
+ const lastNewline = content.lastIndexOf('\n', tag.start);
601
+ const tagIndent = /^[ \t]*/.exec(content.slice(lastNewline + 1, tag.start))[0];
602
+ const indent = `${tagIndent} `;
603
+ const blocks = missing.map((service) => buildServiceXml(service, indent)).join('\n');
604
+ const insertAt = tag.end + 1;
605
+ const next = `${content.slice(0, insertAt)}\n${blocks}${content.slice(insertAt)}`;
606
+
607
+ applyIfChanged(manifestPath, content, next);
608
+ }
609
+
350
610
  function readPlistString(content, key) {
351
611
  const keyIndex = content.indexOf(`<key>${key}</key>`);
352
612
  if (keyIndex === -1) return null;
@@ -410,6 +670,75 @@ function patchInfoPlist(appRoot, entries) {
410
670
  applyIfChanged(plistPath, content, next);
411
671
  }
412
672
 
673
+ // Finds the `<key>NAME</key>` + `<array>…</array>` pair for an array-valued Info.plist key.
674
+ // Returns each item's raw (unescaped) string plus where the array closes, so a caller can append
675
+ // missing items without disturbing what's already there.
676
+ function readPlistArray(content, key) {
677
+ const keyTag = `<key>${key}</key>`;
678
+ const keyIndex = content.indexOf(keyTag);
679
+ if (keyIndex === -1) return null;
680
+ const arrayOpenIndex = content.indexOf('<array>', keyIndex + keyTag.length);
681
+ const arrayCloseIndex = arrayOpenIndex === -1 ? -1 : content.indexOf('</array>', arrayOpenIndex);
682
+ if (arrayOpenIndex === -1 || arrayCloseIndex === -1) return null;
683
+ const inner = content.slice(arrayOpenIndex + '<array>'.length, arrayCloseIndex);
684
+ const items = [...inner.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((match) => match[1]);
685
+ return { arrayCloseIndex, items };
686
+ }
687
+
688
+ // Sibling of patchInfoPlist for the one shape it cannot express: an ARRAY-valued key
689
+ // (UIBackgroundModes, BGTaskSchedulerPermittedIdentifiers, …) — a plain <string> would produce
690
+ // an invalid plist. Multiple packages can want the SAME key (background-fetch and background-task
691
+ // both add to UIBackgroundModes), so every package's items merge into one array instead of the
692
+ // last writer winning. Same additive policy as patchInfoPlist: an item already present, however it
693
+ // got there, is never removed or reordered.
694
+ function patchInfoPlistArrays(appRoot, entries) {
695
+ const wanted = new Map();
696
+ for (const entry of entries) {
697
+ const arrayKeys = entry.manifest.ios && entry.manifest.ios.infoPlistArrayKeys;
698
+ if (!arrayKeys) continue;
699
+ for (const [key, items] of Object.entries(arrayKeys)) {
700
+ if (!wanted.has(key)) wanted.set(key, new Set());
701
+ for (const item of items) wanted.get(key).add(item);
702
+ }
703
+ }
704
+ if (wanted.size === 0) return;
705
+
706
+ const plistPath = findInfoPlistFile(appRoot);
707
+ if (!plistPath) {
708
+ dlog('Info.plist not found, skipping iOS array keys');
709
+ return;
710
+ }
711
+
712
+ const content = fs.readFileSync(plistPath, 'utf8');
713
+ let next = content;
714
+
715
+ for (const [key, itemsSet] of [...wanted].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
716
+ const wantedItems = [...itemsSet];
717
+ const existing = readPlistArray(next, key);
718
+
719
+ if (existing === null) {
720
+ const plistCloseIndex = next.lastIndexOf('</plist>');
721
+ const dictCloseIndex = next.lastIndexOf('</dict>', plistCloseIndex === -1 ? undefined : plistCloseIndex);
722
+ if (dictCloseIndex === -1) {
723
+ warn(`${plistPath} has no closing </dict>, skipping ${key}`);
724
+ continue;
725
+ }
726
+ const itemLines = wantedItems.map((item) => `\t\t<string>${escapeXml(item)}</string>`).join('\n');
727
+ const block = `\t<key>${key}</key>\n\t<array>\n${itemLines}\n\t</array>\n`;
728
+ next = next.slice(0, dictCloseIndex) + block + next.slice(dictCloseIndex);
729
+ continue;
730
+ }
731
+
732
+ const missing = wantedItems.filter((item) => !existing.items.includes(item));
733
+ if (missing.length === 0) continue;
734
+
735
+ const missingLines = missing.map((item) => `\t\t<string>${escapeXml(item)}</string>\n`).join('');
736
+ next = next.slice(0, existing.arrayCloseIndex) + missingLines + next.slice(existing.arrayCloseIndex);
737
+ }
738
+
739
+ applyIfChanged(plistPath, content, next);
740
+ }
741
+
413
742
  // Safe to run any number of times, and after an uninstall.
414
743
  function linkApp(explicitAppRoot) {
415
744
  const appRoot = explicitAppRoot || findAppRoot();
@@ -422,7 +751,11 @@ function linkApp(explicitAppRoot) {
422
751
  patchBuildGradle(appRoot, entries);
423
752
  patchMainApplication(appRoot, entries);
424
753
  patchAndroidManifest(appRoot, entries);
754
+ patchAndroidManifestPermissions(appRoot, entries);
755
+ patchAndroidManifestServices(appRoot, entries);
756
+ patchMainActivityConfigChanges(appRoot, entries);
425
757
  patchInfoPlist(appRoot, entries);
758
+ patchInfoPlistArrays(appRoot, entries);
426
759
  }
427
760
 
428
761
  module.exports = {
@@ -434,5 +767,9 @@ module.exports = {
434
767
  patchBuildGradle,
435
768
  patchMainApplication,
436
769
  patchAndroidManifest,
770
+ patchAndroidManifestPermissions,
771
+ patchAndroidManifestServices,
772
+ patchMainActivityConfigChanges,
437
773
  patchInfoPlist,
774
+ patchInfoPlistArrays,
438
775
  };
package/src/index.d.ts CHANGED
@@ -2,14 +2,80 @@ export type ISymbioteExpoLinkAndroidModule = {
2
2
  importPath: string;
3
3
  className: string;
4
4
  nativeName: string;
5
+ /** True if only reachable via `ModuleRegistry.getModule()`, not `requireNativeModule`. */
6
+ internal?: boolean;
7
+ };
8
+
9
+ /** A `Service` registered in `AppContext.ServicesRegistry` (distinct from the manifest `<service>`
10
+ * below). `gradleProjectName` can differ from the block's own top-level one: a service may belong
11
+ * to a different native package than the one declaring it (see packages/task-manager). */
12
+ export type ISymbioteExpoLinkAndroidService = {
13
+ importPath: string;
14
+ className: string;
15
+ gradleProjectName: string;
16
+ };
17
+
18
+ export type ISymbioteExpoLinkManifestService = {
19
+ /** Fully-qualified native class name, e.g. `expo.modules.audio.service.AudioControlsService`. */
20
+ name: string;
21
+ /** e.g. `mediaPlayback`, `microphone`, `location`. */
22
+ foregroundServiceType?: string;
23
+ /** @default false */
24
+ exported?: boolean;
25
+ /** Action names for a single `<intent-filter>` with one `<action>` per entry. */
26
+ intentFilterActions?: string[];
27
+ };
28
+
29
+ /**
30
+ * A policy-sensitive permission/service group a package deliberately does NOT request
31
+ * unconditionally (see `manifestPermissions`/`manifestServices` below) — surfaced instead for a
32
+ * developer to opt into explicitly, e.g. via `@symbiote-native/cli grant`. The package never
33
+ * applies these itself; something the DEVELOPER runs reads them and edits their own app.
34
+ */
35
+ export type ISymbioteExpoLinkOptionalBundle = {
36
+ /** Stable id within this package, e.g. `background`, `recording`. */
37
+ id: string;
38
+ /** Shown when a developer is choosing which bundle to grant. */
39
+ label: string;
40
+ /** Why requesting this is policy-sensitive (e.g. triggers Play Console review). */
41
+ warning: string;
42
+ /** What JS code the developer still needs to write for this to do anything. */
43
+ nextSteps: string;
44
+ manifestPermissions?: string[];
45
+ manifestServices?: ISymbioteExpoLinkManifestService[];
5
46
  };
6
47
 
7
48
  export type ISymbioteExpoLinkManifest = {
8
49
  android?: {
9
- gradleProjectName: string;
10
- modules: ISymbioteExpoLinkAndroidModule[];
50
+ // Both optional to match the actual runtime contract: every patcher in this package reads
51
+ // them defensively (`entry.manifest.android?.gradleProjectName`, `Array.isArray(...modules)`)
52
+ // and no-ops when absent — a synthetic entry that exists only to carry
53
+ // manifestPermissions/manifestServices (see @symbiote-native/cli's `grant` command) has
54
+ // neither, and is a legitimate entry, not a malformed one.
55
+ gradleProjectName?: string;
56
+ modules?: ISymbioteExpoLinkAndroidModule[];
11
57
  /** Attributes to set on the app's own `<application>` element, e.g. Auto Backup rules. */
12
58
  manifestApplicationAttributes?: Record<string, string>;
59
+ /**
60
+ * `<uses-permission>` names to add, for whatever a package's own AndroidManifest.xml
61
+ * deliberately does not carry (typically because requesting it triggers Play Console policy
62
+ * review for every consumer) — only list one here to make EVERY consumer request it
63
+ * unconditionally, matching whatever upstream's own config-plugin defaults to `true`.
64
+ */
65
+ manifestPermissions?: string[];
66
+ /**
67
+ * `<service>` elements to add to the app's own `<application>`, for a foreground-service
68
+ * native class upstream's config-plugin adds dynamically rather than shipping in the
69
+ * package's own manifest (so autolinking's ordinary merge never adds it either).
70
+ */
71
+ manifestServices?: ISymbioteExpoLinkManifestService[];
72
+ /** Policy-sensitive bundles a developer can opt into by hand — see the type's own doc. */
73
+ optionalManifestBundles?: ISymbioteExpoLinkOptionalBundle[];
74
+ /** Tokens to union into MainActivity's `android:configChanges` (e.g. `locale`,
75
+ * `layoutDirection`) - see expo-localization's `allowDynamicLocaleChangesAndroid`. */
76
+ mainActivityConfigChanges?: string[];
77
+ /** Services to register in AppContext's ServicesRegistry - see the type's own doc. */
78
+ services?: ISymbioteExpoLinkAndroidService[];
13
79
  };
14
80
  ios?: {
15
81
  infoPlistKeys: Record<string, string>;
@@ -25,3 +91,18 @@ export type ISymbioteExpoLinkEntry = {
25
91
  export function linkApp(appRoot?: string): void;
26
92
  export function collectManifests(appRoot: string): ISymbioteExpoLinkEntry[];
27
93
  export function findAppRoot(): string | null;
94
+ /** Adds every `entries[].manifest.android.manifestPermissions` to the app's AndroidManifest.xml. */
95
+ export function patchAndroidManifestPermissions(
96
+ appRoot: string,
97
+ entries: readonly ISymbioteExpoLinkEntry[],
98
+ ): void;
99
+ /** Adds every `entries[].manifest.android.manifestServices` to the app's AndroidManifest.xml. */
100
+ export function patchAndroidManifestServices(
101
+ appRoot: string,
102
+ entries: readonly ISymbioteExpoLinkEntry[],
103
+ ): void;
104
+ /** Unions every entry's `mainActivityConfigChanges` into MainActivity's `android:configChanges`. */
105
+ export function patchMainActivityConfigChanges(
106
+ appRoot: string,
107
+ entries: readonly ISymbioteExpoLinkEntry[],
108
+ ): void;