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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 34 additions & 15 deletions lib/src/interfaces/universal_ble_platform_interface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -127,28 +127,40 @@ abstract class UniversalBlePlatform {
Stream<AvailabilityState> get availabilityStream =>
_availabilityStreamController.stream;

Stream<bool> 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<bool> connectionStream(String deviceId) {
final target = deviceId.toLowerCase();
return bleConnectionUpdateStreamController.stream
.where((e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target)
.map((e) => e.isConnected);
}

Stream<Uint8List> 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<bool> pairingStateStream(String deviceId) => _pairStateStreamController
.stream
.where((e) => e.deviceId == deviceId)
.map((e) => e.isPaired);
Stream<bool> pairingStateStream(String deviceId) {
final target = deviceId.toLowerCase();
return _pairStateStreamController.stream
.where((e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target)
.map((e) => e.isPaired);
}
Comment on lines +158 to +163

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream filtering is now case-insensitive, but _pairStateMap and _lastConnectionParametersMap use case-sensitive device ID keys. Since platforms report device IDs with different cases (Android upper-cases MACs, Windows lower-cases them), the same device will create multiple map entries with different cases. This causes .remove() calls to miss entries, deduplication checks to fail, and state tracking to become inconsistent.

Please add tests when fixing it.


/// Update Handlers
void updateScanResult(BleDevice bleDevice) {
Expand All @@ -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());
}
}

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

Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion lib/src/universal_ble.dart
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@ class UniversalBle {
Duration? timeout,
}) {
timeout ??= const Duration(seconds: 60);
final target = deviceId.toLowerCase();
StreamSubscription? connectionSubscription;
Completer<bool> completer = Completer();

Expand All @@ -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(
Comment thread
fotiDim marked this conversation as resolved.
(e) {
cancelSubscription();
Expand Down
14 changes: 10 additions & 4 deletions lib/src/utils/cache_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,26 @@ class CacheHandler {
/// Internal cache to store discovered services for each device.
final Map<String, List<BleService>> _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<BleService>? 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<BleService>? getServices(String deviceId) => _servicesCache[deviceId];
List<BleService>? 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));
}
}
95 changes: 95 additions & 0 deletions test/device_id_case_insensitivity_test.dart
Original file line number Diff line number Diff line change
@@ -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<int> readRssi(String deviceId) => throw UnimplementedError();

@override
Future<void> 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 = <bool>[];
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<void>.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 = <String>[];
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<void>.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);
});
}
Loading