diff --git a/CHANGELOG.md b/CHANGELOG.md index 7239be07..caeb827d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## 3.0.0 +* iOS 18+: add AccessorySetupKit discovery, authorization, connection, and accessory removal support. * **Breaking:** Add `QueueType.auto` which auto-selects the best queueing strategy per platform: Android uses a per-device queue, all other platforms run commands in parallel. It is now the default for both `UniversalBle` and `UniversalBlePeripheral`, replacing the previous `QueueType.global` default. * iOS/macOS: Handle write-without-response transmit buffer backpressure * iOS/macOS: complete concurrent reads, descriptor operations, notification changes, and RSSI reads one callback at a time. diff --git a/README.md b/README.md index 2e691f6c..4ac96bf5 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,48 @@ Notes: - Once any connection opts in, the behavior is global for the running process — every current and future GATT client is released on engine teardown. It can be disabled at runtime by connecting with `closeGattOnDetach: false`; connecting without the option leaves the current value unchanged (a fresh app launch resets it). - Android-only. Explicit `disconnect()` calls and rotation are unaffected. +#### AccessorySetupKit (iOS 18+) + +Use `connectAccessory` to let the user select and authorize a nearby accessory +with Apple's system picker, then connect through the normal CoreBluetooth path: + +```dart +final deviceId = await UniversalBle.connectAccessory( + AppleAccessorySetupOptions( + displayName: 'My Device', + imageAsset: 'my_device', // Image in Runner/Assets.xcassets + serviceUuid: '12345678-1234-1234-1234-1234567890AB', + nameSubstring: 'MyDevice', // Optional + ), +); + +// Removes an accessory authorized through AccessorySetupKit. +await UniversalBle.unpair(deviceId); +``` + +The picker must be initiated while the app is in the foreground. Add matching +values to the iOS app's `Info.plist`; Apple terminates apps that show the picker +with undeclared discovery identifiers: + +```xml +NSAccessorySetupKitSupports + + Bluetooth + +NSAccessorySetupBluetoothServices + + 12345678-1234-1234-1234-1234567890AB + +NSAccessorySetupBluetoothNames + + MyDevice + +``` + +When the app uses AccessorySetupKit exclusively, it does not need +`NSBluetoothAlwaysUsageDescription`. Keep that key if the app also uses regular +BLE scanning or accesses devices outside AccessorySetupKit. + ### Discovering Services After establishing a connection, services need to be discovered. This method will discover all services and their characteristics. @@ -1100,6 +1142,9 @@ await UniversalBle.startScan(); For Bluetooth usage (including peripheral mode), add both keys to your app's `Info.plist`: +> Apps using only the iOS 18+ AccessorySetupKit flow can omit these Bluetooth +> usage descriptions; see [AccessorySetupKit (iOS 18+)](#accessorysetupkit-ios-18). + - `NSBluetoothAlwaysUsageDescription`: message shown when the app requests Bluetooth access. - `NSBluetoothPeripheralUsageDescription`: message used for peripheral role access on Apple platforms. diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt index 53af89d6..1e1d0af2 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt @@ -1042,6 +1042,71 @@ data class AppleConnectionOptions ( } } +/** + * iOS 18+ options for discovering and authorizing a Bluetooth accessory with + * AccessorySetupKit before connecting to it. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class AppleAccessorySetupOptions ( + /** Name shown in the system accessory picker. */ + val displayName: String, + /** Name of the product image in the iOS app's asset catalog. */ + val imageAsset: String, + /** Advertised Bluetooth service UUID used to discover the accessory. */ + val serviceUuid: String, + /** Optional substring of the accessory's advertised Bluetooth name. */ + val nameSubstring: String? = null, + /** Limit discovery to accessories in the immediate vicinity. */ + val requiresImmediateRange: Boolean? = null, + /** Allow AccessorySetupKit to perform Bluetooth LE pairing when needed. */ + val supportsBluetoothPairing: Boolean? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): AppleAccessorySetupOptions { + val displayName = pigeonVar_list[0] as String + val imageAsset = pigeonVar_list[1] as String + val serviceUuid = pigeonVar_list[2] as String + val nameSubstring = pigeonVar_list[3] as String? + val requiresImmediateRange = pigeonVar_list[4] as Boolean? + val supportsBluetoothPairing = pigeonVar_list[5] as Boolean? + return AppleAccessorySetupOptions(displayName, imageAsset, serviceUuid, nameSubstring, requiresImmediateRange, supportsBluetoothPairing) + } + } + fun toList(): List { + return listOf( + displayName, + imageAsset, + serviceUuid, + nameSubstring, + requiresImmediateRange, + supportsBluetoothPairing, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as AppleAccessorySetupOptions + return UniversalBlePigeonUtils.deepEquals(this.displayName, other.displayName) && UniversalBlePigeonUtils.deepEquals(this.imageAsset, other.imageAsset) && UniversalBlePigeonUtils.deepEquals(this.serviceUuid, other.serviceUuid) && UniversalBlePigeonUtils.deepEquals(this.nameSubstring, other.nameSubstring) && UniversalBlePigeonUtils.deepEquals(this.requiresImmediateRange, other.requiresImmediateRange) && UniversalBlePigeonUtils.deepEquals(this.supportsBluetoothPairing, other.supportsBluetoothPairing) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + UniversalBlePigeonUtils.deepHash(this.displayName) + result = 31 * result + UniversalBlePigeonUtils.deepHash(this.imageAsset) + result = 31 * result + UniversalBlePigeonUtils.deepHash(this.serviceUuid) + result = 31 * result + UniversalBlePigeonUtils.deepHash(this.nameSubstring) + result = 31 * result + UniversalBlePigeonUtils.deepHash(this.requiresImmediateRange) + result = 31 * result + UniversalBlePigeonUtils.deepHash(this.supportsBluetoothPairing) + return result + } +} + /** Generated class from Pigeon that represents data sent in messages. */ data class AndroidConnectionOptions ( /** @@ -1558,45 +1623,50 @@ private open class UniversalBlePigeonCodec : StandardMessageCodec() { } 155.toByte() -> { return (readValue(buffer) as? List)?.let { - AndroidConnectionOptions.fromList(it) + AppleAccessorySetupOptions.fromList(it) } } 156.toByte() -> { return (readValue(buffer) as? List)?.let { - ConnectionPlatformConfig.fromList(it) + AndroidConnectionOptions.fromList(it) } } 157.toByte() -> { return (readValue(buffer) as? List)?.let { - PeripheralAndroidOptions.fromList(it) + ConnectionPlatformConfig.fromList(it) } } 158.toByte() -> { return (readValue(buffer) as? List)?.let { - PeripheralPlatformConfig.fromList(it) + PeripheralAndroidOptions.fromList(it) } } 159.toByte() -> { return (readValue(buffer) as? List)?.let { - PeripheralService.fromList(it) + PeripheralPlatformConfig.fromList(it) } } 160.toByte() -> { return (readValue(buffer) as? List)?.let { - PeripheralCharacteristic.fromList(it) + PeripheralService.fromList(it) } } 161.toByte() -> { return (readValue(buffer) as? List)?.let { - PeripheralDescriptor.fromList(it) + PeripheralCharacteristic.fromList(it) } } 162.toByte() -> { return (readValue(buffer) as? List)?.let { - PeripheralReadRequestResult.fromList(it) + PeripheralDescriptor.fromList(it) } } 163.toByte() -> { + return (readValue(buffer) as? List)?.let { + PeripheralReadRequestResult.fromList(it) + } + } + 164.toByte() -> { return (readValue(buffer) as? List)?.let { PeripheralWriteRequestResult.fromList(it) } @@ -1710,42 +1780,46 @@ private open class UniversalBlePigeonCodec : StandardMessageCodec() { stream.write(154) writeValue(stream, value.toList()) } - is AndroidConnectionOptions -> { + is AppleAccessorySetupOptions -> { stream.write(155) writeValue(stream, value.toList()) } - is ConnectionPlatformConfig -> { + is AndroidConnectionOptions -> { stream.write(156) writeValue(stream, value.toList()) } - is PeripheralAndroidOptions -> { + is ConnectionPlatformConfig -> { stream.write(157) writeValue(stream, value.toList()) } - is PeripheralPlatformConfig -> { + is PeripheralAndroidOptions -> { stream.write(158) writeValue(stream, value.toList()) } - is PeripheralService -> { + is PeripheralPlatformConfig -> { stream.write(159) writeValue(stream, value.toList()) } - is PeripheralCharacteristic -> { + is PeripheralService -> { stream.write(160) writeValue(stream, value.toList()) } - is PeripheralDescriptor -> { + is PeripheralCharacteristic -> { stream.write(161) writeValue(stream, value.toList()) } - is PeripheralReadRequestResult -> { + is PeripheralDescriptor -> { stream.write(162) writeValue(stream, value.toList()) } - is PeripheralWriteRequestResult -> { + is PeripheralReadRequestResult -> { stream.write(163) writeValue(stream, value.toList()) } + is PeripheralWriteRequestResult -> { + stream.write(164) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -1768,6 +1842,11 @@ interface UniversalBlePlatformChannel { fun startScan(filter: UniversalScanFilter?, config: UniversalScanConfig?) fun stopScan() fun isScanning(): Boolean + /** + * Shows the iOS AccessorySetupKit picker and returns the selected + * peripheral identifier. + */ + fun setupAccessory(options: AppleAccessorySetupOptions, callback: (Result) -> Unit) fun connect(deviceId: String, autoConnect: Boolean?, platformConfig: ConnectionPlatformConfig?) fun disconnect(deviceId: String) fun setNotifiable(deviceId: String, service: String, characteristic: String, bleInputProperty: BleInputProperty, callback: (Result) -> Unit) @@ -1779,7 +1858,7 @@ interface UniversalBlePlatformChannel { fun writeDescriptorValue(deviceId: String, service: String, characteristic: String, descriptor: String, value: ByteArray, callback: (Result) -> Unit) fun isPaired(deviceId: String, callback: (Result) -> Unit) fun pair(deviceId: String, callback: (Result) -> Unit) - fun unPair(deviceId: String) + fun unPair(deviceId: String, callback: (Result) -> Unit) fun getSystemDevices(withServices: List, callback: (Result>) -> Unit) fun getConnectionState(deviceId: String): BleConnectionState fun readRssi(deviceId: String, callback: (Result) -> Unit) @@ -1935,6 +2014,26 @@ interface UniversalBlePlatformChannel { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.setupAccessory$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val optionsArg = args[0] as AppleAccessorySetupOptions + api.setupAccessory(optionsArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(UniversalBlePigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(UniversalBlePigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.connect$separatedMessageChannelSuffix", codec) if (api != null) { @@ -2174,13 +2273,14 @@ interface UniversalBlePlatformChannel { channel.setMessageHandler { message, reply -> val args = message as List val deviceIdArg = args[0] as String - val wrapped: List = try { - api.unPair(deviceIdArg) - listOf(null) - } catch (exception: Throwable) { - UniversalBlePigeonUtils.wrapError(exception) + api.unPair(deviceIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(UniversalBlePigeonUtils.wrapError(error)) + } else { + reply.reply(UniversalBlePigeonUtils.wrapResult(null)) + } } - reply.reply(wrapped) } } else { channel.setMessageHandler(null) diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt index 1df2d48b..fb1330b7 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -274,6 +274,20 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), return safeScanner.isScanning() } + override fun setupAccessory( + options: AppleAccessorySetupOptions, + callback: (Result) -> Unit, + ) { + callback( + Result.failure( + createFlutterError( + UniversalBleErrorCode.NOT_SUPPORTED, + "AccessorySetupKit is only supported on iOS 18+", + ) + ) + ) + } + override fun connect( deviceId: String, autoConnect: Boolean?, @@ -1205,11 +1219,14 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), } - override fun unPair(deviceId: String) { - val remoteDevice: BluetoothDevice = - bluetoothManager.adapter.getRemoteDevice(deviceId) - if (remoteDevice.isBonded()) { - remoteDevice.removeBond() + override fun unPair(deviceId: String, callback: (Result) -> Unit) { + try { + val remoteDevice: BluetoothDevice = + bluetoothManager.adapter.getRemoteDevice(deviceId) + if (remoteDevice.isBonded()) remoteDevice.removeBond() + callback(Result.success(Unit)) + } catch (e: Exception) { + callback(Result.failure(e)) } } diff --git a/assets/universal_ble_icon_inverted.png b/assets/universal_ble_icon_inverted.png new file mode 100644 index 00000000..db1fbe99 Binary files /dev/null and b/assets/universal_ble_icon_inverted.png differ diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBle.g.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBle.g.swift index b8e8c9c5..98f0c1b3 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBle.g.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBle.g.swift @@ -908,6 +908,71 @@ struct AppleConnectionOptions: Hashable { } } +/// iOS 18+ options for discovering and authorizing a Bluetooth accessory with +/// AccessorySetupKit before connecting to it. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct AppleAccessorySetupOptions: Hashable { + /// Name shown in the system accessory picker. + var displayName: String + /// Name of the product image in the iOS app's asset catalog. + var imageAsset: String + /// Advertised Bluetooth service UUID used to discover the accessory. + var serviceUuid: String + /// Optional substring of the accessory's advertised Bluetooth name. + var nameSubstring: String? = nil + /// Limit discovery to accessories in the immediate vicinity. + var requiresImmediateRange: Bool? = nil + /// Allow AccessorySetupKit to perform Bluetooth LE pairing when needed. + var supportsBluetoothPairing: Bool? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> AppleAccessorySetupOptions? { + let displayName = pigeonVar_list[0] as! String + let imageAsset = pigeonVar_list[1] as! String + let serviceUuid = pigeonVar_list[2] as! String + let nameSubstring: String? = nilOrValue(pigeonVar_list[3]) + let requiresImmediateRange: Bool? = nilOrValue(pigeonVar_list[4]) + let supportsBluetoothPairing: Bool? = nilOrValue(pigeonVar_list[5]) + + return AppleAccessorySetupOptions( + displayName: displayName, + imageAsset: imageAsset, + serviceUuid: serviceUuid, + nameSubstring: nameSubstring, + requiresImmediateRange: requiresImmediateRange, + supportsBluetoothPairing: supportsBluetoothPairing + ) + } + func toList() -> [Any?] { + return [ + displayName, + imageAsset, + serviceUuid, + nameSubstring, + requiresImmediateRange, + supportsBluetoothPairing, + ] + } + static func == (lhs: AppleAccessorySetupOptions, rhs: AppleAccessorySetupOptions) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsUniversalBle(lhs.displayName, rhs.displayName) && deepEqualsUniversalBle(lhs.imageAsset, rhs.imageAsset) && deepEqualsUniversalBle(lhs.serviceUuid, rhs.serviceUuid) && deepEqualsUniversalBle(lhs.nameSubstring, rhs.nameSubstring) && deepEqualsUniversalBle(lhs.requiresImmediateRange, rhs.requiresImmediateRange) && deepEqualsUniversalBle(lhs.supportsBluetoothPairing, rhs.supportsBluetoothPairing) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("AppleAccessorySetupOptions") + deepHashUniversalBle(value: displayName, hasher: &hasher) + deepHashUniversalBle(value: imageAsset, hasher: &hasher) + deepHashUniversalBle(value: serviceUuid, hasher: &hasher) + deepHashUniversalBle(value: nameSubstring, hasher: &hasher) + deepHashUniversalBle(value: requiresImmediateRange, hasher: &hasher) + deepHashUniversalBle(value: supportsBluetoothPairing, hasher: &hasher) + } +} + /// Generated class from Pigeon that represents data sent in messages. struct AndroidConnectionOptions: Hashable { /// Close the GATT client when the FlutterEngine is detached (for @@ -1387,22 +1452,24 @@ private class UniversalBlePigeonCodecReader: FlutterStandardReader { case 154: return AppleConnectionOptions.fromList(self.readValue() as! [Any?]) case 155: - return AndroidConnectionOptions.fromList(self.readValue() as! [Any?]) + return AppleAccessorySetupOptions.fromList(self.readValue() as! [Any?]) case 156: - return ConnectionPlatformConfig.fromList(self.readValue() as! [Any?]) + return AndroidConnectionOptions.fromList(self.readValue() as! [Any?]) case 157: - return PeripheralAndroidOptions.fromList(self.readValue() as! [Any?]) + return ConnectionPlatformConfig.fromList(self.readValue() as! [Any?]) case 158: - return PeripheralPlatformConfig.fromList(self.readValue() as! [Any?]) + return PeripheralAndroidOptions.fromList(self.readValue() as! [Any?]) case 159: - return PeripheralService.fromList(self.readValue() as! [Any?]) + return PeripheralPlatformConfig.fromList(self.readValue() as! [Any?]) case 160: - return PeripheralCharacteristic.fromList(self.readValue() as! [Any?]) + return PeripheralService.fromList(self.readValue() as! [Any?]) case 161: - return PeripheralDescriptor.fromList(self.readValue() as! [Any?]) + return PeripheralCharacteristic.fromList(self.readValue() as! [Any?]) case 162: - return PeripheralReadRequestResult.fromList(self.readValue() as! [Any?]) + return PeripheralDescriptor.fromList(self.readValue() as! [Any?]) case 163: + return PeripheralReadRequestResult.fromList(self.readValue() as! [Any?]) + case 164: return PeripheralWriteRequestResult.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) @@ -1490,33 +1557,36 @@ private class UniversalBlePigeonCodecWriter: FlutterStandardWriter { } else if let value = value as? AppleConnectionOptions { super.writeByte(154) super.writeValue(value.toList()) - } else if let value = value as? AndroidConnectionOptions { + } else if let value = value as? AppleAccessorySetupOptions { super.writeByte(155) super.writeValue(value.toList()) - } else if let value = value as? ConnectionPlatformConfig { + } else if let value = value as? AndroidConnectionOptions { super.writeByte(156) super.writeValue(value.toList()) - } else if let value = value as? PeripheralAndroidOptions { + } else if let value = value as? ConnectionPlatformConfig { super.writeByte(157) super.writeValue(value.toList()) - } else if let value = value as? PeripheralPlatformConfig { + } else if let value = value as? PeripheralAndroidOptions { super.writeByte(158) super.writeValue(value.toList()) - } else if let value = value as? PeripheralService { + } else if let value = value as? PeripheralPlatformConfig { super.writeByte(159) super.writeValue(value.toList()) - } else if let value = value as? PeripheralCharacteristic { + } else if let value = value as? PeripheralService { super.writeByte(160) super.writeValue(value.toList()) - } else if let value = value as? PeripheralDescriptor { + } else if let value = value as? PeripheralCharacteristic { super.writeByte(161) super.writeValue(value.toList()) - } else if let value = value as? PeripheralReadRequestResult { + } else if let value = value as? PeripheralDescriptor { super.writeByte(162) super.writeValue(value.toList()) - } else if let value = value as? PeripheralWriteRequestResult { + } else if let value = value as? PeripheralReadRequestResult { super.writeByte(163) super.writeValue(value.toList()) + } else if let value = value as? PeripheralWriteRequestResult { + super.writeByte(164) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -1552,6 +1622,9 @@ protocol UniversalBlePlatformChannel { func startScan(filter: UniversalScanFilter?, config: UniversalScanConfig?) throws func stopScan() throws func isScanning() throws -> Bool + /// Shows the iOS AccessorySetupKit picker and returns the selected + /// peripheral identifier. + func setupAccessory(options: AppleAccessorySetupOptions, completion: @escaping (Result) -> Void) func connect(deviceId: String, autoConnect: Bool?, platformConfig: ConnectionPlatformConfig?) throws func disconnect(deviceId: String) throws func setNotifiable(deviceId: String, service: String, characteristic: String, bleInputProperty: BleInputProperty, completion: @escaping (Result) -> Void) @@ -1563,7 +1636,7 @@ protocol UniversalBlePlatformChannel { func writeDescriptorValue(deviceId: String, service: String, characteristic: String, descriptor: String, value: FlutterStandardTypedData, completion: @escaping (Result) -> Void) func isPaired(deviceId: String, completion: @escaping (Result) -> Void) func pair(deviceId: String, completion: @escaping (Result) -> Void) - func unPair(deviceId: String) throws + func unPair(deviceId: String, completion: @escaping (Result) -> Void) func getSystemDevices(withServices: [String], completion: @escaping (Result<[UniversalBleScanResult], Error>) -> Void) func getConnectionState(deviceId: String) throws -> BleConnectionState func readRssi(deviceId: String, completion: @escaping (Result) -> Void) @@ -1696,6 +1769,25 @@ class UniversalBlePlatformChannelSetup { } else { isScanningChannel.setMessageHandler(nil) } + /// Shows the iOS AccessorySetupKit picker and returns the selected + /// peripheral identifier. + let setupAccessoryChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.setupAccessory\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setupAccessoryChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let optionsArg = args[0] as! AppleAccessorySetupOptions + api.setupAccessory(options: optionsArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + setupAccessoryChannel.setMessageHandler(nil) + } let connectChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.connect\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { connectChannel.setMessageHandler { message, reply in @@ -1904,11 +1996,13 @@ class UniversalBlePlatformChannelSetup { unPairChannel.setMessageHandler { message, reply in let args = message as! [Any?] let deviceIdArg = args[0] as! String - do { - try api.unPair(deviceId: deviceIdArg) - reply(wrapResult(nil)) - } catch { - reply(wrapError(error)) + api.unPair(deviceId: deviceIdArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } } } } else { diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift index 81eb5db0..9be22720 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift @@ -1,6 +1,7 @@ import CoreBluetooth #if os(iOS) + import AccessorySetupKit import Flutter import UIKit #elseif os(OSX) @@ -103,6 +104,9 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral private var rssiReadFutures = [RssiReadFuture]() private var isManageScanning = false private var autoConnectDevices = Set() + #if os(iOS) + private var accessorySetupManager: AnyObject? + #endif init(callbackChannel: UniversalBleCallbackChannel) { self.callbackChannel = callbackChannel @@ -204,6 +208,18 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral return isManageScanning } + func setupAccessory(options: AppleAccessorySetupOptions, completion: @escaping (Result) -> Void) { + #if os(iOS) + guard #available(iOS 18.0, *) else { + completion(.failure(createFlutterError(code: .notSupported, message: "AccessorySetupKit requires iOS 18 or later"))) + return + } + getAccessorySetupManager().showPicker(options: options, completion: completion) + #else + completion(.failure(createFlutterError(code: .notSupported, message: "AccessorySetupKit is only supported on iOS 18+"))) + #endif + } + func setLogLevel(logLevel: BleLogLevel) throws { UniversalBleLogger.shared.setLogLevel(logLevel) } @@ -535,10 +551,30 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral completion(Result.failure(createFlutterError(code: .notImplemented))) } - func unPair(deviceId _: String) throws { - throw createFlutterError(code: .notSupported) + func unPair(deviceId: String, completion: @escaping (Result) -> Void) { + #if os(iOS) + guard #available(iOS 18.0, *) else { + completion(.failure(createFlutterError(code: .notSupported, message: "AccessorySetupKit requires iOS 18 or later"))) + return + } + getAccessorySetupManager().removeAccessory(deviceId: deviceId, completion: completion) + #else + completion(.failure(createFlutterError(code: .notSupported, message: "AccessorySetupKit is only supported on iOS 18+"))) + #endif } + #if os(iOS) + @available(iOS 18.0, *) + private func getAccessorySetupManager() -> AccessorySetupManager { + if let manager = accessorySetupManager as? AccessorySetupManager { + return manager + } + let manager = AccessorySetupManager() + accessorySetupManager = manager + return manager + } + #endif + func getSystemDevices(withServices: [String], completion: @escaping (Result<[UniversalBleScanResult], Error>) -> Void) { var servicesFilter = withServices if servicesFilter.isEmpty { @@ -868,6 +904,113 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral } } +#if os(iOS) + @available(iOS 18.0, *) + private final class AccessorySetupManager { + private let session = ASAccessorySession() + private var isActivated = false + private var activationActions: [() -> Void] = [] + private var pickerCompletion: ((Result) -> Void)? + private var selectedIdentifier: UUID? + + init() { + session.activate(on: .main) { [weak self] event in + self?.handle(event) + } + } + + func showPicker(options: AppleAccessorySetupOptions, completion: @escaping (Result) -> Void) { + guard pickerCompletion == nil else { + completion(.failure(createFlutterError(code: .invalidAction, message: "The accessory picker is already open"))) + return + } + guard let image = UIImage(named: options.imageAsset, in: Bundle.main, compatibleWith: nil) else { + completion(.failure(createFlutterError(code: .illegalArgument, message: "Image asset not found: \(options.imageAsset)"))) + return + } + + pickerCompletion = completion + selectedIdentifier = nil + whenActivated { [weak self] in + guard let self else { return } + let descriptor = ASDiscoveryDescriptor() + descriptor.bluetoothServiceUUID = CBUUID(string: options.serviceUuid) + descriptor.bluetoothNameSubstring = options.nameSubstring + if options.requiresImmediateRange == true { + descriptor.bluetoothRange = .immediate + } + if options.supportsBluetoothPairing == true { + descriptor.supportedOptions = .bluetoothPairingLE + } + let item = ASPickerDisplayItem(name: options.displayName, productImage: image, descriptor: descriptor) + session.showPicker(for: [item]) { [weak self] error in + if let error { + self?.completePicker(.failure(error.toFlutterError())) + } + } + } + } + + func removeAccessory(deviceId: String, completion: @escaping (Result) -> Void) { + guard let identifier = UUID(uuidString: deviceId) else { + completion(.failure(createFlutterError(code: .illegalArgument, message: "Invalid deviceId: \(deviceId)"))) + return + } + whenActivated { [weak self] in + guard let self else { return } + guard let accessory = session.accessories.first(where: { $0.bluetoothIdentifier == identifier }) else { + completion(.failure(createFlutterError(code: .deviceNotFound, message: "Accessory is not managed by AccessorySetupKit: \(deviceId)"))) + return + } + session.removeAccessory(accessory) { error in + if let error { + completion(.failure(error.toFlutterError())) + } else { + completion(.success(())) + } + } + } + } + + private func whenActivated(_ action: @escaping () -> Void) { + if isActivated { + action() + } else { + activationActions.append(action) + } + } + + private func handle(_ event: ASAccessoryEvent) { + switch event.eventType { + case .activated: + isActivated = true + let actions = activationActions + activationActions.removeAll() + actions.forEach { $0() } + case .accessoryAdded: + selectedIdentifier = event.accessory?.bluetoothIdentifier + case .pickerDidDismiss: + if let selectedIdentifier { + completePicker(.success(selectedIdentifier.uuidString)) + } else { + completePicker(.failure(createFlutterError(code: .failed, message: "Accessory setup was cancelled"))) + } + case .pickerSetupFailed, .invalidated: + completePicker(.failure(event.error?.toFlutterError() ?? createFlutterError(code: .failed, message: "Accessory setup failed"))) + default: + break + } + } + + private func completePicker(_ result: Result) { + guard let completion = pickerCompletion else { return } + pickerCompletion = nil + selectedIdentifier = nil + completion(result) + } + } +#endif + extension CBPeripheral { func saveCache() { discoveredPeripherals[uuid.uuidString] = self diff --git a/lib/src/interfaces/universal_ble_platform_interface.dart b/lib/src/interfaces/universal_ble_platform_interface.dart index 39e0f33d..b2579509 100644 --- a/lib/src/interfaces/universal_ble_platform_interface.dart +++ b/lib/src/interfaces/universal_ble_platform_interface.dart @@ -61,6 +61,10 @@ abstract class UniversalBlePlatform { Future isScanning(); + Future setupAccessory(AppleAccessorySetupOptions options) { + throw UnsupportedError('AccessorySetupKit is only supported on iOS 18+'); + } + Future connect( String deviceId, { Duration? connectionTimeout, diff --git a/lib/src/universal_ble.dart b/lib/src/universal_ble.dart index 393639d8..4cbf6bad 100644 --- a/lib/src/universal_ble.dart +++ b/lib/src/universal_ble.dart @@ -142,6 +142,29 @@ class UniversalBle { ); } + /// Shows the AccessorySetupKit picker and connects to the accessory selected + /// by the user. Returns its CoreBluetooth device identifier. + /// + /// Supported on iOS 18 and later. The host app must declare matching + /// AccessorySetupKit values in `Info.plist`, and [options.imageAsset] must + /// name an image in the iOS asset catalog. Unlike normal scanning, this flow + /// does not require broad Bluetooth permission. + static Future connectAccessory( + AppleAccessorySetupOptions options, { + Duration? timeout, + bool autoConnect = false, + ConnectionPlatformConfig? platformConfig, + }) async { + final deviceId = await _platform.setupAccessory(options); + await connect( + deviceId, + timeout: timeout, + autoConnect: autoConnect, + platformConfig: platformConfig, + ); + return deviceId; + } + /// Connect to a device. /// It is advised to stop scanning before connecting. /// It throws error if device connection fails. diff --git a/lib/src/universal_ble.g.dart b/lib/src/universal_ble.g.dart index 107131de..d2dd6179 100644 --- a/lib/src/universal_ble.g.dart +++ b/lib/src/universal_ble.g.dart @@ -981,6 +981,86 @@ class AppleConnectionOptions { int get hashCode => _deepHash([runtimeType, ..._toList()]); } +/// iOS 18+ options for discovering and authorizing a Bluetooth accessory with +/// AccessorySetupKit before connecting to it. +class AppleAccessorySetupOptions { + AppleAccessorySetupOptions({ + required this.displayName, + required this.imageAsset, + required this.serviceUuid, + this.nameSubstring, + this.requiresImmediateRange, + this.supportsBluetoothPairing, + }); + + /// Name shown in the system accessory picker. + String displayName; + + /// Name of the product image in the iOS app's asset catalog. + String imageAsset; + + /// Advertised Bluetooth service UUID used to discover the accessory. + String serviceUuid; + + /// Optional substring of the accessory's advertised Bluetooth name. + String? nameSubstring; + + /// Limit discovery to accessories in the immediate vicinity. + bool? requiresImmediateRange; + + /// Allow AccessorySetupKit to perform Bluetooth LE pairing when needed. + bool? supportsBluetoothPairing; + + List _toList() { + return [ + displayName, + imageAsset, + serviceUuid, + nameSubstring, + requiresImmediateRange, + supportsBluetoothPairing, + ]; + } + + Object encode() { + return _toList(); + } + + static AppleAccessorySetupOptions decode(Object result) { + result as List; + return AppleAccessorySetupOptions( + displayName: result[0]! as String, + imageAsset: result[1]! as String, + serviceUuid: result[2]! as String, + nameSubstring: result[3] as String?, + requiresImmediateRange: result[4] as bool?, + supportsBluetoothPairing: result[5] as bool?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AppleAccessorySetupOptions || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(displayName, other.displayName) && + _deepEquals(imageAsset, other.imageAsset) && + _deepEquals(serviceUuid, other.serviceUuid) && + _deepEquals(nameSubstring, other.nameSubstring) && + _deepEquals(requiresImmediateRange, other.requiresImmediateRange) && + _deepEquals(supportsBluetoothPairing, other.supportsBluetoothPairing); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + class AndroidConnectionOptions { AndroidConnectionOptions({ this.closeGattOnDetach, @@ -1543,33 +1623,36 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is AppleConnectionOptions) { buffer.putUint8(154); writeValue(buffer, value.encode()); - } else if (value is AndroidConnectionOptions) { + } else if (value is AppleAccessorySetupOptions) { buffer.putUint8(155); writeValue(buffer, value.encode()); - } else if (value is ConnectionPlatformConfig) { + } else if (value is AndroidConnectionOptions) { buffer.putUint8(156); writeValue(buffer, value.encode()); - } else if (value is PeripheralAndroidOptions) { + } else if (value is ConnectionPlatformConfig) { buffer.putUint8(157); writeValue(buffer, value.encode()); - } else if (value is PeripheralPlatformConfig) { + } else if (value is PeripheralAndroidOptions) { buffer.putUint8(158); writeValue(buffer, value.encode()); - } else if (value is PeripheralService) { + } else if (value is PeripheralPlatformConfig) { buffer.putUint8(159); writeValue(buffer, value.encode()); - } else if (value is PeripheralCharacteristic) { + } else if (value is PeripheralService) { buffer.putUint8(160); writeValue(buffer, value.encode()); - } else if (value is PeripheralDescriptor) { + } else if (value is PeripheralCharacteristic) { buffer.putUint8(161); writeValue(buffer, value.encode()); - } else if (value is PeripheralReadRequestResult) { + } else if (value is PeripheralDescriptor) { buffer.putUint8(162); writeValue(buffer, value.encode()); - } else if (value is PeripheralWriteRequestResult) { + } else if (value is PeripheralReadRequestResult) { buffer.putUint8(163); writeValue(buffer, value.encode()); + } else if (value is PeripheralWriteRequestResult) { + buffer.putUint8(164); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -1648,22 +1731,24 @@ class _PigeonCodec extends StandardMessageCodec { case 154: return AppleConnectionOptions.decode(readValue(buffer)!); case 155: - return AndroidConnectionOptions.decode(readValue(buffer)!); + return AppleAccessorySetupOptions.decode(readValue(buffer)!); case 156: - return ConnectionPlatformConfig.decode(readValue(buffer)!); + return AndroidConnectionOptions.decode(readValue(buffer)!); case 157: - return PeripheralAndroidOptions.decode(readValue(buffer)!); + return ConnectionPlatformConfig.decode(readValue(buffer)!); case 158: - return PeripheralPlatformConfig.decode(readValue(buffer)!); + return PeripheralAndroidOptions.decode(readValue(buffer)!); case 159: - return PeripheralService.decode(readValue(buffer)!); + return PeripheralPlatformConfig.decode(readValue(buffer)!); case 160: - return PeripheralCharacteristic.decode(readValue(buffer)!); + return PeripheralService.decode(readValue(buffer)!); case 161: - return PeripheralDescriptor.decode(readValue(buffer)!); + return PeripheralCharacteristic.decode(readValue(buffer)!); case 162: - return PeripheralReadRequestResult.decode(readValue(buffer)!); + return PeripheralDescriptor.decode(readValue(buffer)!); case 163: + return PeripheralReadRequestResult.decode(readValue(buffer)!); + case 164: return PeripheralWriteRequestResult.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -1842,6 +1927,28 @@ class UniversalBlePlatformChannel { return pigeonVar_replyValue! as bool; } + /// Shows the iOS AccessorySetupKit picker and returns the selected + /// peripheral identifier. + Future setupAccessory(AppleAccessorySetupOptions options) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.setupAccessory$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([options]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as String; + } + Future connect( String deviceId, { bool? autoConnect, diff --git a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart index 3d879a88..e5865237 100644 --- a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart +++ b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart @@ -59,6 +59,10 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform Future isScanning() => _executeWithErrorHandling(() => _channel.isScanning()); + @override + Future setupAccessory(AppleAccessorySetupOptions options) => + _executeWithErrorHandling(() => _channel.setupAccessory(options)); + @override Future getConnectionState(String deviceId) => _executeWithErrorHandling(() => _channel.getConnectionState(deviceId)); diff --git a/lib/universal_ble.dart b/lib/universal_ble.dart index a942e65d..b6b72147 100644 --- a/lib/universal_ble.dart +++ b/lib/universal_ble.dart @@ -13,6 +13,7 @@ export 'package:universal_ble/src/universal_ble.g.dart' AndroidOptions, AndroidConnectionOptions, AppleConnectionOptions, + AppleAccessorySetupOptions, ConnectionPlatformConfig, AndroidScanMode, AndroidScanCallbackType, diff --git a/pigeon/universal_ble.dart b/pigeon/universal_ble.dart index 8ae3e1af..b1384a8a 100644 --- a/pigeon/universal_ble.dart +++ b/pigeon/universal_ble.dart @@ -295,6 +295,37 @@ class AppleConnectionOptions { }); } +/// iOS 18+ options for discovering and authorizing a Bluetooth accessory with +/// AccessorySetupKit before connecting to it. +class AppleAccessorySetupOptions { + /// Name shown in the system accessory picker. + String displayName; + + /// Name of the product image in the iOS app's asset catalog. + String imageAsset; + + /// Advertised Bluetooth service UUID used to discover the accessory. + String serviceUuid; + + /// Optional substring of the accessory's advertised Bluetooth name. + String? nameSubstring; + + /// Limit discovery to accessories in the immediate vicinity. + bool? requiresImmediateRange; + + /// Allow AccessorySetupKit to perform Bluetooth LE pairing when needed. + bool? supportsBluetoothPairing; + + AppleAccessorySetupOptions({ + required this.displayName, + required this.imageAsset, + required this.serviceUuid, + this.nameSubstring, + this.requiresImmediateRange, + this.supportsBluetoothPairing, + }); +} + class AndroidConnectionOptions { /// Close the GATT client when the FlutterEngine is detached (for /// example, when the app is "killed"). The plugin otherwise leaves the @@ -405,6 +436,11 @@ abstract class UniversalBlePlatformChannel { bool isScanning(); + /// Shows the iOS AccessorySetupKit picker and returns the selected + /// peripheral identifier. + @async + String setupAccessory(AppleAccessorySetupOptions options); + void connect( String deviceId, { bool? autoConnect, @@ -465,6 +501,7 @@ abstract class UniversalBlePlatformChannel { @async bool pair(String deviceId); + @async void unPair(String deviceId); @async diff --git a/test/accessory_setup_test.dart b/test/accessory_setup_test.dart new file mode 100644 index 00000000..4e00229f --- /dev/null +++ b/test/accessory_setup_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import 'universal_ble_test_mock.dart'; + +class _AccessorySetupPlatform extends UniversalBlePlatformMock { + AppleAccessorySetupOptions? receivedOptions; + String? connectedDeviceId; + + @override + Future setupAccessory(AppleAccessorySetupOptions options) async { + receivedOptions = options; + return 'selected-device'; + } + + @override + Future connect( + String deviceId, { + bool autoConnect = false, + Duration? connectionTimeout, + ConnectionPlatformConfig? platformConfig, + }) async { + connectedDeviceId = deviceId; + updateConnection(deviceId, true); + } +} + +void main() { + test('connectAccessory connects the device selected by the picker', () async { + final platform = _AccessorySetupPlatform(); + final options = AppleAccessorySetupOptions( + displayName: 'My device', + imageAsset: 'my_device', + serviceUuid: '180D', + ); + UniversalBle.setInstance(platform); + + final deviceId = await UniversalBle.connectAccessory(options); + + expect(deviceId, 'selected-device'); + expect(platform.connectedDeviceId, deviceId); + expect(platform.receivedOptions, same(options)); + }); +} diff --git a/windows/src/generated/universal_ble.g.cpp b/windows/src/generated/universal_ble.g.cpp index 60b88e96..178caade 100644 --- a/windows/src/generated/universal_ble.g.cpp +++ b/windows/src/generated/universal_ble.g.cpp @@ -1298,6 +1298,151 @@ size_t PigeonInternalDeepHash(const AppleConnectionOptions& v) { return v.Hash(); } +// AppleAccessorySetupOptions + +AppleAccessorySetupOptions::AppleAccessorySetupOptions( + const std::string& display_name, + const std::string& image_asset, + const std::string& service_uuid) + : display_name_(display_name), + image_asset_(image_asset), + service_uuid_(service_uuid) {} + +AppleAccessorySetupOptions::AppleAccessorySetupOptions( + const std::string& display_name, + const std::string& image_asset, + const std::string& service_uuid, + const std::string* name_substring, + const bool* requires_immediate_range, + const bool* supports_bluetooth_pairing) + : display_name_(display_name), + image_asset_(image_asset), + service_uuid_(service_uuid), + name_substring_(name_substring ? std::optional(*name_substring) : std::nullopt), + requires_immediate_range_(requires_immediate_range ? std::optional(*requires_immediate_range) : std::nullopt), + supports_bluetooth_pairing_(supports_bluetooth_pairing ? std::optional(*supports_bluetooth_pairing) : std::nullopt) {} + +const std::string& AppleAccessorySetupOptions::display_name() const { + return display_name_; +} + +void AppleAccessorySetupOptions::set_display_name(std::string_view value_arg) { + display_name_ = value_arg; +} + + +const std::string& AppleAccessorySetupOptions::image_asset() const { + return image_asset_; +} + +void AppleAccessorySetupOptions::set_image_asset(std::string_view value_arg) { + image_asset_ = value_arg; +} + + +const std::string& AppleAccessorySetupOptions::service_uuid() const { + return service_uuid_; +} + +void AppleAccessorySetupOptions::set_service_uuid(std::string_view value_arg) { + service_uuid_ = value_arg; +} + + +const std::string* AppleAccessorySetupOptions::name_substring() const { + return name_substring_ ? &(*name_substring_) : nullptr; +} + +void AppleAccessorySetupOptions::set_name_substring(const std::string_view* value_arg) { + name_substring_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void AppleAccessorySetupOptions::set_name_substring(std::string_view value_arg) { + name_substring_ = value_arg; +} + + +const bool* AppleAccessorySetupOptions::requires_immediate_range() const { + return requires_immediate_range_ ? &(*requires_immediate_range_) : nullptr; +} + +void AppleAccessorySetupOptions::set_requires_immediate_range(const bool* value_arg) { + requires_immediate_range_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void AppleAccessorySetupOptions::set_requires_immediate_range(bool value_arg) { + requires_immediate_range_ = value_arg; +} + + +const bool* AppleAccessorySetupOptions::supports_bluetooth_pairing() const { + return supports_bluetooth_pairing_ ? &(*supports_bluetooth_pairing_) : nullptr; +} + +void AppleAccessorySetupOptions::set_supports_bluetooth_pairing(const bool* value_arg) { + supports_bluetooth_pairing_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void AppleAccessorySetupOptions::set_supports_bluetooth_pairing(bool value_arg) { + supports_bluetooth_pairing_ = value_arg; +} + + +EncodableList AppleAccessorySetupOptions::ToEncodableList() const { + EncodableList list; + list.reserve(6); + list.push_back(EncodableValue(display_name_)); + list.push_back(EncodableValue(image_asset_)); + list.push_back(EncodableValue(service_uuid_)); + list.push_back(name_substring_ ? EncodableValue(*name_substring_) : EncodableValue()); + list.push_back(requires_immediate_range_ ? EncodableValue(*requires_immediate_range_) : EncodableValue()); + list.push_back(supports_bluetooth_pairing_ ? EncodableValue(*supports_bluetooth_pairing_) : EncodableValue()); + return list; +} + +AppleAccessorySetupOptions AppleAccessorySetupOptions::FromEncodableList(const EncodableList& list) { + AppleAccessorySetupOptions decoded( + std::get(list[0]), + std::get(list[1]), + std::get(list[2])); + auto& encodable_name_substring = list[3]; + if (!encodable_name_substring.IsNull()) { + decoded.set_name_substring(std::get(encodable_name_substring)); + } + auto& encodable_requires_immediate_range = list[4]; + if (!encodable_requires_immediate_range.IsNull()) { + decoded.set_requires_immediate_range(std::get(encodable_requires_immediate_range)); + } + auto& encodable_supports_bluetooth_pairing = list[5]; + if (!encodable_supports_bluetooth_pairing.IsNull()) { + decoded.set_supports_bluetooth_pairing(std::get(encodable_supports_bluetooth_pairing)); + } + return decoded; +} + +bool AppleAccessorySetupOptions::operator==(const AppleAccessorySetupOptions& other) const { + return PigeonInternalDeepEquals(display_name_, other.display_name_) && PigeonInternalDeepEquals(image_asset_, other.image_asset_) && PigeonInternalDeepEquals(service_uuid_, other.service_uuid_) && PigeonInternalDeepEquals(name_substring_, other.name_substring_) && PigeonInternalDeepEquals(requires_immediate_range_, other.requires_immediate_range_) && PigeonInternalDeepEquals(supports_bluetooth_pairing_, other.supports_bluetooth_pairing_); +} + +bool AppleAccessorySetupOptions::operator!=(const AppleAccessorySetupOptions& other) const { + return !(*this == other); +} + +size_t AppleAccessorySetupOptions::Hash() const { + size_t result = 1; + result = result * 31 + PigeonInternalDeepHash(display_name_); + result = result * 31 + PigeonInternalDeepHash(image_asset_); + result = result * 31 + PigeonInternalDeepHash(service_uuid_); + result = result * 31 + PigeonInternalDeepHash(name_substring_); + result = result * 31 + PigeonInternalDeepHash(requires_immediate_range_); + result = result * 31 + PigeonInternalDeepHash(supports_bluetooth_pairing_); + return result; +} + +size_t PigeonInternalDeepHash(const AppleAccessorySetupOptions& v) { + return v.Hash(); +} + // AndroidConnectionOptions AndroidConnectionOptions::AndroidConnectionOptions() {} @@ -2169,30 +2314,33 @@ EncodableValue PigeonInternalCodecSerializer::ReadValueOfType( return CustomEncodableValue(AppleConnectionOptions::FromEncodableList(std::get(ReadValue(stream)))); } case 155: { - return CustomEncodableValue(AndroidConnectionOptions::FromEncodableList(std::get(ReadValue(stream)))); + return CustomEncodableValue(AppleAccessorySetupOptions::FromEncodableList(std::get(ReadValue(stream)))); } case 156: { - return CustomEncodableValue(ConnectionPlatformConfig::FromEncodableList(std::get(ReadValue(stream)))); + return CustomEncodableValue(AndroidConnectionOptions::FromEncodableList(std::get(ReadValue(stream)))); } case 157: { - return CustomEncodableValue(PeripheralAndroidOptions::FromEncodableList(std::get(ReadValue(stream)))); + return CustomEncodableValue(ConnectionPlatformConfig::FromEncodableList(std::get(ReadValue(stream)))); } case 158: { - return CustomEncodableValue(PeripheralPlatformConfig::FromEncodableList(std::get(ReadValue(stream)))); + return CustomEncodableValue(PeripheralAndroidOptions::FromEncodableList(std::get(ReadValue(stream)))); } case 159: { - return CustomEncodableValue(PeripheralService::FromEncodableList(std::get(ReadValue(stream)))); + return CustomEncodableValue(PeripheralPlatformConfig::FromEncodableList(std::get(ReadValue(stream)))); } case 160: { - return CustomEncodableValue(PeripheralCharacteristic::FromEncodableList(std::get(ReadValue(stream)))); + return CustomEncodableValue(PeripheralService::FromEncodableList(std::get(ReadValue(stream)))); } case 161: { - return CustomEncodableValue(PeripheralDescriptor::FromEncodableList(std::get(ReadValue(stream)))); + return CustomEncodableValue(PeripheralCharacteristic::FromEncodableList(std::get(ReadValue(stream)))); } case 162: { - return CustomEncodableValue(PeripheralReadRequestResult::FromEncodableList(std::get(ReadValue(stream)))); + return CustomEncodableValue(PeripheralDescriptor::FromEncodableList(std::get(ReadValue(stream)))); } case 163: { + return CustomEncodableValue(PeripheralReadRequestResult::FromEncodableList(std::get(ReadValue(stream)))); + } + case 164: { return CustomEncodableValue(PeripheralWriteRequestResult::FromEncodableList(std::get(ReadValue(stream)))); } default: @@ -2334,48 +2482,53 @@ void PigeonInternalCodecSerializer::WriteValue( WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); return; } - if (custom_value->type() == typeid(AndroidConnectionOptions)) { + if (custom_value->type() == typeid(AppleAccessorySetupOptions)) { stream->WriteByte(155); + WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); + return; + } + if (custom_value->type() == typeid(AndroidConnectionOptions)) { + stream->WriteByte(156); WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); return; } if (custom_value->type() == typeid(ConnectionPlatformConfig)) { - stream->WriteByte(156); + stream->WriteByte(157); WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); return; } if (custom_value->type() == typeid(PeripheralAndroidOptions)) { - stream->WriteByte(157); + stream->WriteByte(158); WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); return; } if (custom_value->type() == typeid(PeripheralPlatformConfig)) { - stream->WriteByte(158); + stream->WriteByte(159); WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); return; } if (custom_value->type() == typeid(PeripheralService)) { - stream->WriteByte(159); + stream->WriteByte(160); WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); return; } if (custom_value->type() == typeid(PeripheralCharacteristic)) { - stream->WriteByte(160); + stream->WriteByte(161); WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); return; } if (custom_value->type() == typeid(PeripheralDescriptor)) { - stream->WriteByte(161); + stream->WriteByte(162); WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); return; } if (custom_value->type() == typeid(PeripheralReadRequestResult)) { - stream->WriteByte(162); + stream->WriteByte(163); WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); return; } if (custom_value->type() == typeid(PeripheralWriteRequestResult)) { - stream->WriteByte(163); + stream->WriteByte(164); WriteValue(EncodableValue(std::any_cast(*custom_value).ToEncodableList()), stream); return; } @@ -2591,6 +2744,35 @@ void UniversalBlePlatformChannel::SetUp( channel.SetMessageHandler(nullptr); } } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.setupAccessory" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const ::flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_options_arg = args.at(0); + if (encodable_options_arg.IsNull()) { + reply(WrapError("options_arg unexpectedly null.")); + return; + } + const auto& options_arg = std::any_cast(std::get(encodable_options_arg)); + api->SetupAccessory(options_arg, [reply](ErrorOr&& output) { + if (output.has_error()) { + reply(WrapError(output.error())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue(std::move(output).TakeValue())); + reply(EncodableValue(std::move(wrapped))); + }); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } { BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.connect" + prepended_suffix, &GetCodec()); if (api != nullptr) { @@ -3032,14 +3214,15 @@ void UniversalBlePlatformChannel::SetUp( return; } const auto& device_id_arg = std::get(encodable_device_id_arg); - std::optional output = api->UnPair(device_id_arg); - if (output.has_value()) { - reply(WrapError(output.value())); - return; - } - EncodableList wrapped; - wrapped.push_back(EncodableValue()); - reply(EncodableValue(std::move(wrapped))); + api->UnPair(device_id_arg, [reply](std::optional&& output) { + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + }); } catch (const std::exception& exception) { reply(WrapError(exception.what())); } diff --git a/windows/src/generated/universal_ble.g.h b/windows/src/generated/universal_ble.g.h index 147e7cdc..e295a51c 100644 --- a/windows/src/generated/universal_ble.g.h +++ b/windows/src/generated/universal_ble.g.h @@ -792,6 +792,76 @@ class AppleConnectionOptions { }; +// iOS 18+ options for discovering and authorizing a Bluetooth accessory with +// AccessorySetupKit before connecting to it. +// +// Generated class from Pigeon that represents data sent in messages. +class AppleAccessorySetupOptions { + public: + // Constructs an object setting all non-nullable fields. + explicit AppleAccessorySetupOptions( + const std::string& display_name, + const std::string& image_asset, + const std::string& service_uuid); + + // Constructs an object setting all fields. + explicit AppleAccessorySetupOptions( + const std::string& display_name, + const std::string& image_asset, + const std::string& service_uuid, + const std::string* name_substring, + const bool* requires_immediate_range, + const bool* supports_bluetooth_pairing); + + // Name shown in the system accessory picker. + const std::string& display_name() const; + void set_display_name(std::string_view value_arg); + + // Name of the product image in the iOS app's asset catalog. + const std::string& image_asset() const; + void set_image_asset(std::string_view value_arg); + + // Advertised Bluetooth service UUID used to discover the accessory. + const std::string& service_uuid() const; + void set_service_uuid(std::string_view value_arg); + + // Optional substring of the accessory's advertised Bluetooth name. + const std::string* name_substring() const; + void set_name_substring(const std::string_view* value_arg); + void set_name_substring(std::string_view value_arg); + + // Limit discovery to accessories in the immediate vicinity. + const bool* requires_immediate_range() const; + void set_requires_immediate_range(const bool* value_arg); + void set_requires_immediate_range(bool value_arg); + + // Allow AccessorySetupKit to perform Bluetooth LE pairing when needed. + const bool* supports_bluetooth_pairing() const; + void set_supports_bluetooth_pairing(const bool* value_arg); + void set_supports_bluetooth_pairing(bool value_arg); + + bool operator==(const AppleAccessorySetupOptions& other) const; + bool operator!=(const AppleAccessorySetupOptions& other) const; + /// Returns a hash code value for the object. This method is supported for the benefit of hash tables. + size_t Hash() const; + private: + static AppleAccessorySetupOptions FromEncodableList(const ::flutter::EncodableList& list); + ::flutter::EncodableList ToEncodableList() const; + friend class UniversalBlePlatformChannel; + friend class UniversalBleCallbackChannel; + friend class UniversalBlePeripheralChannel; + friend class UniversalBleAndroidChannel; + friend class UniversalBlePeripheralCallback; + friend class PigeonInternalCodecSerializer; + std::string display_name_; + std::string image_asset_; + std::string service_uuid_; + std::optional name_substring_; + std::optional requires_immediate_range_; + std::optional supports_bluetooth_pairing_; +}; + + // Generated class from Pigeon that represents data sent in messages. class AndroidConnectionOptions { public: @@ -1210,6 +1280,11 @@ class UniversalBlePlatformChannel { const UniversalScanConfig* config) = 0; virtual std::optional StopScan() = 0; virtual ErrorOr IsScanning() = 0; + // Shows the iOS AccessorySetupKit picker and returns the selected + // peripheral identifier. + virtual void SetupAccessory( + const AppleAccessorySetupOptions& options, + std::function reply)> result) = 0; virtual std::optional Connect( const std::string& device_id, const bool* auto_connect, @@ -1260,7 +1335,9 @@ class UniversalBlePlatformChannel { virtual void Pair( const std::string& device_id, std::function reply)> result) = 0; - virtual std::optional UnPair(const std::string& device_id) = 0; + virtual void UnPair( + const std::string& device_id, + std::function reply)> result) = 0; virtual void GetSystemDevices( const ::flutter::EncodableList& with_services, std::function reply)> result) = 0; diff --git a/windows/src/universal_ble_plugin.cpp b/windows/src/universal_ble_plugin.cpp index 947f3031..acea0979 100644 --- a/windows/src/universal_ble_plugin.cpp +++ b/windows/src/universal_ble_plugin.cpp @@ -188,6 +188,13 @@ void UniversalBlePlugin::EnableBluetooth( }); } +void UniversalBlePlugin::SetupAccessory( + const AppleAccessorySetupOptions &, + std::function reply)> result) { + result(create_flutter_error(UniversalBleErrorCode::kNotSupported, + "AccessorySetupKit is only supported on iOS 18+")); +} + void UniversalBlePlugin::DisableBluetooth( std::function reply)> result) { if (!bluetooth_radio_) { @@ -1012,20 +1019,23 @@ void UniversalBlePlugin::Pair(const std::string &device_id, } } -std::optional -UniversalBlePlugin::UnPair(const std::string &device_id) { +void UniversalBlePlugin::UnPair( + const std::string &device_id, + std::function reply)> result) { try { const auto device = async_get(BluetoothLEDevice::FromBluetoothAddressAsync( str_to_mac_address(device_id))); if (device == nullptr) { - return create_flutter_error(UniversalBleErrorCode::kDeviceNotFound, - "Unknown devicesId:" + device_id); + result(create_flutter_error(UniversalBleErrorCode::kDeviceNotFound, + "Unknown devicesId:" + device_id)); + return; } const auto device_information = device.DeviceInformation(); if (!device_information.Pairing().IsPaired()) { - return create_flutter_error(UniversalBleErrorCode::kNotPaired, - "Device is not paired"); + result(create_flutter_error(UniversalBleErrorCode::kNotPaired, + "Device is not paired")); + return; } const auto device_unpairing_result = @@ -1033,11 +1043,12 @@ UniversalBlePlugin::UnPair(const std::string &device_id) { const auto status = device_unpairing_result.Status(); if (status != DeviceUnpairingResultStatus::Unpaired) { - return create_flutter_error_from_unpairing_status(status); + result(create_flutter_error_from_unpairing_status(status)); + return; } - return std::nullopt; + result(std::nullopt); } catch (const FlutterError &err) { - return err; + result(err); } } diff --git a/windows/src/universal_ble_plugin.h b/windows/src/universal_ble_plugin.h index cc438d56..1a25c296 100644 --- a/windows/src/universal_ble_plugin.h +++ b/windows/src/universal_ble_plugin.h @@ -552,6 +552,9 @@ class UniversalBlePlugin : public flutter::Plugin, StartScan(const UniversalScanFilter *filter, const UniversalScanConfig *config) override; std::optional StopScan() override; ErrorOr IsScanning() override; + void SetupAccessory( + const AppleAccessorySetupOptions &options, + std::function reply)> result) override; std::optional Connect(const std::string &device_id, const bool *auto_connect, const ConnectionPlatformConfig *platform_config) override; std::optional Disconnect(const std::string &device_id) override; @@ -597,7 +600,9 @@ class UniversalBlePlugin : public flutter::Plugin, std::function reply)> result) override; void Pair(const std::string &device_id, std::function reply)> result) override; - std::optional UnPair(const std::string &device_id) override; + void UnPair( + const std::string &device_id, + std::function reply)> result) override; void GetSystemDevices(const flutter::EncodableList &with_services, std::function reply)>