Skip to content

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

Merged
fotiDim merged 4 commits into
mainfrom
feat/scan-filter-service-data
Oct 16, 2026
Merged

fotiDim merged 4 commits into
mainfrom
feat/scan-filter-service-data

Conversation

@fotiDim

@fotiDim fotiDim commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Closes #57 (the remaining half of it).

Problem

#57 asks for the advertising Service Data of a specific UUID (0x181A) and reports that ScanFilter(withServices: ["181A"]) discovers nothing, even though nRF Connect and bleak see the UUID.

The data half already landed in #207 — BleDevice.serviceData reports the payloads on every platform. What was still missing is the filter half: every filter path matched only the advertised service UUID list (0x02/0x03, 0x04/0x05, 0x06/0x07), never the Service Data fields (0x16/0x20/0x21). A beacon that carries its UUID only in Service Data — Eddystone (0xFEAA), the ATC_MiThermometer in the issue (0x181A), BLE Mesh provisioning (0x1827) — was therefore invisible to any service filter.

Change

Opt-in ScanFilter.withServiceData, matching the service UUIDs found in the advertising Service Data:

UniversalBle.startScan(scanFilter: ScanFilter(withServiceData: ['181A']));
  • UUIDs accepted in short (181A), 0x-prefixed or full 128-bit form, any case (normalised by the existing BleUuidParser → toValidUUIDList()).
  • Conditions stay in OR relation with withServices / withManufacturerData / withNamePrefix; empty list = ignored.
  • Defaults to empty, so nothing changes unless opted in: Android and Apple still use their native filters otherwise.
  • Matching payloads keep arriving in BleDevice.serviceData.

Platform behaviour:

Platform How Cost when opted in
Android software filter in the scan callback (usesCustomFilters) every advertisement reaches the app process
Apple scan with nil services + software filter same
Windows extended the existing C++ post-filter none
Linux Dart filter none
Web throws notSupported — the browser chooser cannot filter on Service Data n/a

Why the software path: a controller-level ScanFilter (Android) or scanForPeripherals(withServices:) (Apple) discards such a device before the plugin is notified, so the filter cannot be applied later. That is exactly why this is opt-in rather than folded into withServices.

Also fixed along the way

  • Linux: the filter was evaluated against the snapshot taken when BlueZ added the device, and a device rejected there never got its advertisement listener attached — so a peripheral that advertises its Service Data only in a later packet was dropped permanently. That is precisely the reporter's Linux log in the issue (services: [], then UnhandledDevicePropertyChanged … ServiceData). Now re-evaluated on current properties, and devices stay subscribed so they can start matching later.
  • Apple: switched the service comparison to 128-bit strings. CoreBluetooth preserves the advertised width, so Set<CBUUID> equality did not match a 16-bit advertised UUID against the full 128-bit form the API sends — withServices could not filter 16-bit services when the custom filter path was active.

Compatibility

No breaking change to the public API: the new field is optional with an empty default, withServices and BleDevice.services are untouched, and no exported interface gained a member (the Web guard lives in the web implementation instead of adding a capability flag to the exported UniversalBlePlatform).

Pigeon encodes positionally, so withServiceData is appended last on UniversalScanFilter. Native rebuild after upgrading, as usual for a schema change.

Tests

  • test/scan_filter_test.dart — service-data matching: short/0x/full/case variants, non-matching and empty data, OR relation, empty filter, malformed device keys, and that unset withServiceData keeps the old behaviour.
  • test/scan_filter_channel_test.dart — the filter reaches the platform as a canonical 128-bit lowercase UUID.
  • android/src/test/.../UniversalBleFilterUtilTest.kt — usesCustomFilters(), beacon-without-advertised-services regression, and that service-data matching does not leak from/into the advertised service list.
  • android/src/test/.../UniversalBleHelperTest.kt — ScanResult.serviceDataUuids / serviceData parsing.

Verified locally: flutter analyze clean, flutter test (249), flutter test --platform chrome for the touched tests, Android :universal_ble:testDebugUnitTest (50), flutter build macos --debug (compiles the Swift changes). Windows C++ compiles in the windows CI job.

Versioning

Bumped pubspec.yaml to 3.1.0 with the CHANGELOG entry (additive feature → minor). Happy to drop the bump and let you cut the release instead.

Manual verification still worth doing on real hardware: an advertiser that carries 0x181A only in Service Data, on Android (AndroidOptions(legacy: true) for legacy BLE 4.x advertisers) and Apple. I could not run that here.

`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.
…ture

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

fotiDim commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a second commit to this PR: a refactor of the filter path that came out of reviewing the first one. Reviewing the two commits separately is easier — the feature is unchanged by it (ScanFilter.withServiceData behaves identically), and everything below is internal.

One entry point per platform. Android and Apple kept the filter plus two normalised UUID lists as parallel state the plugin had to populate and clear by hand; an else branch that forgets one silently keeps a stale filter. Both now derive everything from a single configure(...) call. Windows' setScanFilter appended to globals and relied on the caller resetting first — it now assigns.

Strict predicates. Each per-dimension check now answers only "does this dimension match?", so the four has…Filter && guards plus the "empty list returns true" branches collapse into one hasAnyFilter check per implementation. The Dart exclusion filter needs the opposite (AND) reading, so it guards its manufacturer dimension explicitly, like its service dimension already did.

Shared conformance fixture. test/fixtures/scan_filter_cases.tsv is now the single description of the semantics, consumed by the Dart, Kotlin and C++ implementations (20 cases covering each dimension, the OR relation, empty filters, and that Service Data and advertised services stay distinct). Drift between the four copies of this rule is what produced the two bugs fixed here, so this is the part I would most want reviewed.

To make C++ testable at all, 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 needs neither Flutter nor WinRT, so it runs in the existing ctest job on the Linux runner too.

Not covered: Swift has no test target (the plugin sources need the Flutter module), so Apple is the one implementation not driven by the fixture; its predicate shape was aligned by hand. Same caveat on #346.

Also: a fresh filter util per test in test/scan_filter_test.dart (it was one mutable instance shared by the whole file), and the Web request-options builder split into validation + translation after growing past 100 lines.

Verified: flutter analyze clean, flutter test 269, flutter test --platform chrome 226, Android :universal_ble:testDebugUnitTest 50, ctest for the new C++ test (20 cases), flutter build macos --debug.

- 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 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
@fotiDim
fotiDim merged commit 59ea9ad into main Oct 16, 2026
3 checks passed
@fotiDim
fotiDim deleted the feat/scan-filter-service-data branch October 16, 2026 06:08
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.

How to receive advertising data on specific UUID?

2 participants