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
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["ExpoAccessorySetupModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.accessorysetup.ExpoAccessorySetupModule"]
8
+ }
9
+ }
@@ -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.1",
4
- "description": "Placeholder. Expo module for native accessory pairing (AccessorySetupKit on iOS, CompanionDeviceManager on Android). Under development; not usable yet.",
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
- "keywords": ["expo", "react-native", "accessorysetupkit", "companion-device-manager", "bluetooth", "ble"],
7
- "files": ["README.md"]
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);