Skip to content

Normalize BleDevice.services to 128-bit UUIDs - #346

Merged
fotiDim merged 3 commits into
feat/scan-filter-service-datafrom
refactor/normalize-device-services
Oct 16, 2026
Merged

fotiDim merged 3 commits into
feat/scan-filter-service-datafrom
refactor/normalize-device-services

Conversation

@fotiDim

@fotiDim fotiDim commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Possibly breaking change — stacked on #345. Base is feat/scan-filter-service-data so the diff below is only this change; retarget to main once #345 lands (or drop this PR if you want the normalization later).

Problem

BleDevice.services had a different shape depending on the platform:

Platform Reported form for a 16-bit service
Apple 180D (CoreBluetooth preserves the advertised width)
Android / Windows / Linux / Web 0000180d-0000-1000-8000-00805f9b34fb

So a value read on iOS/macOS did not compare equal to the same service read on Android, and did not key the same map entry. This is the same class of inconsistency that case-insensitive device IDs fixed in 3.0.0, and it is what made Apple's service filter compare a short advertised UUID against the full form the Dart layer sends (worked around locally in #345, fixed at the source here).

BleDevice.serviceData keys already had a canonical form; services did not.

Change

BleDevice normalises services in its constructor, exactly like the serviceData keys:

BleDevice(deviceId: '..', name: '..', services: ['180D']).services
// -> ['0000180d-0000-1000-8000-00805f9b34fb']
  • Full 128-bit lower-case UUIDs on every platform, so the field is comparable and usable as a key.
  • Short (180D), 0x-prefixed and mixed-case forms remain accepted as input, everywhere.
  • Entries that are not valid UUIDs are kept verbatim rather than dropped, and the matching helpers skip unparseable ones instead of throwing. The platform-channel conversion (UniversalBleScanResult.toBleDevice) no longer parses services itself either, so a malformed advertisement value can no longer throw FormatException and fail a scan on any platform (previously both that conversion and servicesMatch could throw).
  • The scan filter paths drop their now-redundant device-side toValidUUIDList() calls.

Migration

Code that stored or compared a short form read from Apple must switch to the 128-bit form, or parse with BleUuidParser first. Nothing else changes: services keeps its type and order (the list is still mutable).

Tests

  • New test/ble_device_services_test.dart — 16 cases: short/0x/32-bit/mixed-case expansion, already-normalised values, malformed entries kept verbatim, empty list, copyWith, serviceData key symmetry, cross-form filter matching (short advertised vs full filter and vice versa), and the platform-channel conversion (short normalised, malformed kept verbatim without throwing, absent becomes empty).
  • Existing suites unchanged and green: flutter test (282), flutter test --platform chrome (239), flutter analyze clean.

Docs

  • AGENTS.md gains an "Advertisement Field Normalization" section next to the device ID rules, since this is the second cross-platform canonical form.
  • CHANGELOG.md entry under the unreleased 3.1.0 section, flagged as (possibly) breaking.
  • README.md notes that services is emitted as 128-bit lower-case UUIDs.

Versioning

Kept in the unreleased 3.1.0 line rather than a separate 4.0.0. The change alters emitted values, so it is flagged as breaking in the changelog even though the version stays a minor bump.

@fotiDim
fotiDim force-pushed the refactor/normalize-device-services branch 2 times, most recently from 3382722 to 4500535 Compare October 15, 2026 21:44
**Breaking:** `BleDevice.services` had a different shape per platform. Apple
reported 16-bit advertised service UUIDs in their short form (`180D`) while
Android, Windows, Linux and Web reported the full 128-bit form, so a value read
on one platform did not compare equal (or key the same map entry) on another —
the same inconsistency the case-insensitive device IDs fixed in 3.0.0, and what
made the Apple service filter compare short against full.

`BleDevice` now normalises `services` in its constructor, exactly like the
`serviceData` keys, so every emitted value is a full 128-bit lower-case UUID.
Entries that are not valid UUIDs are kept verbatim rather than dropped, and the
matching helpers skip the unparseable ones instead of throwing, so malformed
advertisement data can no longer fail a scan.

Callers that stored or compared a short form from Apple must use the 128-bit
form (short and `0x`-prefixed forms remain accepted as *input* everywhere). The
scan filter paths lose their now-redundant per-site `toValidUUIDList()` calls on
the device side.

Also documents the invariant in AGENTS.md, next to the device ID rules, since
this is the second field with a cross-platform canonical form.
@fotiDim
fotiDim force-pushed the refactor/normalize-device-services branch from 4500535 to 2412be7 Compare October 15, 2026 21:45
…ease docs

- Convert platform-channel scan results without a throwing UUID parse, so
  malformed advertised services are kept verbatim by BleDevice instead of
  failing the scan on Android/Apple/Windows.
- Keep the normalised services list growable, preserving the previous ability
  to mutate it.
- Document the 128-bit canonical form in the README.
- Add regression tests for the platform-channel conversion path.
@fotiDim fotiDim changed the title Normalize BleDevice.services to 128-bit UUIDs (breaking) Normalize BleDevice.services to 128-bit UUIDs Oct 15, 2026
@fotiDim
fotiDim merged commit 89ba74a into feat/scan-filter-service-data Oct 16, 2026
@fotiDim
fotiDim deleted the refactor/normalize-device-services branch October 16, 2026 06:08
fotiDim added a commit that referenced this pull request Oct 16, 2026
#345)

