Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<key>NSAccessorySetupKitSupports</key>
<array>
<string>Bluetooth</string>
</array>
<key>NSAccessorySetupBluetoothServices</key>
<array>
<string>12345678-1234-1234-1234-1234567890AB</string>
</array>
<key>NSAccessorySetupBluetoothNames</key>
<array>
<string>MyDevice</string>
</array>
```

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.
Expand Down Expand Up @@ -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.

Expand Down
148 changes: 124 additions & 24 deletions android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Any?>): 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<Any?> {
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 (
/**
Expand Down Expand Up @@ -1558,45 +1623,50 @@ private open class UniversalBlePigeonCodec : StandardMessageCodec() {
}
155.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
AndroidConnectionOptions.fromList(it)
AppleAccessorySetupOptions.fromList(it)
}
}
156.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ConnectionPlatformConfig.fromList(it)
AndroidConnectionOptions.fromList(it)
}
}
157.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PeripheralAndroidOptions.fromList(it)
ConnectionPlatformConfig.fromList(it)
}
}
158.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PeripheralPlatformConfig.fromList(it)
PeripheralAndroidOptions.fromList(it)
}
}
159.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PeripheralService.fromList(it)
PeripheralPlatformConfig.fromList(it)
}
}
160.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PeripheralCharacteristic.fromList(it)
PeripheralService.fromList(it)
}
}
161.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PeripheralDescriptor.fromList(it)
PeripheralCharacteristic.fromList(it)
}
}
162.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PeripheralReadRequestResult.fromList(it)
PeripheralDescriptor.fromList(it)
}
}
163.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PeripheralReadRequestResult.fromList(it)
}
}
164.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PeripheralWriteRequestResult.fromList(it)
}
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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<String>) -> 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>) -> Unit)
Expand All @@ -1779,7 +1858,7 @@ interface UniversalBlePlatformChannel {
fun writeDescriptorValue(deviceId: String, service: String, characteristic: String, descriptor: String, value: ByteArray, callback: (Result<Unit>) -> Unit)
fun isPaired(deviceId: String, callback: (Result<Boolean>) -> Unit)
fun pair(deviceId: String, callback: (Result<Boolean>) -> Unit)
fun unPair(deviceId: String)
fun unPair(deviceId: String, callback: (Result<Unit>) -> Unit)
fun getSystemDevices(withServices: List<String>, callback: (Result<List<UniversalBleScanResult>>) -> Unit)
fun getConnectionState(deviceId: String): BleConnectionState
fun readRssi(deviceId: String, callback: (Result<Long>) -> Unit)
Expand Down Expand Up @@ -1935,6 +2014,26 @@ interface UniversalBlePlatformChannel {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.setupAccessory$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val optionsArg = args[0] as AppleAccessorySetupOptions
api.setupAccessory(optionsArg) { result: Result<String> ->
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<Any?>(binaryMessenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.connect$separatedMessageChannelSuffix", codec)
if (api != null) {
Expand Down Expand Up @@ -2174,13 +2273,14 @@ interface UniversalBlePlatformChannel {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val deviceIdArg = args[0] as String
val wrapped: List<Any?> = try {
api.unPair(deviceIdArg)
listOf(null)
} catch (exception: Throwable) {
UniversalBlePigeonUtils.wrapError(exception)
api.unPair(deviceIdArg) { result: Result<Unit> ->
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,20 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
return safeScanner.isScanning()
}

override fun setupAccessory(
options: AppleAccessorySetupOptions,
callback: (Result<String>) -> Unit,
) {
callback(
Result.failure(
createFlutterError(
UniversalBleErrorCode.NOT_SUPPORTED,
"AccessorySetupKit is only supported on iOS 18+",
)
)
)
}

override fun connect(
deviceId: String,
autoConnect: Boolean?,
Expand Down Expand Up @@ -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>) -> 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))
}
}

Expand Down
Binary file added assets/universal_ble_icon_inverted.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading