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.
Files changed (51) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +267 -6
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/expo/modules/accessorysetup/AccessorySetupException.kt +20 -0
  7. package/android/src/main/java/expo/modules/accessorysetup/CompanionAssociations.kt +152 -0
  8. package/android/src/main/java/expo/modules/accessorysetup/ExpoAccessorySetupModule.kt +214 -0
  9. package/android/src/main/java/expo/modules/accessorysetup/Records.kt +31 -0
  10. package/app.plugin.js +1 -0
  11. package/build/ExpoAccessorySetup.types.d.ts +63 -0
  12. package/build/ExpoAccessorySetup.types.d.ts.map +1 -0
  13. package/build/ExpoAccessorySetup.types.js +2 -0
  14. package/build/ExpoAccessorySetup.types.js.map +1 -0
  15. package/build/ExpoAccessorySetupModule.d.ts +24 -0
  16. package/build/ExpoAccessorySetupModule.d.ts.map +1 -0
  17. package/build/ExpoAccessorySetupModule.js +4 -0
  18. package/build/ExpoAccessorySetupModule.js.map +1 -0
  19. package/build/errors.d.ts +12 -0
  20. package/build/errors.d.ts.map +1 -0
  21. package/build/errors.js +45 -0
  22. package/build/errors.js.map +1 -0
  23. package/build/index.d.ts +20 -0
  24. package/build/index.d.ts.map +1 -0
  25. package/build/index.js +61 -0
  26. package/build/index.js.map +1 -0
  27. package/build/pickerOptions.d.ts +20 -0
  28. package/build/pickerOptions.d.ts.map +1 -0
  29. package/build/pickerOptions.js +64 -0
  30. package/build/pickerOptions.js.map +1 -0
  31. package/expo-module.config.json +9 -0
  32. package/ios/AccessorySessionController.swift +290 -0
  33. package/ios/AccessorySetupException.swift +50 -0
  34. package/ios/AccessorySetupRecords.swift +32 -0
  35. package/ios/ExpoAccessorySetup.podspec +31 -0
  36. package/ios/ExpoAccessorySetupModule.swift +67 -0
  37. package/mocks/ExpoAccessorySetup.ts +13 -0
  38. package/package.json +63 -4
  39. package/plugin/build/index.d.ts +8 -0
  40. package/plugin/build/index.js +85 -0
  41. package/plugin/build/options.d.ts +29 -0
  42. package/plugin/build/options.js +88 -0
  43. package/plugin/src/index.ts +122 -0
  44. package/plugin/src/options.ts +142 -0
  45. package/plugin/tsconfig.json +18 -0
  46. package/src/ExpoAccessorySetup.types.ts +69 -0
  47. package/src/ExpoAccessorySetupModule.ts +20 -0
  48. package/src/errors.ts +59 -0
  49. package/src/index.ts +87 -0
  50. package/src/pickerOptions.ts +100 -0
  51. 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
- > **Placeholder release.** This package is under active development. Version 0.0.1 contains no code. Do not install it yet.
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
- `expo-accessory-setup` will be an Expo module for system-level accessory pairing:
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
- - iOS: AccessorySetupKit (iOS 18+)
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
- This is a community package. It is not an official Expo module and is not affiliated with or endorsed by Expo.
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 first usable release will be 0.1.0.
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,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -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)