Skip to content

Match device ids case-insensitively across event streams - #269

Merged
fotiDim merged 1 commit into
Navideck:mainfrom
postmaxin:case-insensitive-device-id
Jul 27, 2026
Merged

Match device ids case-insensitively across event streams#269
fotiDim merged 1 commit into
Navideck:mainfrom
postmaxin:case-insensitive-device-id

Conversation

@postmaxin

Copy link
Copy Markdown
Contributor

Problem

A BLE device id is a case-insensitive identifier, but platforms report it in different cases:

  • Android upper-cases MAC addresses (BluetoothDevice.getAddress())
  • Windows/WinRT lower-cases them (mac_address_to_str uses lower-case std::hex)

The event streams that route connection / characteristic-value / pairing events — and the completer that connect()/disconnect() await — filter with an exact-case ==:

.where((e) => e.deviceId == deviceId)

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 and connect() 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.connectionStream
  • UniversalBlePlatform.characteristicValueStream
  • UniversalBlePlatform.pairingStateStream
  • UniversalBle._connectionEventCompleter (the connect()/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:496device?.address == deviceId
  • Android UniversalBlePlugin.kt — e.g. it.deviceId == gatt.device.address (:428, :446, :639, :764, …)
  • Swift UniversalBlePlugin.swiftfuture.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

@Navideck Navideck deleted a comment from gemini-code-assist Bot Jul 26, 2026
Comment on lines +156 to +161
Stream<bool> pairingStateStream(String deviceId) {
final target = deviceId.toLowerCase();
return _pairStateStreamController.stream
.where((e) => e.deviceId.toLowerCase() == target)
.map((e) => e.isPaired);
}

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.

} catch (_) {}

if (!isConnected) {
CacheHandler.instance.resetDeviceCache(deviceId);

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.

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 fotiDim left a comment

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.

A couple of things to address. I would like to include that in the next release.

Copilot AI left a comment

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.

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’s connectionStream, characteristicValueStream, and pairingStateStream match deviceId case-insensitively.
  • Make UniversalBle._connectionEventCompleter (used by connect() / disconnect()) match deviceId case-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

  • characteristicValueStream can be very hot (notifications/indications). Calling toLowerCase() on every event adds avoidable overhead when the event deviceId already matches exactly; consider checking == deviceId first 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 == deviceId keeps 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 in UniversalBle to be case-insensitive. Adding a small test that calls UniversalBle.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.

Comment thread lib/src/interfaces/universal_ble_platform_interface.dart
Comment thread lib/src/universal_ble.dart
Comment thread test/device_id_case_insensitivity_test.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. 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>
@postmaxin
postmaxin force-pushed the case-insensitive-device-id branch from 1e44d13 to a89087e Compare July 26, 2026 21:20
@postmaxin

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @fotiDim — updated to address all of it.

Internal keys are now normalized (your main point). Beyond the case-insensitive stream / connect() matching, all per-device state is now keyed by a canonical lower-case id, so a device reported in two cases can't split across entries:

  • _pairStateMap and _lastConnectionParametersMap key by the normalized id (and I dropped the now-redundant last.deviceId == update.deviceId check — it would itself have failed across cases and broken dedup).
  • CacheHandler keys its service cache by the normalized id, so a cache saved when subscribing with one case is found and cleared when the platform reports another — fixing the stale-services-on-reconnect case you flagged.

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.

Perf (Copilot's note): the hot paths (esp. characteristicValueStream) now short-circuit on an exact == match before lower-casing, so the common same-case path allocates nothing extra.

Tests + CI: added coverage for the stream matching, the connect() completer (mock emits a lower-case update for an upper-case connect()), both dedup maps, and the service cache; and fixed the unused import that broke flutter analyze. This time I ran flutter analyze (clean) and the full flutter test suite (92 pass) locally before pushing.

@fotiDim fotiDim left a comment

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.

@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.

@fotiDim
fotiDim merged commit f8922e1 into Navideck:main Jul 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants