diff --git a/CHANGELOG.md b/CHANGELOG.md index 97052695..2284e018 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. 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/README.md b/README.md index ac0b732d..efc0e3f4 100644 --- a/README.md +++ b/README.md @@ -40,51 +40,61 @@ 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 | | :---------------------------- | :-----: | :-: | :---: | :-----: | :---: | :-: | | startScan/stopScan | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | connect/disconnect | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| autoConnect | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | -| getSystemDevices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | +| autoConnect | ✔️ | ✔️ | ✔️ | — | — | — | +| getSystemDevices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | | discoverServices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | read | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | write | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| pair | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | -| unpair | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ | +| pair | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺¹ | +| unpair | ✔️ | ⏺² | — | ✔️ | ✔️ | — | | isPaired | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| onPairingStateChange | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | -| getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | -| enable/disable Bluetooth | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ | +| onPairingStateChange | ✔️ | ⏺³ | ⏺³ | ✔️ | ✔️ | ⏺³ | +| getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | +| enable/disable Bluetooth | ✔️ | — | — | ✔️ | ✔️ | — | | onAvailabilityChange | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| requestMtu | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | -| requestConnectionPriority | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | -| onConnectionParametersChange | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | -| readRssi | ✔️ | ✔️ | ✔️ | ❌ | 🚧 | ❌ | +| requestMtu | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | — | +| requestConnectionPriority | ✔️ | — | — | — | — | — | +| onConnectionParametersChange | ✔️ | — | — | — | — | — | +| readRssi | ✔️ | ✔️ | ✔️ | — | 🚧 | — | | requestPermissions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +¹ 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` only works for accessories authorized through AccessorySetupKit; other devices fail with `deviceNotFound`. + +³ 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`) | 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`). + \*\*\* `advertisingStateStream`, `characteristicSubscriptionStream`, `connectionStateStream`, `serviceAddedStream`, `mtuChangedStream`, `subscribedCentralsStream`. ## Getting Started @@ -625,6 +635,8 @@ bleDevice.pairingStateStream.listen((bool paired) { }); ``` +On `Apple` and `Web`, see note ³ in [API Support](#api-support). + #### Unpair ```dart diff --git a/lib/src/universal_ble.dart b/lib/src/universal_ble.dart index 702e443c..53b1f69d 100644 --- a/lib/src/universal_ble.dart +++ b/lib/src/universal_ble.dart @@ -740,11 +740,15 @@ 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`. + /// + /// 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, @@ -766,6 +770,7 @@ class UniversalBle { await _connectAndExecuteBleCommand( deviceId, pairingCommand, + updateCallbackValue: true, timeout: timeout, queueId: queueId, ); @@ -998,6 +1003,19 @@ 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] 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, { @@ -1023,41 +1041,62 @@ class UniversalBle { ); UniversalLogger.logInfo("Discovered services: ${services.length}"); - if (bleCommand == null) { - // Just attempt pairing - await _attemptPairingReadingAll( - deviceId, - services, - timeout: timeout, - queueId: queueId, - ); - return; + 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; } - await _executeBleCommand( - deviceId, - services, - bleCommand, - timeout: timeout, - queueId: queueId, - ); + // 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); } - // 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, @@ -1065,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 new file mode 100644 index 00000000..ad0c1493 --- /dev/null +++ b/test/apple_web_pairing_state_test.dart @@ -0,0 +1,177 @@ +// 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]); + } +} + +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(); + + 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() 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')), + ); + 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('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()); + 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); + }); +}