expo-accessory-setup 0.0.1 → 0.1.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +267 -6
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/accessorysetup/AccessorySetupException.kt +20 -0
- package/android/src/main/java/expo/modules/accessorysetup/CompanionAssociations.kt +152 -0
- package/android/src/main/java/expo/modules/accessorysetup/ExpoAccessorySetupModule.kt +214 -0
- package/android/src/main/java/expo/modules/accessorysetup/Records.kt +31 -0
- package/app.plugin.js +1 -0
- package/build/ExpoAccessorySetup.types.d.ts +63 -0
- package/build/ExpoAccessorySetup.types.d.ts.map +1 -0
- package/build/ExpoAccessorySetup.types.js +2 -0
- package/build/ExpoAccessorySetup.types.js.map +1 -0
- package/build/ExpoAccessorySetupModule.d.ts +24 -0
- package/build/ExpoAccessorySetupModule.d.ts.map +1 -0
- package/build/ExpoAccessorySetupModule.js +4 -0
- package/build/ExpoAccessorySetupModule.js.map +1 -0
- package/build/errors.d.ts +12 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +45 -0
- package/build/errors.js.map +1 -0
- package/build/index.d.ts +20 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +61 -0
- package/build/index.js.map +1 -0
- package/build/pickerOptions.d.ts +20 -0
- package/build/pickerOptions.d.ts.map +1 -0
- package/build/pickerOptions.js +64 -0
- package/build/pickerOptions.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AccessorySessionController.swift +290 -0
- package/ios/AccessorySetupException.swift +50 -0
- package/ios/AccessorySetupRecords.swift +32 -0
- package/ios/ExpoAccessorySetup.podspec +31 -0
- package/ios/ExpoAccessorySetupModule.swift +67 -0
- package/mocks/ExpoAccessorySetup.ts +13 -0
- package/package.json +63 -4
- package/plugin/build/index.d.ts +8 -0
- package/plugin/build/index.js +85 -0
- package/plugin/build/options.d.ts +29 -0
- package/plugin/build/options.js +88 -0
- package/plugin/src/index.ts +122 -0
- package/plugin/src/options.ts +142 -0
- package/plugin/tsconfig.json +18 -0
- package/src/ExpoAccessorySetup.types.ts +69 -0
- package/src/ExpoAccessorySetupModule.ts +20 -0
- package/src/errors.ts +59 -0
- package/src/index.ts +87 -0
- package/src/pickerOptions.ts +100 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type AccessorySetupPluginProps = {
|
|
2
|
+
ios?: {
|
|
3
|
+
/** 16-bit (`"180D"`) or 128-bit service UUIDs that `showPicker` filters may use. */
|
|
4
|
+
bluetoothServices?: string[];
|
|
5
|
+
/** Advertised names or name substrings that `showPicker` filters may use. */
|
|
6
|
+
bluetoothNames?: string[];
|
|
7
|
+
/** Bluetooth SIG company identifiers (0 to 0xFFFF) that `showPicker` filters may use. */
|
|
8
|
+
bluetoothCompanyIdentifiers?: number[];
|
|
9
|
+
};
|
|
10
|
+
android?: {
|
|
11
|
+
/** Adds `REQUEST_COMPANION_RUN_IN_BACKGROUND`. */
|
|
12
|
+
runInBackground?: boolean;
|
|
13
|
+
/** Adds `REQUEST_COMPANION_USE_DATA_IN_BACKGROUND`. */
|
|
14
|
+
useDataInBackground?: boolean;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export type AccessorySetupOptions = {
|
|
18
|
+
ios: {
|
|
19
|
+
bluetoothServices: string[];
|
|
20
|
+
bluetoothNames: string[];
|
|
21
|
+
/** Four upper-case hex digits, e.g. `"004C"`. */
|
|
22
|
+
bluetoothCompanyIdentifiers: string[];
|
|
23
|
+
};
|
|
24
|
+
android: {
|
|
25
|
+
runInBackground: boolean;
|
|
26
|
+
useDataInBackground: boolean;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export declare function parseOptions(props: unknown): AccessorySetupOptions;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseOptions = parseOptions;
|
|
4
|
+
const UUID_16 = /^[0-9A-F]{4}$/;
|
|
5
|
+
const UUID_128 = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/;
|
|
6
|
+
function fail(message) {
|
|
7
|
+
throw new Error(`[expo-accessory-setup] Invalid plugin options: ${message}`);
|
|
8
|
+
}
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function parseSection(value, path, keys) {
|
|
13
|
+
if (value === undefined) {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
if (!isRecord(value)) {
|
|
17
|
+
fail(`${path} must be an object.`);
|
|
18
|
+
}
|
|
19
|
+
const unknownKey = Object.keys(value).find((key) => !keys.includes(key));
|
|
20
|
+
if (unknownKey !== undefined) {
|
|
21
|
+
fail(`${path}.${unknownKey} is not a known option. Known options: ${keys.join(', ')}.`);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function parseArray(value, path, parseItem) {
|
|
26
|
+
if (value === undefined) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
if (!Array.isArray(value)) {
|
|
30
|
+
fail(`${path} must be an array.`);
|
|
31
|
+
}
|
|
32
|
+
return [...new Set(value.map((item, index) => parseItem(item, `${path}[${index}]`)))];
|
|
33
|
+
}
|
|
34
|
+
function parseServiceUUID(value, path) {
|
|
35
|
+
const uuid = typeof value === 'string' ? value.trim().toUpperCase() : '';
|
|
36
|
+
if (!UUID_16.test(uuid) && !UUID_128.test(uuid)) {
|
|
37
|
+
fail(`${path} must be a 16-bit ("180D") or 128-bit UUID string, got ${JSON.stringify(value)}.`);
|
|
38
|
+
}
|
|
39
|
+
return uuid;
|
|
40
|
+
}
|
|
41
|
+
function parseName(value, path) {
|
|
42
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
43
|
+
fail(`${path} must be a non-empty string, got ${JSON.stringify(value)}.`);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function parseCompanyIdentifier(value, path) {
|
|
48
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffff) {
|
|
49
|
+
fail(`${path} must be an integer between 0 and 65535 (0xFFFF), got ${JSON.stringify(value)}.`);
|
|
50
|
+
}
|
|
51
|
+
return value.toString(16).toUpperCase().padStart(4, '0');
|
|
52
|
+
}
|
|
53
|
+
function parseBoolean(value, path) {
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (typeof value !== 'boolean') {
|
|
58
|
+
fail(`${path} must be a boolean, got ${JSON.stringify(value)}.`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function parseOptions(props) {
|
|
63
|
+
const root = parseSection(props ?? {}, 'options', ['ios', 'android']);
|
|
64
|
+
const ios = parseSection(root.ios, 'ios', [
|
|
65
|
+
'bluetoothServices',
|
|
66
|
+
'bluetoothNames',
|
|
67
|
+
'bluetoothCompanyIdentifiers',
|
|
68
|
+
]);
|
|
69
|
+
const android = parseSection(root.android, 'android', ['runInBackground', 'useDataInBackground']);
|
|
70
|
+
const options = {
|
|
71
|
+
ios: {
|
|
72
|
+
bluetoothServices: parseArray(ios.bluetoothServices, 'ios.bluetoothServices', parseServiceUUID),
|
|
73
|
+
bluetoothNames: parseArray(ios.bluetoothNames, 'ios.bluetoothNames', parseName),
|
|
74
|
+
bluetoothCompanyIdentifiers: parseArray(ios.bluetoothCompanyIdentifiers, 'ios.bluetoothCompanyIdentifiers', parseCompanyIdentifier),
|
|
75
|
+
},
|
|
76
|
+
android: {
|
|
77
|
+
runInBackground: parseBoolean(android.runInBackground, 'android.runInBackground'),
|
|
78
|
+
useDataInBackground: parseBoolean(android.useDataInBackground, 'android.useDataInBackground'),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
if (options.ios.bluetoothServices.length === 0 &&
|
|
82
|
+
options.ios.bluetoothCompanyIdentifiers.length === 0) {
|
|
83
|
+
fail('ios.bluetoothServices or ios.bluetoothCompanyIdentifiers must list at least one value. ' +
|
|
84
|
+
'AccessorySetupKit only discovers accessories by service UUID or company identifier, ' +
|
|
85
|
+
'and the app crashes if a picker filter uses a value missing from Info.plist.');
|
|
86
|
+
}
|
|
87
|
+
return options;
|
|
88
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AndroidConfig,
|
|
3
|
+
createRunOncePlugin,
|
|
4
|
+
withAndroidManifest,
|
|
5
|
+
withInfoPlist,
|
|
6
|
+
type ConfigPlugin,
|
|
7
|
+
type InfoPlist,
|
|
8
|
+
} from '@expo/config-plugins';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
parseOptions,
|
|
12
|
+
type AccessorySetupOptions,
|
|
13
|
+
type AccessorySetupPluginProps,
|
|
14
|
+
} from './options';
|
|
15
|
+
|
|
16
|
+
const pkg = require('../../package.json');
|
|
17
|
+
|
|
18
|
+
export type { AccessorySetupPluginProps } from './options';
|
|
19
|
+
|
|
20
|
+
type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
|
|
21
|
+
|
|
22
|
+
export function setAccessorySetupInfoPlist(
|
|
23
|
+
infoPlist: InfoPlist,
|
|
24
|
+
{ ios }: AccessorySetupOptions
|
|
25
|
+
): InfoPlist {
|
|
26
|
+
const supports = Array.isArray(infoPlist.NSAccessorySetupKitSupports)
|
|
27
|
+
? (infoPlist.NSAccessorySetupKitSupports as string[])
|
|
28
|
+
: [];
|
|
29
|
+
const result: InfoPlist = {
|
|
30
|
+
...infoPlist,
|
|
31
|
+
NSAccessorySetupKitSupports: supports.includes('Bluetooth')
|
|
32
|
+
? supports
|
|
33
|
+
: [...supports, 'Bluetooth'],
|
|
34
|
+
};
|
|
35
|
+
// ASAccessorySession terminates the app when one of these keys is present with no values,
|
|
36
|
+
// so unused keys are left out instead of written as empty arrays.
|
|
37
|
+
const keys = {
|
|
38
|
+
NSAccessorySetupBluetoothServices: ios.bluetoothServices,
|
|
39
|
+
NSAccessorySetupBluetoothNames: ios.bluetoothNames,
|
|
40
|
+
// TODO(verify): Apple documents these as "two-byte UUID" strings without a format example.
|
|
41
|
+
// Four hex digits ("004C") is the assumed format.
|
|
42
|
+
NSAccessorySetupBluetoothCompanyIdentifiers: ios.bluetoothCompanyIdentifiers,
|
|
43
|
+
};
|
|
44
|
+
for (const [key, values] of Object.entries(keys)) {
|
|
45
|
+
if (values.length > 0) {
|
|
46
|
+
result[key] = values;
|
|
47
|
+
} else {
|
|
48
|
+
delete result[key];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const COMPANION_DEVICE_SETUP_FEATURE = 'android.software.companion_device_setup';
|
|
55
|
+
|
|
56
|
+
function ensureUsesFeature(manifest: AndroidManifest, name: string) {
|
|
57
|
+
manifest.manifest['uses-feature'] ??= [];
|
|
58
|
+
const features = manifest.manifest['uses-feature'];
|
|
59
|
+
if (!features.some((feature) => feature.$['android:name'] === name)) {
|
|
60
|
+
// Not required, so the app still installs where isSupported() returns false.
|
|
61
|
+
features.push({ $: { 'android:name': name, 'android:required': 'false' } });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Expo's manifest types leave out android:maxSdkVersion, which the manifest XML accepts.
|
|
66
|
+
type UsesPermission = { $: { 'android:name': string; 'android:maxSdkVersion'?: string } };
|
|
67
|
+
|
|
68
|
+
function ensureLegacyPermission(manifest: AndroidManifest, name: string) {
|
|
69
|
+
manifest.manifest['uses-permission'] ??= [];
|
|
70
|
+
const permissions: UsesPermission[] = manifest.manifest['uses-permission'];
|
|
71
|
+
const existing = permissions.find((permission) => permission.$['android:name'] === name);
|
|
72
|
+
// Android 12 ignores these permissions, so the limit also applies when another plugin, such as
|
|
73
|
+
// react-native-ble-plx, declared them first.
|
|
74
|
+
if (existing) {
|
|
75
|
+
existing.$['android:maxSdkVersion'] ??= '30';
|
|
76
|
+
} else {
|
|
77
|
+
permissions.push({ $: { 'android:name': name, 'android:maxSdkVersion': '30' } });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function setAccessorySetupManifest(
|
|
82
|
+
manifest: AndroidManifest,
|
|
83
|
+
{ android }: AccessorySetupOptions
|
|
84
|
+
): AndroidManifest {
|
|
85
|
+
ensureUsesFeature(manifest, COMPANION_DEVICE_SETUP_FEATURE);
|
|
86
|
+
|
|
87
|
+
// CompanionDeviceManager scans on the app's behalf, so no scan or location permission is
|
|
88
|
+
// needed. These cover connecting to the paired accessory afterwards (react-native-ble-plx).
|
|
89
|
+
ensureLegacyPermission(manifest, 'android.permission.BLUETOOTH');
|
|
90
|
+
ensureLegacyPermission(manifest, 'android.permission.BLUETOOTH_ADMIN');
|
|
91
|
+
AndroidConfig.Permissions.ensurePermission(manifest, 'android.permission.BLUETOOTH_CONNECT');
|
|
92
|
+
|
|
93
|
+
if (android.runInBackground) {
|
|
94
|
+
AndroidConfig.Permissions.ensurePermission(
|
|
95
|
+
manifest,
|
|
96
|
+
'android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (android.useDataInBackground) {
|
|
100
|
+
AndroidConfig.Permissions.ensurePermission(
|
|
101
|
+
manifest,
|
|
102
|
+
'android.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return manifest;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const withAccessorySetup: ConfigPlugin<AccessorySetupPluginProps | void> = (config, props) => {
|
|
109
|
+
const options = parseOptions(props);
|
|
110
|
+
|
|
111
|
+
config = withInfoPlist(config, (config) => {
|
|
112
|
+
config.modResults = setAccessorySetupInfoPlist(config.modResults, options);
|
|
113
|
+
return config;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return withAndroidManifest(config, (config) => {
|
|
117
|
+
config.modResults = setAccessorySetupManifest(config.modResults, options);
|
|
118
|
+
return config;
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export default createRunOncePlugin(withAccessorySetup, pkg.name, pkg.version);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export type AccessorySetupPluginProps = {
|
|
2
|
+
ios?: {
|
|
3
|
+
/** 16-bit (`"180D"`) or 128-bit service UUIDs that `showPicker` filters may use. */
|
|
4
|
+
bluetoothServices?: string[];
|
|
5
|
+
/** Advertised names or name substrings that `showPicker` filters may use. */
|
|
6
|
+
bluetoothNames?: string[];
|
|
7
|
+
/** Bluetooth SIG company identifiers (0 to 0xFFFF) that `showPicker` filters may use. */
|
|
8
|
+
bluetoothCompanyIdentifiers?: number[];
|
|
9
|
+
};
|
|
10
|
+
android?: {
|
|
11
|
+
/** Adds `REQUEST_COMPANION_RUN_IN_BACKGROUND`. */
|
|
12
|
+
runInBackground?: boolean;
|
|
13
|
+
/** Adds `REQUEST_COMPANION_USE_DATA_IN_BACKGROUND`. */
|
|
14
|
+
useDataInBackground?: boolean;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type AccessorySetupOptions = {
|
|
19
|
+
ios: {
|
|
20
|
+
bluetoothServices: string[];
|
|
21
|
+
bluetoothNames: string[];
|
|
22
|
+
/** Four upper-case hex digits, e.g. `"004C"`. */
|
|
23
|
+
bluetoothCompanyIdentifiers: string[];
|
|
24
|
+
};
|
|
25
|
+
android: {
|
|
26
|
+
runInBackground: boolean;
|
|
27
|
+
useDataInBackground: boolean;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const UUID_16 = /^[0-9A-F]{4}$/;
|
|
32
|
+
const UUID_128 = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/;
|
|
33
|
+
|
|
34
|
+
function fail(message: string): never {
|
|
35
|
+
throw new Error(`[expo-accessory-setup] Invalid plugin options: ${message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseSection(value: unknown, path: string, keys: string[]): Record<string, unknown> {
|
|
43
|
+
if (value === undefined) {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
if (!isRecord(value)) {
|
|
47
|
+
fail(`${path} must be an object.`);
|
|
48
|
+
}
|
|
49
|
+
const unknownKey = Object.keys(value).find((key) => !keys.includes(key));
|
|
50
|
+
if (unknownKey !== undefined) {
|
|
51
|
+
fail(`${path}.${unknownKey} is not a known option. Known options: ${keys.join(', ')}.`);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseArray<T>(
|
|
57
|
+
value: unknown,
|
|
58
|
+
path: string,
|
|
59
|
+
parseItem: (item: unknown, path: string) => T
|
|
60
|
+
) {
|
|
61
|
+
if (value === undefined) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(value)) {
|
|
65
|
+
fail(`${path} must be an array.`);
|
|
66
|
+
}
|
|
67
|
+
return [...new Set(value.map((item, index) => parseItem(item, `${path}[${index}]`)))];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseServiceUUID(value: unknown, path: string): string {
|
|
71
|
+
const uuid = typeof value === 'string' ? value.trim().toUpperCase() : '';
|
|
72
|
+
if (!UUID_16.test(uuid) && !UUID_128.test(uuid)) {
|
|
73
|
+
fail(`${path} must be a 16-bit ("180D") or 128-bit UUID string, got ${JSON.stringify(value)}.`);
|
|
74
|
+
}
|
|
75
|
+
return uuid;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseName(value: unknown, path: string): string {
|
|
79
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
80
|
+
fail(`${path} must be a non-empty string, got ${JSON.stringify(value)}.`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseCompanyIdentifier(value: unknown, path: string): string {
|
|
86
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffff) {
|
|
87
|
+
fail(`${path} must be an integer between 0 and 65535 (0xFFFF), got ${JSON.stringify(value)}.`);
|
|
88
|
+
}
|
|
89
|
+
return value.toString(16).toUpperCase().padStart(4, '0');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseBoolean(value: unknown, path: string): boolean {
|
|
93
|
+
if (value === undefined) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (typeof value !== 'boolean') {
|
|
97
|
+
fail(`${path} must be a boolean, got ${JSON.stringify(value)}.`);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseOptions(props: unknown): AccessorySetupOptions {
|
|
103
|
+
const root = parseSection(props ?? {}, 'options', ['ios', 'android']);
|
|
104
|
+
const ios = parseSection(root.ios, 'ios', [
|
|
105
|
+
'bluetoothServices',
|
|
106
|
+
'bluetoothNames',
|
|
107
|
+
'bluetoothCompanyIdentifiers',
|
|
108
|
+
]);
|
|
109
|
+
const android = parseSection(root.android, 'android', ['runInBackground', 'useDataInBackground']);
|
|
110
|
+
|
|
111
|
+
const options: AccessorySetupOptions = {
|
|
112
|
+
ios: {
|
|
113
|
+
bluetoothServices: parseArray(
|
|
114
|
+
ios.bluetoothServices,
|
|
115
|
+
'ios.bluetoothServices',
|
|
116
|
+
parseServiceUUID
|
|
117
|
+
),
|
|
118
|
+
bluetoothNames: parseArray(ios.bluetoothNames, 'ios.bluetoothNames', parseName),
|
|
119
|
+
bluetoothCompanyIdentifiers: parseArray(
|
|
120
|
+
ios.bluetoothCompanyIdentifiers,
|
|
121
|
+
'ios.bluetoothCompanyIdentifiers',
|
|
122
|
+
parseCompanyIdentifier
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
android: {
|
|
126
|
+
runInBackground: parseBoolean(android.runInBackground, 'android.runInBackground'),
|
|
127
|
+
useDataInBackground: parseBoolean(android.useDataInBackground, 'android.useDataInBackground'),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
options.ios.bluetoothServices.length === 0 &&
|
|
133
|
+
options.ios.bluetoothCompanyIdentifiers.length === 0
|
|
134
|
+
) {
|
|
135
|
+
fail(
|
|
136
|
+
'ios.bluetoothServices or ios.bluetoothCompanyIdentifiers must list at least one value. ' +
|
|
137
|
+
'AccessorySetupKit only discovers accessories by service UUID or company identifier, ' +
|
|
138
|
+
'and the app crashes if a picker filter uses a value missing from Info.plist.'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return options;
|
|
142
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"lib": ["es2022"],
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"noUnusedLocals": true,
|
|
13
|
+
"noImplicitReturns": true,
|
|
14
|
+
"rootDir": "./src",
|
|
15
|
+
"outDir": "./build"
|
|
16
|
+
},
|
|
17
|
+
"include": ["./src"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type AccessoryPlatform = 'ios' | 'android';
|
|
2
|
+
|
|
3
|
+
export type PairedAccessory = {
|
|
4
|
+
/**
|
|
5
|
+
* Stable identifier for `removeAccessory()` and for matching events.
|
|
6
|
+
* Equal to `bleIdentifier` for Bluetooth accessories.
|
|
7
|
+
*/
|
|
8
|
+
id: string;
|
|
9
|
+
/**
|
|
10
|
+
* Pass this to react-native-ble-plx (`connectToDevice`, `devices`, ...).
|
|
11
|
+
* iOS: the CoreBluetooth peripheral UUID. Android: the MAC address, upper case.
|
|
12
|
+
*/
|
|
13
|
+
bleIdentifier: string;
|
|
14
|
+
displayName: string;
|
|
15
|
+
platform: AccessoryPlatform;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type AccessoryFilter = {
|
|
19
|
+
/**
|
|
20
|
+
* 16-bit (`"180D"`), 32-bit, or 128-bit Bluetooth service UUID.
|
|
21
|
+
* Must also be listed in the config plugin's `ios.bluetoothServices`.
|
|
22
|
+
*/
|
|
23
|
+
serviceUUID?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Bluetooth SIG company identifier, 0 to 0xFFFF.
|
|
26
|
+
* Must also be listed in the config plugin's `ios.bluetoothCompanyIdentifiers`.
|
|
27
|
+
*/
|
|
28
|
+
companyIdentifier?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Matches accessories whose advertised name contains this string.
|
|
31
|
+
* Must also be listed in the config plugin's `ios.bluetoothNames`.
|
|
32
|
+
*/
|
|
33
|
+
nameSubstring?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ImageSource = number | { uri: string };
|
|
37
|
+
|
|
38
|
+
export type IosPickerOptions = {
|
|
39
|
+
/** Product name shown in the system picker. */
|
|
40
|
+
displayName: string;
|
|
41
|
+
/** Product image shown in the system picker: `require('./accessory.png')` or `{ uri }`. */
|
|
42
|
+
productImage: ImageSource;
|
|
43
|
+
/** `immediate` only discovers accessories held close to the device. Defaults to `default`. */
|
|
44
|
+
range?: 'default' | 'immediate';
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type AndroidPickerOptions = {
|
|
48
|
+
/** Stop scanning at the first match and show a single-device confirmation. Defaults to `true`. */
|
|
49
|
+
singleDevice?: boolean;
|
|
50
|
+
/** Java regex matched against the advertised name. Replaces `filter.nameSubstring` on Android. */
|
|
51
|
+
namePattern?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ShowPickerOptions = {
|
|
55
|
+
/** Requires `serviceUUID` or `companyIdentifier`. */
|
|
56
|
+
filter: AccessoryFilter;
|
|
57
|
+
ios: IosPickerOptions;
|
|
58
|
+
android?: AndroidPickerOptions;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type AccessoryEventType = 'accessoryAdded' | 'accessoryRemoved' | 'accessoryChanged';
|
|
62
|
+
|
|
63
|
+
export type AccessoryEvent = {
|
|
64
|
+
accessory: PairedAccessory;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type AccessorySubscription = {
|
|
68
|
+
remove(): void;
|
|
69
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NativeModule, requireOptionalNativeModule } from 'expo';
|
|
2
|
+
|
|
3
|
+
import type { PairedAccessory } from './ExpoAccessorySetup.types';
|
|
4
|
+
import type { NativePickerOptions } from './pickerOptions';
|
|
5
|
+
|
|
6
|
+
export type ExpoAccessorySetupModuleEvents = {
|
|
7
|
+
onAccessoryAdded: (event: { accessory: PairedAccessory }) => void;
|
|
8
|
+
onAccessoryRemoved: (event: { accessory: PairedAccessory }) => void;
|
|
9
|
+
onAccessoryChanged: (event: { accessory: PairedAccessory }) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
declare class ExpoAccessorySetupModule extends NativeModule<ExpoAccessorySetupModuleEvents> {
|
|
13
|
+
isSupported(): boolean;
|
|
14
|
+
showPicker(options: NativePickerOptions): Promise<PairedAccessory>;
|
|
15
|
+
getPairedAccessories(): Promise<PairedAccessory[]>;
|
|
16
|
+
removeAccessory(id: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `null` in Expo Go, on web, and in builds that don't include the module. */
|
|
20
|
+
export default requireOptionalNativeModule<ExpoAccessorySetupModule>('ExpoAccessorySetup');
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const AccessorySetupErrorCodes = [
|
|
2
|
+
'UNSUPPORTED',
|
|
3
|
+
'USER_CANCELLED',
|
|
4
|
+
'PERMISSION_DENIED',
|
|
5
|
+
'ALREADY_IN_PROGRESS',
|
|
6
|
+
'NOT_FOUND',
|
|
7
|
+
'DISCOVERY_TIMEOUT',
|
|
8
|
+
'INVALID_OPTIONS',
|
|
9
|
+
'UNKNOWN',
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export type AccessorySetupErrorCode = (typeof AccessorySetupErrorCodes)[number];
|
|
13
|
+
|
|
14
|
+
export class AccessorySetupError extends Error {
|
|
15
|
+
readonly code: AccessorySetupErrorCode;
|
|
16
|
+
|
|
17
|
+
constructor(code: AccessorySetupErrorCode, message: string, options?: { cause?: unknown }) {
|
|
18
|
+
super(message, options);
|
|
19
|
+
this.name = 'AccessorySetupError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isAccessorySetupError(
|
|
25
|
+
error: unknown,
|
|
26
|
+
code?: AccessorySetupErrorCode
|
|
27
|
+
): error is AccessorySetupError {
|
|
28
|
+
return error instanceof AccessorySetupError && (code === undefined || error.code === code);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isErrorCode(value: unknown): value is AccessorySetupErrorCode {
|
|
32
|
+
return (AccessorySetupErrorCodes as readonly unknown[]).includes(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Expo wraps the module's exception in a function call exception, so the module's reason is the
|
|
37
|
+
* first cause: `Calling 'x' failed (at …)\n→ Caused by: AccessorySetupException: <reason> (at File.swift:22)`.
|
|
38
|
+
* Debug iOS builds add the exception name and Swift source location, which are stripped here.
|
|
39
|
+
*/
|
|
40
|
+
function nativeReason(message: string): string {
|
|
41
|
+
const [, reason] = message.split('\n→ Caused by: ');
|
|
42
|
+
if (reason === undefined) {
|
|
43
|
+
return message;
|
|
44
|
+
}
|
|
45
|
+
return reason.replace(/^\w+Exception: /, '').replace(/ \(at [^()]*\)$/, '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Native modules reject with `{ code, message }`. Anything unrecognized becomes `UNKNOWN`. */
|
|
49
|
+
export function toAccessorySetupError(error: unknown): AccessorySetupError {
|
|
50
|
+
if (error instanceof AccessorySetupError) {
|
|
51
|
+
return error;
|
|
52
|
+
}
|
|
53
|
+
const { code, message } = (error ?? {}) as { code?: unknown; message?: unknown };
|
|
54
|
+
return new AccessorySetupError(
|
|
55
|
+
isErrorCode(code) ? code : 'UNKNOWN',
|
|
56
|
+
typeof message === 'string' ? nativeReason(message) : String(error),
|
|
57
|
+
{ cause: error }
|
|
58
|
+
);
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AccessoryEvent,
|
|
5
|
+
AccessoryEventType,
|
|
6
|
+
AccessorySubscription,
|
|
7
|
+
PairedAccessory,
|
|
8
|
+
ShowPickerOptions,
|
|
9
|
+
} from './ExpoAccessorySetup.types';
|
|
10
|
+
import NativeModule from './ExpoAccessorySetupModule';
|
|
11
|
+
import { AccessorySetupError, toAccessorySetupError } from './errors';
|
|
12
|
+
import { toNativePickerOptions } from './pickerOptions';
|
|
13
|
+
|
|
14
|
+
export * from './ExpoAccessorySetup.types';
|
|
15
|
+
export {
|
|
16
|
+
AccessorySetupError,
|
|
17
|
+
AccessorySetupErrorCodes,
|
|
18
|
+
isAccessorySetupError,
|
|
19
|
+
type AccessorySetupErrorCode,
|
|
20
|
+
} from './errors';
|
|
21
|
+
|
|
22
|
+
function requireModule() {
|
|
23
|
+
if (!NativeModule || !NativeModule.isSupported()) {
|
|
24
|
+
throw new AccessorySetupError(
|
|
25
|
+
'UNSUPPORTED',
|
|
26
|
+
NativeModule
|
|
27
|
+
? 'Accessory setup is not supported on this device (iOS 18+ or Android with companion device setup is required).'
|
|
28
|
+
: 'The ExpoAccessorySetup native module is not available. Use a development build, not Expo Go.'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return NativeModule;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function call<T>(body: () => Promise<T>): Promise<T> {
|
|
35
|
+
try {
|
|
36
|
+
return await body();
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw toAccessorySetupError(error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `true` on iOS 18+ and on Android devices with the companion device setup feature. */
|
|
43
|
+
export function isSupported(): boolean {
|
|
44
|
+
return NativeModule?.isSupported() ?? false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Shows the system accessory picker and resolves with the accessory the user selected.
|
|
49
|
+
* Rejects with `USER_CANCELLED` when the user dismisses the picker and `ALREADY_IN_PROGRESS`
|
|
50
|
+
* when another picker is showing.
|
|
51
|
+
*/
|
|
52
|
+
export function showPicker(options: ShowPickerOptions): Promise<PairedAccessory> {
|
|
53
|
+
return call(() => {
|
|
54
|
+
const module = requireModule();
|
|
55
|
+
const platform = Platform.OS === 'ios' ? 'ios' : 'android';
|
|
56
|
+
return module.showPicker(toNativePickerOptions(options, platform));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getPairedAccessories(): Promise<PairedAccessory[]> {
|
|
61
|
+
return call(() => requireModule().getPairedAccessories());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Rejects with `NOT_FOUND` when no paired accessory has this `id`. */
|
|
65
|
+
export function removeAccessory(id: string): Promise<void> {
|
|
66
|
+
return call(() => requireModule().removeAccessory(id));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const nativeEventNames = {
|
|
70
|
+
accessoryAdded: 'onAccessoryAdded',
|
|
71
|
+
accessoryRemoved: 'onAccessoryRemoved',
|
|
72
|
+
accessoryChanged: 'onAccessoryChanged',
|
|
73
|
+
} as const satisfies Record<AccessoryEventType, string>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* On Android, events only fire for changes made through this module. Accessories removed from
|
|
77
|
+
* system settings disappear from the next `getPairedAccessories()` result without an event.
|
|
78
|
+
*/
|
|
79
|
+
export function addAccessoryListener(
|
|
80
|
+
type: AccessoryEventType,
|
|
81
|
+
listener: (event: AccessoryEvent) => void
|
|
82
|
+
): AccessorySubscription {
|
|
83
|
+
if (!NativeModule) {
|
|
84
|
+
return { remove() {} };
|
|
85
|
+
}
|
|
86
|
+
return NativeModule.addListener(nativeEventNames[type], listener);
|
|
87
|
+
}
|