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/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 e932c949..b1cf14c2 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(); + } + @override String toString() { return 'BleDevice: ' 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/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/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 diff --git a/test/ble_device_services_test.dart b/test/ble_device_services_test.dart new file mode 100644 index 00000000..552f48a9 --- /dev/null +++ b/test/ble_device_services_test.dart @@ -0,0 +1,122 @@ +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'; + +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); + }); + }); + + 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); + }); + }); +}