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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unpublished
|
|
4
|
+
|
|
5
|
+
### 🛠 Breaking changes
|
|
6
|
+
|
|
7
|
+
### 🎉 New features
|
|
8
|
+
|
|
9
|
+
### 🐛 Bug fixes
|
|
10
|
+
|
|
11
|
+
### 💡 Others
|
|
12
|
+
|
|
13
|
+
## 0.1.0
|
|
14
|
+
|
|
15
|
+
### 🎉 New features
|
|
16
|
+
|
|
17
|
+
- `showPicker()` presents the system pairing UI: AccessorySetupKit on iOS 18+, CompanionDeviceManager on Android.
|
|
18
|
+
- `getPairedAccessories()` and `removeAccessory()` list and remove the accessories the app has paired.
|
|
19
|
+
- `addAccessoryListener()` reports `accessoryAdded`, `accessoryRemoved`, and `accessoryChanged` events.
|
|
20
|
+
- `isSupported()` reports whether the device and build can use the module.
|
|
21
|
+
- Typed `AccessorySetupError` rejections with `UNSUPPORTED`, `USER_CANCELLED`, `PERMISSION_DENIED`, `ALREADY_IN_PROGRESS`, `NOT_FOUND`, `DISCOVERY_TIMEOUT`, `INVALID_OPTIONS`, and `UNKNOWN` codes.
|
|
22
|
+
- Config plugin that writes the AccessorySetupKit Info.plist keys on iOS, and the companion device feature and Bluetooth permissions on Android.
|
|
23
|
+
- iOS picker filters are checked against Info.plist and reject with `INVALID_OPTIONS` instead of letting AccessorySetupKit terminate the app.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,12 +1,273 @@
|
|
|
1
1
|
# expo-accessory-setup
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
System accessory pairing for Expo apps: [AccessorySetupKit](https://developer.apple.com/documentation/accessorysetupkit) on iOS and [CompanionDeviceManager](https://developer.android.com/develop/connectivity/bluetooth/companion-device-pairing) on Android.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> This is a community package. It is not an official Expo module, and Expo does not maintain or endorse it.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
- Android: CompanionDeviceManager
|
|
8
|
+
The module shows the system's own pairing UI, lists the accessories your app has paired, and removes them. It hands you a Bluetooth identifier that you pass to [react-native-ble-plx](https://github.com/dotintent/react-native-ble-plx) (or any BLE library) to connect and exchange data. It does not connect to accessories itself.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
| Platform | System API | Requirement |
|
|
11
|
+
| --- | --- | --- |
|
|
12
|
+
| iOS | AccessorySetupKit | iOS 18 or later |
|
|
13
|
+
| Android | CompanionDeviceManager | Android 8.0 (API 26) or later, with the `android.software.companion_device_setup` feature |
|
|
11
14
|
|
|
12
|
-
The
|
|
15
|
+
The module needs a [development build](https://docs.expo.dev/develop/development-builds/introduction/). It does not work in Expo Go. It is built and tested with Expo SDK 58.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
bunx expo install expo-accessory-setup
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Add the config plugin to `app.json` and list every identifier your picker filters will use:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"expo": {
|
|
28
|
+
"plugins": [
|
|
29
|
+
[
|
|
30
|
+
"expo-accessory-setup",
|
|
31
|
+
{
|
|
32
|
+
"ios": {
|
|
33
|
+
"bluetoothServices": ["180D"],
|
|
34
|
+
"bluetoothNames": ["Acme"],
|
|
35
|
+
"bluetoothCompanyIdentifiers": [76]
|
|
36
|
+
},
|
|
37
|
+
"android": {
|
|
38
|
+
"runInBackground": false,
|
|
39
|
+
"useDataInBackground": false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then rebuild the native project:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
bunx expo prebuild --clean
|
|
52
|
+
bunx expo run:ios # or run:android
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Config plugin options
|
|
56
|
+
|
|
57
|
+
All options are optional, except that `ios.bluetoothServices` or `ios.bluetoothCompanyIdentifiers` must contain at least one value. Invalid options fail `expo prebuild` with a message that names the field.
|
|
58
|
+
|
|
59
|
+
| Option | Type | Effect |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `ios.bluetoothServices` | `string[]` | 16-bit (`"180D"`) or 128-bit service UUIDs. Written to `NSAccessorySetupBluetoothServices`. |
|
|
62
|
+
| `ios.bluetoothNames` | `string[]` | Advertised names or name substrings. Written to `NSAccessorySetupBluetoothNames`. |
|
|
63
|
+
| `ios.bluetoothCompanyIdentifiers` | `number[]` | Bluetooth SIG company identifiers, 0 to 65535. Written to `NSAccessorySetupBluetoothCompanyIdentifiers` as four hex digits (`76` becomes `"004C"`). |
|
|
64
|
+
| `android.runInBackground` | `boolean` | Adds `REQUEST_COMPANION_RUN_IN_BACKGROUND`. |
|
|
65
|
+
| `android.useDataInBackground` | `boolean` | Adds `REQUEST_COMPANION_USE_DATA_IN_BACKGROUND`. |
|
|
66
|
+
|
|
67
|
+
The plugin always writes:
|
|
68
|
+
|
|
69
|
+
- **iOS:** `NSAccessorySetupKitSupports` with `Bluetooth`, plus each key in the table that has values. Existing `NSAccessorySetupKitSupports` values, such as `WiFi`, are kept. Keys without values are left out, because AccessorySetupKit terminates the app when one of them is present and empty.
|
|
70
|
+
- **Android:** `<uses-feature android:name="android.software.companion_device_setup" android:required="false" />`, `BLUETOOTH_CONNECT`, and `BLUETOOTH` and `BLUETOOTH_ADMIN` limited to API 30 and lower. CompanionDeviceManager scans for your app, so the plugin adds no scan or location permission. The feature is not required, so the app still installs on devices without it, and `isSupported()` returns `false` there.
|
|
71
|
+
|
|
72
|
+
> [!IMPORTANT]
|
|
73
|
+
> On iOS, every value that a picker filter uses must appear in Info.plist. AccessorySetupKit terminates the app when a filter uses an undeclared service, name, or company identifier. This module checks the filter against Info.plist first and rejects with `INVALID_OPTIONS` instead.
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
import {
|
|
79
|
+
addAccessoryListener,
|
|
80
|
+
getPairedAccessories,
|
|
81
|
+
isAccessorySetupError,
|
|
82
|
+
isSupported,
|
|
83
|
+
removeAccessory,
|
|
84
|
+
showPicker,
|
|
85
|
+
} from 'expo-accessory-setup';
|
|
86
|
+
import { BleManager } from 'react-native-ble-plx';
|
|
87
|
+
|
|
88
|
+
if (isSupported()) {
|
|
89
|
+
try {
|
|
90
|
+
const accessory = await showPicker({
|
|
91
|
+
filter: { serviceUUID: '180D' },
|
|
92
|
+
ios: {
|
|
93
|
+
displayName: 'Acme Heart Rate Strap',
|
|
94
|
+
productImage: require('./assets/strap.png'),
|
|
95
|
+
},
|
|
96
|
+
android: { singleDevice: true },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const device = await new BleManager().connectToDevice(accessory.bleIdentifier);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (isAccessorySetupError(error, 'USER_CANCELLED')) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The [example app](./example/App.tsx) shows the whole flow. It pairs an accessory, lists the paired accessories, connects with react-native-ble-plx, and removes an accessory.
|
|
110
|
+
|
|
111
|
+
## API
|
|
112
|
+
|
|
113
|
+
### `isSupported(): boolean`
|
|
114
|
+
|
|
115
|
+
Returns `true` on iOS 18 or later, and on Android devices with the companion device setup feature on API 26 or later. Returns `false` in Expo Go, on web, and on older systems. When it returns `false`, every other function rejects with `UNSUPPORTED`.
|
|
116
|
+
|
|
117
|
+
### `showPicker(options: ShowPickerOptions): Promise<PairedAccessory>`
|
|
118
|
+
|
|
119
|
+
Shows the system picker and resolves with the accessory the user selected.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
type ShowPickerOptions = {
|
|
123
|
+
filter: {
|
|
124
|
+
serviceUUID?: string; // 16-, 32-, or 128-bit UUID
|
|
125
|
+
companyIdentifier?: number; // 0 to 0xFFFF
|
|
126
|
+
nameSubstring?: string;
|
|
127
|
+
};
|
|
128
|
+
ios: {
|
|
129
|
+
displayName: string;
|
|
130
|
+
productImage: number | { uri: string }; // require('./image.png') or a URI
|
|
131
|
+
range?: 'default' | 'immediate'; // 'immediate' only finds accessories held close
|
|
132
|
+
};
|
|
133
|
+
android?: {
|
|
134
|
+
singleDevice?: boolean; // default true
|
|
135
|
+
namePattern?: string; // Java regex. Replaces filter.nameSubstring on Android.
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`filter` must contain `serviceUUID` or `companyIdentifier`. The `ios` section is required because the iOS picker needs a product name and image. You must pass it even if you only target Android.
|
|
141
|
+
|
|
142
|
+
The promise rejects with:
|
|
143
|
+
|
|
144
|
+
- `USER_CANCELLED` when the user closes the picker.
|
|
145
|
+
- `ALREADY_IN_PROGRESS` when another picker is showing.
|
|
146
|
+
- `INVALID_OPTIONS` when an option is malformed, or when an iOS filter value is missing from Info.plist.
|
|
147
|
+
- `DISCOVERY_TIMEOUT` when the system finds no matching device and reports a timeout.
|
|
148
|
+
|
|
149
|
+
### `getPairedAccessories(): Promise<PairedAccessory[]>`
|
|
150
|
+
|
|
151
|
+
Returns the accessories that this app has paired.
|
|
152
|
+
|
|
153
|
+
### `removeAccessory(id: string): Promise<void>`
|
|
154
|
+
|
|
155
|
+
Removes the pairing. Rejects with `NOT_FOUND` when no paired accessory has this `id`.
|
|
156
|
+
|
|
157
|
+
### `addAccessoryListener(type, listener): { remove(): void }`
|
|
158
|
+
|
|
159
|
+
`type` is `'accessoryAdded'`, `'accessoryRemoved'`, or `'accessoryChanged'`. The listener receives `{ accessory: PairedAccessory }`. See [Platform differences](#platform-differences) for when each event fires.
|
|
160
|
+
|
|
161
|
+
### `PairedAccessory`
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
type PairedAccessory = {
|
|
165
|
+
id: string; // pass to removeAccessory()
|
|
166
|
+
bleIdentifier: string; // pass to react-native-ble-plx
|
|
167
|
+
displayName: string;
|
|
168
|
+
platform: 'ios' | 'android';
|
|
169
|
+
};
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
| Field | iOS | Android |
|
|
173
|
+
| --- | --- | --- |
|
|
174
|
+
| `id` | CoreBluetooth peripheral UUID | MAC address, upper case |
|
|
175
|
+
| `bleIdentifier` | CoreBluetooth peripheral UUID (`ASAccessory.bluetoothIdentifier`) | MAC address, upper case |
|
|
176
|
+
| `displayName` | Name the system shows for the accessory | Name from the association or the scan. Falls back to the MAC address. |
|
|
177
|
+
|
|
178
|
+
### Errors
|
|
179
|
+
|
|
180
|
+
Every rejection is an `AccessorySetupError` with a `code` and the native message. Use `isAccessorySetupError(error, code?)` to check for one.
|
|
181
|
+
|
|
182
|
+
| Code | Meaning |
|
|
183
|
+
| --- | --- |
|
|
184
|
+
| `UNSUPPORTED` | The device or build can't use this module. See `isSupported()`. |
|
|
185
|
+
| `USER_CANCELLED` | The user closed or rejected the picker. |
|
|
186
|
+
| `PERMISSION_DENIED` | iOS refused to show the picker: the app is in the background, or the user restricted access. |
|
|
187
|
+
| `ALREADY_IN_PROGRESS` | Another picker is showing. |
|
|
188
|
+
| `NOT_FOUND` | `removeAccessory()` got an unknown `id`. |
|
|
189
|
+
| `DISCOVERY_TIMEOUT` | The system reported a discovery timeout. |
|
|
190
|
+
| `INVALID_OPTIONS` | Malformed options, an iOS filter value missing from Info.plist, or an empty `NSAccessorySetupBluetooth*` key in Info.plist. |
|
|
191
|
+
| `UNKNOWN` | Any other native error. `error.message` holds the native message, and `error.cause` holds the original error. |
|
|
192
|
+
|
|
193
|
+
## Using it with react-native-ble-plx
|
|
194
|
+
|
|
195
|
+
Pass `accessory.bleIdentifier` to `connectToDevice()`. You don't need to scan first:
|
|
196
|
+
|
|
197
|
+
- **iOS:** ble-plx connects through `retrievePeripherals(withIdentifiers:)`, which finds the peripheral by its UUID.
|
|
198
|
+
- **Android:** ble-plx connects by MAC address. On Android 12 (API 31) and later, request the `BLUETOOTH_CONNECT` runtime permission before you connect. The config plugin declares it, but your app must still request it.
|
|
199
|
+
|
|
200
|
+
### CoreBluetooth changes on iOS
|
|
201
|
+
|
|
202
|
+
When an app declares AccessorySetupKit support, iOS changes how CoreBluetooth behaves for that app. In [WWDC24 session 10203, "Meet AccessorySetupKit"](https://developer.apple.com/videos/play/wwdc2024/10203/), Apple describes three changes:
|
|
203
|
+
|
|
204
|
+
- People are not asked for Bluetooth permission.
|
|
205
|
+
- `CBCentralManager` only reaches the `poweredOn` state after the app has paired an accessory.
|
|
206
|
+
- Scans only return accessories paired with the app.
|
|
207
|
+
|
|
208
|
+
This affects ble-plx in three ways:
|
|
209
|
+
|
|
210
|
+
- `BleManager.state()` doesn't report `PoweredOn` until the first accessory is paired. Don't wait for a powered-on state before you show the picker.
|
|
211
|
+
- `startDeviceScan()` won't show unpaired devices. Use `showPicker()` for discovery, then use scans only to find accessories you have already paired.
|
|
212
|
+
- This affects the whole app. If other parts of your app scan for arbitrary BLE devices, they stop seeing devices that weren't paired through the picker.
|
|
213
|
+
|
|
214
|
+
## Platform differences
|
|
215
|
+
|
|
216
|
+
| Behavior | iOS | Android |
|
|
217
|
+
| --- | --- | --- |
|
|
218
|
+
| Picker UI | Full-screen sheet with your product name and image | System dialog listing matching devices |
|
|
219
|
+
| Name filter | `filter.nameSubstring` (substring match) | `filter.nameSubstring`, or `android.namePattern` as a regex |
|
|
220
|
+
| `accessoryAdded` | Fires for every accessory paired through the picker | Fires after `showPicker()` resolves |
|
|
221
|
+
| `accessoryRemoved` | Fires for removals from the app and from Settings | Fires only for `removeAccessory()`. Removals in system settings show up in the next `getPairedAccessories()` result. |
|
|
222
|
+
| `accessoryChanged` | Fires when AccessorySetupKit reports that an accessory's properties changed | Never fires |
|
|
223
|
+
| Bluetooth permission prompt | None | `BLUETOOTH_CONNECT` (API 31+), requested by your app before connecting |
|
|
224
|
+
| Location | Not needed | No permission needed, but CompanionDeviceManager requires the device's Location Services to be on |
|
|
225
|
+
|
|
226
|
+
## Limitations
|
|
227
|
+
|
|
228
|
+
- **Bluetooth LE only.** The module does not cover Wi-Fi accessories, Wi-Fi Aware, or AccessorySetupKit discovery extensions.
|
|
229
|
+
- **One display item per picker on iOS.** `showPicker()` presents a single product.
|
|
230
|
+
- **No migration of existing pairings on iOS.** Accessories that your app paired through CoreBluetooth before adopting AccessorySetupKit don't appear in `getPairedAccessories()`. AccessorySetupKit supports migration through `ASMigrationDisplayItem`, but this module does not use it.
|
|
231
|
+
- **No self-managed or profile-based associations on Android.** Watch, glasses, and other device profiles need extra permissions and roles that the module doesn't request.
|
|
232
|
+
- **Audio accessories may not appear in the iOS picker.** On iOS 27, AccessorySetupKit never offered a pair of Sony WH-1000XM6 headphones, even though their advertisement matched the filter. iOS logged no reason. Test with the accessory you target.
|
|
233
|
+
- **Picker filters must match the plugin config on iOS.** Changing a filter value requires a native rebuild.
|
|
234
|
+
- **Unverified details.** These are assumptions in the current implementation. Each one is marked with `TODO(verify)` in the source:
|
|
235
|
+
- Apple doesn't document the string format for `NSAccessorySetupBluetoothCompanyIdentifiers`. The plugin writes four hex digits (`"004C"`).
|
|
236
|
+
- On Android, `filter.companyIdentifier` becomes a scan filter with empty manufacturer data, which should match any data from that company.
|
|
237
|
+
|
|
238
|
+
## Troubleshooting
|
|
239
|
+
|
|
240
|
+
**`UNSUPPORTED` in Expo Go.** Expo Go doesn't include this module. Build a development build with `bunx expo run:ios` or `bunx expo run:android`.
|
|
241
|
+
|
|
242
|
+
**`INVALID_OPTIONS: filter.serviceUUID … is not listed in NSAccessorySetupBluetoothServices`.** Add the value to the plugin's `ios.bluetoothServices`, run `bunx expo prebuild --clean`, and rebuild the app.
|
|
243
|
+
|
|
244
|
+
**`UNKNOWN: The accessory session was invalidated` in the iOS Simulator.** The Simulator has no Bluetooth, so AccessorySetupKit invalidates the session. Test the picker on a physical iPhone.
|
|
245
|
+
|
|
246
|
+
**`INVALID_OPTIONS: Info.plist key … has no values`.** An `NSAccessorySetupBluetooth*` key is present with an empty array, which makes AccessorySetupKit terminate the app. Remove the key, or let the config plugin write Info.plist with `bunx expo prebuild --clean`.
|
|
247
|
+
|
|
248
|
+
**The iOS picker shows no devices.** Check that the accessory advertises the filtered service UUID or company identifier. Some accessories, such as Bluetooth headphones, don't appear even when they match. See [Limitations](#limitations). With `range: 'immediate'`, hold the device next to the accessory.
|
|
249
|
+
|
|
250
|
+
**`PERMISSION_DENIED` on iOS.** The picker only shows while the app is in the foreground. Call `showPicker()` from a user action, such as a button press.
|
|
251
|
+
|
|
252
|
+
**The Android dialog never finds the device.** Turn on Location Services. CompanionDeviceManager requires them even though your app needs no location permission. Also make sure your app isn't running its own Bluetooth scan at the same time.
|
|
253
|
+
|
|
254
|
+
**ble-plx can't connect on Android.** Request `PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT` first on Android 12 and later.
|
|
255
|
+
|
|
256
|
+
**ble-plx never reports `PoweredOn` on iOS.** This is expected before the first accessory is paired. See [CoreBluetooth changes on iOS](#corebluetooth-changes-on-ios).
|
|
257
|
+
|
|
258
|
+
## Contributing
|
|
259
|
+
|
|
260
|
+
```sh
|
|
261
|
+
bun install
|
|
262
|
+
bun run test # Jest: TypeScript API and config plugin
|
|
263
|
+
bun run lint
|
|
264
|
+
bun run build # TypeScript API
|
|
265
|
+
bun run build plugin # config plugin, used by the example's prebuild
|
|
266
|
+
cd example && bunx expo run:ios
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The iOS Simulator can't show the picker. Test pairing on a physical device.
|
|
270
|
+
|
|
271
|
+
## License
|
|
272
|
+
|
|
273
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id 'com.android.library'
|
|
3
|
+
id 'expo-module-gradle-plugin'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
group = 'expo.modules.accessorysetup'
|
|
7
|
+
version = '0.1.0'
|
|
8
|
+
|
|
9
|
+
android {
|
|
10
|
+
namespace "expo.modules.accessorysetup"
|
|
11
|
+
defaultConfig {
|
|
12
|
+
versionCode 1
|
|
13
|
+
versionName "0.1.0"
|
|
14
|
+
}
|
|
15
|
+
lintOptions {
|
|
16
|
+
abortOnError false
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
dependencies {
|
|
21
|
+
// IntentSenderRequest and StartIntentSenderForResult, for launching the association dialog
|
|
22
|
+
// through Expo's activity result registry. React Native already brings this in at runtime.
|
|
23
|
+
implementation "androidx.activity:activity:1.8.0"
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package expo.modules.accessorysetup
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.exception.CodedException
|
|
4
|
+
|
|
5
|
+
internal enum class AccessorySetupErrorCode {
|
|
6
|
+
UNSUPPORTED,
|
|
7
|
+
USER_CANCELLED,
|
|
8
|
+
PERMISSION_DENIED,
|
|
9
|
+
ALREADY_IN_PROGRESS,
|
|
10
|
+
NOT_FOUND,
|
|
11
|
+
DISCOVERY_TIMEOUT,
|
|
12
|
+
INVALID_OPTIONS,
|
|
13
|
+
UNKNOWN
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
internal class AccessorySetupException(
|
|
17
|
+
code: AccessorySetupErrorCode,
|
|
18
|
+
message: String,
|
|
19
|
+
cause: Throwable? = null
|
|
20
|
+
) : CodedException(code.name, message, cause)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
package expo.modules.accessorysetup
|
|
2
|
+
|
|
3
|
+
import android.bluetooth.BluetoothDevice
|
|
4
|
+
import android.bluetooth.le.ScanFilter
|
|
5
|
+
import android.bluetooth.le.ScanResult
|
|
6
|
+
import android.companion.AssociationInfo
|
|
7
|
+
import android.companion.AssociationRequest
|
|
8
|
+
import android.companion.BluetoothLeDeviceFilter
|
|
9
|
+
import android.companion.CompanionDeviceManager
|
|
10
|
+
import android.content.Context
|
|
11
|
+
import android.content.Intent
|
|
12
|
+
import android.content.pm.PackageManager
|
|
13
|
+
import android.os.Build
|
|
14
|
+
import android.os.Handler
|
|
15
|
+
import android.os.Looper
|
|
16
|
+
import android.os.ParcelUuid
|
|
17
|
+
import android.os.Parcelable
|
|
18
|
+
import androidx.annotation.ChecksSdkIntAtLeast
|
|
19
|
+
import androidx.annotation.RequiresApi
|
|
20
|
+
import androidx.core.content.ContextCompat
|
|
21
|
+
import androidx.core.content.edit
|
|
22
|
+
import java.util.regex.Pattern
|
|
23
|
+
import java.util.regex.PatternSyntaxException
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wraps CompanionDeviceManager and hides the split between the API 33+ `AssociationInfo` APIs and
|
|
27
|
+
* the older MAC-address APIs.
|
|
28
|
+
*/
|
|
29
|
+
@RequiresApi(Build.VERSION_CODES.O)
|
|
30
|
+
internal class CompanionAssociations(private val context: Context) {
|
|
31
|
+
private val manager = context.getSystemService(CompanionDeviceManager::class.java)
|
|
32
|
+
|
|
33
|
+
// CompanionDeviceManager doesn't keep a usable name for non-self-managed associations, and below
|
|
34
|
+
// API 33 it only returns MAC addresses, so the name seen at pick time is stored here.
|
|
35
|
+
private val names = context.getSharedPreferences("expo.modules.accessorysetup.names", Context.MODE_PRIVATE)
|
|
36
|
+
|
|
37
|
+
fun associate(options: PickerOptions, callback: CompanionDeviceManager.Callback) {
|
|
38
|
+
val request = buildRequest(options)
|
|
39
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
40
|
+
manager.associate(request, ContextCompat.getMainExecutor(context), callback)
|
|
41
|
+
} else {
|
|
42
|
+
@Suppress("DEPRECATION")
|
|
43
|
+
manager.associate(request, callback, Handler(Looper.getMainLooper()))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fun remember(accessory: PairedAccessory) {
|
|
48
|
+
names.edit { putString(accessory.macAddress, accessory.displayName) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fun list(): List<PairedAccessory> =
|
|
52
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
53
|
+
manager.myAssociations.mapNotNull(::toAccessory)
|
|
54
|
+
} else {
|
|
55
|
+
@Suppress("DEPRECATION")
|
|
56
|
+
manager.associations.map(::fromMacAddress)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fun remove(id: String): PairedAccessory {
|
|
60
|
+
val accessory: PairedAccessory
|
|
61
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
62
|
+
val info = manager.myAssociations.firstOrNull { it.deviceMacAddress?.toString().equals(id, ignoreCase = true) }
|
|
63
|
+
?: throw notFound(id)
|
|
64
|
+
accessory = toAccessory(info) ?: throw notFound(id)
|
|
65
|
+
manager.disassociate(info.id)
|
|
66
|
+
} else {
|
|
67
|
+
// Addresses are case-sensitive below API 33, so pass back the stored spelling.
|
|
68
|
+
@Suppress("DEPRECATION")
|
|
69
|
+
val address = manager.associations.firstOrNull { it.equals(id, ignoreCase = true) } ?: throw notFound(id)
|
|
70
|
+
accessory = fromMacAddress(address)
|
|
71
|
+
@Suppress("DEPRECATION")
|
|
72
|
+
manager.disassociate(address)
|
|
73
|
+
}
|
|
74
|
+
names.edit { remove(accessory.macAddress) }
|
|
75
|
+
return accessory
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Reads the association from the result of the system's association activity. */
|
|
79
|
+
fun fromActivityResult(data: Intent): PairedAccessory? {
|
|
80
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
81
|
+
val info = data.getParcelableExtra(CompanionDeviceManager.EXTRA_ASSOCIATION, AssociationInfo::class.java)
|
|
82
|
+
return info?.let(::toAccessory)
|
|
83
|
+
}
|
|
84
|
+
@Suppress("DEPRECATION")
|
|
85
|
+
return when (val device = data.getParcelableExtra<Parcelable>(CompanionDeviceManager.EXTRA_DEVICE)) {
|
|
86
|
+
is ScanResult -> PairedAccessory(
|
|
87
|
+
device.device.address.uppercase(),
|
|
88
|
+
device.scanRecord?.deviceName ?: device.device.address.uppercase()
|
|
89
|
+
)
|
|
90
|
+
is BluetoothDevice -> fromMacAddress(device.address)
|
|
91
|
+
else -> null
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
|
96
|
+
fun toAccessory(info: AssociationInfo): PairedAccessory? {
|
|
97
|
+
val macAddress = info.deviceMacAddress?.toString()?.uppercase() ?: return null
|
|
98
|
+
val scannedName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
99
|
+
info.associatedDevice?.bleDevice?.scanRecord?.deviceName
|
|
100
|
+
} else {
|
|
101
|
+
null
|
|
102
|
+
}
|
|
103
|
+
val displayName = info.displayName?.toString() ?: scannedName ?: names.getString(macAddress, null) ?: macAddress
|
|
104
|
+
return PairedAccessory(macAddress, displayName)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun fromMacAddress(address: String): PairedAccessory {
|
|
108
|
+
val macAddress = address.uppercase()
|
|
109
|
+
return PairedAccessory(macAddress, names.getString(macAddress, null) ?: macAddress)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private fun buildRequest(options: PickerOptions): AssociationRequest {
|
|
113
|
+
val scanFilter = ScanFilter.Builder().apply {
|
|
114
|
+
options.serviceUUID?.let { uuid ->
|
|
115
|
+
val parcelUuid = try {
|
|
116
|
+
ParcelUuid.fromString(uuid)
|
|
117
|
+
} catch (e: IllegalArgumentException) {
|
|
118
|
+
throw AccessorySetupException(AccessorySetupErrorCode.INVALID_OPTIONS, "filter.serviceUUID $uuid is not a UUID.", e)
|
|
119
|
+
}
|
|
120
|
+
setServiceUuid(parcelUuid)
|
|
121
|
+
}
|
|
122
|
+
// An empty data array matches any manufacturer data from this company.
|
|
123
|
+
// TODO(verify): on a device, that an empty array matches without a mask.
|
|
124
|
+
options.companyIdentifier?.let { setManufacturerData(it, ByteArray(0)) }
|
|
125
|
+
}.build()
|
|
126
|
+
|
|
127
|
+
val regex = options.namePattern ?: options.nameSubstring?.let { ".*${Pattern.quote(it)}.*" }
|
|
128
|
+
val namePattern = try {
|
|
129
|
+
regex?.let(Pattern::compile)
|
|
130
|
+
} catch (e: PatternSyntaxException) {
|
|
131
|
+
throw AccessorySetupException(AccessorySetupErrorCode.INVALID_OPTIONS, "android.namePattern is not a valid regex: ${e.description}", e)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
val deviceFilter = BluetoothLeDeviceFilter.Builder()
|
|
135
|
+
.setScanFilter(scanFilter)
|
|
136
|
+
.apply { namePattern?.let(::setNamePattern) }
|
|
137
|
+
.build()
|
|
138
|
+
|
|
139
|
+
return AssociationRequest.Builder()
|
|
140
|
+
.addDeviceFilter(deviceFilter)
|
|
141
|
+
.setSingleDevice(options.singleDevice)
|
|
142
|
+
.build()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private fun notFound(id: String) =
|
|
146
|
+
AccessorySetupException(AccessorySetupErrorCode.NOT_FOUND, "No paired accessory has the id $id.")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
|
|
150
|
+
internal fun isCompanionDeviceSetupSupported(context: Context) =
|
|
151
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
|
152
|
+
context.packageManager.hasSystemFeature(PackageManager.FEATURE_COMPANION_DEVICE_SETUP)
|