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(