From a89087e9277f8f589d95253614a3627b5fcb5aaf Mon Sep 17 00:00:00 2001 From: Tyler MacDonald Date: Fri, 24 Jul 2026 18:18:48 -0400 Subject: [PATCH] Handle device ids case-insensitively (event matching + internal keys) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A BLE device id is a case-insensitive identifier, but platforms report it in different cases: Android upper-cases MACs, Windows/WinRT lower-cases them. Two problems followed: subscribers whose id case differed from the platform's missed their own events (and connect() hung to timeout), and — because per-device state was keyed by the raw id — a device reported in two cases could split across map entries (dedup/removal/cache-cleanup then miss, leaving stale service caches on reconnect). - Match the connection / value / pairing event streams and the connect()/disconnect() completer case-insensitively, short-circuiting on an exact match first so hot paths avoid the lower-case allocation. - Key all per-device state by a canonical lower-case id: the pairing-state and connection-parameters dedup maps and CacheHandler's service cache. Emitted device ids keep the platform's case, so this stays non-breaking for consumers. - Add tests for the stream matching, the connect() completer, both dedup maps, and the service cache. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../universal_ble_platform_interface.dart | 49 +++++++--- lib/src/universal_ble.dart | 3 +- lib/src/utils/cache_handler.dart | 14 ++- test/device_id_case_insensitivity_test.dart | 95 +++++++++++++++++++ 4 files changed, 141 insertions(+), 20 deletions(-) create mode 100644 test/device_id_case_insensitivity_test.dart diff --git a/lib/src/interfaces/universal_ble_platform_interface.dart b/lib/src/interfaces/universal_ble_platform_interface.dart index b08cf4e3..aacd62c4 100644 --- a/lib/src/interfaces/universal_ble_platform_interface.dart +++ b/lib/src/interfaces/universal_ble_platform_interface.dart @@ -127,28 +127,40 @@ abstract class UniversalBlePlatform { Stream get availabilityStream => _availabilityStreamController.stream; - Stream connectionStream(String deviceId) => - bleConnectionUpdateStreamController.stream - .where((e) => e.deviceId == deviceId) - .map((e) => e.isConnected); + // 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. + Stream connectionStream(String deviceId) { + final target = deviceId.toLowerCase(); + return bleConnectionUpdateStreamController.stream + .where((e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target) + .map((e) => e.isConnected); + } Stream characteristicValueStream( String deviceId, String characteristicId, ) { + final target = deviceId.toLowerCase(); characteristicId = BleUuidParser.string(characteristicId); return _valueStreamController.stream .where((e) { - return e.deviceId == deviceId && + return (e.deviceId == deviceId || e.deviceId.toLowerCase() == target) && e.characteristicId == characteristicId; }) .map((e) => e.value); } - Stream pairingStateStream(String deviceId) => _pairStateStreamController - .stream - .where((e) => e.deviceId == deviceId) - .map((e) => e.isPaired); + Stream pairingStateStream(String deviceId) { + final target = deviceId.toLowerCase(); + return _pairStateStreamController.stream + .where((e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target) + .map((e) => e.isPaired); + } /// Update Handlers void updateScanResult(BleDevice bleDevice) { @@ -171,8 +183,10 @@ 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). CacheHandler.instance.resetDeviceCache(deviceId); - _lastConnectionParametersMap.remove(deviceId); + _lastConnectionParametersMap.remove(deviceId.toLowerCase()); } } @@ -202,8 +216,11 @@ abstract class UniversalBlePlatform { } void updatePairingState(String deviceId, bool isPaired) { - if (_pairStateMap[deviceId] == isPaired) return; - _pairStateMap[deviceId] = 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; _pairStateStreamController.add((deviceId: deviceId, isPaired: isPaired)); @@ -213,16 +230,18 @@ abstract class UniversalBlePlatform { } void updateConnectionParameters(BleConnectionParametersUpdated update) { - final last = _lastConnectionParametersMap[update.deviceId]; + // 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]; if (last != null && - last.deviceId == update.deviceId && last.interval == update.interval && last.latency == update.latency && last.supervisionTimeout == update.supervisionTimeout && last.status == update.status) { return; } - _lastConnectionParametersMap[update.deviceId] = update; + _lastConnectionParametersMap[key] = update; try { onConnectionParametersChange?.call(update); diff --git a/lib/src/universal_ble.dart b/lib/src/universal_ble.dart index 11ddfa1e..f3d9e306 100644 --- a/lib/src/universal_ble.dart +++ b/lib/src/universal_ble.dart @@ -675,6 +675,7 @@ class UniversalBle { Duration? timeout, }) { timeout ??= const Duration(seconds: 60); + final target = deviceId.toLowerCase(); StreamSubscription? connectionSubscription; Completer completer = Completer(); @@ -692,7 +693,7 @@ class UniversalBle { connectionSubscription = _platform .bleConnectionUpdateStreamController .stream - .where((e) => e.deviceId == deviceId) + .where((e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target) .listen( (e) { cancelSubscription(); diff --git a/lib/src/utils/cache_handler.dart b/lib/src/utils/cache_handler.dart index 8cef8399..25bbc3d1 100644 --- a/lib/src/utils/cache_handler.dart +++ b/lib/src/utils/cache_handler.dart @@ -9,20 +9,26 @@ 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(deviceId); + _servicesCache.remove(_key(deviceId)); } else { - _servicesCache[deviceId] = services; + _servicesCache[_key(deviceId)] = services; } } /// Retrieves the cached Bluetooth services for a specific device. - List? getServices(String deviceId) => _servicesCache[deviceId]; + List? getServices(String deviceId) => _servicesCache[_key(deviceId)]; /// Resets the cache for a specific device, removing all stored services. void resetDeviceCache(String deviceId) { - _servicesCache.remove(deviceId); + _servicesCache.remove(_key(deviceId)); } } diff --git a/test/device_id_case_insensitivity_test.dart b/test/device_id_case_insensitivity_test.dart new file mode 100644 index 00000000..22d8a4e0 --- /dev/null +++ b/test/device_id_case_insensitivity_test.dart @@ -0,0 +1,95 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_ble/src/utils/cache_handler.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import 'universal_ble_test_mock.dart'; + +/// A BLE device id is a case-insensitive identifier, but platforms report it in different cases (Android +/// upper-cases MACs, Windows/WinRT lower-cases them). These tests guard that universal_ble treats the two +/// cases as ONE device — both the event-stream matching AND the per-device state keyed internally (pairing +/// dedup, connection-parameters dedup, the service cache) — so a caller holding the id in a different case +/// than the platform reports doesn't miss events, doesn't hang connect(), and doesn't split across entries. +class _MockPlatform extends UniversalBlePlatformMock { + @override + Future readRssi(String deviceId) => throw UnimplementedError(); + + @override + Future connect( + String deviceId, { + bool autoConnect = false, + Duration? connectionTimeout, + ConnectionPlatformConfig? platformConfig, + }) async { + // Report the connection back with a DIFFERENT case than the caller passed — the original hang scenario. + updateConnection(deviceId.toLowerCase(), true); + } +} + +void main() { + const upper = 'AA:BB:CC:DD:EE:FF'; + 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 { + 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 { + final platform = _MockPlatform(); + final event = platform.characteristicValueStream(upper, charId).first; + platform.updateCharacteristicValue( + lower, charId, Uint8List.fromList([1, 2, 3]), null); + expect(await event, Uint8List.fromList([1, 2, 3])); + }); + + 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 { + 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)); + }); + + test('pairing-state dedup treats the two cases as one device', () async { + final platform = _MockPlatform(); + 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 + 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 { + 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); + platform.updateConnectionParameters(params(lower)); // first -> fires + 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)', () { + 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); + }); +}