Match device ids case-insensitively across event streams - #269
Conversation
| Stream<bool> pairingStateStream(String deviceId) { | ||
| final target = deviceId.toLowerCase(); | ||
| return _pairStateStreamController.stream | ||
| .where((e) => e.deviceId.toLowerCase() == target) | ||
| .map((e) => e.isPaired); | ||
| } |
There was a problem hiding this comment.
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.
| } catch (_) {} | ||
|
|
||
| if (!isConnected) { | ||
| CacheHandler.instance.resetDeviceCache(deviceId); |
There was a problem hiding this comment.
The cache is now inconsistently cleared. When an app subscribes with an upper-case device ID, services are cached with that upper-case key. If the platform reports connection updates with a different case, the stream now matches (case-insensitive), but when disconnecting, the cache cleanup uses the platform's device ID case, failing to find and clear the cache entry. Reconnecting later uses stale cached services.
fotiDim
left a comment
There was a problem hiding this comment.
A couple of things to address. I would like to include that in the next release.
There was a problem hiding this comment.
Pull request overview
Fixes a cross-platform inconsistency where BLE device IDs (case-insensitive by nature) were matched with case-sensitive equality in shared Dart event streams and the connect()/disconnect() completion path, which could cause subscribers to miss their own events and connect() to hang until timeout (notably on Windows vs Android casing differences).
Changes:
- Make
UniversalBlePlatform’sconnectionStream,characteristicValueStream, andpairingStateStreammatchdeviceIdcase-insensitively. - Make
UniversalBle._connectionEventCompleter(used byconnect()/disconnect()) matchdeviceIdcase-insensitively. - Add a unit test file validating case-insensitive matching for the three stream helpers.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| test/device_id_case_insensitivity_test.dart | Adds tests to ensure platform event streams match device IDs case-insensitively. |
| lib/src/universal_ble.dart | Updates the connection/disconnection completion matching to be case-insensitive. |
| lib/src/interfaces/universal_ble_platform_interface.dart | Updates platform-level event stream helpers to match device IDs case-insensitively. |
Comments suppressed due to low confidence (3)
lib/src/interfaces/universal_ble_platform_interface.dart:152
characteristicValueStreamcan be very hot (notifications/indications). CallingtoLowerCase()on every event adds avoidable overhead when the event deviceId already matches exactly; consider checking== deviceIdfirst and only lowercasing on mismatch.
return _valueStreamController.stream
.where((e) {
return e.deviceId.toLowerCase() == target &&
e.characteristicId == characteristicId;
})
lib/src/interfaces/universal_ble_platform_interface.dart:160
- Same as above: doing
toLowerCase()on every pairing-state event is avoidable when the id already matches exactly. Short-circuiting on== deviceIdkeeps the common path fast.
final target = deviceId.toLowerCase();
return _pairStateStreamController.stream
.where((e) => e.deviceId.toLowerCase() == target)
.map((e) => e.isPaired);
test/device_id_case_insensitivity_test.dart:42
- The PR also changes the
connect()/disconnect()completion matching logic inUniversalBleto be case-insensitive. Adding a small test that callsUniversalBle.connect()with an upper-case id while the mock emits a lower-case connection update would prevent regressions in the original hang scenario.
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);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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) <noreply@anthropic.com>
1e44d13 to
a89087e
Compare
|
Thanks for the review, @fotiDim — updated to address all of it. Internal keys are now normalized (your main point). Beyond the case-insensitive stream /
Tradeoff — I kept this non-breaking. I normalize only the internal keys and matching; the device ids emitted to consumers (streams + callbacks) still carry the platform's native case. The fully-universal alternative — normalizing the emitted case too, so ids are lower-case everywhere — is cleaner internally but would change what existing callers receive (e.g. Android callers get upper-case today). I'm happy to switch to that (normalize at the ingestion boundary in the Perf (Copilot's note): the hot paths (esp. Tests + CI: added coverage for the stream matching, the |
fotiDim
left a comment
There was a problem hiding this comment.
@postmaxin approving this PR as is. Thanks for the fixes.
Could you do another PR for the following?
Tradeoff — I kept this non-breaking. I normalize only the internal keys and matching; the device ids emitted to consumers (streams + callbacks) still carry the platform's native case. The fully-universal alternative — normalizing the emitted case too, so ids are lower-case everywhere — is cleaner internally but would change what existing callers receive (e.g. Android callers get upper-case today). I'm happy to switch to that (normalize at the ingestion boundary in the update* handlers) if you'd rather have it for the next release — just say the word.
I like the consistency and since this would be a breaking change we can include it in the next major release.
Problem
A BLE device id is a case-insensitive identifier, but platforms report it in different cases:
BluetoothDevice.getAddress())mac_address_to_struses lower-casestd::hex)The event streams that route connection / characteristic-value / pairing events — and the completer that
connect()/disconnect()await — filter with an exact-case==:So if a caller holds a device id in a different case than the platform reports (e.g. an upper-case MAC passed to
connect()on Windows, where events arrive lower-cased), the filter never matches: the subscriber receives none of its own events andconnect()hangs until timeout even though the device actually connected. This is easy to hit when the id comes from somewhere other than a fresh scan result — config, a database, a hard-coded roster.Fix
Compare device ids case-insensitively at the four matching sites in the shared (non-Linux/Web) layer:
UniversalBlePlatform.connectionStreamUniversalBlePlatform.characteristicValueStreamUniversalBlePlatform.pairingStateStreamUniversalBle._connectionEventCompleter(theconnect()/disconnect()await)The caller's id is lower-cased once per subscription (hoisted out of the per-event filter). No emitted value changes — only the internal match tolerates case — so this is non-breaking.
Test
Adds
test/device_id_case_insensitivity_test.dart: subscribe with an upper-case id, emit a lower-case event, assert it is received — across all three stream types. The full suite passes.Not addressed here (potential follow-ups)
For a focused change I left the native completion-matching paths alone; they also compare device ids case-sensitively and could bite a caller using a non-native case:
lib/src/universal_ble_linux/universal_ble_linux.dart:496—device?.address == deviceIdUniversalBlePlugin.kt— e.g.it.deviceId == gatt.device.address(:428,:446,:639,:764, …)UniversalBlePlugin.swift—future.deviceId == peripheral.uuid.uuidString(lower risk: Apple UUIDs are consistently upper-case)Windows C++ is already safe — it parses the id to a
uint64(str_to_mac_address), which erases case.Happy to fold those in if you'd prefer a single comprehensive PR.
🤖 Generated with Claude Code