From b7bbc97046a5bcb1b7e6a56632385f717de4478d Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 12:21:57 +0200 Subject: [PATCH 01/13] docs: use neutral dash for unsupported cells in API support tables --- README.md | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ac0b732d..063d8daf 100644 --- a/README.md +++ b/README.md @@ -40,48 +40,50 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d ## API Support +**Legend:** ✔️ supported · — not supported · ⏺ platform-dependent (e.g. pairing is triggered by the OS rather than by an API call) · 🚧 work in progress. + ### Client Mode (`UniversalBle`) | | Android | iOS | macOS | Windows | Linux | Web | | :---------------------------- | :-----: | :-: | :---: | :-----: | :---: | :-: | | startScan/stopScan | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | connect/disconnect | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| autoConnect | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | -| getSystemDevices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | +| autoConnect | ✔️ | ✔️ | ✔️ | — | — | — | +| getSystemDevices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | | discoverServices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | read | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | write | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | pair | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | -| unpair | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ | +| unpair | ✔️ | — | — | ✔️ | ✔️ | — | | isPaired | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | onPairingStateChange | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | -| getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | -| enable/disable Bluetooth | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ | +| getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | +| enable/disable Bluetooth | ✔️ | — | — | ✔️ | ✔️ | — | | onAvailabilityChange | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| requestMtu | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | -| requestConnectionPriority | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | -| onConnectionParametersChange | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | -| readRssi | ✔️ | ✔️ | ✔️ | ❌ | 🚧 | ❌ | +| requestMtu | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | +| requestConnectionPriority | ✔️ | — | — | — | — | — | +| onConnectionParametersChange | ✔️ | — | — | — | — | — | +| readRssi | ✔️ | ✔️ | ✔️ | — | 🚧 | — | | requestPermissions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ### Peripheral Mode (`UniversalBlePeripheral`) | API | Android | iOS | macOS | Windows | Linux | Web | | :---------------------------- | :-----: | :-: | :---: | :-----: | :---: | :-: | -| getCapabilities | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| getAvailabilityState\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| getAdvertisingState | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| addService | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| removeService | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| clearServices | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| getServices | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| startAdvertising | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| stopAdvertising | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| updateCharacteristicValue\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| getSubscribedClients | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| getMaximumNotifyLength | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | -| event streams\*\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | ❌ | +| getCapabilities | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| getAvailabilityState\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| getAdvertisingState | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| addService | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| removeService | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| clearServices | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| getServices | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| startAdvertising | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| stopAdvertising | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| updateCharacteristicValue\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| getSubscribedClients | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| getMaximumNotifyLength | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +| event streams\*\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | \* `getAvailabilityState` returns a snapshot. Listen to `advertisingStateStream`, `connectionStateStream`, and related streams for runtime updates. \*\* Pass `deviceId` to notify one client; omit it to notify all subscribed clients (when supported — see `getCapabilities().supportsTargetedCharacteristicUpdate`). From 9650765d6756602bfb3ce84c0a1d698394c58671 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 12:24:37 +0200 Subject: [PATCH 02/13] docs: move API support legend below the tables --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 063d8daf..147f29b0 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,6 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d ## API Support -**Legend:** ✔️ supported · — not supported · ⏺ platform-dependent (e.g. pairing is triggered by the OS rather than by an API call) · 🚧 work in progress. - ### Client Mode (`UniversalBle`) | | Android | iOS | macOS | Windows | Linux | Web | @@ -85,6 +83,8 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | getMaximumNotifyLength | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | | event streams\*\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | +**Legend:** ✔️ supported · — not supported · ⏺ platform-dependent (e.g. pairing is triggered by the OS rather than by an API call) · 🚧 work in progress. + \* `getAvailabilityState` returns a snapshot. Listen to `advertisingStateStream`, `connectionStateStream`, and related streams for runtime updates. \*\* Pass `deviceId` to notify one client; omit it to notify all subscribed clients (when supported — see `getCapabilities().supportsTargetedCharacteristicUpdate`). \*\*\* `advertisingStateStream`, `characteristicSubscriptionStream`, `connectionStateStream`, `serviceAddedStream`, `mtuChangedStream`, `subscribedCentralsStream`. From bcf5b9bd9986d6a0f334ce60e82223a2ba89cb16 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 12:32:38 +0200 Subject: [PATCH 03/13] =?UTF-8?q?docs:=20clarify=20=E2=8F=BA=20marker=20an?= =?UTF-8?q?d=20pairing-state=20support=20in=20API=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 147f29b0..f8de49b3 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,10 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | read | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | write | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| pair | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | +| pair¹ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | | unpair | ✔️ | — | — | ✔️ | ✔️ | — | | isPaired | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| onPairingStateChange | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | +| onPairingStateChange² | ✔️ | — | — | ✔️ | ✔️ | — | | getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | | enable/disable Bluetooth | ✔️ | — | — | ✔️ | ✔️ | — | | onAvailabilityChange | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | @@ -65,6 +65,9 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | readRssi | ✔️ | ✔️ | ✔️ | — | 🚧 | — | | requestPermissions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +¹ There is no dedicated pairing API on iOS, macOS, or Web. `pair()` connects and reads an encrypted characteristic so the OS triggers pairing (pass a `pairingCommand` to target a specific characteristic). On Web/Windows and Web/Linux it does not work for devices that use ConfirmOnly ("Just Works") pairing — hence ⏺. +² iOS, macOS, and Web do not report pairing-state changes — `pairingStateStream` and `onPairingStateChange` never emit there. Read the current state with `isPaired(pairingCommand:)` instead. + ### Peripheral Mode (`UniversalBlePeripheral`) | API | Android | iOS | macOS | Windows | Linux | Web | @@ -83,7 +86,7 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | getMaximumNotifyLength | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | | event streams\*\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | -**Legend:** ✔️ supported · — not supported · ⏺ platform-dependent (e.g. pairing is triggered by the OS rather than by an API call) · 🚧 work in progress. +**Legend:** ✔️ supported · — not supported · ⏺ no dedicated API (see notes) · 🚧 work in progress. \* `getAvailabilityState` returns a snapshot. Listen to `advertisingStateStream`, `connectionStateStream`, and related streams for runtime updates. \*\* Pass `deviceId` to notify one client; omit it to notify all subscribed clients (when supported — see `getCapabilities().supportsTargetedCharacteristicUpdate`). From 1653466b09b795263019bf9c6fad8b5bc83bb48e Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 12:42:05 +0200 Subject: [PATCH 04/13] docs: note isPaired pairingCommand requirement and iOS unpair support --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f8de49b3..02a78327 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,9 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | write | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | pair¹ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | -| unpair | ✔️ | — | — | ✔️ | ✔️ | — | -| isPaired | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| onPairingStateChange² | ✔️ | — | — | ✔️ | ✔️ | — | +| unpair² | ✔️ | ⏺ | — | ✔️ | ✔️ | — | +| isPaired³ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| onPairingStateChange⁴ | ✔️ | — | — | ✔️ | ✔️ | — | | getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | | enable/disable Bluetooth | ✔️ | — | — | ✔️ | ✔️ | — | | onAvailabilityChange | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | @@ -65,8 +65,10 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | readRssi | ✔️ | ✔️ | ✔️ | — | 🚧 | — | | requestPermissions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -¹ There is no dedicated pairing API on iOS, macOS, or Web. `pair()` connects and reads an encrypted characteristic so the OS triggers pairing (pass a `pairingCommand` to target a specific characteristic). On Web/Windows and Web/Linux it does not work for devices that use ConfirmOnly ("Just Works") pairing — hence ⏺. -² iOS, macOS, and Web do not report pairing-state changes — `pairingStateStream` and `onPairingStateChange` never emit there. Read the current state with `isPaired(pairingCommand:)` instead. +¹ On iOS, macOS, and Web there is no dedicated pairing API: `pair()` connects and reads an encrypted characteristic so the OS triggers pairing (pass a `pairingCommand` to target a specific one). This works for all pairing methods on iOS/macOS; on Web it cannot trigger ConfirmOnly ("Just Works") pairing on Web/Windows and Web/Linux — hence ⏺. +² On iOS, `unpair` requires iOS 18+ and only removes accessories authorized through AccessorySetupKit (`requestDevice`); other devices fail with `deviceNotFound` — hence ⏺. +³ On iOS, macOS, and Web, `isPaired` needs a `pairingCommand` with an encrypted read or write characteristic and returns `null` without one. It also triggers pairing if the device is not already paired. +⁴ iOS, macOS, and Web do not report pairing-state changes — `pairingStateStream` and `onPairingStateChange` never emit there. Read the current state with `isPaired(pairingCommand:)` instead. ### Peripheral Mode (`UniversalBlePeripheral`) @@ -86,7 +88,7 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | getMaximumNotifyLength | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | | event streams\*\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | -**Legend:** ✔️ supported · — not supported · ⏺ no dedicated API (see notes) · 🚧 work in progress. +**Legend:** ✔️ supported · — not supported · ⏺ partial support (see notes) · 🚧 work in progress. \* `getAvailabilityState` returns a snapshot. Listen to `advertisingStateStream`, `connectionStateStream`, and related streams for runtime updates. \*\* Pass `deviceId` to notify one client; omit it to notify all subscribed clients (when supported — see `getCapabilities().supportsTargetedCharacteristicUpdate`). From 84a5437dda198b7697057e657bc5b4cb884d1bbc Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 12:44:44 +0200 Subject: [PATCH 05/13] docs: use commas as legend separators --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 02a78327..a64f7b4d 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | getMaximumNotifyLength | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | | event streams\*\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | -**Legend:** ✔️ supported · — not supported · ⏺ partial support (see notes) · 🚧 work in progress. +**Legend:** ✔️ supported, — not supported, ⏺ partial support (see notes), 🚧 work in progress. \* `getAvailabilityState` returns a snapshot. Listen to `advertisingStateStream`, `connectionStateStream`, and related streams for runtime updates. \*\* Pass `deviceId` to notify one client; omit it to notify all subscribed clients (when supported — see `getCapabilities().supportsTargetedCharacteristicUpdate`). From 7f1a68466380502cd6b85764eb80a601271adc46 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 12:55:42 +0200 Subject: [PATCH 06/13] docs: refine pairing support notes in the API tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Footnote ¹: say pair() "reads or writes" an encrypted characteristic, and reword the Web caveat to key off the browser host OS instead of the duplicated "on Web … on Web/Windows and Web/Linux" phrasing. - isPaired: mark iOS/macOS/Web ⏺ (needs a pairingCommand, returns null without one) and note it in footnote ³, consistent with pair/unpair. - Give each footnote its own paragraph so GitHub does not collapse them into a single wrapped blob. - Move the legend above the client table so ⏺/— are defined before first use, and drop the duplicate below the peripheral table. --- README.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a64f7b4d..7c6236f3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d ## API Support +**Legend:** ✔️ supported, — not supported, ⏺ partial support (see notes), 🚧 work in progress. + ### Client Mode (`UniversalBle`) | | Android | iOS | macOS | Windows | Linux | Web | @@ -54,7 +56,7 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | pair¹ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | | unpair² | ✔️ | ⏺ | — | ✔️ | ✔️ | — | -| isPaired³ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| isPaired³ | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | | onPairingStateChange⁴ | ✔️ | — | — | ✔️ | ✔️ | — | | getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | | enable/disable Bluetooth | ✔️ | — | — | ✔️ | ✔️ | — | @@ -65,9 +67,12 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | readRssi | ✔️ | ✔️ | ✔️ | — | 🚧 | — | | requestPermissions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -¹ On iOS, macOS, and Web there is no dedicated pairing API: `pair()` connects and reads an encrypted characteristic so the OS triggers pairing (pass a `pairingCommand` to target a specific one). This works for all pairing methods on iOS/macOS; on Web it cannot trigger ConfirmOnly ("Just Works") pairing on Web/Windows and Web/Linux — hence ⏺. +¹ On iOS, macOS, and Web there is no dedicated pairing API: `pair()` connects and reads or writes an encrypted characteristic so the OS triggers pairing (pass a `pairingCommand` to target a specific one). This works for all pairing methods on iOS/macOS. On Web the outcome depends on the browser's host OS: ConfirmOnly ("Just Works") pairing can be triggered when the browser runs on macOS or Android, but not on Windows or Linux — hence the conservative ⏺. + ² On iOS, `unpair` requires iOS 18+ and only removes accessories authorized through AccessorySetupKit (`requestDevice`); other devices fail with `deviceNotFound` — hence ⏺. -³ On iOS, macOS, and Web, `isPaired` needs a `pairingCommand` with an encrypted read or write characteristic and returns `null` without one. It also triggers pairing if the device is not already paired. + +³ On iOS, macOS, and Web, `isPaired` needs a `pairingCommand` with an encrypted read or write characteristic and returns `null` without one — hence ⏺. It also triggers pairing if the device is not already paired. + ⁴ iOS, macOS, and Web do not report pairing-state changes — `pairingStateStream` and `onPairingStateChange` never emit there. Read the current state with `isPaired(pairingCommand:)` instead. ### Peripheral Mode (`UniversalBlePeripheral`) @@ -88,10 +93,10 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | getMaximumNotifyLength | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | | event streams\*\*\* | ✔️ | ✔️ | ✔️ | ✔️ | 🚧 | — | -**Legend:** ✔️ supported, — not supported, ⏺ partial support (see notes), 🚧 work in progress. - \* `getAvailabilityState` returns a snapshot. Listen to `advertisingStateStream`, `connectionStateStream`, and related streams for runtime updates. + \*\* Pass `deviceId` to notify one client; omit it to notify all subscribed clients (when supported — see `getCapabilities().supportsTargetedCharacteristicUpdate`). + \*\*\* `advertisingStateStream`, `characteristicSubscriptionStream`, `connectionStateStream`, `serviceAddedStream`, `mtuChangedStream`, `subscribedCentralsStream`. ## Getting Started From ad7ea1102de18a42467d1c81b8daeab034795cdc Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 12:55:42 +0200 Subject: [PATCH 07/13] docs: document updateCallbackValue pairing-state guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note on _connectAndExecuteBleCommand that updateCallbackValue is false at every call site, so nothing emits pairing state from there on any platform (including iOS/macOS/Web, which have no native pairing-state callback), and that enabling it on those platforms would require flipping the README's onPairingStateChange row back to ⏺. --- lib/src/universal_ble.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/src/universal_ble.dart b/lib/src/universal_ble.dart index 702e443c..08ab297d 100644 --- a/lib/src/universal_ble.dart +++ b/lib/src/universal_ble.dart @@ -998,6 +998,17 @@ class UniversalBle { ); } + /// Connects (if needed), discovers services and runs a pairing probe: either + /// [bleCommand] on an encrypted characteristic, or a read of every readable + /// characteristic when none is given. + /// + /// [updateCallbackValue] makes a successful command report a pairing state + /// change via [UniversalBlePlatform.updatePairingState]. It is currently `false` + /// at every call site, so no platform emits pairing state from here — including + /// iOS/macOS/Web, which have no native pairing-state callback at all. Passing + /// `true` on those platforms would start emitting `pairingStateStream` / + /// `onPairingStateChange`, so the README's `onPairingStateChange` row (currently + /// `—` for iOS/macOS/Web) would have to become `⏺`. static Future _connectAndExecuteBleCommand( String deviceId, BleCommand? bleCommand, { @@ -1041,6 +1052,8 @@ class UniversalBle { timeout: timeout, queueId: queueId, ); + // See [updateCallbackValue]: no caller opts in today, so nothing emits pairing + // state from here on any platform. if (updateCallbackValue) _platform.updatePairingState(deviceId, true); } From 951b109fb3af0195d0db2311fc1425aaf59fdf3c Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 13:06:08 +0200 Subject: [PATCH 08/13] fix: emit pairing-state changes from pair() on Apple/Web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple and Web have no native bond API, so onPairingStateChange / pairingStateStream were never wired there even though pair() drives pairing by executing an encrypted characteristic. The 0.12.0 changelog claimed support, but updateCallbackValue was false at every call site. pair() now reports the probe outcome: updatePairingState(true) on success and updatePairingFailure(false) when it throws. isPaired() keeps running the same probe as a query without emitting. - Wire updateCallbackValue: true from pair()'s Apple/Web branch, and make _connectAndExecuteBleCommand report success for both the pairingCommand and the read-all probes. - Add test/apple_web_pairing_state_test.dart covering success, failure, the no-command read-all path, and that isPaired() stays silent. - README: onPairingStateChange is ⏺ on iOS/macOS/Web again; rewrite footnote ⁴ and note the behaviour in the Pairing state section. - CHANGELOG: document the fix and the incorrect 0.12.0 claim. --- CHANGELOG.md | 1 + README.md | 6 +- lib/src/universal_ble.dart | 56 ++++++----- test/apple_web_pairing_state_test.dart | 127 +++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 25 deletions(-) create mode 100644 test/apple_web_pairing_state_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 97052695..e821fe42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * **Breaking:** `onValueChange` now receives a single `BleCharacteristicValue` (`deviceId`, `characteristicId`, `value`, `timestamp`) instead of four positional arguments. * **Breaking:** Rename `BleConnectionParametersUpdated` to `BleConnectionParametersChange`, aligning the payload with the `…Change` event naming. The `onConnectionParametersChange` callback and the model's fields are unchanged. * **Breaking:** On iOS 18+ `unpair` now removes an accessory authorized through AccessorySetupKit (it previously threw `notSupported` on iOS). Devices not managed by AccessorySetupKit fail with `deviceNotFound`. +* iOS/macOS/Web: `onPairingStateChange` / `pairingStateStream` now fire. These platforms have no native bond API, so `pair()` drives them from its encrypted-characteristic probe: `true` when the probe succeeds, a failure event (`false`) when it throws. The 0.12.0 changelog claimed this support, but the callback was never actually wired on those platforms until now. * `requestMtu` now discovers the device's services on demand on iOS/macOS and Linux, instead of returning a misleading MTU (Apple reported the default ATT MTU, 23, as if it were the negotiated value) or failing with an unclear `operationNotSupported` (Linux). It is a no-op when the services were already discovered, so calling `discoverServices` first still works and is not required, and concurrent calls (including one racing a `discoverServices`) share a single discovery. `timeout` bounds each step, so a call that must discover first can take up to roughly twice `timeout`. Android and Windows read the MTU off the connection itself and are unchanged. Add `BleCapabilities.mtuRequiresServiceDiscovery`. * Add `requestDevice`, a single entry point for the platform's native device picker: the browser's Bluetooth chooser on Web and the iOS 18+ AccessorySetupKit picker on Apple (configured via `PickerPlatformConfig.apple`), returning the selected `BleDevice`. Connect it with the existing `connect(device.deviceId)`. `AppleAccessorySetupOptions` holds a list of `AppleAccessoryPickerItem`s, so the picker can offer several accessories. Platforms without a native picker throw `notSupported`. * Windows: enable extended BLE advertisement scanning (`AllowExtendedAdvertisements`), with graceful fallback on unsupported OS versions or adapters. diff --git a/README.md b/README.md index 7c6236f3..bf3d51d0 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | pair¹ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | | unpair² | ✔️ | ⏺ | — | ✔️ | ✔️ | — | | isPaired³ | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | -| onPairingStateChange⁴ | ✔️ | — | — | ✔️ | ✔️ | — | +| onPairingStateChange⁴ | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | | getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | | enable/disable Bluetooth | ✔️ | — | — | ✔️ | ✔️ | — | | onAvailabilityChange | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | @@ -73,7 +73,7 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d ³ On iOS, macOS, and Web, `isPaired` needs a `pairingCommand` with an encrypted read or write characteristic and returns `null` without one — hence ⏺. It also triggers pairing if the device is not already paired. -⁴ iOS, macOS, and Web do not report pairing-state changes — `pairingStateStream` and `onPairingStateChange` never emit there. Read the current state with `isPaired(pairingCommand:)` instead. +⁴ On iOS, macOS, and Web, `pair()` reports the outcome it drove: `true` when the encrypted-characteristic probe succeeds and `false` on failure. There is no system-initiated bond event and `unpair` does not report, so other changes stay invisible — read the current state with `isPaired(pairingCommand:)`. ### Peripheral Mode (`UniversalBlePeripheral`) @@ -637,6 +637,8 @@ bleDevice.pairingStateStream.listen((bool paired) { }); ``` +On `Apple` and `Web`, events are driven by `pair()`: `true` when it successfully triggers pairing and `false` on failure. Those platforms have no system-initiated bond event, and `unpair` does not report, so read the current state with `isPaired(pairingCommand:)` after those operations. + #### Unpair ```dart diff --git a/lib/src/universal_ble.dart b/lib/src/universal_ble.dart index 08ab297d..9476507c 100644 --- a/lib/src/universal_ble.dart +++ b/lib/src/universal_ble.dart @@ -745,6 +745,10 @@ class UniversalBle { /// /// On `Web/Windows` and `Web/Linux`, it does not work for devices that use `ConfirmOnly` pairing. /// Can throw `PairingException`, `ConnectionException` or `PlatformException`. + /// + /// On `Apple` and `Web` the outcome is also reported through [pairingStateStream] / + /// [onPairingStateChange]: `true` when the encrypted-characteristic probe succeeds, + /// `false` when it fails. static Future pair( String deviceId, { BleCommand? pairingCommand, @@ -763,12 +767,20 @@ class UniversalBle { if (pairingCommand == null) { UniversalLogger.logWarning("PairingCommand required to get result"); } - await _connectAndExecuteBleCommand( - deviceId, - pairingCommand, - timeout: timeout, - queueId: queueId, - ); + try { + await _connectAndExecuteBleCommand( + deviceId, + pairingCommand, + updateCallbackValue: true, + timeout: timeout, + queueId: queueId, + ); + } catch (_) { + // A failed bond is an event, not a state: report it before surfacing the + // error, matching the native platforms' onPairStateChange(isFailure: true). + _platform.updatePairingFailure(deviceId); + rethrow; + } } } @@ -1002,13 +1014,11 @@ class UniversalBle { /// [bleCommand] on an encrypted characteristic, or a read of every readable /// characteristic when none is given. /// - /// [updateCallbackValue] makes a successful command report a pairing state - /// change via [UniversalBlePlatform.updatePairingState]. It is currently `false` - /// at every call site, so no platform emits pairing state from here — including - /// iOS/macOS/Web, which have no native pairing-state callback at all. Passing - /// `true` on those platforms would start emitting `pairingStateStream` / - /// `onPairingStateChange`, so the README's `onPairingStateChange` row (currently - /// `—` for iOS/macOS/Web) would have to become `⏺`. + /// [updateCallbackValue] reports a successful probe as a pairing state change via + /// [UniversalBlePlatform.updatePairingState]. [pair] enables it on the platforms + /// without a system pairing API (Apple/Web), where this probe is the only signal + /// that the OS paired the device. [isPaired] runs the same probe as a query and + /// leaves it `false`, so checking the state does not emit. static Future _connectAndExecuteBleCommand( String deviceId, BleCommand? bleCommand, { @@ -1042,18 +1052,18 @@ class UniversalBle { timeout: timeout, queueId: queueId, ); - return; + } else { + await _executeBleCommand( + deviceId, + services, + bleCommand, + timeout: timeout, + queueId: queueId, + ); } - await _executeBleCommand( - deviceId, - services, - bleCommand, - timeout: timeout, - queueId: queueId, - ); - // See [updateCallbackValue]: no caller opts in today, so nothing emits pairing - // state from here on any platform. + // The probe completed, so the OS paired the device. Apple/Web have no native + // bond-state API, which is why this is the only pairing-state signal there. if (updateCallbackValue) _platform.updatePairingState(deviceId, true); } diff --git a/test/apple_web_pairing_state_test.dart b/test/apple_web_pairing_state_test.dart new file mode 100644 index 00000000..df20046f --- /dev/null +++ b/test/apple_web_pairing_state_test.dart @@ -0,0 +1,127 @@ +// Apple and Web have no native bond-state API, so `pair()` drives pairing by running +// an encrypted-characteristic probe and reports the outcome through `pairingStateStream` / +// `onPairingStateChange`. `isPaired()` runs the same probe as a query and must not emit. +@TestOn('vm') +library; + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import 'universal_ble_test_mock.dart'; + +class _EncryptedProbePlatform extends UniversalBlePlatformMock { + _EncryptedProbePlatform({this.readError}); + + final Object? readError; + + @override + Future getConnectionState(String deviceId) async => + BleConnectionState.connected; + + @override + Future> discoverServices( + String deviceId, + bool withDescriptors, { + DiscoverServicesPlatformConfig? platformConfig, + }) async => + [ + BleService('180a', [ + BleCharacteristic('2a00', [CharacteristicProperty.read], const []), + ]), + ]; + + @override + Future readValue( + String deviceId, + String service, + String characteristic, { + Duration? timeout, + }) async { + if (readError != null) throw readError!; + return Uint8List.fromList([1]); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const deviceId = 'AA:BB:CC:DD:EE:FF'; + final encryptedCharacteristic = BleCommand( + service: '180a', + characteristic: '2a00', + ); + final originalHasSystemPairingApi = BleCapabilities.hasSystemPairingApi; + + setUp(() { + // Simulate Apple/Web: no system pairing API, so UniversalBle.pair() takes the + // encrypted-characteristic path instead of calling _platform.pair(). + BleCapabilities.hasSystemPairingApi = false; + }); + + tearDown(() { + BleCapabilities.hasSystemPairingApi = originalHasSystemPairingApi; + UniversalBle.clearQueue(); + }); + + test('pair() reports isPaired=true when the encrypted probe succeeds', + () async { + UniversalBle.setInstance(_EncryptedProbePlatform()); + final events = []; + final sub = UniversalBle.pairingStateStream(deviceId).listen(events.add); + + await UniversalBle.pair(deviceId, pairingCommand: encryptedCharacteristic); + await pumpEventQueue(); + await sub.cancel(); + + expect(events, [true]); + }); + + test('pair() without a pairingCommand reports success via the read-all probe', + () async { + UniversalBle.setInstance(_EncryptedProbePlatform()); + final events = []; + final sub = UniversalBle.pairingStateStream(deviceId).listen(events.add); + + await UniversalBle.pair(deviceId); + await pumpEventQueue(); + await sub.cancel(); + + expect(events, [true]); + }); + + test('pair() reports a failure event when the probe throws', () async { + UniversalBle.setInstance( + _EncryptedProbePlatform(readError: Exception('encrypted read failed')), + ); + final events = []; + final sub = UniversalBle.pairingStateStream(deviceId).listen(events.add); + + await expectLater( + UniversalBle.pair(deviceId, pairingCommand: encryptedCharacteristic), + throwsA(isA()), + ); + await pumpEventQueue(); + await sub.cancel(); + + expect(events, [false]); + }); + + test('isPaired() runs the probe but does not emit a pairing state change', + () async { + UniversalBle.setInstance(_EncryptedProbePlatform()); + final events = []; + final sub = UniversalBle.pairingStateStream(deviceId).listen(events.add); + + expect( + await UniversalBle.isPaired(deviceId, + pairingCommand: encryptedCharacteristic), + isTrue, + ); + await pumpEventQueue(); + await sub.cancel(); + + expect(events, isEmpty); + }); +} From 0644ab5504b886053f580cd0fdaf90a3a73b50a9 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 13:12:34 +0200 Subject: [PATCH 09/13] fix: report Apple/Web pairing probe failure only on real read failure The no-command pair() path emitted isPaired=true unconditionally once the device exposed a readable characteristic, because _attemptPairingReadingAll swallowed every read error. It now returns whether any read succeeded and the probe reports failure (false) when none could be read. Scope the pairing-failure event to the probe step so connection/discovery failures no longer surface as pairing state changes, and add tests for the failing read-all and connection-failure paths. --- CHANGELOG.md | 2 +- lib/src/universal_ble.dart | 113 +++++++++++++++---------- test/apple_web_pairing_state_test.dart | 50 +++++++++++ 3 files changed, 117 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e821fe42..2284e018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ * **Breaking:** `onValueChange` now receives a single `BleCharacteristicValue` (`deviceId`, `characteristicId`, `value`, `timestamp`) instead of four positional arguments. * **Breaking:** Rename `BleConnectionParametersUpdated` to `BleConnectionParametersChange`, aligning the payload with the `…Change` event naming. The `onConnectionParametersChange` callback and the model's fields are unchanged. * **Breaking:** On iOS 18+ `unpair` now removes an accessory authorized through AccessorySetupKit (it previously threw `notSupported` on iOS). Devices not managed by AccessorySetupKit fail with `deviceNotFound`. -* iOS/macOS/Web: `onPairingStateChange` / `pairingStateStream` now fire. These platforms have no native bond API, so `pair()` drives them from its encrypted-characteristic probe: `true` when the probe succeeds, a failure event (`false`) when it throws. The 0.12.0 changelog claimed this support, but the callback was never actually wired on those platforms until now. +* iOS/macOS/Web: `onPairingStateChange` / `pairingStateStream` now fire. These platforms have no native bond API, so `pair()` drives them from its encrypted-characteristic probe: `true` when the probe succeeds, a failure event (`false`) when it throws. Without a `pairingCommand` the probe reads every readable characteristic and now fails if none can be read, rather than resolving successfully. The 0.12.0 changelog claimed this support, but the callback was never actually wired on those platforms until now. * `requestMtu` now discovers the device's services on demand on iOS/macOS and Linux, instead of returning a misleading MTU (Apple reported the default ATT MTU, 23, as if it were the negotiated value) or failing with an unclear `operationNotSupported` (Linux). It is a no-op when the services were already discovered, so calling `discoverServices` first still works and is not required, and concurrent calls (including one racing a `discoverServices`) share a single discovery. `timeout` bounds each step, so a call that must discover first can take up to roughly twice `timeout`. Android and Windows read the MTU off the connection itself and are unchanged. Add `BleCapabilities.mtuRequiresServiceDiscovery`. * Add `requestDevice`, a single entry point for the platform's native device picker: the browser's Bluetooth chooser on Web and the iOS 18+ AccessorySetupKit picker on Apple (configured via `PickerPlatformConfig.apple`), returning the selected `BleDevice`. Connect it with the existing `connect(device.deviceId)`. `AppleAccessorySetupOptions` holds a list of `AppleAccessoryPickerItem`s, so the picker can offer several accessories. Platforms without a native picker throw `notSupported`. * Windows: enable extended BLE advertisement scanning (`AllowExtendedAdvertisements`), with graceful fallback on unsupported OS versions or adapters. diff --git a/lib/src/universal_ble.dart b/lib/src/universal_ble.dart index 9476507c..53b1f69d 100644 --- a/lib/src/universal_ble.dart +++ b/lib/src/universal_ble.dart @@ -740,8 +740,8 @@ class UniversalBle { /// /// On `Apple` and `Web`, it only works on devices with encrypted characteristics. /// It is advised to pass a pairingCommand with an encrypted read or write characteristic. - /// When not passing a pairingCommand, you should afterwards use [isPaired] with a pairingCommand - /// to verify the pairing state. + /// When not passing a pairingCommand, it reads every readable characteristic and fails if none + /// can be read; afterwards use [isPaired] with a pairingCommand to verify the pairing state. /// /// On `Web/Windows` and `Web/Linux`, it does not work for devices that use `ConfirmOnly` pairing. /// Can throw `PairingException`, `ConnectionException` or `PlatformException`. @@ -767,20 +767,13 @@ class UniversalBle { if (pairingCommand == null) { UniversalLogger.logWarning("PairingCommand required to get result"); } - try { - await _connectAndExecuteBleCommand( - deviceId, - pairingCommand, - updateCallbackValue: true, - timeout: timeout, - queueId: queueId, - ); - } catch (_) { - // A failed bond is an event, not a state: report it before surfacing the - // error, matching the native platforms' onPairStateChange(isFailure: true). - _platform.updatePairingFailure(deviceId); - rethrow; - } + await _connectAndExecuteBleCommand( + deviceId, + pairingCommand, + updateCallbackValue: true, + timeout: timeout, + queueId: queueId, + ); } } @@ -1014,11 +1007,15 @@ class UniversalBle { /// [bleCommand] on an encrypted characteristic, or a read of every readable /// characteristic when none is given. /// - /// [updateCallbackValue] reports a successful probe as a pairing state change via - /// [UniversalBlePlatform.updatePairingState]. [pair] enables it on the platforms - /// without a system pairing API (Apple/Web), where this probe is the only signal - /// that the OS paired the device. [isPaired] runs the same probe as a query and - /// leaves it `false`, so checking the state does not emit. + /// [updateCallbackValue] reports the probe outcome as a pairing state change: + /// [UniversalBlePlatform.updatePairingState] on success, + /// [UniversalBlePlatform.updatePairingFailure] on failure. [pair] enables it on + /// the platforms without a system pairing API (Apple/Web), where this probe is + /// the only signal that the OS paired the device. [isPaired] runs the same probe + /// as a query and leaves it `false`, so checking the state does not emit. + /// + /// Only the probe itself is reported; a failure to connect or discover services + /// happens before it and propagates without emitting a pairing state. static Future _connectAndExecuteBleCommand( String deviceId, BleCommand? bleCommand, { @@ -1044,22 +1041,32 @@ class UniversalBle { ); UniversalLogger.logInfo("Discovered services: ${services.length}"); - if (bleCommand == null) { - // Just attempt pairing - await _attemptPairingReadingAll( - deviceId, - services, - timeout: timeout, - queueId: queueId, - ); - } else { - await _executeBleCommand( - deviceId, - services, - bleCommand, - timeout: timeout, - queueId: queueId, - ); + try { + if (bleCommand == null) { + // Just attempt pairing + final probeSucceeded = await _attemptPairingReadingAll( + deviceId, + services, + timeout: timeout, + queueId: queueId, + ); + if (!probeSucceeded) { + throw PairingException("No readable characteristic could be read"); + } + } else { + await _executeBleCommand( + deviceId, + services, + bleCommand, + timeout: timeout, + queueId: queueId, + ); + } + } catch (_) { + // A failed bond is an event, not a state: report it before surfacing the + // error, matching the native platforms' onPairStateChange(isFailure: true). + if (updateCallbackValue) _platform.updatePairingFailure(deviceId); + rethrow; } // The probe completed, so the OS paired the device. Apple/Web have no native @@ -1067,20 +1074,29 @@ class UniversalBle { if (updateCallbackValue) _platform.updatePairingState(deviceId, true); } - // Fire and forget, and do not rely on result - static Future _attemptPairingReadingAll( + /// Reads every readable characteristic to trigger pairing, since Apple/Web pair + /// the device when an encrypted characteristic is accessed. + /// + /// Returns `true` when at least one read succeeded and `false` when reads were + /// attempted but all failed, so a probe can be reported as a pairing outcome. + /// Individual read failures are ignored, so one unreachable characteristic does + /// not stop the others from being tried. Throws [PairingException] when the + /// device exposes no readable characteristic to probe. + static Future _attemptPairingReadingAll( String deviceId, List services, { Duration? timeout, String? queueId, }) async { bool containsReadCharacteristics = false; - try { - // If BleCommand not given, fallback to reading all characteristics - for (BleService service in services) { - for (BleCharacteristic characteristic in service.characteristics) { - if (characteristic.properties.contains(CharacteristicProperty.read)) { - containsReadCharacteristics = true; + bool readSucceeded = false; + + // If BleCommand not given, fallback to reading all characteristics + for (BleService service in services) { + for (BleCharacteristic characteristic in service.characteristics) { + if (characteristic.properties.contains(CharacteristicProperty.read)) { + containsReadCharacteristics = true; + try { await read( deviceId, service.uuid, @@ -1088,14 +1104,17 @@ class UniversalBle { timeout: timeout ?? const Duration(seconds: 30), queueId: queueId, ); - } + readSucceeded = true; + } catch (_) {} } } - } catch (_) {} + } if (!containsReadCharacteristics) { throw PairingException("No readable characteristic found"); } + + return readSucceeded; } static Future _executeBleCommand( diff --git a/test/apple_web_pairing_state_test.dart b/test/apple_web_pairing_state_test.dart index df20046f..ad0c1493 100644 --- a/test/apple_web_pairing_state_test.dart +++ b/test/apple_web_pairing_state_test.dart @@ -44,6 +44,22 @@ class _EncryptedProbePlatform extends UniversalBlePlatformMock { } } +class _ConnectFailurePlatform extends UniversalBlePlatformMock { + @override + Future getConnectionState(String deviceId) async => + BleConnectionState.disconnected; + + @override + Future connect( + String deviceId, { + bool autoConnect = false, + Duration? connectionTimeout, + ConnectionPlatformConfig? platformConfig, + }) async { + throw ConnectionException('connect failed'); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -91,6 +107,24 @@ void main() { expect(events, [true]); }); + test('pair() without a pairingCommand reports a failure event when all reads fail', + () async { + UniversalBle.setInstance( + _EncryptedProbePlatform(readError: Exception('encrypted read failed')), + ); + final events = []; + final sub = UniversalBle.pairingStateStream(deviceId).listen(events.add); + + await expectLater( + UniversalBle.pair(deviceId), + throwsA(isA()), + ); + await pumpEventQueue(); + await sub.cancel(); + + expect(events, [false]); + }); + test('pair() reports a failure event when the probe throws', () async { UniversalBle.setInstance( _EncryptedProbePlatform(readError: Exception('encrypted read failed')), @@ -108,6 +142,22 @@ void main() { expect(events, [false]); }); + test('pair() connection failure does not emit a pairing state change', + () async { + UniversalBle.setInstance(_ConnectFailurePlatform()); + final events = []; + final sub = UniversalBle.pairingStateStream(deviceId).listen(events.add); + + await expectLater( + UniversalBle.pair(deviceId, pairingCommand: encryptedCharacteristic), + throwsA(isA()), + ); + await pumpEventQueue(); + await sub.cancel(); + + expect(events, isEmpty); + }); + test('isPaired() runs the probe but does not emit a pairing state change', () async { UniversalBle.setInstance(_EncryptedProbePlatform()); From c6975d45b523fc8d93b5017d9e21202e6494dc91 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 13:14:32 +0200 Subject: [PATCH 10/13] =?UTF-8?q?docs:=20drop=20redundant=20'hence=20?= =?UTF-8?q?=E2=8F=BA'=20notes=20from=20API=20support=20footnotes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bf3d51d0..33b4acdf 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,11 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | readRssi | ✔️ | ✔️ | ✔️ | — | 🚧 | — | | requestPermissions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -¹ On iOS, macOS, and Web there is no dedicated pairing API: `pair()` connects and reads or writes an encrypted characteristic so the OS triggers pairing (pass a `pairingCommand` to target a specific one). This works for all pairing methods on iOS/macOS. On Web the outcome depends on the browser's host OS: ConfirmOnly ("Just Works") pairing can be triggered when the browser runs on macOS or Android, but not on Windows or Linux — hence the conservative ⏺. +¹ On iOS, macOS, and Web there is no dedicated pairing API: `pair()` connects and reads or writes an encrypted characteristic so the OS triggers pairing (pass a `pairingCommand` to target a specific one). This works for all pairing methods on iOS/macOS. On Web the outcome depends on the browser's host OS: ConfirmOnly ("Just Works") pairing can be triggered when the browser runs on macOS or Android, but not on Windows or Linux. -² On iOS, `unpair` requires iOS 18+ and only removes accessories authorized through AccessorySetupKit (`requestDevice`); other devices fail with `deviceNotFound` — hence ⏺. +² On iOS, `unpair` requires iOS 18+ and only removes accessories authorized through AccessorySetupKit (`requestDevice`); other devices fail with `deviceNotFound`. -³ On iOS, macOS, and Web, `isPaired` needs a `pairingCommand` with an encrypted read or write characteristic and returns `null` without one — hence ⏺. It also triggers pairing if the device is not already paired. +³ On iOS, macOS, and Web, `isPaired` needs a `pairingCommand` with an encrypted read or write characteristic and returns `null` without one. It also triggers pairing if the device is not already paired. ⁴ On iOS, macOS, and Web, `pair()` reports the outcome it drove: `true` when the encrypted-characteristic probe succeeds and `false` on failure. There is no system-initiated bond event and `unpair` does not report, so other changes stay invisible — read the current state with `isPaired(pairingCommand:)`. From 304ecb6c65b314633c8f14f0e031f13a19986489 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 13:18:28 +0200 Subject: [PATCH 11/13] docs: align isPaired with pair on Apple and avoid 'bond' in README isPaired relies on the same encrypted-characteristic probe as pair, so mark it supported (checkmark) on iOS/macOS to match pair, keeping the Web column at partial. Also replace the word 'bond' in the pairing notes with 'pairing'. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 33b4acdf..809a20a2 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | pair¹ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | | unpair² | ✔️ | ⏺ | — | ✔️ | ✔️ | — | -| isPaired³ | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | +| isPaired³ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | | onPairingStateChange⁴ | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | | getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | | enable/disable Bluetooth | ✔️ | — | — | ✔️ | ✔️ | — | @@ -73,7 +73,7 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d ³ On iOS, macOS, and Web, `isPaired` needs a `pairingCommand` with an encrypted read or write characteristic and returns `null` without one. It also triggers pairing if the device is not already paired. -⁴ On iOS, macOS, and Web, `pair()` reports the outcome it drove: `true` when the encrypted-characteristic probe succeeds and `false` on failure. There is no system-initiated bond event and `unpair` does not report, so other changes stay invisible — read the current state with `isPaired(pairingCommand:)`. +⁴ On iOS, macOS, and Web, `pair()` reports the outcome it drove: `true` when the encrypted-characteristic probe succeeds and `false` on failure. There is no system-initiated pairing event and `unpair` does not report, so other changes stay invisible — read the current state with `isPaired(pairingCommand:)`. ### Peripheral Mode (`UniversalBlePeripheral`) @@ -637,7 +637,7 @@ bleDevice.pairingStateStream.listen((bool paired) { }); ``` -On `Apple` and `Web`, events are driven by `pair()`: `true` when it successfully triggers pairing and `false` on failure. Those platforms have no system-initiated bond event, and `unpair` does not report, so read the current state with `isPaired(pairingCommand:)` after those operations. +On `Apple` and `Web`, events are driven by `pair()`: `true` when it successfully triggers pairing and `false` on failure. Those platforms have no system-initiated pairing event, and `unpair` does not report, so read the current state with `isPaired(pairingCommand:)` after those operations. #### Unpair From 46c5b5fe2c099895daf8d78484c57ed5cc386426 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 13:23:13 +0200 Subject: [PATCH 12/13] docs: mark isPaired as fully supported on Web The Web probe reads an already-paired device's encrypted characteristic on any host OS, so the query returns a correct answer regardless of whether the browser can trigger ConfirmOnly pairing. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 809a20a2..fcd7ee08 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | pair¹ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | | unpair² | ✔️ | ⏺ | — | ✔️ | ✔️ | — | -| isPaired³ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | +| isPaired³ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | onPairingStateChange⁴ | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | | getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | | enable/disable Bluetooth | ✔️ | — | — | ✔️ | ✔️ | — | From 7fd6181402bf801675a3ea7459bc770d77b7b8fa Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Wed, 14 Oct 2026 14:19:10 +0200 Subject: [PATCH 13/13] docs: move API support indicators onto circles and trim footnotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Put the superscript indicators next to the ⏺ circle instead of the API name, and only circle rows carry one (isPaired is now all-checkmark). Drop the onPairingStateChange and pair notes that repeated the Pairing section, and shorten the 'Pairing state changes' prose to point at the note. --- README.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fcd7ee08..efc0e3f4 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,10 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | read | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | write | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| pair¹ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | -| unpair² | ✔️ | ⏺ | — | ✔️ | ✔️ | — | -| isPaired³ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| onPairingStateChange⁴ | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | +| pair | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺¹ | +| unpair | ✔️ | ⏺² | — | ✔️ | ✔️ | — | +| isPaired | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| onPairingStateChange | ✔️ | ⏺³ | ⏺³ | ✔️ | ✔️ | ⏺³ | | getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | | enable/disable Bluetooth | ✔️ | — | — | ✔️ | ✔️ | — | | onAvailabilityChange | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | @@ -67,13 +67,11 @@ Looking for Bluetooth Classic? Check [universal_bluetooth_classic](https://pub.d | readRssi | ✔️ | ✔️ | ✔️ | — | 🚧 | — | | requestPermissions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -¹ On iOS, macOS, and Web there is no dedicated pairing API: `pair()` connects and reads or writes an encrypted characteristic so the OS triggers pairing (pass a `pairingCommand` to target a specific one). This works for all pairing methods on iOS/macOS. On Web the outcome depends on the browser's host OS: ConfirmOnly ("Just Works") pairing can be triggered when the browser runs on macOS or Android, but not on Windows or Linux. +¹ On Web, `pair()` depends on the browser's host OS: ConfirmOnly ("Just Works") pairing can be triggered when the browser runs on macOS or Android, but not on Windows or Linux. -² On iOS, `unpair` requires iOS 18+ and only removes accessories authorized through AccessorySetupKit (`requestDevice`); other devices fail with `deviceNotFound`. +² On iOS, `unpair` only works for accessories authorized through AccessorySetupKit; other devices fail with `deviceNotFound`. -³ On iOS, macOS, and Web, `isPaired` needs a `pairingCommand` with an encrypted read or write characteristic and returns `null` without one. It also triggers pairing if the device is not already paired. - -⁴ On iOS, macOS, and Web, `pair()` reports the outcome it drove: `true` when the encrypted-characteristic probe succeeds and `false` on failure. There is no system-initiated pairing event and `unpair` does not report, so other changes stay invisible — read the current state with `isPaired(pairingCommand:)`. +³ On iOS, macOS, and Web, events are driven by `pair()`: `true` when the encrypted-characteristic probe succeeds and `false` on failure. There is no system-initiated event and `unpair` does not report, so read the current state with `isPaired(pairingCommand:)`. ### Peripheral Mode (`UniversalBlePeripheral`) @@ -637,7 +635,7 @@ bleDevice.pairingStateStream.listen((bool paired) { }); ``` -On `Apple` and `Web`, events are driven by `pair()`: `true` when it successfully triggers pairing and `false` on failure. Those platforms have no system-initiated pairing event, and `unpair` does not report, so read the current state with `isPaired(pairingCommand:)` after those operations. +On `Apple` and `Web`, see note ³ in [API Support](#api-support). #### Unpair