* Add ScanFilter.withServiceData to match advertising Service Data (#57)

`ScanFilter.withServices` only matches the advertised service UUID list, so a
device that carries a service UUID only inside its advertising Service Data —
beacons like Eddystone (0xFEAA) or the ATC_MiThermometer (0x181A) — was never
discovered, even though its bytes were already reported in
`BleDevice.serviceData` (#57).

Add an opt-in `ScanFilter.withServiceData` that matches the service UUIDs found
in the Service Data fields (0x16/0x20/0x21). UUIDs are accepted in short,
0x-prefixed or full 128-bit form, in any case, and the new condition stays in
OR relation with the others. Defaults to empty, so existing scans (and the
native filter paths) behave exactly as before.

Android and Apple cannot express this as a platform filter — a controller-level
scan filter or `scanForPeripherals(withServices:)` drops the packet before the
plugin is notified — so opting in switches those platforms to the software
filter path, as already done for name prefixes. Web throws `notSupported`,
since the browser chooser cannot filter on Service Data. Linux matches in Dart.

Also:
- Linux: re-evaluate the filter against the device's current properties instead
  of the snapshot taken when BlueZ added it, and keep listening to devices that
  did not match at that point. A device that only advertises its Service Data in
  a later advertisement was previously dropped permanently.
- Apple: compare service UUIDs in 128-bit form on the custom filter path, since
  `CBUUID` equality does not match a 16-bit advertised UUID against the full
  form the API sends.
- Append `withServiceData` last on the pigeon model to keep positional encoding
  stable, and document `BleDevice.serviceData` in the README.

* Refactor the scan filter path and pin its semantics with a shared fixture

Follow-up to the Service Data filter work, addressing the drift and duplication
the four filter implementations had accumulated.

One entry point per platform
- Android and Apple kept the filter plus two normalised UUID lists as parallel
  state that the plugin had to populate and clear by hand; both are now derived
  by a single `configure(...)` call, so a new filter dimension cannot be left
  half-wired.
- Windows set `setScanFilter` mutated accumulating globals and relied on the
  caller to reset first. It now assigns a single value.

Strict predicates, one combination point
- Each per-dimension check now answers only "does this dimension match?", so the
  OR chain no longer repeats `has…Filter &&` four times per language, and the
  redundant "empty filter list returns true" branches are gone. The allowance
  for a filter with no conditions lives in exactly one place per implementation.
- The Dart exclusion filter relies on the opposite (AND) semantics, so it now
  guards its manufacturer dimension explicitly, like its service dimension
  already did.

Shared conformance fixture
- `test/fixtures/scan_filter_cases.tsv` is the single description of the
  semantics, consumed by the Dart, Kotlin and C++ implementations
  (20 cases: each dimension, the OR relation, empty filters, and that Service
  Data and advertised services stay distinct).
- To make the C++ side testable, the matching rules moved out of the
  flutter/winrt-typed file into `windows/src/scan_filter_matcher.{h,cpp}`, with
  `universal_ble_filter_util.cpp` reduced to a type adapter. The new
  `windows/test/scan_filter_matcher_test.cpp` runs in the same ctest job as the
  existing tests and needs neither Flutter nor WinRT, so it also runs on the
  Linux CI runner.
- Swift has no test target (the plugin sources need the Flutter module), so it
  is the one implementation not driven by the fixture; its predicate shape was
  aligned by hand.

Smaller cleanups
- Linux: filter and emit through one `_reportIfAccepted` helper instead of
  building the device twice at two different points.
- Web: validation (`_validateScanFilter`) split from translation, and the
  exclusion-filter mapping extracted, in what had grown into a 100-line function.
- Tests: a fresh filter util per test instead of one mutable instance shared by
  the whole file.

* Address scan-filter review feedback

- Fix vacuous matchedScanFilter assertion in manufacturer_data_filter_test
  (assign scanFilter so the empty-filter path is actually exercised)
- Drop the unused ToLower helper from scan_filter_matcher.cpp and the
  unused ToBytes helper plus <unordered_set> include from
  universal_ble_filter_util.cpp
- Remove the no-op `.also { it.first() }` in the Kotlin conformance test
- List serviceData among the filter conditions in the Scan Filter README intro
- Revert the incidental CocoaPods version bump in example/macos/Podfile.lock

* Normalize BleDevice.services to 128-bit UUIDs (#346)

* Normalize BleDevice.services to 128-bit UUIDs on every platform

**Breaking:** `BleDevice.services` had a different shape per platform. Apple
reported 16-bit advertised service UUIDs in their short form (`180D`) while
Android, Windows, Linux and Web reported the full 128-bit form, so a value read
on one platform did not compare equal (or key the same map entry) on another —
the same inconsistency the case-insensitive device IDs fixed in 3.0.0, and what
made the Apple service filter compare short against full.

`BleDevice` now normalises `services` in its constructor, exactly like the
`serviceData` keys, so every emitted value is a full 128-bit lower-case UUID.
Entries that are not valid UUIDs are kept verbatim rather than dropped, and the
matching helpers skip the unparseable ones instead of throwing, so malformed
advertisement data can no longer fail a scan.

Callers that stored or compared a short form from Apple must use the 128-bit
form (short and `0x`-prefixed forms remain accepted as *input* everywhere). The
scan filter paths lose their now-redundant per-site `toValidUUIDList()` calls on
the device side.

Also documents the invariant in AGENTS.md, next to the device ID rules, since
this is the second field with a cross-platform canonical form.

* Address review: harden malformed-service handling and align 3.1.0 release docs

- Convert platform-channel scan results without a throwing UUID parse, so
  malformed advertised services are kept verbatim by BleDevice instead of
  failing the scan on Android/Apple/Windows.
- Keep the normalised services list growable, preserving the previous ability
  to mutate it.
- Document the 128-bit canonical form in the README.
- Add regression tests for the platform-channel conversion path.

* Document BleDevice.services normalisation in the 2.x -> 3.x migration skill
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.

2 participants