@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 +18 -1
- package/package.json +15 -2
- package/src/index.cjs +346 -9
- package/src/index.d.ts +83 -2
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.
|
|
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.
|
|
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
|
-
".":
|
|
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
|
|
187
|
+
const primaryProjectNames = entries
|
|
184
188
|
.filter((entry) => entry.manifest.android && entry.manifest.android.gradleProjectName)
|
|
185
189
|
.map((entry) => entry.manifest.android.gradleProjectName);
|
|
186
|
-
|
|
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
|
|
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
|
|
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, '"');
|
|
260
300
|
}
|
|
261
301
|
|
|
262
|
-
// The bounds of
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
function
|
|
266
|
-
const match =
|
|
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
|
-
|
|
10
|
-
modules
|
|
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;
|