Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 17 additions & 2 deletions lib/src/models/ble_device.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> services;
bool? isSystemDevice;
List<ManufacturerData> manufacturerDataList;
Expand Down Expand Up @@ -61,14 +67,15 @@ class BleDevice {
required String? name,
this.rssi,
this.paired,
this.services = const [],
List<String> services = const [],
this.isSystemDevice,
this.manufacturerDataList = const [],
Map<String, Uint8List> 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();

Expand Down Expand Up @@ -111,6 +118,14 @@ class BleDevice {
);
}

/// Normalises advertised service UUIDs to the full 128-bit lower-case form.
static List<String> _validateServices(List<String> services) {
if (services.isEmpty) return const [];
return services
.map((uuid) => BleUuidParser.stringOrNull(uuid) ?? uuid)
.toList();
}

@override
String toString() {
return 'BleDevice: '
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 11 additions & 6 deletions lib/src/utils/universal_ble_filter_util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<String>()
.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<String> _normalizedUuids(Iterable<String> uuids) => uuids
.map(BleUuidParser.stringOrNull)
.whereType<String>()
.toSet();

bool manufacturerDataMatches(
List<ManufacturerDataFilter> filters,
BleDevice device,
Expand Down
66 changes: 55 additions & 11 deletions skills/universal-ble-migrate-2-to-3/SKILL.md
Original file line number Diff line number Diff line change
@@ -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

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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions test/ble_device_services_test.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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);
});
});
}