Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## Unreleased (next major)
* **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. Follow-up to the case-insensitive matching in 2.1.1.

## 2.1.1
* Android: Fix BluetoothDevice null-safety compile error under Kotlin 2.x
* Android: Make Android write-completion delivery thread-safe
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ packages:
path: ".."
relative: true
source: path
version: "2.1.0"
version: "2.1.1"
vector_math:
dependency: transitive
description:
Expand Down
44 changes: 20 additions & 24 deletions lib/src/interfaces/universal_ble_platform_interface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,16 @@ abstract class UniversalBlePlatform {

// 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.
// (`mac_address_to_str` emits lower-case hex). For consistency, ids are canonicalised to LOWER-CASE
// throughout the Dart layer and emitted lower-case: the update* handlers below lower-case on ingestion, so
// every stream event, callback and per-device map key is lower-case. Native BLE calls want the upper-case
// form (Android's getRemoteDevice REQUIRES it), so the platform implementations convert back at the
// boundary (see `_nativeId` in the pigeon channel / Linux instance). Consumers may still pass an id in any
// case; we lower-case the query when matching.
Stream<bool> connectionStream(String deviceId) {
final target = deviceId.toLowerCase();
return bleConnectionUpdateStreamController.stream
.where((e) => e.deviceId == deviceId || e.deviceId.toLowerCase() == target)
.where((e) => e.deviceId == target)
.map((e) => e.isConnected);
}

Expand All @@ -148,22 +149,20 @@ abstract class UniversalBlePlatform {
final target = deviceId.toLowerCase();
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<bool> pairingStateStream(String deviceId) {
final target = deviceId.toLowerCase();
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 = bleDevice.deviceId.toLowerCase(); // canonical lower-case id
_scanStreamController.add(bleDevice);

try {
Expand All @@ -172,6 +171,7 @@ abstract class UniversalBlePlatform {
}

void updateConnection(String deviceId, bool isConnected, [String? error]) {
deviceId = deviceId.toLowerCase();
bleConnectionUpdateStreamController.add((
deviceId: deviceId,
isConnected: isConnected,
Expand All @@ -183,10 +183,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 lower-case id).
CacheHandler.instance.resetDeviceCache(deviceId);
_lastConnectionParametersMap.remove(deviceId.toLowerCase());
_lastConnectionParametersMap.remove(deviceId);
}
}

Expand All @@ -196,6 +195,7 @@ abstract class UniversalBlePlatform {
Uint8List value,
int? timestamp,
) {
deviceId = deviceId.toLowerCase();
characteristicId = BleUuidParser.string(characteristicId);
_valueStreamController.add((
deviceId: deviceId,
Expand All @@ -216,11 +216,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.toLowerCase();
if (_pairStateMap[deviceId] == isPaired) return;
_pairStateMap[deviceId] = isPaired;

_pairStateStreamController.add((deviceId: deviceId, isPaired: isPaired));

Expand All @@ -230,18 +228,16 @@ 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 = update.deviceId.toLowerCase();
final last = _lastConnectionParametersMap[update.deviceId];
if (last != null &&
last.interval == update.interval &&
last.latency == update.latency &&
last.supervisionTimeout == update.supervisionTimeout &&
last.status == update.status) {
return;
}
_lastConnectionParametersMap[key] = update;
_lastConnectionParametersMap[update.deviceId] = update;

try {
onConnectionParametersChange?.call(update);
Expand Down
3 changes: 3 additions & 0 deletions lib/src/universal_ble_linux/universal_ble_linux.dart
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,9 @@ class UniversalBleLinux extends UniversalBlePlatform {

/// Get device by id from cache or from client
BlueZDevice? _getDeviceById(String deviceId) {
// Ids are lower-case in the Dart layer; BlueZ addresses are upper-case, so canonicalise the lookup here
// (every device resolution funnels through this). Emitted ids + cache keys keep the lower-case form.
deviceId = deviceId.toUpperCase();
return _devices[deviceId] ??
_client.devices.cast<BlueZDevice?>().firstWhere(
(device) => device?.address == deviceId,
Expand Down
33 changes: 19 additions & 14 deletions lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import 'package:universal_ble/src/universal_ble.g.dart';
import 'package:universal_ble/src/utils/universal_ble_filter_util.dart';
import 'package:universal_ble/universal_ble.dart';

// Device ids are lower-case throughout the Dart layer (see UniversalBlePlatform), but the native side wants
// the upper-case form — Android's BluetoothAdapter.getRemoteDevice REQUIRES upper case, and Apple's peripheral
// cache / Windows' address parse / Linux's BlueZ address are upper-case too. Convert here, at the boundary.
Comment on lines +6 to +8
String _nativeId(String deviceId) => deviceId.toUpperCase();

class UniversalBlePigeonChannel extends UniversalBlePlatform
implements UniversalBleCallbackChannel {
static UniversalBlePigeonChannel? _instance;
Expand Down Expand Up @@ -61,7 +66,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform

@override
Future<BleConnectionState> getConnectionState(String deviceId) =>
_executeWithErrorHandling(() => _channel.getConnectionState(deviceId));
_executeWithErrorHandling(() => _channel.getConnectionState(_nativeId(deviceId)));

@override
Future<void> connect(
Expand All @@ -71,15 +76,15 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform
ConnectionPlatformConfig? platformConfig,
}) => _executeWithErrorHandling(
() => _channel.connect(
deviceId,
_nativeId(deviceId),
autoConnect: autoConnect,
platformConfig: platformConfig,
),
);

@override
Future<void> disconnect(String deviceId) =>
_executeWithErrorHandling(() => _channel.disconnect(deviceId));
_executeWithErrorHandling(() => _channel.disconnect(_nativeId(deviceId)));

@override
Future<List<BleService>> discoverServices(
Expand All @@ -88,12 +93,12 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform
) async {
List<UniversalBleService?> universalBleServices =
await _executeWithErrorHandling(
() => _channel.discoverServices(deviceId, withDescriptors),
() => _channel.discoverServices(_nativeId(deviceId), withDescriptors),
);
return List<BleService>.from(
universalBleServices
.where((e) => e != null)
.map((e) => e!.toBleService(deviceId))
.map((e) => e!.toBleService(deviceId.toLowerCase())) // emitted id stays lower-case
.toList(),
);
}
Expand All @@ -107,7 +112,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform
) {
return _executeWithErrorHandling(
() => _channel.setNotifiable(
deviceId,
_nativeId(deviceId),
service,
characteristic,
bleInputProperty,
Expand All @@ -123,7 +128,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform
Duration? timeout,
}) {
return _executeWithErrorHandling(
() => _channel.readValue(deviceId, service, characteristic),
() => _channel.readValue(_nativeId(deviceId), service, characteristic),
);
}

Expand All @@ -137,7 +142,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform
) {
return _executeWithErrorHandling(
() => _channel.writeValue(
deviceId,
_nativeId(deviceId),
service,
characteristic,
value,
Expand All @@ -149,32 +154,32 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform
@override
Future<int> requestMtu(String deviceId, int expectedMtu) =>
_executeWithErrorHandling(
() => _channel.requestMtu(deviceId, expectedMtu),
() => _channel.requestMtu(_nativeId(deviceId), expectedMtu),
);

@override
Future<int> readRssi(String deviceId) =>
_executeWithErrorHandling(() => _channel.readRssi(deviceId));
_executeWithErrorHandling(() => _channel.readRssi(_nativeId(deviceId)));

@override
Future<void> requestConnectionPriority(
String deviceId,
BleConnectionPriority priority,
) => _executeWithErrorHandling(
() => _channel.requestConnectionPriority(deviceId, priority),
() => _channel.requestConnectionPriority(_nativeId(deviceId), priority),
);

@override
Future<bool> isPaired(String deviceId) =>
_executeWithErrorHandling(() => _channel.isPaired(deviceId));
_executeWithErrorHandling(() => _channel.isPaired(_nativeId(deviceId)));

@override
Future<bool> pair(String deviceId) =>
_executeWithErrorHandling(() => _channel.pair(deviceId));
_executeWithErrorHandling(() => _channel.pair(_nativeId(deviceId)));

@override
Future<void> unpair(String deviceId) =>
_executeWithErrorHandling(() => _channel.unPair(deviceId));
_executeWithErrorHandling(() => _channel.unPair(_nativeId(deviceId)));

@override
Future<bool> hasPermissions({bool withAndroidFineLocation = false}) =>
Expand Down
51 changes: 51 additions & 0 deletions test/device_id_case_insensitivity_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,55 @@ void main() {
cache.resetDeviceCache(lower); // cleared via the other case
expect(cache.getServices(upper), isNull);
});

// 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);
});
}
Loading