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,290 @@
|
|
|
1
|
+
import AccessorySetupKit
|
|
2
|
+
import CoreBluetooth
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
enum AccessoryEventName: String {
|
|
6
|
+
case added = "onAccessoryAdded"
|
|
7
|
+
case removed = "onAccessoryRemoved"
|
|
8
|
+
case changed = "onAccessoryChanged"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/// Owns the app's single `ASAccessorySession`. All state lives on the main actor, which is also
|
|
12
|
+
/// the queue the session delivers events on.
|
|
13
|
+
@available(iOS 18.0, *)
|
|
14
|
+
@MainActor
|
|
15
|
+
final class AccessorySessionController {
|
|
16
|
+
private enum Activation {
|
|
17
|
+
case inactive
|
|
18
|
+
case activating([CheckedContinuation<Void, Error>])
|
|
19
|
+
case active
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private final class PresentedPicker {
|
|
23
|
+
let continuation: CheckedContinuation<ASAccessory, Error>
|
|
24
|
+
var pickedAccessory: ASAccessory?
|
|
25
|
+
var setupError: Error?
|
|
26
|
+
|
|
27
|
+
init(_ continuation: CheckedContinuation<ASAccessory, Error>) {
|
|
28
|
+
self.continuation = continuation
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private enum PickerState {
|
|
33
|
+
case idle
|
|
34
|
+
/// Reserved synchronously so concurrent calls are rejected across the awaits that follow.
|
|
35
|
+
case preparing
|
|
36
|
+
case presented(PresentedPicker)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private var session = ASAccessorySession()
|
|
40
|
+
private var activation = Activation.inactive
|
|
41
|
+
private var picker = PickerState.idle
|
|
42
|
+
private let emit: (AccessoryEventName, PairedAccessoryRecord) -> Void
|
|
43
|
+
|
|
44
|
+
init(emit: @escaping (AccessoryEventName, PairedAccessoryRecord) -> Void) {
|
|
45
|
+
self.emit = emit
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// `ASAccessorySession()` terminates the app when an `NSAccessorySetupBluetooth*` key is present
|
|
49
|
+
/// with no values, so this rejects that Info.plist before a session is created.
|
|
50
|
+
static func validateInfoPlist() throws {
|
|
51
|
+
let info = Bundle.main.infoDictionary ?? [:]
|
|
52
|
+
let keys = [
|
|
53
|
+
"NSAccessorySetupBluetoothServices", "NSAccessorySetupBluetoothNames",
|
|
54
|
+
"NSAccessorySetupBluetoothCompanyIdentifiers",
|
|
55
|
+
]
|
|
56
|
+
for key in keys {
|
|
57
|
+
if let values = info[key] as? [Any], values.isEmpty {
|
|
58
|
+
throw AccessorySetupException(
|
|
59
|
+
.invalidOptions,
|
|
60
|
+
"Info.plist key \(key) has no values. Remove the key, or rebuild with the config plugin, which leaves out unused keys."
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func invalidate() {
|
|
67
|
+
session.invalidate()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func pairedAccessories() async throws -> [PairedAccessoryRecord] {
|
|
71
|
+
try await activate()
|
|
72
|
+
return session.accessories.compactMap(PairedAccessoryRecord.init)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func removeAccessory(id: String) async throws {
|
|
76
|
+
try await activate()
|
|
77
|
+
guard
|
|
78
|
+
let accessory = session.accessories.first(where: {
|
|
79
|
+
$0.bluetoothIdentifier?.uuidString.caseInsensitiveCompare(id) == .orderedSame
|
|
80
|
+
})
|
|
81
|
+
else {
|
|
82
|
+
throw AccessorySetupException(.notFound, "No paired accessory has the id \(id).")
|
|
83
|
+
}
|
|
84
|
+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
85
|
+
session.removeAccessory(accessory) { error in
|
|
86
|
+
if let error {
|
|
87
|
+
continuation.resume(throwing: AccessorySetupException.from(error))
|
|
88
|
+
} else {
|
|
89
|
+
continuation.resume()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func showPicker(_ options: PickerOptions) async throws -> PairedAccessoryRecord {
|
|
96
|
+
guard case .idle = picker else {
|
|
97
|
+
throw AccessorySetupException(.alreadyInProgress, "Another accessory picker is already showing.")
|
|
98
|
+
}
|
|
99
|
+
picker = .preparing
|
|
100
|
+
defer {
|
|
101
|
+
picker = .idle
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let descriptor = try Self.makeDescriptor(options)
|
|
105
|
+
let image = try await Self.loadProductImage(options.productImageUri)
|
|
106
|
+
try await activate()
|
|
107
|
+
let item = ASPickerDisplayItem(name: options.displayName, productImage: image, descriptor: descriptor)
|
|
108
|
+
|
|
109
|
+
let accessory = try await withCheckedThrowingContinuation { continuation in
|
|
110
|
+
picker = .presented(PresentedPicker(continuation))
|
|
111
|
+
session.showPicker(for: [item]) { [weak self] error in
|
|
112
|
+
guard let error else {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
Task { @MainActor in
|
|
116
|
+
self?.finishPicker(.failure(AccessorySetupException.from(error)))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
guard let record = PairedAccessoryRecord(accessory) else {
|
|
121
|
+
throw AccessorySetupException(.unknown, "The picked accessory has no Bluetooth identifier.")
|
|
122
|
+
}
|
|
123
|
+
return record
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// MARK: - Session lifecycle
|
|
127
|
+
|
|
128
|
+
private func activate() async throws {
|
|
129
|
+
switch activation {
|
|
130
|
+
case .active:
|
|
131
|
+
return
|
|
132
|
+
case .activating:
|
|
133
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
134
|
+
if case .activating(let waiters) = activation {
|
|
135
|
+
activation = .activating(waiters + [continuation])
|
|
136
|
+
} else {
|
|
137
|
+
continuation.resume()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
case .inactive:
|
|
141
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
142
|
+
activation = .activating([continuation])
|
|
143
|
+
session.activate(on: .main) { [weak self] event in
|
|
144
|
+
MainActor.assumeIsolated {
|
|
145
|
+
self?.handle(event)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private func finishActivation(_ result: Result<Void, Error>) {
|
|
153
|
+
guard case .activating(let waiters) = activation else {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
activation = if case .success = result { .active } else { .inactive }
|
|
157
|
+
waiters.forEach { $0.resume(with: result) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private func handle(_ event: ASAccessoryEvent) {
|
|
161
|
+
switch event.eventType {
|
|
162
|
+
case .activated:
|
|
163
|
+
finishActivation(.success(()))
|
|
164
|
+
case .invalidated:
|
|
165
|
+
let reason = event.error.map { " \($0.localizedDescription)" } ?? ""
|
|
166
|
+
let error = AccessorySetupException(.unknown, "The accessory session was invalidated.\(reason)", cause: event.error)
|
|
167
|
+
finishActivation(.failure(error))
|
|
168
|
+
finishPicker(.failure(error))
|
|
169
|
+
// An invalidated session can't be activated again.
|
|
170
|
+
session = ASAccessorySession()
|
|
171
|
+
activation = .inactive
|
|
172
|
+
case .accessoryAdded:
|
|
173
|
+
if case .presented(let presented) = picker {
|
|
174
|
+
presented.pickedAccessory = event.accessory
|
|
175
|
+
}
|
|
176
|
+
emitEvent(.added, event.accessory)
|
|
177
|
+
case .accessoryRemoved:
|
|
178
|
+
emitEvent(.removed, event.accessory)
|
|
179
|
+
case .accessoryChanged:
|
|
180
|
+
emitEvent(.changed, event.accessory)
|
|
181
|
+
case .pickerSetupFailed:
|
|
182
|
+
if case .presented(let presented) = picker {
|
|
183
|
+
presented.setupError = event.error
|
|
184
|
+
}
|
|
185
|
+
case .pickerDidDismiss:
|
|
186
|
+
// Apple delivers accessoryAdded before pickerDidDismiss, so the pick is known by now.
|
|
187
|
+
guard case .presented(let presented) = picker else {
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
if let accessory = presented.pickedAccessory {
|
|
191
|
+
finishPicker(.success(accessory))
|
|
192
|
+
} else if let error = presented.setupError {
|
|
193
|
+
finishPicker(.failure(AccessorySetupException.from(error)))
|
|
194
|
+
} else {
|
|
195
|
+
finishPicker(.failure(AccessorySetupException(.userCancelled, "The user dismissed the accessory picker.")))
|
|
196
|
+
}
|
|
197
|
+
default:
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private func finishPicker(_ result: Result<ASAccessory, Error>) {
|
|
203
|
+
guard case .presented(let presented) = picker else {
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
// Back to `.preparing` until showPicker's `defer` runs; a new call must not start before then.
|
|
207
|
+
picker = .preparing
|
|
208
|
+
presented.continuation.resume(with: result)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private func emitEvent(_ name: AccessoryEventName, _ accessory: ASAccessory?) {
|
|
212
|
+
if let accessory, let record = PairedAccessoryRecord(accessory) {
|
|
213
|
+
emit(name, record)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// MARK: - Picker inputs
|
|
218
|
+
|
|
219
|
+
/// Apple terminates the app when a descriptor uses a service, name, or company identifier
|
|
220
|
+
/// missing from Info.plist, so this rejects those filters first.
|
|
221
|
+
private static func makeDescriptor(_ options: PickerOptions) throws -> ASDiscoveryDescriptor {
|
|
222
|
+
let info = Bundle.main.infoDictionary ?? [:]
|
|
223
|
+
func declared(_ key: String) -> [String] {
|
|
224
|
+
info[key] as? [String] ?? []
|
|
225
|
+
}
|
|
226
|
+
func missing(_ field: String, _ value: String, _ key: String, _ pluginOption: String) -> AccessorySetupException {
|
|
227
|
+
AccessorySetupException(
|
|
228
|
+
.invalidOptions,
|
|
229
|
+
"\(field) \(value) is not listed in \(key). Add it to the config plugin's ios.\(pluginOption) and rebuild."
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let descriptor = ASDiscoveryDescriptor()
|
|
234
|
+
if let serviceUUID = options.serviceUUID {
|
|
235
|
+
let services = declared("NSAccessorySetupBluetoothServices").map(expandedUUID)
|
|
236
|
+
guard services.contains(expandedUUID(serviceUUID)) else {
|
|
237
|
+
throw missing("filter.serviceUUID", serviceUUID, "NSAccessorySetupBluetoothServices", "bluetoothServices")
|
|
238
|
+
}
|
|
239
|
+
descriptor.bluetoothServiceUUID = CBUUID(string: serviceUUID)
|
|
240
|
+
}
|
|
241
|
+
if let companyIdentifier = options.companyIdentifier {
|
|
242
|
+
let companies = declared("NSAccessorySetupBluetoothCompanyIdentifiers").compactMap(parseCompanyIdentifier)
|
|
243
|
+
guard let identifier = UInt16(exactly: companyIdentifier), companies.contains(identifier) else {
|
|
244
|
+
throw missing(
|
|
245
|
+
"filter.companyIdentifier", String(companyIdentifier), "NSAccessorySetupBluetoothCompanyIdentifiers",
|
|
246
|
+
"bluetoothCompanyIdentifiers")
|
|
247
|
+
}
|
|
248
|
+
descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(rawValue: identifier)
|
|
249
|
+
}
|
|
250
|
+
if let nameSubstring = options.nameSubstring {
|
|
251
|
+
guard declared("NSAccessorySetupBluetoothNames").contains(nameSubstring) else {
|
|
252
|
+
throw missing("filter.nameSubstring", nameSubstring, "NSAccessorySetupBluetoothNames", "bluetoothNames")
|
|
253
|
+
}
|
|
254
|
+
descriptor.bluetoothNameSubstring = nameSubstring
|
|
255
|
+
}
|
|
256
|
+
descriptor.bluetoothRange = options.range == "immediate" ? .immediate : .default
|
|
257
|
+
return descriptor
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private static func expandedUUID(_ uuid: String) -> String {
|
|
261
|
+
let uuid = uuid.uppercased()
|
|
262
|
+
switch uuid.count {
|
|
263
|
+
case 4: return "0000\(uuid)-0000-1000-8000-00805F9B34FB"
|
|
264
|
+
case 8: return "\(uuid)-0000-1000-8000-00805F9B34FB"
|
|
265
|
+
default: return uuid
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private static func parseCompanyIdentifier(_ value: String) -> UInt16? {
|
|
270
|
+
let hex = value.lowercased().hasPrefix("0x") ? String(value.dropFirst(2)) : value
|
|
271
|
+
return UInt16(hex, radix: 16)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/// Loads `file://` URIs from release bundles and `http://` URIs served by Metro in development.
|
|
275
|
+
private static func loadProductImage(_ uri: String) async throws -> UIImage {
|
|
276
|
+
guard let url = URL(string: uri) else {
|
|
277
|
+
throw AccessorySetupException(.invalidOptions, "ios.productImage URI \(uri) is not a valid URL.")
|
|
278
|
+
}
|
|
279
|
+
do {
|
|
280
|
+
let (data, _) = try await URLSession.shared.data(from: url)
|
|
281
|
+
if let image = UIImage(data: data) {
|
|
282
|
+
return image
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
throw AccessorySetupException(
|
|
286
|
+
.invalidOptions, "ios.productImage could not be loaded from \(uri): \(error.localizedDescription)", cause: error)
|
|
287
|
+
}
|
|
288
|
+
throw AccessorySetupException(.invalidOptions, "ios.productImage at \(uri) is not a supported image.")
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import AccessorySetupKit
|
|
2
|
+
import ExpoModulesCore
|
|
3
|
+
|
|
4
|
+
enum AccessorySetupErrorCode: String {
|
|
5
|
+
case unsupported = "UNSUPPORTED"
|
|
6
|
+
case userCancelled = "USER_CANCELLED"
|
|
7
|
+
case permissionDenied = "PERMISSION_DENIED"
|
|
8
|
+
case alreadyInProgress = "ALREADY_IN_PROGRESS"
|
|
9
|
+
case notFound = "NOT_FOUND"
|
|
10
|
+
case discoveryTimeout = "DISCOVERY_TIMEOUT"
|
|
11
|
+
case invalidOptions = "INVALID_OPTIONS"
|
|
12
|
+
case unknown = "UNKNOWN"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
final class AccessorySetupException: Exception, @unchecked Sendable {
|
|
16
|
+
private let errorCode: AccessorySetupErrorCode
|
|
17
|
+
private let detail: String
|
|
18
|
+
|
|
19
|
+
init(_ code: AccessorySetupErrorCode, _ message: String, cause: Error? = nil) {
|
|
20
|
+
self.errorCode = code
|
|
21
|
+
self.detail = message
|
|
22
|
+
super.init()
|
|
23
|
+
self.cause = cause
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override var code: String {
|
|
27
|
+
errorCode.rawValue
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override var reason: String {
|
|
31
|
+
detail
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@available(iOS 18.0, *)
|
|
35
|
+
static func from(_ error: Error) -> AccessorySetupException {
|
|
36
|
+
if let error = error as? AccessorySetupException {
|
|
37
|
+
return error
|
|
38
|
+
}
|
|
39
|
+
let code: AccessorySetupErrorCode =
|
|
40
|
+
switch (error as? ASError)?.code {
|
|
41
|
+
case .userCancelled: .userCancelled
|
|
42
|
+
case .pickerAlreadyActive: .alreadyInProgress
|
|
43
|
+
case .pickerRestricted, .userRestricted: .permissionDenied
|
|
44
|
+
case .discoveryTimeout: .discoveryTimeout
|
|
45
|
+
case .invalidRequest: .invalidOptions
|
|
46
|
+
default: .unknown
|
|
47
|
+
}
|
|
48
|
+
return AccessorySetupException(code, error.localizedDescription, cause: error)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import AccessorySetupKit
|
|
2
|
+
import ExpoModulesCore
|
|
3
|
+
|
|
4
|
+
struct PickerOptions: Record {
|
|
5
|
+
@Field var serviceUUID: String?
|
|
6
|
+
@Field var companyIdentifier: Int?
|
|
7
|
+
@Field var nameSubstring: String?
|
|
8
|
+
@Field var displayName: String = ""
|
|
9
|
+
@Field var productImageUri: String = ""
|
|
10
|
+
@Field var range: String = "default"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
struct PairedAccessoryRecord: Record {
|
|
14
|
+
@Field var id: String = ""
|
|
15
|
+
@Field var bleIdentifier: String = ""
|
|
16
|
+
@Field var displayName: String = ""
|
|
17
|
+
@Field var platform: String = "ios"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@available(iOS 18.0, *)
|
|
21
|
+
extension PairedAccessoryRecord {
|
|
22
|
+
/// `nil` for accessories without a Bluetooth identity (Wi-Fi only), which this module doesn't expose.
|
|
23
|
+
init?(_ accessory: ASAccessory) {
|
|
24
|
+
guard let identifier = accessory.bluetoothIdentifier?.uuidString else {
|
|
25
|
+
return nil
|
|
26
|
+
}
|
|
27
|
+
self.init()
|
|
28
|
+
id = identifier
|
|
29
|
+
bleIdentifier = identifier
|
|
30
|
+
displayName = accessory.displayName
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'ExpoAccessorySetup'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.description = package['description']
|
|
10
|
+
s.license = package['license']
|
|
11
|
+
s.author = package['author']
|
|
12
|
+
s.homepage = package['homepage']
|
|
13
|
+
s.platforms = {
|
|
14
|
+
:ios => '16.4'
|
|
15
|
+
}
|
|
16
|
+
s.source = { git: package['repository'] }
|
|
17
|
+
s.static_framework = true
|
|
18
|
+
|
|
19
|
+
s.dependency 'ExpoModulesCore'
|
|
20
|
+
|
|
21
|
+
# AccessorySetupKit only exists on iOS 18+. Weak linking lets the app launch on older versions,
|
|
22
|
+
# where isSupported() returns false.
|
|
23
|
+
s.weak_frameworks = 'AccessorySetupKit'
|
|
24
|
+
|
|
25
|
+
# Swift/Objective-C compatibility
|
|
26
|
+
s.pod_target_xcconfig = {
|
|
27
|
+
'DEFINES_MODULE' => 'YES',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
31
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
// Sendable because its only mutable state, `sessionController`, is isolated to the main actor.
|
|
4
|
+
public final class ExpoAccessorySetupModule: Module, @unchecked Sendable {
|
|
5
|
+
/// An `AccessorySessionController` on iOS 18+. Typed as `AnyObject` so the module loads on older iOS.
|
|
6
|
+
@MainActor private var sessionController: AnyObject?
|
|
7
|
+
|
|
8
|
+
public func definition() -> ModuleDefinition {
|
|
9
|
+
Name("ExpoAccessorySetup")
|
|
10
|
+
|
|
11
|
+
Events(AccessoryEventName.added.rawValue, AccessoryEventName.removed.rawValue, AccessoryEventName.changed.rawValue)
|
|
12
|
+
|
|
13
|
+
Function("isSupported") { () -> Bool in
|
|
14
|
+
if #available(iOS 18.0, *) {
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
AsyncFunction("showPicker") { (options: PickerOptions) async throws -> PairedAccessoryRecord in
|
|
21
|
+
guard #available(iOS 18.0, *) else {
|
|
22
|
+
throw Self.unsupported()
|
|
23
|
+
}
|
|
24
|
+
return try await self.controller().showPicker(options)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
AsyncFunction("getPairedAccessories") { () async throws -> [PairedAccessoryRecord] in
|
|
28
|
+
guard #available(iOS 18.0, *) else {
|
|
29
|
+
throw Self.unsupported()
|
|
30
|
+
}
|
|
31
|
+
return try await self.controller().pairedAccessories()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
AsyncFunction("removeAccessory") { (id: String) async throws in
|
|
35
|
+
guard #available(iOS 18.0, *) else {
|
|
36
|
+
throw Self.unsupported()
|
|
37
|
+
}
|
|
38
|
+
return try await self.controller().removeAccessory(id: id)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
OnDestroy {
|
|
42
|
+
Task { @MainActor in
|
|
43
|
+
if #available(iOS 18.0, *) {
|
|
44
|
+
(self.sessionController as? AccessorySessionController)?.invalidate()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private static func unsupported() -> AccessorySetupException {
|
|
51
|
+
AccessorySetupException(.unsupported, "AccessorySetupKit requires iOS 18 or later.")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@available(iOS 18.0, *)
|
|
55
|
+
@MainActor
|
|
56
|
+
private func controller() throws -> AccessorySessionController {
|
|
57
|
+
if let controller = sessionController as? AccessorySessionController {
|
|
58
|
+
return controller
|
|
59
|
+
}
|
|
60
|
+
try AccessorySessionController.validateInfoPlist()
|
|
61
|
+
let controller = AccessorySessionController { [weak self] name, accessory in
|
|
62
|
+
self?.sendEvent(name.rawValue, ["accessory": accessory.toDictionary()])
|
|
63
|
+
}
|
|
64
|
+
sessionController = controller
|
|
65
|
+
return controller
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function isSupported(): boolean {
|
|
2
|
+
return true;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export async function showPicker(): Promise<never> {
|
|
6
|
+
throw Object.assign(new Error('User cancelled'), { code: 'USER_CANCELLED' });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function getPairedAccessories(): Promise<never[]> {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function removeAccessory(): Promise<void> {}
|
package/package.json
CHANGED
|
@@ -1,8 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-accessory-setup",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "System accessory pairing for Expo: AccessorySetupKit on iOS, CompanionDeviceManager on Android",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "node internal/module_scripts/build.js",
|
|
9
|
+
"clean": "node internal/module_scripts/clean.js",
|
|
10
|
+
"lint": "eslint src/ plugin/src/ plugin/__tests__/ mocks/",
|
|
11
|
+
"test": "node internal/module_scripts/test.js",
|
|
12
|
+
"prepare": "node internal/module_scripts/prepare.js",
|
|
13
|
+
"open:ios": "node internal/module_scripts/open-ios.js",
|
|
14
|
+
"open:android": "node internal/module_scripts/open-android.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react-native",
|
|
18
|
+
"expo",
|
|
19
|
+
"expo-accessory-setup",
|
|
20
|
+
"accessorysetupkit",
|
|
21
|
+
"companion-device-manager",
|
|
22
|
+
"bluetooth",
|
|
23
|
+
"ble"
|
|
24
|
+
],
|
|
25
|
+
"repository": "https://github.com/nguyenphutrong/expo-accessory-setup",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/nguyenphutrong/expo-accessory-setup/issues"
|
|
28
|
+
},
|
|
29
|
+
"author": "Trong Nguyen <nguyenphutrong.dev@gmail.com> (https://github.com/nguyenphutrong)",
|
|
5
30
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
31
|
+
"homepage": "https://github.com/nguyenphutrong/expo-accessory-setup#readme",
|
|
32
|
+
"dependencies": {},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@babel/core": "^7.26.0",
|
|
35
|
+
"@expo/config-plugins": "~58.0.9",
|
|
36
|
+
"@react-native/jest-preset": "0.88.0",
|
|
37
|
+
"@types/jest": "^29.2.1",
|
|
38
|
+
"@types/node": "^22",
|
|
39
|
+
"@types/react": "~19.3.0",
|
|
40
|
+
"babel-preset-expo": "~58.0.17",
|
|
41
|
+
"eslint": "~9.39.4",
|
|
42
|
+
"eslint-config-universe": "^15.0.3",
|
|
43
|
+
"expo": "~58.0.14",
|
|
44
|
+
"jest": "^29.7.0",
|
|
45
|
+
"jest-expo": "~58.0.11",
|
|
46
|
+
"prettier": "^3.0.0",
|
|
47
|
+
"react-native": "0.88.0",
|
|
48
|
+
"typescript": "^5.9.2"
|
|
49
|
+
},
|
|
50
|
+
"jest": {
|
|
51
|
+
"projects": [
|
|
52
|
+
{
|
|
53
|
+
"displayName": "module",
|
|
54
|
+
"preset": "jest-expo",
|
|
55
|
+
"roots": [
|
|
56
|
+
"<rootDir>/src"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"<rootDir>/plugin/jest.config.js"
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"expo": "*",
|
|
64
|
+
"react": "*",
|
|
65
|
+
"react-native": "*"
|
|
66
|
+
}
|
|
8
67
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { AndroidConfig, type ConfigPlugin, type InfoPlist } from '@expo/config-plugins';
|
|
2
|
+
import { type AccessorySetupOptions, type AccessorySetupPluginProps } from './options';
|
|
3
|
+
export type { AccessorySetupPluginProps } from './options';
|
|
4
|
+
type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
|
|
5
|
+
export declare function setAccessorySetupInfoPlist(infoPlist: InfoPlist, { ios }: AccessorySetupOptions): InfoPlist;
|
|
6
|
+
export declare function setAccessorySetupManifest(manifest: AndroidManifest, { android }: AccessorySetupOptions): AndroidManifest;
|
|
7
|
+
declare const _default: ConfigPlugin<void | AccessorySetupPluginProps>;
|
|
8
|
+
export default _default;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.setAccessorySetupInfoPlist = setAccessorySetupInfoPlist;
|
|
4
|
+
exports.setAccessorySetupManifest = setAccessorySetupManifest;
|
|
5
|
+
const config_plugins_1 = require("@expo/config-plugins");
|
|
6
|
+
const options_1 = require("./options");
|
|
7
|
+
const pkg = require('../../package.json');
|
|
8
|
+
function setAccessorySetupInfoPlist(infoPlist, { ios }) {
|
|
9
|
+
const supports = Array.isArray(infoPlist.NSAccessorySetupKitSupports)
|
|
10
|
+
? infoPlist.NSAccessorySetupKitSupports
|
|
11
|
+
: [];
|
|
12
|
+
const result = {
|
|
13
|
+
...infoPlist,
|
|
14
|
+
NSAccessorySetupKitSupports: supports.includes('Bluetooth')
|
|
15
|
+
? supports
|
|
16
|
+
: [...supports, 'Bluetooth'],
|
|
17
|
+
};
|
|
18
|
+
// ASAccessorySession terminates the app when one of these keys is present with no values,
|
|
19
|
+
// so unused keys are left out instead of written as empty arrays.
|
|
20
|
+
const keys = {
|
|
21
|
+
NSAccessorySetupBluetoothServices: ios.bluetoothServices,
|
|
22
|
+
NSAccessorySetupBluetoothNames: ios.bluetoothNames,
|
|
23
|
+
// TODO(verify): Apple documents these as "two-byte UUID" strings without a format example.
|
|
24
|
+
// Four hex digits ("004C") is the assumed format.
|
|
25
|
+
NSAccessorySetupBluetoothCompanyIdentifiers: ios.bluetoothCompanyIdentifiers,
|
|
26
|
+
};
|
|
27
|
+
for (const [key, values] of Object.entries(keys)) {
|
|
28
|
+
if (values.length > 0) {
|
|
29
|
+
result[key] = values;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
delete result[key];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
const COMPANION_DEVICE_SETUP_FEATURE = 'android.software.companion_device_setup';
|
|
38
|
+
function ensureUsesFeature(manifest, name) {
|
|
39
|
+
manifest.manifest['uses-feature'] ??= [];
|
|
40
|
+
const features = manifest.manifest['uses-feature'];
|
|
41
|
+
if (!features.some((feature) => feature.$['android:name'] === name)) {
|
|
42
|
+
// Not required, so the app still installs where isSupported() returns false.
|
|
43
|
+
features.push({ $: { 'android:name': name, 'android:required': 'false' } });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function ensureLegacyPermission(manifest, name) {
|
|
47
|
+
manifest.manifest['uses-permission'] ??= [];
|
|
48
|
+
const permissions = manifest.manifest['uses-permission'];
|
|
49
|
+
const existing = permissions.find((permission) => permission.$['android:name'] === name);
|
|
50
|
+
// Android 12 ignores these permissions, so the limit also applies when another plugin, such as
|
|
51
|
+
// react-native-ble-plx, declared them first.
|
|
52
|
+
if (existing) {
|
|
53
|
+
existing.$['android:maxSdkVersion'] ??= '30';
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
permissions.push({ $: { 'android:name': name, 'android:maxSdkVersion': '30' } });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function setAccessorySetupManifest(manifest, { android }) {
|
|
60
|
+
ensureUsesFeature(manifest, COMPANION_DEVICE_SETUP_FEATURE);
|
|
61
|
+
// CompanionDeviceManager scans on the app's behalf, so no scan or location permission is
|
|
62
|
+
// needed. These cover connecting to the paired accessory afterwards (react-native-ble-plx).
|
|
63
|
+
ensureLegacyPermission(manifest, 'android.permission.BLUETOOTH');
|
|
64
|
+
ensureLegacyPermission(manifest, 'android.permission.BLUETOOTH_ADMIN');
|
|
65
|
+
config_plugins_1.AndroidConfig.Permissions.ensurePermission(manifest, 'android.permission.BLUETOOTH_CONNECT');
|
|
66
|
+
if (android.runInBackground) {
|
|
67
|
+
config_plugins_1.AndroidConfig.Permissions.ensurePermission(manifest, 'android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND');
|
|
68
|
+
}
|
|
69
|
+
if (android.useDataInBackground) {
|
|
70
|
+
config_plugins_1.AndroidConfig.Permissions.ensurePermission(manifest, 'android.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND');
|
|
71
|
+
}
|
|
72
|
+
return manifest;
|
|
73
|
+
}
|
|
74
|
+
const withAccessorySetup = (config, props) => {
|
|
75
|
+
const options = (0, options_1.parseOptions)(props);
|
|
76
|
+
config = (0, config_plugins_1.withInfoPlist)(config, (config) => {
|
|
77
|
+
config.modResults = setAccessorySetupInfoPlist(config.modResults, options);
|
|
78
|
+
return config;
|
|
79
|
+
});
|
|
80
|
+
return (0, config_plugins_1.withAndroidManifest)(config, (config) => {
|
|
81
|
+
config.modResults = setAccessorySetupManifest(config.modResults, options);
|
|
82
|
+
return config;
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
exports.default = (0, config_plugins_1.createRunOncePlugin)(withAccessorySetup, pkg.name, pkg.version);
|