diff --git a/CHANGELOG.md b/CHANGELOG.md index 7239be07..e2a72164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## 3.0.0 * **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. +* **Breaking:** Device IDs are now emitted in lower-case on every platform (scan results, connection/value/pairing/connection-parameter callbacks and streams). Previously each platform reported its native case — Android upper-cased MACs, Windows/WinRT lower-cased them. IDs are now canonicalised to lower-case throughout the Dart layer; the native side converts back to the case it requires at its boundary (Android's `getRemoteDevice` needs upper-case). Callers that stored or compared an emitted ID by exact case (e.g. an Android upper-case MAC) must now lower-case it, or compare case-insensitively. Web is exempt: Web Bluetooth IDs are opaque, case-sensitive browser tokens rather than addresses, so they are emitted and matched verbatim. Follow-up to the case-insensitive matching in 2.1.1. +* **Breaking:** The same applies to peripheral mode — central device IDs in `UniversalBlePeripheral` streams, read/write request handlers, and `getSubscribedClients` results are emitted in lower-case, and IDs passed to `updateCharacteristicValue` / `getMaximumNotifyLength` are accepted in any case. * 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 0ac31c24..15f473f9 100644 --- a/README.md +++ b/README.md @@ -989,6 +989,12 @@ UniversalBlePeripheral.mtuChangedStream.listen( - Windows peripheral advertising does not expose all advertising payload customization options from Android/Apple stacks. - iOS/macOS setup (including required `Info.plist` keys for peripheral usage) is documented in [Permissions → iOS / macOS](#ios--macos). +## Device ID Format + +A device ID is a case-insensitive address on every native platform — a MAC on Android/Windows/Linux, a UUID on Apple — but each platform reports it in its own case (Android upper-cases MACs, Windows lower-cases them). Universal BLE canonicalises them, so **device IDs are emitted in lower-case** in scan results and in every connection, value, pairing and connection-parameter callback or stream. You may pass an ID back in any case; conversion to whatever the native side requires happens internally. + +Web is the exception: Web Bluetooth IDs are opaque, case-sensitive browser tokens rather than addresses, so they are emitted exactly as the browser reports them and must be compared exactly. + ## UUID Format Agnostic Universal BLE is agnostic to the UUID format of services and characteristics regardless of the platform the app runs on. When passing a UUID, you can pass it in any format (long/short) or character case (upper/lower case) you want. Universal BLE will take care of necessary conversions, across all platforms, so that you don't need to worry about underlying platform differences. diff --git a/lib/src/extensions/ble_device_extension.dart b/lib/src/extensions/ble_device_extension.dart index 3436bd19..9c736ea3 100644 --- a/lib/src/extensions/ble_device_extension.dart +++ b/lib/src/extensions/ble_device_extension.dart @@ -106,7 +106,10 @@ extension BleDeviceExtension on BleDevice { timeout: timeout, queueId: queueId, ); - CacheHandler.instance.saveServices(deviceId, servicesCache); + CacheHandler.instance.saveServices( + UniversalBle.canonicalDeviceId(deviceId), + servicesCache, + ); return servicesCache; } @@ -123,7 +126,10 @@ extension BleDeviceExtension on BleDevice { }) async { List discoveredServices = []; if (preferCached) { - discoveredServices = CacheHandler.instance.getServices(deviceId) ?? []; + discoveredServices = CacheHandler.instance.getServices( + UniversalBle.canonicalDeviceId(deviceId), + ) ?? + []; } if (discoveredServices.isEmpty) { discoveredServices = await discoverServices( diff --git a/lib/src/interfaces/universal_ble_peripheral_platform_interface.dart b/lib/src/interfaces/universal_ble_peripheral_platform_interface.dart index 83fc6a44..fefca3c6 100644 --- a/lib/src/interfaces/universal_ble_peripheral_platform_interface.dart +++ b/lib/src/interfaces/universal_ble_peripheral_platform_interface.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:universal_ble/src/universal_ble.g.dart'; +import 'package:universal_ble/src/utils/device_id.dart'; import 'package:universal_ble/src/utils/universal_ble_stream_controller.dart'; import 'package:universal_ble/universal_ble.dart'; @@ -11,9 +12,8 @@ abstract class UniversalBlePeripheralPlatform { _blePeripheralStreamHandler.advertisingStateStreamController.stream; Stream - get characteristicSubscriptionStream => _blePeripheralStreamHandler - .characteristicSubscriptionStreamController - .stream; + get characteristicSubscriptionStream => _blePeripheralStreamHandler + .characteristicSubscriptionStreamController.stream; Stream get connectionStateStream => _blePeripheralStreamHandler.connectionStateStreamController.stream; @@ -81,13 +81,23 @@ abstract class UniversalBlePeripheralPlatform { BlePeripheralCharacteristicSubscriptionChanged event, ) { _blePeripheralStreamHandler.characteristicSubscriptionStreamController.add( - event, + BlePeripheralCharacteristicSubscriptionChanged( + deviceId: DeviceId.address(event.deviceId).canonical, + characteristicId: event.characteristicId, + isSubscribed: event.isSubscribed, + name: event.name, + ), ); } /// Push connection state update to stream listeners. void updateConnectionState(BlePeripheralConnectionStateChanged event) { - _blePeripheralStreamHandler.connectionStateStreamController.add(event); + _blePeripheralStreamHandler.connectionStateStreamController.add( + BlePeripheralConnectionStateChanged( + DeviceId.address(event.deviceId).canonical, + event.connected, + ), + ); } /// Push service added update to stream listeners. @@ -97,7 +107,12 @@ abstract class UniversalBlePeripheralPlatform { /// Push MTU update to stream listeners. void updateMtu(BlePeripheralMtuChanged event) { - _blePeripheralStreamHandler.mtuChangedStreamController.add(event); + _blePeripheralStreamHandler.mtuChangedStreamController.add( + BlePeripheralMtuChanged( + DeviceId.address(event.deviceId).canonical, + event.mtu, + ), + ); } /// Called when this platform implementation is being replaced. @@ -216,8 +231,7 @@ class _BlePeripheralStreamHandler { UniversalBleStreamController(); final characteristicSubscriptionStreamController = UniversalBleStreamController< - BlePeripheralCharacteristicSubscriptionChanged - >(); + BlePeripheralCharacteristicSubscriptionChanged>(); final connectionStateStreamController = UniversalBleStreamController(); final serviceAddedStreamController = diff --git a/lib/src/interfaces/universal_ble_platform_interface.dart b/lib/src/interfaces/universal_ble_platform_interface.dart index 39e0f33d..6cf073d5 100644 --- a/lib/src/interfaces/universal_ble_platform_interface.dart +++ b/lib/src/interfaces/universal_ble_platform_interface.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:universal_ble/src/utils/cache_handler.dart'; +import 'package:universal_ble/src/utils/device_id.dart'; import 'package:universal_ble/src/utils/universal_ble_stream_controller.dart'; import 'package:universal_ble/src/utils/universal_logger.dart'; import 'package:universal_ble/universal_ble.dart'; @@ -143,17 +144,28 @@ abstract class UniversalBlePlatform { Stream get availabilityStream => _availabilityStreamController.stream; - // A BLE device id is a case-insensitive identifier (a MAC on Android/Windows/Linux, a UUID on Apple), but - // platforms report it in different cases — Android upper-cases MACs, Windows/WinRT lower-cases them - // (`mac_address_to_str` emits lower-case hex). So we (a) match the event streams case-insensitively, and - // (b) key all per-device state by a canonical lower-case id (see updatePairingState / - // updateConnectionParameters / CacheHandler) so a device reported in two cases can't split across map - // entries. Emitted device ids are left AS the platform reports them, so this is non-breaking for consumers. - // Hot paths short-circuit on an exact match before lower-casing. + /// Whether this platform reports device ids as Bluetooth *addresses* — a MAC on + /// Android/Windows/Linux, a UUID on Apple — which are case-insensitive and which platforms + /// report in different cases (Android upper-cases MACs, Windows/WinRT lower-cases them). Ids are + /// therefore canonicalised: the update* handlers below canonicalise on ingestion, so every + /// stream event, callback and per-device map key is canonical, and the stream filters + /// canonicalise the query so consumers may still pass an id in any case. Native calls take the + /// other form the same [DeviceId] carries; see that class for both conversions. + /// + /// Web overrides this to `false`: Web Bluetooth ids are opaque, case-sensitive browser tokens + /// rather than addresses, so they are emitted and matched verbatim. + bool get hasAddressDeviceIds => true; + + /// [deviceId] in the two forms this platform needs. Emission and matching both go through it, so + /// an override of [hasAddressDeviceIds] cannot leave the two halves disagreeing. + DeviceId _deviceId(String deviceId) => + DeviceId.of(deviceId, isAddress: hasAddressDeviceIds); + + Stream connectionStream(String deviceId) { - final target = deviceId.toLowerCase(); + final target = _deviceId(deviceId).canonical; return bleConnectionUpdateStreamController.stream - .where((e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target) + .where((e) => e.deviceId == target) .map((e) => e.isConnected); } @@ -161,25 +173,23 @@ abstract class UniversalBlePlatform { String deviceId, String characteristicId, ) { - final target = deviceId.toLowerCase(); + final target = _deviceId(deviceId).canonical; characteristicId = BleUuidParser.string(characteristicId); return _valueStreamController.stream - .where((e) { - return (e.deviceId == deviceId || e.deviceId.toLowerCase() == target) && - e.characteristicId == characteristicId; - }) + .where((e) => e.deviceId == target && e.characteristicId == characteristicId) .map((e) => e.value); } Stream pairingStateStream(String deviceId) { - final target = deviceId.toLowerCase(); + final target = _deviceId(deviceId).canonical; return _pairStateStreamController.stream - .where((e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target) + .where((e) => e.deviceId == target) .map((e) => e.isPaired); } /// Update Handlers void updateScanResult(BleDevice bleDevice) { + bleDevice.deviceId = _deviceId(bleDevice.deviceId).canonical; _scanStreamController.add(bleDevice); try { @@ -188,6 +198,7 @@ abstract class UniversalBlePlatform { } void updateConnection(String deviceId, bool isConnected, [String? error]) { + deviceId = _deviceId(deviceId).canonical; bleConnectionUpdateStreamController.add(( deviceId: deviceId, isConnected: isConnected, @@ -199,10 +210,9 @@ abstract class UniversalBlePlatform { } catch (_) {} if (!isConnected) { - // Clear per-device state by the canonical id so cleanup can't miss an entry stored under another case - // (CacheHandler normalizes internally). + // Clear per-device state (all keyed by the canonical id). CacheHandler.instance.resetDeviceCache(deviceId); - _lastConnectionParametersMap.remove(deviceId.toLowerCase()); + _lastConnectionParametersMap.remove(deviceId); } } @@ -212,6 +222,7 @@ abstract class UniversalBlePlatform { Uint8List value, int? timestamp, ) { + deviceId = _deviceId(deviceId).canonical; characteristicId = BleUuidParser.string(characteristicId); // StandardMessageCodec decodes typed data as a view into the complete // platform-message buffer. Normalize that view before exposing it so @@ -246,11 +257,9 @@ abstract class UniversalBlePlatform { } void updatePairingState(String deviceId, bool isPaired) { - // Key by the canonical id so the same device reported in another case doesn't create a second entry and - // slip past this dedup. The emitted deviceId keeps the platform's case. - final key = deviceId.toLowerCase(); - if (_pairStateMap[key] == isPaired) return; - _pairStateMap[key] = isPaired; + deviceId = _deviceId(deviceId).canonical; + if (_pairStateMap[deviceId] == isPaired) return; + _pairStateMap[deviceId] = isPaired; _pairStateStreamController.add((deviceId: deviceId, isPaired: isPaired)); @@ -260,10 +269,8 @@ abstract class UniversalBlePlatform { } void updateConnectionParameters(BleConnectionParametersUpdated update) { - // Key by the canonical id (dropping the now-redundant last.deviceId == update.deviceId check, which would - // itself have failed across cases and broken dedup for a device reported in two cases). - final key = update.deviceId.toLowerCase(); - final last = _lastConnectionParametersMap[key]; + update.deviceId = _deviceId(update.deviceId).canonical; + final last = _lastConnectionParametersMap[update.deviceId]; if (last != null && last.interval == update.interval && last.latency == update.latency && @@ -271,7 +278,7 @@ abstract class UniversalBlePlatform { last.status == update.status) { return; } - _lastConnectionParametersMap[key] = update; + _lastConnectionParametersMap[update.deviceId] = update; try { onConnectionParametersChange?.call(update); diff --git a/lib/src/universal_ble.dart b/lib/src/universal_ble.dart index 393639d8..ed9cfd7e 100644 --- a/lib/src/universal_ble.dart +++ b/lib/src/universal_ble.dart @@ -6,6 +6,7 @@ import 'package:universal_ble/src/universal_ble_pigeon/universal_ble_pigeon_chan import 'package:universal_ble/src/universal_ble_web/universal_ble_web.dart'; import 'package:universal_ble/src/utils/ble_command_queue.dart'; import 'package:universal_ble/src/utils/cache_handler.dart'; +import 'package:universal_ble/src/utils/device_id.dart'; import 'package:universal_ble/src/utils/universal_logger.dart'; import 'package:universal_ble/universal_ble.dart'; @@ -315,16 +316,31 @@ class UniversalBle { ); } + /// [deviceId] in the form the active platform emits, matches and keys per-device state by. + /// + /// The public API accepts an id in any case, so caller-supplied ids are canonicalised here — + /// at the boundary, where the platform's id kind is known — and everything downstream (the + /// service/subscription cache, stream matching) sees canonical ids only. All conversion lives + /// in [DeviceId]. + @internal + static String canonicalDeviceId(String deviceId) => + DeviceId.of(deviceId, isAddress: _platform.hasAddressDeviceIds).canonical; + /// Returns whether this app is currently subscribed to notifications/indications for [characteristic]. /// /// Subscription state is updated when [subscribeNotifications], [subscribeIndications], /// or [unsubscribe] completes, and is automatically cleared when the device disconnects. static bool isSubscribed(String deviceId, String characteristic) => - CacheHandler.instance.isSubscribed(deviceId, characteristic); + CacheHandler.instance.isSubscribed( + canonicalDeviceId(deviceId), + characteristic, + ); /// Returns the list of characteristic UUIDs currently subscribed to on [deviceId]. static List getSubscribedCharacteristics(String deviceId) => - CacheHandler.instance.getSubscribedCharacteristics(deviceId); + CacheHandler.instance.getSubscribedCharacteristics( + canonicalDeviceId(deviceId), + ); /// Read a characteristic value. /// On iOS and MacOS this command will also trigger [onValueChange] listener. @@ -731,7 +747,7 @@ class UniversalBle { Duration? timeout, }) { timeout ??= const Duration(seconds: 60); - final target = deviceId.toLowerCase(); + final target = canonicalDeviceId(deviceId); StreamSubscription? connectionSubscription; Completer completer = Completer(); @@ -748,8 +764,7 @@ class UniversalBle { connectionSubscription = _platform .bleConnectionUpdateStreamController.stream - .where( - (e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target) + .where((e) => e.deviceId == target) .listen( (e) { cancelSubscription(); @@ -794,7 +809,7 @@ class UniversalBle { queueId: queueId, ); CacheHandler.instance.updateSubscription( - deviceId, + canonicalDeviceId(deviceId), characteristic, bleInputProperty != BleInputProperty.disabled, ); diff --git a/lib/src/universal_ble_linux/universal_ble_linux.dart b/lib/src/universal_ble_linux/universal_ble_linux.dart index e0db8ee3..5bd075e5 100644 --- a/lib/src/universal_ble_linux/universal_ble_linux.dart +++ b/lib/src/universal_ble_linux/universal_ble_linux.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:bluez/bluez.dart'; import 'package:flutter/services.dart'; import 'package:universal_ble/src/models/model_exports.dart'; +import 'package:universal_ble/src/utils/device_id.dart'; import 'package:universal_ble/src/utils/universal_ble_error_parser.dart'; import 'package:universal_ble/src/utils/universal_ble_filter_util.dart'; import 'package:universal_ble/src/universal_ble.g.dart'; @@ -562,6 +563,9 @@ class UniversalBleLinux extends UniversalBlePlatform { /// Get device by id from cache or from client BlueZDevice? _getDeviceById(String deviceId) { + // Every Linux device resolution funnels through here, so this is where the id becomes the + // native form BlueZ addresses use; emitted ids and cache keys keep the canonical form. + deviceId = DeviceId.address(deviceId).native; return _devices[deviceId] ?? _client.devices.cast().firstWhere( (device) => device?.address == deviceId, diff --git a/lib/src/universal_ble_pigeon/universal_ble_peripheral_pigeon.dart b/lib/src/universal_ble_pigeon/universal_ble_peripheral_pigeon.dart index 5d256fb1..c02e6d78 100644 --- a/lib/src/universal_ble_pigeon/universal_ble_peripheral_pigeon.dart +++ b/lib/src/universal_ble_pigeon/universal_ble_peripheral_pigeon.dart @@ -2,8 +2,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:universal_ble/src/universal_ble.g.dart'; +import 'package:universal_ble/src/utils/device_id.dart'; import 'package:universal_ble/universal_ble.dart'; +// Native channel calls take the native form of the id; see DeviceId for both conversions. +String _nativeId(String deviceId) => DeviceId.address(deviceId).native; + class UniversalBlePeripheralPigeon extends UniversalBlePeripheralPlatform implements UniversalBlePeripheralCallback { static UniversalBlePeripheralPigeon? _instance; @@ -127,17 +131,20 @@ class UniversalBlePeripheralPigeon extends UniversalBlePeripheralPlatform return _channel.updateCharacteristic( BleUuidParser.string(characteristicId), value, - deviceId, + deviceId == null ? null : _nativeId(deviceId), ); } @override - Future> getSubscribedClients(String characteristicId) => - _channel.getSubscribedClients(characteristicId); + Future> getSubscribedClients(String characteristicId) async { + final clients = await _channel.getSubscribedClients(characteristicId); + // Emitted ids are lower-case (see UniversalBlePeripheralPlatform). + return clients.map((e) => DeviceId.address(e).canonical).toList(); + } @override Future getMaximumNotifyLength(String deviceId) => - _channel.getMaximumNotifyLength(deviceId); + _channel.getMaximumNotifyLength(_nativeId(deviceId)); @override void setReadRequestHandler(OnPeripheralReadRequest? handler) => @@ -204,7 +211,7 @@ class UniversalBlePeripheralPigeon extends UniversalBlePeripheralPlatform Uint8List? value, ) { final result = _readRequestHandler?.call( - deviceId, + DeviceId.address(deviceId).canonical, BleUuidParser.string(characteristicId), offset, value, @@ -232,7 +239,7 @@ class UniversalBlePeripheralPigeon extends UniversalBlePeripheralPlatform Uint8List? value, ) { final result = _writeRequestHandler?.call( - deviceId, + DeviceId.address(deviceId).canonical, BleUuidParser.string(characteristicId), offset, value, @@ -254,7 +261,7 @@ class UniversalBlePeripheralPigeon extends UniversalBlePeripheralPlatform Uint8List? value, ) { final result = _descriptorReadRequestHandler?.call( - deviceId, + DeviceId.address(deviceId).canonical, BleUuidParser.string(characteristicId), BleUuidParser.string(descriptorId), offset, @@ -277,7 +284,7 @@ class UniversalBlePeripheralPigeon extends UniversalBlePeripheralPlatform Uint8List? value, ) { final result = _descriptorWriteRequestHandler?.call( - deviceId, + DeviceId.address(deviceId).canonical, BleUuidParser.string(characteristicId), BleUuidParser.string(descriptorId), offset, diff --git a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart index 3d879a88..eaf38473 100644 --- a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart +++ b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart @@ -1,8 +1,12 @@ import 'package:flutter/foundation.dart'; import 'package:universal_ble/src/universal_ble.g.dart'; +import 'package:universal_ble/src/utils/device_id.dart'; import 'package:universal_ble/src/utils/universal_ble_filter_util.dart'; import 'package:universal_ble/universal_ble.dart'; +// Native channel calls take the native form of the id; see DeviceId for both conversions. +String _nativeId(String deviceId) => DeviceId.address(deviceId).native; + class UniversalBlePigeonChannel extends UniversalBlePlatform implements UniversalBleCallbackChannel { static UniversalBlePigeonChannel? _instance; @@ -61,7 +65,9 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform @override Future getConnectionState(String deviceId) => - _executeWithErrorHandling(() => _channel.getConnectionState(deviceId)); + _executeWithErrorHandling( + () => _channel.getConnectionState(_nativeId(deviceId)), + ); @override Future connect( @@ -72,7 +78,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform }) => _executeWithErrorHandling( () => _channel.connect( - deviceId, + _nativeId(deviceId), autoConnect: autoConnect, platformConfig: platformConfig, ), @@ -80,7 +86,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform @override Future disconnect(String deviceId) => - _executeWithErrorHandling(() => _channel.disconnect(deviceId)); + _executeWithErrorHandling(() => _channel.disconnect(_nativeId(deviceId))); @override Future> discoverServices( @@ -89,12 +95,14 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform ) async { List universalBleServices = await _executeWithErrorHandling( - () => _channel.discoverServices(deviceId, withDescriptors), + () => _channel.discoverServices(_nativeId(deviceId), withDescriptors), ); return List.from( universalBleServices .where((e) => e != null) - .map((e) => e!.toBleService(deviceId)) + .map( + (e) => e!.toBleService(DeviceId.address(deviceId).canonical), + ) // emitted id stays lower-case .toList(), ); } @@ -108,7 +116,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform ) { return _executeWithErrorHandling( () => _channel.setNotifiable( - deviceId, + _nativeId(deviceId), service, characteristic, bleInputProperty, @@ -124,7 +132,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform Duration? timeout, }) { return _executeWithErrorHandling( - () => _channel.readValue(deviceId, service, characteristic), + () => _channel.readValue(_nativeId(deviceId), service, characteristic), ); } @@ -138,7 +146,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform }) { return _executeWithErrorHandling( () => _channel.readDescriptorValue( - deviceId, + _nativeId(deviceId), service, characteristic, descriptor, @@ -156,7 +164,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform ) { return _executeWithErrorHandling( () => _channel.writeValue( - deviceId, + _nativeId(deviceId), service, characteristic, value, @@ -175,7 +183,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform ) { return _executeWithErrorHandling( () => _channel.writeDescriptorValue( - deviceId, + _nativeId(deviceId), service, characteristic, descriptor, @@ -187,12 +195,12 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform @override Future requestMtu(String deviceId, int expectedMtu) => _executeWithErrorHandling( - () => _channel.requestMtu(deviceId, expectedMtu), + () => _channel.requestMtu(_nativeId(deviceId), expectedMtu), ); @override Future readRssi(String deviceId) => - _executeWithErrorHandling(() => _channel.readRssi(deviceId)); + _executeWithErrorHandling(() => _channel.readRssi(_nativeId(deviceId))); @override Future requestConnectionPriority( @@ -200,20 +208,20 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform BleConnectionPriority priority, ) => _executeWithErrorHandling( - () => _channel.requestConnectionPriority(deviceId, priority), + () => _channel.requestConnectionPriority(_nativeId(deviceId), priority), ); @override Future isPaired(String deviceId) => - _executeWithErrorHandling(() => _channel.isPaired(deviceId)); + _executeWithErrorHandling(() => _channel.isPaired(_nativeId(deviceId))); @override Future pair(String deviceId) => - _executeWithErrorHandling(() => _channel.pair(deviceId)); + _executeWithErrorHandling(() => _channel.pair(_nativeId(deviceId))); @override Future unpair(String deviceId) => - _executeWithErrorHandling(() => _channel.unPair(deviceId)); + _executeWithErrorHandling(() => _channel.unPair(_nativeId(deviceId))); @override Future hasPermissions({bool withAndroidFineLocation = false}) => diff --git a/lib/src/universal_ble_web/universal_ble_web.dart b/lib/src/universal_ble_web/universal_ble_web.dart index b415caa7..c0e4892d 100644 --- a/lib/src/universal_ble_web/universal_ble_web.dart +++ b/lib/src/universal_ble_web/universal_ble_web.dart @@ -14,6 +14,13 @@ class UniversalBleWeb extends UniversalBlePlatform { _setupListeners(); } + /// Web Bluetooth device ids are opaque, case-sensitive browser tokens (Chromium emits Base64 of a + /// random value), not the case-insensitive addresses the other platforms report — so they are + /// emitted and matched verbatim. Case-folding one would corrupt the id the caller sees and break + /// `_bluetoothDeviceList`, which is keyed by the browser's own `BluetoothDevice.id`. + @override + bool get hasAddressDeviceIds => false; + final Map _bluetoothDeviceList = {}; final Map _deviceAdvertisementStreamList = {}; final Map _connectedDeviceStreamList = {}; diff --git a/lib/src/utils/cache_handler.dart b/lib/src/utils/cache_handler.dart index df19ac6a..0c73b7e2 100644 --- a/lib/src/utils/cache_handler.dart +++ b/lib/src/utils/cache_handler.dart @@ -1,6 +1,14 @@ import 'package:universal_ble/src/models/model_exports.dart'; -/// Manages an in-memory cache for Bluetooth devices +/// Manages an in-memory cache for Bluetooth devices. +/// +/// Every entry is keyed by a CANONICAL device id: a device id is case-insensitive on platforms +/// that report addresses (Android upper-cases MACs, Windows lower-cases them), so services saved +/// when subscribing with one case must still be found and cleared when the platform reports +/// another (e.g. on the disconnect cleanup) — otherwise stale services linger and a reconnect +/// reuses them. Canonicalising is the caller's job, at the boundary where the platform's id kind +/// is known (`UniversalBle.canonicalDeviceId`), so ids that are NOT case-insensitive — Web's +/// opaque tokens — are never folded together here. class CacheHandler { static CacheHandler? _instance; static CacheHandler get instance => _instance ??= CacheHandler._(); @@ -9,23 +17,17 @@ class CacheHandler { /// Internal cache to store discovered services for each device. final Map> _servicesCache = {}; - // A device id is a case-insensitive identifier reported in different cases by different platforms (Android - // upper-cases MACs, Windows lower-cases them). Key the cache by a canonical lower-case id so services saved - // when subscribing with one case are still found/cleared when the platform reports another (e.g. on the - // disconnect cleanup) — otherwise stale services linger and a reconnect reuses them. - static String _key(String deviceId) => deviceId.toLowerCase(); - /// Saves the discovered Bluetooth services for a specific device in the cache. void saveServices(String deviceId, List? services) { if (services == null) { - _servicesCache.remove(_key(deviceId)); + _servicesCache.remove(deviceId); } else { - _servicesCache[_key(deviceId)] = services; + _servicesCache[deviceId] = services; } } /// Retrieves the cached Bluetooth services for a specific device. - List? getServices(String deviceId) => _servicesCache[_key(deviceId)]; + List? getServices(String deviceId) => _servicesCache[deviceId]; /// Internal cache to store subscribed characteristic UUIDs for each device. final Map> _subscriptionsCache = {}; @@ -36,14 +38,13 @@ class CacheHandler { String characteristicId, bool isSubscribed, ) { - final key = _key(deviceId); final normalizedCharId = BleUuidParser.string(characteristicId); if (isSubscribed) { - (_subscriptionsCache[key] ??= {}).add(normalizedCharId); + (_subscriptionsCache[deviceId] ??= {}).add(normalizedCharId); } else { - _subscriptionsCache[key]?.remove(normalizedCharId); - if (_subscriptionsCache[key]?.isEmpty ?? false) { - _subscriptionsCache.remove(key); + _subscriptionsCache[deviceId]?.remove(normalizedCharId); + if (_subscriptionsCache[deviceId]?.isEmpty ?? false) { + _subscriptionsCache.remove(deviceId); } } } @@ -52,7 +53,7 @@ class CacheHandler { bool isSubscribed(String deviceId, String characteristicId) { try { final normalizedCharId = BleUuidParser.string(characteristicId); - return _subscriptionsCache[_key(deviceId)]?.contains(normalizedCharId) ?? + return _subscriptionsCache[deviceId]?.contains(normalizedCharId) ?? false; } catch (_) { return false; @@ -61,11 +62,11 @@ class CacheHandler { /// Retrieves the list of subscribed characteristic UUIDs for a specific device. List getSubscribedCharacteristics(String deviceId) => - _subscriptionsCache[_key(deviceId)]?.toList() ?? []; + _subscriptionsCache[deviceId]?.toList() ?? []; /// Resets the cache for a specific device, removing all stored services and subscriptions. void resetDeviceCache(String deviceId) { - _servicesCache.remove(_key(deviceId)); - _subscriptionsCache.remove(_key(deviceId)); + _servicesCache.remove(deviceId); + _subscriptionsCache.remove(deviceId); } } diff --git a/lib/src/utils/device_id.dart b/lib/src/utils/device_id.dart new file mode 100644 index 00000000..b5e81c9d --- /dev/null +++ b/lib/src/utils/device_id.dart @@ -0,0 +1,53 @@ +/// A BLE device id in the two forms universal_ble needs, and the only place the conversion +/// between them lives. +/// +/// A device id reaches us from a platform in whatever case that platform happens to report — and +/// two of its consumers want different ones: +/// +/// * The Dart layer emits, compares and keys per-device state by the [canonical] form, so a caller +/// holding an id in another case cannot split state or miss events. +/// * Native channels want the [native] form: Android's `BluetoothAdapter.getRemoteDevice` +/// REQUIRES upper case and throws otherwise, Apple's peripheral cache is keyed by the upper-case +/// `uuidString`, and Linux's BlueZ address is upper-case. Windows formats MACs lower-case but +/// parses and compares them case-insensitively, so upper case is safe there too. +/// +/// The two forms only differ for an *address* — a MAC on Android/Windows/Linux, a UUID on Apple — +/// which is case-insensitive. Web Bluetooth ids are not addresses but opaque, case-sensitive +/// browser tokens (Chromium emits Base64 of a random value), so [DeviceId.opaque] carries them +/// through untouched: folding one would corrupt the id the caller sees and break the device +/// lookup it is passed back to. +/// +/// Internal: the public API takes and returns ids as plain [String]s. +class DeviceId { + /// The form the Dart layer emits, matches and keys state by. + final String canonical; + + /// The form native channel calls take. + final String native; + + const DeviceId._(this.canonical, this.native); + + /// A case-insensitive Bluetooth address (a MAC, or a UUID on Apple). + factory DeviceId.address(String id) => + DeviceId._(id.toLowerCase(), id.toUpperCase()); + + /// An opaque, case-sensitive token — a Web Bluetooth id. + factory DeviceId.opaque(String id) => DeviceId._(id, id); + + /// [DeviceId.address] when [isAddress], else [DeviceId.opaque]. Platforms report their kind via + /// `UniversalBlePlatform.hasAddressDeviceIds`. + factory DeviceId.of(String id, {required bool isAddress}) => + isAddress ? DeviceId.address(id) : DeviceId.opaque(id); + + @override + String toString() => canonical; + + @override + bool operator ==(Object other) => + other is DeviceId && + other.canonical == canonical && + other.native == native; + + @override + int get hashCode => Object.hash(canonical, native); +} diff --git a/test/device_id_case_insensitivity_test.dart b/test/device_id_case_insensitivity_test.dart index 9aedefd6..7aa207b6 100644 --- a/test/device_id_case_insensitivity_test.dart +++ b/test/device_id_case_insensitivity_test.dart @@ -32,14 +32,17 @@ void main() { const lower = 'aa:bb:cc:dd:ee:ff'; const charId = '0000fff1-0000-1000-8000-00805f9b34fb'; - test('connectionStream matches a device id reported in a different case', () async { + test('connectionStream matches a device id reported in a different case', + () async { final platform = _MockPlatform(); final event = platform.connectionStream(upper).first; platform.updateConnection(lower, true); expect(await event, isTrue); }); - test('characteristicValueStream matches a device id reported in a different case', () async { + test( + 'characteristicValueStream matches a device id reported in a different case', + () async { final platform = _MockPlatform(); final event = platform.characteristicValueStream(upper, charId).first; platform.updateCharacteristicValue( @@ -63,9 +66,8 @@ void main() { 5, ]); final decodedView = Uint8List.sublistView(backingBuffer, 4, 8); - final streamValue = platform - .characteristicValueStream(upper, charId) - .first; + final streamValue = + platform.characteristicValueStream(upper, charId).first; Uint8List? callbackValue; platform.onValueChange = (deviceId, characteristicId, value, error) => callbackValue = value; @@ -86,9 +88,8 @@ void main() { () async { final platform = _MockPlatform(); final original = Uint8List.fromList([1, 2, 3]); - final streamValue = platform - .characteristicValueStream(upper, charId) - .first; + final streamValue = + platform.characteristicValueStream(upper, charId).first; platform.updateCharacteristicValue(lower, charId, original, null); @@ -96,14 +97,17 @@ void main() { }, ); - test('pairingStateStream matches a device id reported in a different case', () async { + test('pairingStateStream matches a device id reported in a different case', + () async { final platform = _MockPlatform(); final event = platform.pairingStateStream(upper).first; platform.updatePairingState(lower, true); expect(await event, isTrue); }); - test('connect() completes when the platform reports the id in a different case', () async { + test( + 'connect() completes when the platform reports the id in a different case', + () async { UniversalBle.setInstance(_MockPlatform()); // Must not throw / time out: connect(upper) awaits a lower-case connection update (the original hang). await UniversalBle.connect(upper, timeout: const Duration(seconds: 2)); @@ -114,31 +118,207 @@ void main() { final events = []; final sub = platform.pairingStateStream(upper).listen(events.add); platform.updatePairingState(lower, true); // first -> emits - platform.updatePairingState(upper, true); // same device+value, other case -> deduped, no second emit + platform.updatePairingState(upper, + true); // same device+value, other case -> deduped, no second emit await Future.delayed(const Duration(milliseconds: 20)); await sub.cancel(); expect(events, [true]); }); - test('connection-parameters dedup treats the two cases as one device', () async { + test('connection-parameters dedup treats the two cases as one device', + () async { final platform = _MockPlatform(); final events = []; platform.onConnectionParametersChange = (u) => events.add(u.deviceId); BleConnectionParametersUpdated params(String id) => BleConnectionParametersUpdated( - deviceId: id, interval: 12, latency: 0, supervisionTimeout: 500, status: 0); + deviceId: id, + interval: 12, + latency: 0, + supervisionTimeout: 500, + status: 0); platform.updateConnectionParameters(params(lower)); // first -> fires - platform.updateConnectionParameters(params(upper)); // identical params, other case -> deduped + platform.updateConnectionParameters( + params(upper)); // identical params, other case -> deduped await Future.delayed(const Duration(milliseconds: 20)); expect(events, [lower]); }); - test('service cache is keyed case-insensitively (save one case, get/clear another)', () { + test( + 'service cache is keyed case-insensitively (save one case, get/clear another)', + () { + // Caller-supplied ids are canonicalised at the API boundary, so the cache itself sees canonical + // keys only — an address reaching it in either case lands on the same entry. + UniversalBle.setInstance(_MockPlatform()); final cache = CacheHandler.instance; - cache.resetDeviceCache(upper); // clean slate - cache.saveServices(upper, const []); // non-null -> cached - expect(cache.getServices(lower), isNotNull); // found via the other case - cache.resetDeviceCache(lower); // cleared via the other case - expect(cache.getServices(upper), isNull); + final upperKey = UniversalBle.canonicalDeviceId(upper); + final lowerKey = UniversalBle.canonicalDeviceId(lower); + cache.resetDeviceCache(upperKey); // clean slate + cache.saveServices(upperKey, const []); // non-null -> cached + expect(cache.getServices(lowerKey), isNotNull); // found via the other case + cache.resetDeviceCache(lowerKey); // cleared via the other case + expect(cache.getServices(upperKey), isNull); + }); + + test('an opaque id is NOT folded into the cache key', () { + // Web's ids are case-sensitive, so two that differ only in case are two devices, not one. + UniversalBle.setInstance(_OpaqueIdPlatform()); + const opaque = 'mHZbW+PZqBpUlZlVQrPzOQ=='; + final cache = CacheHandler.instance; + final key = UniversalBle.canonicalDeviceId(opaque); + final foldedKey = UniversalBle.canonicalDeviceId(opaque.toLowerCase()); + cache.resetDeviceCache(key); + cache.resetDeviceCache(foldedKey); + cache.saveServices(key, const []); + expect(cache.getServices(foldedKey), isNull); + cache.resetDeviceCache(key); + }); + + // Device ids are canonicalised to lower-case on the way OUT too: every callback/stream now emits the + // lower-case form regardless of the case the platform reported. (Breaking, for the next major — the native + // side converts back to upper-case at its boundary; see `_nativeId` in the pigeon channel / Linux instance.) + + test( + 'updateConnection emits a lower-case id even when the platform reports upper-case', + () { + final platform = _MockPlatform(); + String? emitted; + platform.onConnectionChange = (id, isConnected, error) => emitted = id; + platform.updateConnection(upper, true); + expect(emitted, lower); + }); + + test('updateCharacteristicValue emits a lower-case id', () { + final platform = _MockPlatform(); + String? emitted; + platform.onValueChange = + (id, characteristicId, value, timestamp) => emitted = id; + platform.updateCharacteristicValue( + upper, charId, Uint8List.fromList([1]), null); + expect(emitted, lower); + }); + + test('updatePairingState emits a lower-case id', () { + final platform = _MockPlatform(); + String? emitted; + platform.onPairingStateChange = (id, isPaired) => emitted = id; + platform.updatePairingState(upper, true); + expect(emitted, lower); + }); + + test('updateConnectionParameters emits a lower-case id', () { + final platform = _MockPlatform(); + String? emitted; + platform.onConnectionParametersChange = (u) => emitted = u.deviceId; + platform.updateConnectionParameters(BleConnectionParametersUpdated( + deviceId: upper, + interval: 12, + latency: 0, + supervisionTimeout: 500, + status: 0)); + expect(emitted, lower); + }); + + test('updateScanResult emits a lower-case id', () { + final platform = _MockPlatform(); + String? emitted; + platform.onScanResultUpdate = (d) => emitted = d.deviceId; + platform.updateScanResult(BleDevice(deviceId: upper, name: null)); + expect(emitted, lower); }); + + // A platform whose ids are NOT addresses (Web Bluetooth: opaque, case-sensitive browser tokens) + // reports hasAddressDeviceIds == false, so its ids pass through verbatim. Emission and matching + // both go through DeviceId, so a scanned id round-trips byte-for-byte and still matches. + + group('opaque (Web-style) device ids', () { + const opaque = 'mHZbW+PZqBpUlZlVQrPzOQ=='; + + test('updateScanResult emits an opaque id unchanged', () { + final platform = _OpaqueIdPlatform(); + String? emitted; + platform.onScanResultUpdate = (d) => emitted = d.deviceId; + platform.updateScanResult(BleDevice(deviceId: opaque, name: null)); + expect(emitted, opaque); + }); + + test('updateConnection emits an opaque id unchanged', () { + final platform = _OpaqueIdPlatform(); + String? emitted; + platform.onConnectionChange = (id, isConnected, error) => emitted = id; + platform.updateConnection(opaque, true); + expect(emitted, opaque); + }); + + test('connectionStream matches the id exactly as scanned', () async { + final platform = _OpaqueIdPlatform(); + final event = platform.connectionStream(opaque).first; + platform.updateConnection(opaque, true); + expect(await event, isTrue); + }); + + test('characteristicValueStream matches the id exactly as scanned', + () async { + final platform = _OpaqueIdPlatform(); + final event = platform.characteristicValueStream(opaque, charId).first; + platform.updateCharacteristicValue( + opaque, charId, Uint8List.fromList([1, 2, 3]), null); + expect(await event, Uint8List.fromList([1, 2, 3])); + }); + + test('a lower-cased id does NOT match, so lookups keep their own case', + () async { + final platform = _OpaqueIdPlatform(); + final events = []; + final sub = + platform.connectionStream(opaque.toLowerCase()).listen(events.add); + platform.updateConnection(opaque, true); + await Future.delayed(Duration.zero); + await sub.cancel(); + expect(events, isEmpty); + }); + }); + + // Peripheral mode canonicalises the same way: central ids in its streams are lower-case + // regardless of the case the platform reports. + + group('peripheral', () { + test('connectionStateStream emits a lower-case id', () async { + final platform = _MockPeripheralPlatform(); + final event = platform.connectionStateStream.first; + platform.updateConnectionState( + BlePeripheralConnectionStateChanged(upper, true), + ); + expect((await event).deviceId, lower); + }); + + test('characteristicSubscriptionStream emits a lower-case id', () async { + final platform = _MockPeripheralPlatform(); + final event = platform.characteristicSubscriptionStream.first; + platform.updateCharacteristicSubscription( + BlePeripheralCharacteristicSubscriptionChanged( + deviceId: upper, + characteristicId: charId, + isSubscribed: true, + name: null, + ), + ); + expect((await event).deviceId, lower); + }); + + test('mtuChangedStream emits a lower-case id', () async { + final platform = _MockPeripheralPlatform(); + final event = platform.mtuChangedStream.first; + platform.updateMtu(BlePeripheralMtuChanged(upper, 247)); + expect((await event).deviceId, lower); + }); + }); +} + +class _MockPeripheralPlatform extends UniversalBlePeripheralUnsupported {} + +/// Stands in for `UniversalBleWeb`, whose ids are opaque, case-sensitive browser tokens. +class _OpaqueIdPlatform extends UniversalBlePlatformMock { + @override + bool get hasAddressDeviceIds => false; } diff --git a/test/device_id_test.dart b/test/device_id_test.dart new file mode 100644 index 00000000..00a0b2b3 --- /dev/null +++ b/test/device_id_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_ble/src/utils/device_id.dart'; + +/// [DeviceId] is the one place device-id case conversion lives: the canonical form the Dart layer +/// emits/matches/keys by, and the native form channel calls take. +void main() { + const upper = 'AA:BB:CC:DD:EE:FF'; + const lower = 'aa:bb:cc:dd:ee:ff'; + + group('address ids', () { + test('canonicalise to lower-case whatever case the platform reports', () { + expect(DeviceId.address(upper).canonical, lower); + expect(DeviceId.address(lower).canonical, lower); + }); + + test('convert to upper-case for native calls', () { + // Android's getRemoteDevice throws on a lower-case MAC. + expect(DeviceId.address(lower).native, upper); + expect(DeviceId.address(upper).native, upper); + }); + + test('round-trip through both forms is stable', () { + final id = DeviceId.address(upper); + expect(DeviceId.address(id.native).canonical, id.canonical); + expect(DeviceId.address(id.canonical).native, id.native); + }); + + test('the two cases are the same DeviceId', () { + expect(DeviceId.address(upper), DeviceId.address(lower)); + expect(DeviceId.address(upper).hashCode, DeviceId.address(lower).hashCode); + }); + }); + + group('opaque ids', () { + // Chromium reports Base64 of a random value; folding one corrupts it. + const opaque = 'mHZbW+PZqBpUlZlVQrPzOQ=='; + + test('are carried through untouched in both forms', () { + expect(DeviceId.opaque(opaque).canonical, opaque); + expect(DeviceId.opaque(opaque).native, opaque); + }); + + test('differing only in case are different devices', () { + expect( + DeviceId.opaque(opaque), + isNot(DeviceId.opaque(opaque.toLowerCase())), + ); + }); + }); + + test('DeviceId.of picks the kind the platform reports', () { + expect(DeviceId.of(upper, isAddress: true), DeviceId.address(upper)); + expect(DeviceId.of(upper, isAddress: false), DeviceId.opaque(upper)); + }); + + test('toString is the canonical form, so ids interpolate consistently', () { + expect('${DeviceId.address(upper)}', lower); + }); +}