From 2412be762f72498acd44ddf0bbbe9b233682f03a Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Thu, 15 Oct 2026 23:29:52 +0200 Subject: [PATCH 1/3] Normalize BleDevice.services to 128-bit UUIDs on every platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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. --- AGENTS.md | 12 +++ CHANGELOG.md | 1 + lib/src/models/ble_device.dart | 19 +++- lib/src/utils/universal_ble_filter_util.dart | 17 ++-- test/ble_device_services_test.dart | 94 ++++++++++++++++++++ 5 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 test/ble_device_services_test.dart diff --git a/AGENTS.md b/AGENTS.md index 50ac00e5..1e433c18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,18 @@ When modifying any device ID handling or API methods taking or returning device --- +## Advertisement Field Normalization + +Scan results normalise their UUID-bearing fields in the Dart layer, so the same device compares equal whichever platform reported it. Do not re-implement this per platform. + +- **`BleDevice.services`** — the advertised service UUIDs (advertising data types `0x02`/`0x03`, `0x04`/`0x05`, `0x06`/`0x07`). Emitted as full 128-bit lower-case UUIDs on every platform. Platforms report their own form (Apple, for instance, reports a 16-bit UUID as `180D`), so `BleDevice` normalises them via `BleUuidParser`. +- **`BleDevice.serviceData` keys** — the *Service Data* data types (`0x16`/`0x20`/`0x21`), normalised the same way. The values are the raw payload bytes and are never transformed. +- Entries that are not valid UUIDs are kept verbatim rather than dropped; matching simply skips them, so malformed advertisement data cannot fail a scan. +- **Inputs** accept any case and short (`181A`), `0x`-prefixed, or full 128-bit form, canonicalised with `BleUuidParser`/`toValidUUIDList()` at the boundary. Native code and the filter matchers only ever see the full form. +- When changing anything that emits or compares these fields, test cross-form equivalence (short, full, `0x`-prefixed, mixed case). When scan filter semantics change, update the shared conformance fixture `test/fixtures/scan_filter_cases.tsv`, which drives the Dart, Kotlin and C++ filter implementations. + +--- + ## Architecture & Code Conventions 1. **Platform Interface Pattern:** diff --git a/CHANGELOG.md b/CHANGELOG.md index b7db5d79..67ebf504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## 3.1.0 +* **Breaking:** `BleDevice.services` is now normalised to full 128-bit lower-case UUIDs on every platform, matching the keys of `BleDevice.serviceData`. Apple was the outlier: it reported 16-bit advertised service UUIDs in their short form (`180D`), so the same field had a different shape per platform and stored keys or comparisons did not transfer between them. Compare against, or store, the 128-bit form; short (`180D`) and `0x`-prefixed forms are still accepted as *input*. Entries that are not valid UUIDs are kept verbatim rather than dropped. * Add `ScanFilter.withServiceData` to discover devices that carry a service UUID inside their advertising Service Data (the `0x16`/`0x20`/`0x21` advertising data types) — beacons such as Eddystone (`0xFEAA`) or the ATC_MiThermometer (`0x181A`) — which `withServices` cannot match, because it only looks at the advertised service UUID list (#57). The matching payloads are reported in `BleDevice.serviceData`. On Android and Apple this switches the scan to the software filter path (the platform cannot filter on Service Data), so more advertisements reach the app process; on Web it throws `notSupported`. * Apple: compare service UUIDs in their 128-bit form when the custom filter path is active (`withNamePrefix`/`withManufacturerData`), instead of relying on `CBUUID` equality, which does not match a 16-bit advertised service UUID against the full 128-bit form the API sends. `ScanFilter.withServices` can now filter 16-bit services on that path. * Linux: re-evaluate the scan filter against the device's current properties instead of the snapshot taken when BlueZ added it, and keep listening to a device that did not match at that point. A peripheral that only advertises its Service Data (or UUIDs) in a later advertisement was previously dropped permanently by any filter. diff --git a/lib/src/models/ble_device.dart b/lib/src/models/ble_device.dart index e932c949..11f3fd41 100644 --- a/lib/src/models/ble_device.dart +++ b/lib/src/models/ble_device.dart @@ -22,6 +22,12 @@ class BleDevice { bool? connectable; /// List of services advertised by the device. + /// + /// Normalised to full 128-bit lower-case UUIDs on every platform (like the keys + /// of [serviceData]), so the value is comparable across platforms and against + /// user input of either form. Entries that are not valid UUIDs are kept + /// verbatim rather than dropped, so unexpected advertisement data cannot lose + /// information. List services; bool? isSystemDevice; List manufacturerDataList; @@ -61,14 +67,15 @@ class BleDevice { required String? name, this.rssi, this.paired, - this.services = const [], + List services = const [], this.isSystemDevice, this.manufacturerDataList = const [], Map serviceData = const {}, this.timestamp, this.timestampMicroseconds, this.connectable, - }) : serviceData = _validateServiceData(serviceData), + }) : services = _validateServices(services), + serviceData = _validateServiceData(serviceData), rawName = name, name = name?.replaceAll(RegExp(r'[^ -~]'), '').trim(); @@ -111,6 +118,14 @@ class BleDevice { ); } + /// Normalises advertised service UUIDs to the full 128-bit lower-case form. + static List _validateServices(List services) { + if (services.isEmpty) return const []; + return services + .map((uuid) => BleUuidParser.stringOrNull(uuid) ?? uuid) + .toList(growable: false); + } + @override String toString() { return 'BleDevice: ' diff --git a/lib/src/utils/universal_ble_filter_util.dart b/lib/src/utils/universal_ble_filter_util.dart index 0f0d3e4e..24f7d527 100644 --- a/lib/src/utils/universal_ble_filter_util.dart +++ b/lib/src/utils/universal_ble_filter_util.dart @@ -20,7 +20,7 @@ class UniversalBleFilterUtil { if (exclusionFilters == null || exclusionFilters.isEmpty) return false; final deviceName = device.name?.toLowerCase() ?? ''; - final deviceServices = device.services.toValidUUIDList(); + final deviceServices = _normalizedUuids(device.services); for (var filter in exclusionFilters) { if (!filter.hasValidFilters) continue; @@ -88,7 +88,7 @@ class UniversalBleFilterUtil { bool servicesMatch(ScanFilter scanFilter, BleDevice device) { final serviceFilters = scanFilter.withServices.toValidUUIDList(); - final serviceUuids = device.services.toValidUUIDList(); + final serviceUuids = _normalizedUuids(device.services); if (serviceUuids.isEmpty) { return false; } @@ -105,13 +105,18 @@ class UniversalBleFilterUtil { /// scan. bool serviceDataMatch(ScanFilter scanFilter, BleDevice device) { final serviceDataFilters = scanFilter.withServiceData.toValidUUIDList(); - final advertisedUuids = device.serviceData.keys - .map(BleUuidParser.stringOrNull) - .whereType() - .toSet(); + final advertisedUuids = _normalizedUuids(device.serviceData.keys); return serviceDataFilters.any(advertisedUuids.contains); } + /// The device-side UUIDs of an advertisement, as the full 128-bit lower-case + /// form. [BleDevice] normalises them already; anything unparseable is skipped + /// rather than throwing, so malformed advertisement data cannot fail a scan. + Set _normalizedUuids(Iterable uuids) => uuids + .map(BleUuidParser.stringOrNull) + .whereType() + .toSet(); + bool manufacturerDataMatches( List filters, BleDevice device, diff --git a/test/ble_device_services_test.dart b/test/ble_device_services_test.dart new file mode 100644 index 00000000..464d2b48 --- /dev/null +++ b/test/ble_device_services_test.dart @@ -0,0 +1,94 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_ble/src/utils/universal_ble_filter_util.dart'; +import 'package:universal_ble/universal_ble.dart'; + +void main() { + const full = '0000180d-0000-1000-8000-00805f9b34fb'; + + BleDevice deviceWith(List services) => + BleDevice(deviceId: '1', name: 'device', services: services); + + group('BleDevice.services normalization', () { + test('expands a short UUID', () { + expect(deviceWith(['180d']).services, [full]); + }); + + test('accepts the 0x-prefixed form', () { + expect(deviceWith(['0x180d']).services, [full]); + }); + + test('lower-cases and keeps the full form', () { + expect( + deviceWith(['0000180D-0000-1000-8000-00805F9B34FB']).services, + [full], + ); + }); + + test('keeps already normalised UUIDs unchanged', () { + expect(deviceWith([full]).services, [full]); + }); + + test('expands a 32-bit UUID', () { + expect( + deviceWith(['0000180d']).services, + ['0000180d-0000-1000-8000-00805f9b34fb'], + ); + }); + + test('normalises every entry of a list', () { + expect( + deviceWith(['180d', '181a']).services, + const [ + full, + '0000181a-0000-1000-8000-00805f9b34fb', + ], + ); + }); + + test('keeps entries that are not UUIDs verbatim', () { + expect(deviceWith(['not-a-uuid']).services, ['not-a-uuid']); + }); + + test('leaves an empty list empty', () { + expect(deviceWith(const []).services, isEmpty); + }); + + test('survives copyWith', () { + expect( + deviceWith(['180d']).copyWith(deviceId: '2').services, + [full], + ); + }); + + test('normalises serviceData keys the same way', () { + final device = BleDevice( + deviceId: '1', + name: 'device', + serviceData: {'181a': Uint8List.fromList([1])}, + ); + + expect(device.serviceData.keys, ['0000181a-0000-1000-8000-00805f9b34fb']); + }); + }); + + group('filter matching across UUID forms', () { + final filterUtil = UniversalBleFilterUtil(); + + test('a short advertised service matches a full filter', () { + filterUtil.scanFilter = ScanFilter(withServices: [full]); + expect(filterUtil.matchedScanFilter(deviceWith(['180d'])), isTrue); + }); + + test('a full advertised service matches a short filter', () { + filterUtil.scanFilter = ScanFilter(withServices: ['180d']); + expect(filterUtil.matchedScanFilter(deviceWith([full])), isTrue); + }); + + test('a malformed advertised service does not throw', () { + filterUtil.scanFilter = ScanFilter(withServices: [full]); + expect(filterUtil.matchedScanFilter(deviceWith(['not-a-uuid'])), isFalse); + }); + }); +} From a3378ef790d7f2711d376441a46d7dc120c46a79 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Thu, 15 Oct 2026 23:49:41 +0200 Subject: [PATCH 2/3] 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. --- README.md | 2 +- lib/src/models/ble_device.dart | 2 +- .../universal_ble_pigeon_channel.dart | 5 +++- test/ble_device_services_test.dart | 28 +++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f321e950..2f23e28b 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ UniversalBle.stopScan(); UniversalBle.isScanning(); ``` -Each scan result is a `BleDevice`. Besides `deviceId`, `name` and `rssi` it carries `services` (the advertised service UUIDs), `manufacturerDataList`, `serviceData` (the advertising Service Data payloads, keyed by service UUID), `connectable` and the reception timestamps. +Each scan result is a `BleDevice`. Besides `deviceId`, `name` and `rssi` it carries `services` (the advertised service UUIDs, normalised to full 128-bit lower-case UUIDs on every platform), `manufacturerDataList`, `serviceData` (the advertising Service Data payloads, keyed by service UUID), `connectable` and the reception timestamps. #### Skipping duplicate scan results diff --git a/lib/src/models/ble_device.dart b/lib/src/models/ble_device.dart index 11f3fd41..b1cf14c2 100644 --- a/lib/src/models/ble_device.dart +++ b/lib/src/models/ble_device.dart @@ -123,7 +123,7 @@ class BleDevice { if (services.isEmpty) return const []; return services .map((uuid) => BleUuidParser.stringOrNull(uuid) ?? uuid) - .toList(growable: false); + .toList(); } @override diff --git a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart index 038a3f5a..7ce9e626 100644 --- a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart +++ b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart @@ -384,7 +384,10 @@ extension UniversalBleScanResultExtension on UniversalBleScanResult { rssi: rssi, paired: isPaired, isSystemDevice: isSystemDevice, - services: services?.map(BleUuidParser.string).toList() ?? [], + // `BleDevice` normalises these to full 128-bit lower-case UUIDs and keeps + // anything unparseable verbatim, so a malformed advertisement value cannot + // throw here and fail the scan. + services: services ?? [], timestamp: timestamp, timestampMicroseconds: timestampMicroseconds, manufacturerDataList: manufacturerDataList diff --git a/test/ble_device_services_test.dart b/test/ble_device_services_test.dart index 464d2b48..552f48a9 100644 --- a/test/ble_device_services_test.dart +++ b/test/ble_device_services_test.dart @@ -1,6 +1,8 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_ble/src/universal_ble.g.dart'; +import 'package:universal_ble/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart'; import 'package:universal_ble/src/utils/universal_ble_filter_util.dart'; import 'package:universal_ble/universal_ble.dart'; @@ -91,4 +93,30 @@ void main() { expect(filterUtil.matchedScanFilter(deviceWith(['not-a-uuid'])), isFalse); }); }); + + group('native scan result conversion', () { + test('normalises a short advertised service', () { + final device = UniversalBleScanResult( + deviceId: '1', + services: ['180d'], + ).toBleDevice(); + + expect(device.services, [full]); + }); + + test('malformed advertised services do not throw', () { + final device = UniversalBleScanResult( + deviceId: '1', + services: ['not-a-uuid'], + ).toBleDevice(); + + expect(device.services, ['not-a-uuid']); + }); + + test('absent advertised services become an empty list', () { + final device = UniversalBleScanResult(deviceId: '1').toBleDevice(); + + expect(device.services, isEmpty); + }); + }); } From 7fd78b80356d77280e5377c767ee175c40156d60 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Thu, 15 Oct 2026 23:53:02 +0200 Subject: [PATCH 3/3] Document BleDevice.services normalisation in the 2.x -> 3.x migration skill --- skills/universal-ble-migrate-2-to-3/SKILL.md | 66 ++++++++++++++++---- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/skills/universal-ble-migrate-2-to-3/SKILL.md b/skills/universal-ble-migrate-2-to-3/SKILL.md index 5717ff0b..88ed1f2b 100644 --- a/skills/universal-ble-migrate-2-to-3/SKILL.md +++ b/skills/universal-ble-migrate-2-to-3/SKILL.md @@ -1,24 +1,27 @@ --- name: universal-ble-migrate-2-to-3 description: >- - Migrate a Flutter app from universal_ble 2.x to 3.0. Use when upgrading + Migrate a Flutter app from universal_ble 2.x to 3.x. Use when upgrading universal_ble to ^3.0.0 and fixing its breaking changes: the QueueType.auto default, device IDs now emitted in lower-case, the onConnectionChange callback now receiving a BleConnectionChange, the onValueChange callback now receiving a BleCharacteristicValue, the new connectionChangeStream, the BleConnectionParametersUpdated rename, the iOS 18+ unpair behavior with - AccessorySetupKit, or custom UniversalBlePlatform implementations that no - longer compile. Also use when a queue no longer serializes commands the same - way, an onConnectionChange or onValueChange handler fails to compile, stored - device IDs no longer compare equal, a custom platform mock is missing - parameters after the upgrade, or unpair behaves differently on iOS. + AccessorySetupKit, BleDevice.services now emitted as full 128-bit UUIDs, or + custom UniversalBlePlatform implementations that no longer compile. Also use + when a queue no longer serializes commands the same way, an onConnectionChange + or onValueChange handler fails to compile, stored device IDs no longer compare + equal, advertised service UUIDs no longer match a stored short form, a custom + platform mock is missing parameters after the upgrade, or unpair behaves + differently on iOS. --- -# Migrating universal_ble 2.x → 3.0 +# Migrating universal_ble 2.x → 3.x -3.0 is a focused release. There are six breaking changes to the public API, +3.0 is a focused release. There are seven breaking changes to the public API +across the 3.x line (the `BleDevice.services` normalisation landed in 3.1.0), plus signature changes to `UniversalBlePlatform` for custom implementations; -everything else in 3.0 is additive. +everything else is additive. ## Breaking changes at a glance @@ -30,7 +33,8 @@ everything else in 3.0 is additive. | 4 | `onValueChange` now receives a single `BleCharacteristicValue` | Every app using the callback | | 5 | `BleConnectionParametersUpdated` renamed to `BleConnectionParametersChange` | Code that references the type by name | | 6 | On iOS 18+, `unpair` removes an AccessorySetupKit accessory instead of throwing | Apps that call `unpair` on iOS | -| 7 | `UniversalBlePlatform` interface signatures changed | Custom platform implementations / mocks (`UniversalBle.setInstance`) | +| 7 | `BleDevice.services` normalised to full 128-bit lower-case UUIDs (3.1.0) | Code that stored or compared an advertised service UUID in its short Apple form | +| 8 | `UniversalBlePlatform` interface signatures changed | Custom platform implementations / mocks (`UniversalBle.setInstance`) | Work top to bottom and re-run `flutter analyze` after each step. @@ -244,7 +248,44 @@ foreground, and the accessory's discovery identifiers must be declared in the app's `Info.plist` (see README → Native device picker). Apps using only this flow can omit `NSBluetoothAlwaysUsageDescription`. -## 7. Custom `UniversalBlePlatform` implementations +## 7. `BleDevice.services` is now full 128-bit UUIDs + +`BleDevice.services` (the advertised service UUIDs on a scan result) used to keep +whatever width the reporting platform used: Apple preserved the advertised width, +so a 16-bit service came back as `180D`, while Android, Windows, Linux and Web +reported the full `0000180d-0000-1000-8000-00805f9b34fb`. As of 3.1.0 every entry +is normalised by `BleDevice` to the full 128-bit lower-case form, matching the +keys of `BleDevice.serviceData`. + +```dart +// Before (3.0): shape depended on the platform +UniversalBle.onScanResult = (device) { + // Apple: ['180D']; Android: ['0000180d-0000-1000-8000-00805f9b34fb'] + print(device.services); +}; + +// After (3.1.0): full 128-bit lower-case on every platform +UniversalBle.onScanResult = (device) { + print(device.services); // ['0000180d-0000-1000-8000-00805f9b34fb'] +}; +``` + +This only affects code that stored, compared, or keyed a `services` entry by its +short Apple form. Compare in a form-agnostic way with `BleUuidParser` instead of +string equality: + +```dart +final advertised = device.services.any( + (uuid) => BleUuidParser.compareStrings(uuid, '180D'), +); +``` + +Short (`180D`), `0x`-prefixed and mixed-case forms are still accepted as *input* +everywhere, so `ScanFilter.withServices` / `withServiceData` and the `BleDevice` +constructor need no change. Entries that are not valid UUIDs are kept verbatim +rather than dropped, and the matching helpers skip them instead of throwing. + +## 8. Custom `UniversalBlePlatform` implementations Only relevant if you extend `UniversalBlePlatform` (e.g. a mock via `UniversalBle.setInstance`). Several signatures changed: @@ -311,6 +352,9 @@ including IDs pushed directly onto exported stream controllers. - [ ] Reviewed code that stored or compared emitted device IDs by exact case, and normalised it with `UniversalBle.canonicalDeviceId` (Web IDs stay verbatim — do not blanket lower-case). +- [ ] Updated code that compared or keyed an advertised service UUID + (`BleDevice.services`) in its short Apple form (`180D`); parse both sides + with `BleUuidParser` or compare the 128-bit form. - [ ] Rewrote every `onConnectionChange` handler to take a `BleConnectionChange`, and every `onValueChange` handler to take a `BleCharacteristicValue`. - [ ] Renamed `BleConnectionParametersUpdated` references to