From 544c121bd9b47f2932cdc86a20752aeef7324253 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Thu, 15 Oct 2026 23:11:05 +0200 Subject: [PATCH 1/4] Add ScanFilter.withServiceData to match advertising Service Data (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- CHANGELOG.md | 6 + README.md | 32 +++++ .../navideck/universal_ble/UniversalBle.g.kt | 15 +- .../universal_ble/UniversalBleFilterUtil.kt | 33 ++++- .../universal_ble/UniversalBleHelper.kt | 8 ++ .../universal_ble/UniversalBlePlugin.kt | 7 +- .../UniversalBleFilterUtilTest.kt | 97 +++++++++++++ .../universal_ble/UniversalBleHelperTest.kt | 34 +++++ .../universal_ble/UniversalBle.g.swift | 12 +- .../UniversalBleFilterUtil.swift | 43 +++++- .../universal_ble/UniversalBleHelper.swift | 15 ++ .../universal_ble/UniversalBlePlugin.swift | 9 +- example/pubspec.lock | 2 +- lib/src/models/scan_filter.dart | 28 +++- lib/src/universal_ble.g.dart | 11 +- .../universal_ble_linux.dart | 17 ++- .../universal_ble_pigeon_channel.dart | 1 + .../universal_ble_web/universal_ble_web.dart | 15 ++ lib/src/utils/universal_ble_filter_util.dart | 22 +++ pigeon/universal_ble.dart | 6 + pubspec.yaml | 2 +- test/scan_filter_channel_test.dart | 63 +++++++++ test/scan_filter_test.dart | 133 ++++++++++++++++-- windows/src/generated/universal_ble.g.cpp | 24 +++- windows/src/generated/universal_ble.g.h | 10 +- windows/src/universal_ble_filter_util.cpp | 42 +++++- 26 files changed, 647 insertions(+), 40 deletions(-) create mode 100644 android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterUtilTest.kt create mode 100644 test/scan_filter_channel_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f21429..b7db5d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 3.1.0 +* 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. +* Add `BleDevice.serviceData` documentation to the README (the API existed but was undocumented). + ## 3.0.2 * Fix the broken "Try it online" demo URL in the README. diff --git a/README.md b/README.md index f42781ac..f321e950 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ UniversalBle.startScan(); UniversalBle.startScan( scanFilter: ScanFilter( withServices: ["SERVICE_UUID"], + withServiceData: ["SERVICE_DATA_UUID"], withManufacturerData: [ManufacturerDataFilter(companyIdentifier: 0x004c)], withNamePrefix: ["NAME_PREFIX"], ) @@ -147,6 +148,8 @@ 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. + #### Skipping duplicate scan results By default every advertisement packet is reported, so the same device can show up many times per second — each one waking your app and drawing battery. Pass `skipDuplicates: true` to report each device only once per scan session: later sightings are suppressed until the scan is stopped (`UniversalBle.stopScan`) or the next `startScan`, including sightings whose advertisement payload changed. @@ -210,6 +213,35 @@ List withServices; Note: On web **you have to** specify services before you are able to use them. See the [web](#web) section for more details. +##### With ServiceData + +When setting this parameter, the scan results will only include devices that advertise **Service Data** for any of the specified services. + +Service Data is the advertising data type that pairs a service UUID with a payload (`0x16`, `0x20` and `0x21` in the Bluetooth specification). It is a *different* field from the advertised service UUID list matched by `withServices`, and beacons commonly carry their UUID only there — Eddystone (`0xFEAA`), the ATC_MiThermometer (`0x181A`), BLE Mesh provisioning (`0x1827`) — which makes them invisible to a `withServices` filter. + +```dart +List withServiceData; +``` + +```dart +UniversalBle.startScan( + scanFilter: ScanFilter(withServiceData: ['181A']), +); + +UniversalBle.onScanResult = (device) { + // The payload of the device's 181A Service Data field. + final payload = device.serviceData['0000181a-0000-1000-8000-00805f9b34fb']; +}; +``` + +UUIDs are accepted in any case and in short (`181A`), `0x`-prefixed, or full 128-bit form. Like the other conditions, an empty list is ignored and the conditions are in `OR` relation, so combining `withServiceData` with `withServices` returns devices matching either. + +Platform notes: + +- On `Android` and `Apple` this moves the scan to the software filter path: the platform cannot filter on Service Data (a controller-level scan filter, or `scanForPeripherals(withServices:)`, would discard the device before the plugin is notified), so more advertisements reach the app process at a battery cost. Set it only when you need it. +- On `Web` it is not supported and `startScan` / `requestDevice` throw `notSupported`: the browser's device chooser cannot filter on Service Data. Scan without it and read `BleDevice.serviceData` from the advertisement events instead. +- On `Android`, a peripheral using legacy BLE 4.x advertisements additionally needs `AndroidOptions(legacy: true)`. + ##### With ManufacturerData Use the `withManufacturerData` parameter to filter devices by manufacturer data. When you pass a list of `ManufacturerDataFilter` objects to this parameter, the scan results will only include devices that contain any of the specified manufacturer data. diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt index 8dd99c40..0a412e37 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt @@ -905,7 +905,13 @@ data class UniversalScanConfig ( data class UniversalScanFilter ( val withServices: List, val withNamePrefix: List, - val withManufacturerData: List + val withManufacturerData: List, + /** + * Service data UUIDs to match. Appended last on purpose: pigeon encodes + * fields positionally, so inserting this anywhere else would shift the + * indices of the fields above and break against a stale native binary. + */ + val withServiceData: List ) { companion object { @@ -913,7 +919,8 @@ data class UniversalScanFilter ( val withServices = pigeonVar_list[0] as List val withNamePrefix = pigeonVar_list[1] as List val withManufacturerData = pigeonVar_list[2] as List - return UniversalScanFilter(withServices, withNamePrefix, withManufacturerData) + val withServiceData = pigeonVar_list[3] as List + return UniversalScanFilter(withServices, withNamePrefix, withManufacturerData, withServiceData) } } fun toList(): List { @@ -921,6 +928,7 @@ data class UniversalScanFilter ( withServices, withNamePrefix, withManufacturerData, + withServiceData, ) } override fun equals(other: Any?): Boolean { @@ -931,7 +939,7 @@ data class UniversalScanFilter ( return true } val other = other as UniversalScanFilter - return UniversalBlePigeonUtils.deepEquals(this.withServices, other.withServices) && UniversalBlePigeonUtils.deepEquals(this.withNamePrefix, other.withNamePrefix) && UniversalBlePigeonUtils.deepEquals(this.withManufacturerData, other.withManufacturerData) + return UniversalBlePigeonUtils.deepEquals(this.withServices, other.withServices) && UniversalBlePigeonUtils.deepEquals(this.withNamePrefix, other.withNamePrefix) && UniversalBlePigeonUtils.deepEquals(this.withManufacturerData, other.withManufacturerData) && UniversalBlePigeonUtils.deepEquals(this.withServiceData, other.withServiceData) } override fun hashCode(): Int { @@ -939,6 +947,7 @@ data class UniversalScanFilter ( result = 31 * result + UniversalBlePigeonUtils.deepHash(this.withServices) result = 31 * result + UniversalBlePigeonUtils.deepHash(this.withNamePrefix) result = 31 * result + UniversalBlePigeonUtils.deepHash(this.withManufacturerData) + result = 31 * result + UniversalBlePigeonUtils.deepHash(this.withServiceData) return result } } diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleFilterUtil.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleFilterUtil.kt index 13cfdff0..8f40cd5c 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleFilterUtil.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleFilterUtil.kt @@ -13,21 +13,25 @@ private const val TAG = "UniversalBlePlugin" class UniversalBleFilterUtil { var scanFilter: UniversalScanFilter? = null var serviceFilterUUIDS: List = emptyList() + var serviceDataFilterUUIDS: List = emptyList() fun filterDevice( name: String?, manufacturerDataList: List, serviceUuids: Array, + serviceDataUuids: List, ): Boolean { val filter = scanFilter ?: return true val hasNamePrefixFilter = filter.withNamePrefix.isNotEmpty() val hasServiceFilter = filter.withServices.isNotEmpty() + val hasServiceDataFilter = filter.withServiceData.isNotEmpty() val hasManufacturerDataFilter = filter.withManufacturerData.isNotEmpty() // If there is no filter at all, then allow device if (!hasNamePrefixFilter && !hasServiceFilter && + !hasServiceDataFilter && !hasManufacturerDataFilter ) { return true @@ -36,6 +40,7 @@ class UniversalBleFilterUtil { // For not, we only have DeviceName filter return hasNamePrefixFilter && isNameMatchingFilters(filter, name) || hasServiceFilter && isServicesMatchingFilters(serviceUuids) || + hasServiceDataFilter && isServiceDataMatchingFilters(serviceDataUuids) || hasManufacturerDataFilter && isManufacturerDataMatchingFilters( filter, manufacturerDataList @@ -67,6 +72,25 @@ class UniversalBleFilterUtil { } } + /// Matches a device by the service UUIDs it carries in its advertising + /// Service Data (the `0x16`/`0x20`/`0x21` data types). This is a different + /// set from the advertised service UUID list matched by + /// [isServicesMatchingFilters]: beacons such as Eddystone (`0xFEAA`) or the + /// ATC_MiThermometer (`0x181A`) only appear here (#57). + private fun isServiceDataMatchingFilters( + serviceDataUuids: List, + ): Boolean { + if (serviceDataFilterUUIDS.isEmpty()) { + return true + } + if (serviceDataUuids.isEmpty()) { + return false + } + return serviceDataFilterUUIDS.any { + serviceDataUuids.contains(it) + } + } + private fun isManufacturerDataMatchingFilters( scanFilter: UniversalScanFilter, manufacturerDataList: List, @@ -100,8 +124,13 @@ class UniversalBleFilterUtil { } fun UniversalScanFilter.usesCustomFilters(): Boolean { - // NamePrefix Filtering is not supported in native filters - return withNamePrefix.isNotEmpty() + // NamePrefix filtering is not supported in native filters. Service Data + // matching cannot be expressed as a native ScanFilter either: the controller + // would drop a device that carries the UUID only inside its Service Data + // before the app is ever notified, so both have to be applied in the scan + // callback instead. Callers that opt in pay for it in battery (every + // advertisement reaches the app process before being filtered). + return withNamePrefix.isNotEmpty() || withServiceData.isNotEmpty() } // Convert UniversalScanFilter to ScanFilter diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleHelper.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleHelper.kt index 812186e8..878ac347 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleHelper.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleHelper.kt @@ -157,6 +157,14 @@ val ScanResult.serviceData: Map return scanRecord?.serviceData?.mapKeys { it.key.uuid.toString() } ?: emptyMap() } +/// Service UUIDs the packet carries in its advertising Service Data +/// (`0x16`/`0x20`/`0x21` data types), as opposed to [serviceUuids] which are the +/// advertised service UUID lists. +val ScanResult.serviceDataUuids: List + get() { + return scanRecord?.serviceData?.keys?.map { it.uuid } ?: emptyList() + } + fun SparseArray.toList(): List> { return (0 until size).map { index -> keyAt(index) to valueAt(index) diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt index fc26b622..0300599f 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -272,6 +272,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), try { val filterServices = filter?.withServices?.toUUIDList() ?: emptyList() + val filterServiceData = filter?.withServiceData?.toUUIDList() ?: emptyList() var scanFilters = emptyList() // Set custom scan filter only if required @@ -279,8 +280,11 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), UniversalBleLogger.logError("Using Custom Filters") universalBleFilterUtil.scanFilter = filter universalBleFilterUtil.serviceFilterUUIDS = filterServices + universalBleFilterUtil.serviceDataFilterUUIDS = filterServiceData } else { universalBleFilterUtil.scanFilter = null + universalBleFilterUtil.serviceFilterUUIDS = emptyList() + universalBleFilterUtil.serviceDataFilterUUIDS = emptyList() scanFilters = filter?.toScanFilters(filterServices) ?: emptyList() } @@ -1674,7 +1678,8 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), if (!universalBleFilterUtil.filterDevice( name, manufacturerDataList, - serviceUuids + serviceUuids, + result.serviceDataUuids ) ) return diff --git a/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterUtilTest.kt b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterUtilTest.kt new file mode 100644 index 00000000..568dd814 --- /dev/null +++ b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterUtilTest.kt @@ -0,0 +1,97 @@ +package com.navideck.universal_ble + +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class UniversalBleFilterUtilTest { + + private val serviceDataUuid = UUID.fromString("0000181a-0000-1000-8000-00805f9b34fb") + private val otherUuid = UUID.fromString("00002a6e-0000-1000-8000-00805f9b34fb") + + private fun scanFilter( + withServices: List = emptyList(), + withNamePrefix: List = emptyList(), + withServiceData: List = emptyList(), + ) = UniversalScanFilter(withServices, withNamePrefix, emptyList(), withServiceData) + + private fun filterUtil(filter: UniversalScanFilter): UniversalBleFilterUtil { + return UniversalBleFilterUtil().apply { + scanFilter = filter + serviceFilterUUIDS = filter.withServices.map { UUID.fromString(it) } + serviceDataFilterUUIDS = filter.withServiceData.map { UUID.fromString(it) } + } + } + + @Test + fun serviceDataMatchingRequiresTheFilterUuid() { + val util = filterUtil(scanFilter(withServiceData = listOf(serviceDataUuid.toString()))) + + assertTrue(util.filterDevice("ATC", emptyList(), emptyArray(), listOf(serviceDataUuid))) + assertFalse(util.filterDevice("ATC", emptyList(), emptyArray(), listOf(otherUuid))) + assertFalse(util.filterDevice("ATC", emptyList(), emptyArray(), emptyList())) + } + + @Test + fun acceptsBeaconWithoutAdvertisedServices() { + // Regression for #57: a device that only carries the UUID in its Service + // Data has an empty advertised service list, so plain service filters + // could never discover it. (The Dart layer normalises UUIDs to the full + // 128-bit form before they reach the channel, so short forms are not + // parsed here.) + val util = filterUtil(scanFilter(withServiceData = listOf(serviceDataUuid.toString()))) + + assertTrue( + util.filterDevice("ATC_DD7A81", emptyList(), emptyArray(), listOf(serviceDataUuid)) + ) + } + + @Test + fun serviceDataFilterIgnoresAdvertisedServiceList() { + val util = filterUtil(scanFilter(withServiceData = listOf(serviceDataUuid.toString()))) + + // The UUID is advertised as a service here, which is a different + // advertising data type: it must not satisfy withServiceData. + assertFalse(util.filterDevice("ATC", emptyList(), arrayOf(serviceDataUuid), emptyList())) + } + + @Test + fun advertisedServiceFilterIgnoresServiceData() { + val util = filterUtil(scanFilter(withServices = listOf(serviceDataUuid.toString()))) + + assertTrue(util.filterDevice("ATC", emptyList(), arrayOf(serviceDataUuid), emptyList())) + assertFalse(util.filterDevice("ATC", emptyList(), emptyArray(), listOf(serviceDataUuid))) + } + + @Test + fun filtersAreInOrRelation() { + val util = filterUtil( + scanFilter( + withServices = listOf(otherUuid.toString()), + withServiceData = listOf(serviceDataUuid.toString()), + ) + ) + + assertTrue(util.filterDevice("a", emptyList(), arrayOf(otherUuid), emptyList())) + assertTrue(util.filterDevice("a", emptyList(), emptyArray(), listOf(serviceDataUuid))) + assertFalse(util.filterDevice("a", emptyList(), emptyArray(), emptyList())) + } + + @Test + fun noFiltersAcceptEverything() { + val util = UniversalBleFilterUtil() + + assertTrue(util.filterDevice("a", emptyList(), emptyArray(), emptyList())) + assertTrue(util.filterDevice("a", emptyList(), arrayOf(otherUuid), listOf(serviceDataUuid))) + } + + @Test + fun usesCustomFiltersOnlyWhenNativeFilterCannotExpressIt() { + // Advertised services are still handled by the controller-level ScanFilter. + assertFalse(scanFilter(withServices = listOf("181a")).usesCustomFilters()) + // Name prefix and Service Data cannot be, so they are applied in the callback. + assertTrue(scanFilter(withServiceData = listOf("181a")).usesCustomFilters()) + assertTrue(scanFilter(withNamePrefix = listOf("ATC")).usesCustomFilters()) + } +} diff --git a/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleHelperTest.kt b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleHelperTest.kt index f090c4f2..7ee70668 100644 --- a/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleHelperTest.kt +++ b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleHelperTest.kt @@ -2,6 +2,8 @@ package com.navideck.universal_ble import android.bluetooth.le.ScanRecord import android.bluetooth.le.ScanResult +import android.os.ParcelUuid +import java.util.UUID import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -116,6 +118,38 @@ internal class UniversalBleHelperTest { assertTrue(scanResult.manufacturerDataList.isEmpty()) } + @Test + fun parsesServiceDataAndItsUuids() { + val parcelUuid = mock(ParcelUuid::class.java) + `when`(parcelUuid.uuid).thenReturn( + UUID.fromString("0000181a-0000-1000-8000-00805f9b34fb") + ) + val scanRecord = mock(ScanRecord::class.java) + `when`(scanRecord.serviceData).thenReturn(mapOf(parcelUuid to byteArrayOf(1, 2))) + val scanResult = mock(ScanResult::class.java) + `when`(scanResult.scanRecord).thenReturn(scanRecord) + + val serviceData = scanResult.serviceData + assertEquals(1, serviceData.size) + assertEquals("0000181a-0000-1000-8000-00805f9b34fb", serviceData.keys.first()) + assertContentEquals(byteArrayOf(1, 2), serviceData.values.first()) + assertEquals( + listOf(UUID.fromString("0000181a-0000-1000-8000-00805f9b34fb")), + scanResult.serviceDataUuids, + ) + } + + @Test + fun missingServiceDataReturnsEmpty() { + val scanResult = mock(ScanResult::class.java) + val scanRecord = mock(ScanRecord::class.java) + `when`(scanResult.scanRecord).thenReturn(scanRecord) + `when`(scanRecord.serviceData).thenReturn(null) + + assertTrue(scanResult.serviceData.isEmpty()) + assertTrue(scanResult.serviceDataUuids.isEmpty()) + } + private fun manufacturerAd(companyId: Int, payload: ByteArray): ByteArray { val companyBytes = byteArrayOf( (companyId and 0xFF).toByte(), diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBle.g.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBle.g.swift index 7560783a..6d38a796 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBle.g.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBle.g.swift @@ -783,6 +783,10 @@ struct UniversalScanFilter: Hashable { var withServices: [String] var withNamePrefix: [String] var withManufacturerData: [ManufacturerDataFilter] + /// Service data UUIDs to match. Appended last on purpose: pigeon encodes + /// fields positionally, so inserting this anywhere else would shift the + /// indices of the fields above and break against a stale native binary. + var withServiceData: [String] // swift-format-ignore: AlwaysUseLowerCamelCase @@ -790,11 +794,13 @@ struct UniversalScanFilter: Hashable { let withServices = pigeonVar_list[0] as! [String] let withNamePrefix = pigeonVar_list[1] as! [String] let withManufacturerData = pigeonVar_list[2] as! [ManufacturerDataFilter] + let withServiceData = pigeonVar_list[3] as! [String] return UniversalScanFilter( withServices: withServices, withNamePrefix: withNamePrefix, - withManufacturerData: withManufacturerData + withManufacturerData: withManufacturerData, + withServiceData: withServiceData ) } func toList() -> [Any?] { @@ -802,13 +808,14 @@ struct UniversalScanFilter: Hashable { withServices, withNamePrefix, withManufacturerData, + withServiceData, ] } static func == (lhs: UniversalScanFilter, rhs: UniversalScanFilter) -> Bool { if Swift.type(of: lhs) != Swift.type(of: rhs) { return false } - return deepEqualsUniversalBle(lhs.withServices, rhs.withServices) && deepEqualsUniversalBle(lhs.withNamePrefix, rhs.withNamePrefix) && deepEqualsUniversalBle(lhs.withManufacturerData, rhs.withManufacturerData) + return deepEqualsUniversalBle(lhs.withServices, rhs.withServices) && deepEqualsUniversalBle(lhs.withNamePrefix, rhs.withNamePrefix) && deepEqualsUniversalBle(lhs.withManufacturerData, rhs.withManufacturerData) && deepEqualsUniversalBle(lhs.withServiceData, rhs.withServiceData) } func hash(into hasher: inout Hasher) { @@ -816,6 +823,7 @@ struct UniversalScanFilter: Hashable { deepHashUniversalBle(value: withServices, hasher: &hasher) deepHashUniversalBle(value: withNamePrefix, hasher: &hasher) deepHashUniversalBle(value: withManufacturerData, hasher: &hasher) + deepHashUniversalBle(value: withServiceData, hasher: &hasher) } } diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBleFilterUtil.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBleFilterUtil.swift index fd67f9b5..482d5f1e 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBleFilterUtil.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBleFilterUtil.swift @@ -12,23 +12,28 @@ public class UniversalBleFilterUtil { var scanFilter: UniversalScanFilter? var scanFilterServicesUUID: [CBUUID] = [] - func filterDevice(name: String?, manufacturerData: UniversalManufacturerData?, services: [CBUUID]?) -> Bool { + /// Service Data UUIDs to match, normalised to 128-bit lowercase form. + var scanFilterServiceDataUUID: [String] = [] + + func filterDevice(name: String?, manufacturerData: UniversalManufacturerData?, services: [CBUUID]?, serviceData: [CBUUID]? = nil) -> Bool { guard let filter = scanFilter else { return true } let hasNamePrefixFilter = !filter.withNamePrefix.isEmpty let hasServiceFilter = !filter.withServices.isEmpty + let hasServiceDataFilter = !filter.withServiceData.isEmpty let hasManufacturerDataFilter = !filter.withManufacturerData.isEmpty // If there is no filter at all, then allow device - if !hasNamePrefixFilter && !hasServiceFilter && !hasManufacturerDataFilter { + if !hasNamePrefixFilter && !hasServiceFilter && !hasServiceDataFilter && !hasManufacturerDataFilter { return true } // Else check one of the filter passes return hasNamePrefixFilter && isNameMatchingFilters(filter: filter, name: name) || hasServiceFilter && isServicesMatchingFilters(services: services) || + hasServiceDataFilter && isServiceDataMatchingFilters(serviceData: serviceData) || hasManufacturerDataFilter && isManufacturerDataMatchingFilters(scanFilter: filter, msd: manufacturerData) } @@ -47,7 +52,10 @@ public class UniversalBleFilterUtil { } func isServicesMatchingFilters(services: [CBUUID]?) -> Bool { - let serviceFilters = Set(scanFilterServicesUUID.compactMap { $0 }) + // Compared as 128-bit strings: CoreBluetooth reports a 16-bit advertised + // service UUID as its short form, which is not equal to the full form the + // Dart layer sends. + let serviceFilters = Set(scanFilterServicesUUID.map { $0.uuid128Str }) guard !serviceFilters.isEmpty else { return true @@ -57,7 +65,27 @@ public class UniversalBleFilterUtil { return false } - return !Set(services).isDisjoint(with: serviceFilters) + return !Set(services.map { $0.uuid128Str }).isDisjoint(with: serviceFilters) + } + + /// Matches a device by the Service Data it advertises (the `0x16`/`0x20`/`0x21` + /// advertising data types). + /// + /// This is a different set from the advertised service UUID lists matched by + /// [isServicesMatchingFilters]: beacons such as Eddystone (`0xFEAA`) or the + /// ATC_MiThermometer (`0x181A`) carry the UUID only inside their Service Data. + func isServiceDataMatchingFilters(serviceData: [CBUUID]?) -> Bool { + let serviceDataFilters = Set(scanFilterServiceDataUUID) + + guard !serviceDataFilters.isEmpty else { + return true + } + + guard let serviceData = serviceData, !serviceData.isEmpty else { + return false + } + + return !Set(serviceData.map { $0.uuid128Str }).isDisjoint(with: serviceDataFilters) } func isManufacturerDataMatchingFilters(scanFilter: UniversalScanFilter, msd: UniversalManufacturerData?) -> Bool { @@ -102,6 +130,11 @@ public class UniversalBleFilterUtil { extension UniversalScanFilter { var usesCustomFilters: Bool { - return !withManufacturerData.isEmpty || !withNamePrefix.isEmpty + // Service Data matching cannot be expressed as a CoreBluetooth filter: + // `scanForPeripherals(withServices:)` matches advertised service UUIDs + // only, so a device carrying the UUID inside its Service Data would never + // be delivered to the app. Scan with no service filter and match in + // software instead — the caller opted into the extra battery cost. + return !withManufacturerData.isEmpty || !withNamePrefix.isEmpty || !withServiceData.isEmpty } } diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBleHelper.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBleHelper.swift index 37a19fe5..b407ba9a 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBleHelper.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBleHelper.swift @@ -172,6 +172,21 @@ public extension CBUUID { var uuidStr: String { uuidString.lowercased() } + + /// The UUID in the canonical 128-bit lowercase form used across the plugin. + /// + /// CoreBluetooth preserves the width a UUID was advertised with, so a 16-bit + /// service UUID arrives as `181A` while the Dart layer always sends the full + /// 128-bit form. Comparing `CBUUID` values directly therefore misses 16-bit + /// UUIDs; compare these strings instead. + var uuid128Str: String { + let uuid = uuidString.lowercased() + switch uuid.count { + case 4: return "0000\(uuid)-0000-1000-8000-00805f9b34fb" + case 8: return "\(uuid)-0000-1000-8000-00805f9b34fb" + default: return uuid + } + } } public extension CBPeripheral { diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift index 1269e585..78a47465 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift @@ -184,10 +184,12 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral UniversalBleLogger.shared.logInfo("Using Custom Filters") universalBleFilterUtil.scanFilter = filter universalBleFilterUtil.scanFilterServicesUUID = withServices + universalBleFilterUtil.scanFilterServiceDataUUID = filter?.withServiceData.map { $0.lowercased() } ?? [] withServices = [] } else { universalBleFilterUtil.scanFilter = nil universalBleFilterUtil.scanFilterServicesUUID = [] + universalBleFilterUtil.scanFilterServiceDataUUID = [] } // When the caller opts into de-duplication, omit the allow-duplicates key so @@ -674,7 +676,12 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral peripheralCache.rememberAdvertisedName(displayName, for: peripheral.uuid.uuidString) // Apply custom filters and return early if the peripheral doesn't match - if !universalBleFilterUtil.filterDevice(name: displayName, manufacturerData: universalManufacturerData, services: services) { + if !universalBleFilterUtil.filterDevice( + name: displayName, + manufacturerData: universalManufacturerData, + services: services, + serviceData: serviceDataDict.map { Array($0.keys) } + ) { return } diff --git a/example/pubspec.lock b/example/pubspec.lock index d2608a3f..e543da0b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -389,7 +389,7 @@ packages: path: ".." relative: true source: path - version: "3.0.2" + version: "3.1.0" vector_math: dependency: transitive description: diff --git a/lib/src/models/scan_filter.dart b/lib/src/models/scan_filter.dart index 5ef36f3a..6275d730 100644 --- a/lib/src/models/scan_filter.dart +++ b/lib/src/models/scan_filter.dart @@ -3,6 +3,31 @@ import 'package:universal_ble/universal_ble.dart'; /// Filter criteria used to discover matching devices during BLE scanning. class ScanFilter { List withServices; + + /// Matches devices that advertise [Service Data](https://www.bluetooth.com/blog/advertising-works-part-2/) + /// for any of the given service UUIDs. + /// + /// This is a different field from the advertised service UUIDs matched by + /// [withServices]: a device may carry a service UUID only inside its Service + /// Data (the `0x16`/`0x20`/`0x21` advertising data types) without listing it in + /// its advertised service UUID list — beacons such as Eddystone (`0xFEAA`) or + /// the ATC_MiThermometer (`0x181A`) do exactly that, and are invisible to + /// [withServices]. See [#57](https://github.com/Navideck/universal_ble/issues/57). + /// + /// UUIDs are accepted in any case and in short (`181A`), `0x`-prefixed, or + /// full 128-bit form. As with the other conditions, an empty list is ignored + /// and the conditions are in `OR` relation. + /// + /// The matching Service Data bytes are reported in [BleDevice.serviceData]. + /// + /// Platform notes: on `Android` and `Apple` this switches the scan to the + /// software filter path — the advertisement packets cannot be filtered by the + /// platform (a controller-level scan filter or `withServices` would discard + /// the device before the plugin sees it), so more advertisements reach the app + /// process at some battery cost. Unsupported on Web, which throws + /// `notSupported`; scan without a service filter there and read + /// [BleDevice.serviceData] from the advertisement events instead. + List withServiceData; List withManufacturerData; List withNamePrefix; List exclusionFilters; @@ -16,6 +41,7 @@ class ScanFilter { ScanFilter({ this.withServices = const [], + this.withServiceData = const [], this.withManufacturerData = const [], this.withNamePrefix = const [], this.exclusionFilters = const [], @@ -24,7 +50,7 @@ class ScanFilter { @override String toString() { - return 'ScanFilter(withServices: $withServices, withManufacturerData: $withManufacturerData, withNamePrefix: $withNamePrefix, exclusionFilters: $exclusionFilters, connectableOnly: $connectableOnly)'; + return 'ScanFilter(withServices: $withServices, withServiceData: $withServiceData, withManufacturerData: $withManufacturerData, withNamePrefix: $withNamePrefix, exclusionFilters: $exclusionFilters, connectableOnly: $connectableOnly)'; } } diff --git a/lib/src/universal_ble.g.dart b/lib/src/universal_ble.g.dart index c45b8ec8..7c20d1aa 100644 --- a/lib/src/universal_ble.g.dart +++ b/lib/src/universal_ble.g.dart @@ -821,6 +821,7 @@ class UniversalScanFilter { required this.withServices, required this.withNamePrefix, required this.withManufacturerData, + required this.withServiceData, }); List withServices; @@ -829,11 +830,17 @@ class UniversalScanFilter { List withManufacturerData; + /// Service data UUIDs to match. Appended last on purpose: pigeon encodes + /// fields positionally, so inserting this anywhere else would shift the + /// indices of the fields above and break against a stale native binary. + List withServiceData; + List _toList() { return [ withServices, withNamePrefix, withManufacturerData, + withServiceData, ]; } @@ -848,6 +855,7 @@ class UniversalScanFilter { withNamePrefix: (result[1]! as List).cast(), withManufacturerData: (result[2]! as List).cast(), + withServiceData: (result[3]! as List).cast(), ); } @@ -862,7 +870,8 @@ class UniversalScanFilter { } return _deepEquals(withServices, other.withServices) && _deepEquals(withNamePrefix, other.withNamePrefix) && - _deepEquals(withManufacturerData, other.withManufacturerData); + _deepEquals(withManufacturerData, other.withManufacturerData) && + _deepEquals(withServiceData, other.withServiceData); } @override diff --git a/lib/src/universal_ble_linux/universal_ble_linux.dart b/lib/src/universal_ble_linux/universal_ble_linux.dart index 1c824572..93010111 100644 --- a/lib/src/universal_ble_linux/universal_ble_linux.dart +++ b/lib/src/universal_ble_linux/universal_ble_linux.dart @@ -655,16 +655,16 @@ class UniversalBleLinux extends UniversalBlePlatform { void _onDeviceAdd(BlueZDevice device) { BleDevice bleDevice = device.toBleDevice(); - if (!_bleFilter.shouldAcceptDevice(bleDevice)) { - return; - } // Update scan results only if rssi is available - if (device.rssi != 0) { + if (device.rssi != 0 && _bleFilter.shouldAcceptDevice(bleDevice)) { updateScanResult(bleDevice); } - // Setup Cache + // Setup Cache. Devices are cached (and their listeners attached) even when + // the current filter rejects them: a device that has not advertised any + // ServiceData/UUIDs yet may only start matching once BlueZ reports them in a + // later advertisement (#57). _devices[device.address] = device; // Setup advertisements Listener @@ -675,8 +675,11 @@ class UniversalBleLinux extends UniversalBlePlatform { e.contains(BluezProperty.uuids) || e.contains(BluezProperty.serviceData); }).listen((_) { - if (_bleFilter.shouldAcceptDevice(bleDevice)) { - updateScanResult(device.toBleDevice()); + // Re-evaluate the filter on the current properties, not on the snapshot + // taken when the device was added. + final updatedBleDevice = device.toBleDevice(); + if (_bleFilter.shouldAcceptDevice(updatedBleDevice)) { + updateScanResult(updatedBleDevice); } }); 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 4beb8b30..038a3f5a 100644 --- a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart +++ b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart @@ -412,6 +412,7 @@ extension _ScanFilterExtension on ScanFilter? { withServices: this?.withServices.toValidUUIDList() ?? [], withNamePrefix: this?.withNamePrefix ?? [], withManufacturerData: this?.withManufacturerData ?? [], + withServiceData: this?.withServiceData.toValidUUIDList() ?? [], ); } } diff --git a/lib/src/universal_ble_web/universal_ble_web.dart b/lib/src/universal_ble_web/universal_ble_web.dart index d726bac4..aecb9180 100644 --- a/lib/src/universal_ble_web/universal_ble_web.dart +++ b/lib/src/universal_ble_web/universal_ble_web.dart @@ -746,6 +746,21 @@ class UniversalBleWeb extends UniversalBlePlatform { List optionalManufacturerData = []; List optionalServices = []; + // The browser chooser can only filter on advertised service UUIDs and + // manufacturer data, so a Service Data match cannot be expressed (and the + // chooser's result is the only scan result web reports). Fail loudly instead + // of silently returning unrelated devices. + if (scanFilter != null && scanFilter.withServiceData.isNotEmpty) { + throw UniversalBleException( + code: UniversalBleErrorCode.notSupported, + message: + "ScanFilter.withServiceData is not supported on Web: the browser's " + "device chooser cannot filter on advertising Service Data. Scan " + "without it (or use requestDevice) and read BleDevice.serviceData " + "from the advertisement events instead.", + ); + } + if (webOptions != null) { optionalServices.addAll(webOptions.optionalServices.toValidUUIDList()); optionalManufacturerData.addAll(webOptions.optionalManufacturerData); diff --git a/lib/src/utils/universal_ble_filter_util.dart b/lib/src/utils/universal_ble_filter_util.dart index c771b378..bbee2c1b 100644 --- a/lib/src/utils/universal_ble_filter_util.dart +++ b/lib/src/utils/universal_ble_filter_util.dart @@ -60,11 +60,13 @@ class UniversalBleFilterUtil { final hasNamePrefixFilter = filter.withNamePrefix.isNotEmpty; final hasServiceFilter = filter.withServices.isNotEmpty; + final hasServiceDataFilter = filter.withServiceData.isNotEmpty; final hasManufacturerDataFilter = filter.withManufacturerData.isNotEmpty; // If there is no filter at all, then allow device if (!hasNamePrefixFilter && !hasServiceFilter && + !hasServiceDataFilter && !hasManufacturerDataFilter) { return true; } @@ -72,6 +74,7 @@ class UniversalBleFilterUtil { // Else check one of the filter passes return (hasNamePrefixFilter && nameMatches(filter, device)) || (hasServiceFilter && servicesMatch(filter, device)) || + (hasServiceDataFilter && serviceDataMatch(filter, device)) || (hasManufacturerDataFilter && manufacturerDataMatches(filter.withManufacturerData, device)); } @@ -96,6 +99,25 @@ class UniversalBleFilterUtil { return serviceFilters.any(serviceUuids.contains); } + /// Returns true if the device advertises Service Data for any of the filter's + /// UUIDs. + /// + /// Unlike [servicesMatch], this looks at [BleDevice.serviceData], so devices + /// that carry a service UUID only in their Service Data (beacons) are matched. + /// The device keys are normalised by [BleDevice], but anything unparseable is + /// skipped rather than throwing, so malformed advertisement data cannot fail a + /// scan. + bool serviceDataMatch(ScanFilter scanFilter, BleDevice device) { + final serviceDataFilters = scanFilter.withServiceData.toValidUUIDList(); + if (serviceDataFilters.isEmpty) return true; + + final advertisedUuids = device.serviceData.keys + .map(BleUuidParser.stringOrNull) + .whereType() + .toSet(); + return serviceDataFilters.any(advertisedUuids.contains); + } + bool manufacturerDataMatches( List filters, BleDevice device, diff --git a/pigeon/universal_ble.dart b/pigeon/universal_ble.dart index 588a3d77..551fd082 100644 --- a/pigeon/universal_ble.dart +++ b/pigeon/universal_ble.dart @@ -245,10 +245,16 @@ class UniversalScanFilter { final List withNamePrefix; final List withManufacturerData; + /// Service data UUIDs to match. Appended last on purpose: pigeon encodes + /// fields positionally, so inserting this anywhere else would shift the + /// indices of the fields above and break against a stale native binary. + final List withServiceData; + UniversalScanFilter( this.withServices, this.withNamePrefix, this.withManufacturerData, + this.withServiceData, ); } diff --git a/pubspec.yaml b/pubspec.yaml index 17ce70d4..ee3a6212 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: universal_ble description: A cross-platform (Android/iOS/macOS/Windows/Linux/Web) Bluetooth Low Energy (BLE) plugin for Flutter -version: 3.0.2 +version: 3.1.0 homepage: https://navideck.com repository: https://github.com/Navideck/universal_ble issue_tracker: https://github.com/Navideck/universal_ble/issues diff --git a/test/scan_filter_channel_test.dart b/test/scan_filter_channel_test.dart new file mode 100644 index 00000000..46966db5 --- /dev/null +++ b/test/scan_filter_channel_test.dart @@ -0,0 +1,63 @@ +// Guards that `ScanFilter.withServiceData` reaches the platform scan filter in the +// canonical 128-bit lowercase form the native filters compare against, whatever +// casing or short form the caller used (#57). +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +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/universal_ble.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = BasicMessageChannel( + 'dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.startScan', + UniversalBlePlatformChannel.pigeonChannelCodec, + ); + + UniversalScanFilter? capturedFilter; + + setUp(() { + capturedFilter = null; + // Windows does not request permissions in `_ensureInitialized`, and runs the + // command immediately, keeping the test focused on the scan filter. + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + UniversalBle.setInstance(UniversalBlePigeonChannel.instance); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockDecodedMessageHandler(channel, (message) async { + final args = message! as List; + capturedFilter = args[0] as UniversalScanFilter?; + return [null]; + }); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockDecodedMessageHandler(channel, null); + }); + + test('withServiceData is forwarded in full 128-bit lowercase form', () async { + await UniversalBle.startScan( + scanFilter: ScanFilter(withServiceData: ['181A']), + ); + + expect(capturedFilter, isNotNull); + expect(capturedFilter!.withServiceData, [ + '0000181a-0000-1000-8000-00805f9b34fb', + ]); + }); + + test('withServiceData stays empty when the caller does not set it', () async { + await UniversalBle.startScan( + scanFilter: ScanFilter(withServices: ['180d']), + ); + + expect(capturedFilter, isNotNull); + expect(capturedFilter!.withServiceData, isEmpty); + expect(capturedFilter!.withServices, [ + '0000180d-0000-1000-8000-00805f9b34fb', + ]); + }); +} diff --git a/test/scan_filter_test.dart b/test/scan_filter_test.dart index 2f523878..3274a05c 100644 --- a/test/scan_filter_test.dart +++ b/test/scan_filter_test.dart @@ -312,24 +312,141 @@ void main() { // When connectableOnly is true, non-connectable is rejected universalBleFilter.scanFilter = ScanFilter(connectableOnly: true); expect(universalBleFilter.matchedScanFilter(connectableDevice), isTrue); - expect(universalBleFilter.matchedScanFilter(nonConnectableDevice), isFalse); + expect( + universalBleFilter.matchedScanFilter(nonConnectableDevice), isFalse); expect(universalBleFilter.matchedScanFilter(unknownDevice), isTrue); - expect(universalBleFilter.matchesConnectableFilter(connectableDevice), isTrue); - expect(universalBleFilter.matchesConnectableFilter(nonConnectableDevice), isFalse); - expect(universalBleFilter.matchesConnectableFilter(unknownDevice), isTrue); + expect(universalBleFilter.matchesConnectableFilter(connectableDevice), + isTrue); + expect(universalBleFilter.matchesConnectableFilter(nonConnectableDevice), + isFalse); + expect( + universalBleFilter.matchesConnectableFilter(unknownDevice), isTrue); // When connectableOnly is null or false, non-connectable is allowed universalBleFilter.scanFilter = ScanFilter(connectableOnly: null); expect(universalBleFilter.matchedScanFilter(connectableDevice), isTrue); - expect(universalBleFilter.matchedScanFilter(nonConnectableDevice), isTrue); + expect( + universalBleFilter.matchedScanFilter(nonConnectableDevice), isTrue); expect(universalBleFilter.matchedScanFilter(unknownDevice), isTrue); - expect(universalBleFilter.matchesConnectableFilter(nonConnectableDevice), isTrue); + expect(universalBleFilter.matchesConnectableFilter(nonConnectableDevice), + isTrue); universalBleFilter.scanFilter = ScanFilter(connectableOnly: false); expect(universalBleFilter.matchedScanFilter(connectableDevice), isTrue); - expect(universalBleFilter.matchedScanFilter(nonConnectableDevice), isTrue); + expect( + universalBleFilter.matchedScanFilter(nonConnectableDevice), isTrue); expect(universalBleFilter.matchedScanFilter(unknownDevice), isTrue); - expect(universalBleFilter.matchesConnectableFilter(nonConnectableDevice), isTrue); + expect(universalBleFilter.matchesConnectableFilter(nonConnectableDevice), + isTrue); + }); + }); + + group("Test Service Data Filter", () { + // A beacon that carries 0x181A only in its advertising Service Data, without + // listing it among its advertised services (#57). + final serviceDataBeacon = BleDevice( + deviceId: 'sd1', + name: 'ATC_DD7A81', + services: [], + serviceData: { + '181a': Uint8List.fromList([0x81, 0x7a, 0xdd]), + }, + ); + // Advertises the service UUID the old way, and carries full-form service + // data for another UUID. + final advertisedDevice = BleDevice( + deviceId: 'sd2', + name: 'other', + services: ['20a2'], + serviceData: { + '0000feaa-0000-1000-8000-00805f9b34fb': Uint8List.fromList([1]), + }, + ); + // Neither advertised services nor service data. + final emptyDevice = BleDevice( + deviceId: 'sd3', + name: 'empty', + services: [], + serviceData: {}, + ); + + test('Test serviceDataMatch: short, case-insensitive and full form', () { + for (final filterValue in [ + '181a', + '181A', + '0x181a', + '0000181a-0000-1000-8000-00805f9b34fb', + '0000181A-0000-1000-8000-00805F9B34FB', + ]) { + expect( + universalBleFilter.serviceDataMatch( + ScanFilter(withServiceData: [filterValue]), + serviceDataBeacon, + ), + isTrue, + reason: '$filterValue should match the advertised Service Data UUID', + ); + } + }); + + test('Test serviceDataMatch: non-matching and empty service data', () { + final scanFilter = ScanFilter(withServiceData: ['2a6e']); + expect(universalBleFilter.serviceDataMatch(scanFilter, advertisedDevice), + isFalse); + expect(universalBleFilter.serviceDataMatch(scanFilter, emptyDevice), + isFalse); + expect(universalBleFilter.serviceDataMatch(scanFilter, serviceDataBeacon), + isFalse); + }); + + test('Test serviceDataMatch: empty filter matches everything', () { + expect( + universalBleFilter.serviceDataMatch(ScanFilter(), serviceDataBeacon), + isTrue, + ); + }); + + test('Test matchedScanFilter: Service Data only device is discovered', () { + // withServices alone does not see the beacon... + universalBleFilter.scanFilter = ScanFilter(withServices: ['181a']); + expect(universalBleFilter.matchedScanFilter(serviceDataBeacon), isFalse); + + // ...but withServiceData does. + universalBleFilter.scanFilter = ScanFilter(withServiceData: ['181a']); + expect(universalBleFilter.matchedScanFilter(serviceDataBeacon), isTrue); + expect(universalBleFilter.matchedScanFilter(advertisedDevice), isFalse); + expect(universalBleFilter.matchedScanFilter(emptyDevice), isFalse); + }); + + test('Test matchedScanFilter: conditions stay in OR relation', () { + universalBleFilter.scanFilter = ScanFilter( + withServices: ['20a2'], + withServiceData: ['181a'], + ); + expect(universalBleFilter.matchedScanFilter(serviceDataBeacon), isTrue); + expect(universalBleFilter.matchedScanFilter(advertisedDevice), isTrue); + expect(universalBleFilter.matchedScanFilter(emptyDevice), isFalse); + }); + + test('Test matchedScanFilter: unset withServiceData keeps old behaviour', + () { + universalBleFilter.scanFilter = ScanFilter(withServices: ['181a']); + expect(universalBleFilter.matchedScanFilter(serviceDataBeacon), isFalse); + + universalBleFilter.scanFilter = ScanFilter(); + expect(universalBleFilter.matchedScanFilter(serviceDataBeacon), isTrue); + }); + + test('Test matchedScanFilter: malformed device keys do not throw', () { + final malformed = BleDevice( + deviceId: 'sd4', + name: 'malformed', + serviceData: { + 'not-a-uuid': Uint8List.fromList([1]) + }, + ); + universalBleFilter.scanFilter = ScanFilter(withServiceData: ['181a']); + expect(universalBleFilter.matchedScanFilter(malformed), isFalse); }); }); } diff --git a/windows/src/generated/universal_ble.g.cpp b/windows/src/generated/universal_ble.g.cpp index d77f2a63..3cbcc1cc 100644 --- a/windows/src/generated/universal_ble.g.cpp +++ b/windows/src/generated/universal_ble.g.cpp @@ -1096,10 +1096,12 @@ size_t PigeonInternalDeepHash(const UniversalScanConfig& v) { UniversalScanFilter::UniversalScanFilter( const EncodableList& with_services, const EncodableList& with_name_prefix, - const EncodableList& with_manufacturer_data) + const EncodableList& with_manufacturer_data, + const EncodableList& with_service_data) : with_services_(with_services), with_name_prefix_(with_name_prefix), - with_manufacturer_data_(with_manufacturer_data) {} + with_manufacturer_data_(with_manufacturer_data), + with_service_data_(with_service_data) {} const EncodableList& UniversalScanFilter::with_services() const { return with_services_; @@ -1128,12 +1130,22 @@ void UniversalScanFilter::set_with_manufacturer_data(const EncodableList& value_ } +const EncodableList& UniversalScanFilter::with_service_data() const { + return with_service_data_; +} + +void UniversalScanFilter::set_with_service_data(const EncodableList& value_arg) { + with_service_data_ = value_arg; +} + + EncodableList UniversalScanFilter::ToEncodableList() const { EncodableList list; - list.reserve(3); + list.reserve(4); list.push_back(EncodableValue(with_services_)); list.push_back(EncodableValue(with_name_prefix_)); list.push_back(EncodableValue(with_manufacturer_data_)); + list.push_back(EncodableValue(with_service_data_)); return list; } @@ -1141,12 +1153,13 @@ UniversalScanFilter UniversalScanFilter::FromEncodableList(const EncodableList& UniversalScanFilter decoded( std::get(list[0]), std::get(list[1]), - std::get(list[2])); + std::get(list[2]), + std::get(list[3])); return decoded; } bool UniversalScanFilter::operator==(const UniversalScanFilter& other) const { - return PigeonInternalDeepEquals(with_services_, other.with_services_) && PigeonInternalDeepEquals(with_name_prefix_, other.with_name_prefix_) && PigeonInternalDeepEquals(with_manufacturer_data_, other.with_manufacturer_data_); + return PigeonInternalDeepEquals(with_services_, other.with_services_) && PigeonInternalDeepEquals(with_name_prefix_, other.with_name_prefix_) && PigeonInternalDeepEquals(with_manufacturer_data_, other.with_manufacturer_data_) && PigeonInternalDeepEquals(with_service_data_, other.with_service_data_); } bool UniversalScanFilter::operator!=(const UniversalScanFilter& other) const { @@ -1158,6 +1171,7 @@ size_t UniversalScanFilter::Hash() const { result = result * 31 + PigeonInternalDeepHash(with_services_); result = result * 31 + PigeonInternalDeepHash(with_name_prefix_); result = result * 31 + PigeonInternalDeepHash(with_manufacturer_data_); + result = result * 31 + PigeonInternalDeepHash(with_service_data_); return result; } diff --git a/windows/src/generated/universal_ble.g.h b/windows/src/generated/universal_ble.g.h index 058f98df..71980405 100644 --- a/windows/src/generated/universal_ble.g.h +++ b/windows/src/generated/universal_ble.g.h @@ -676,7 +676,8 @@ class UniversalScanFilter { explicit UniversalScanFilter( const ::flutter::EncodableList& with_services, const ::flutter::EncodableList& with_name_prefix, - const ::flutter::EncodableList& with_manufacturer_data); + const ::flutter::EncodableList& with_manufacturer_data, + const ::flutter::EncodableList& with_service_data); const ::flutter::EncodableList& with_services() const; void set_with_services(const ::flutter::EncodableList& value_arg); @@ -687,6 +688,12 @@ class UniversalScanFilter { const ::flutter::EncodableList& with_manufacturer_data() const; void set_with_manufacturer_data(const ::flutter::EncodableList& value_arg); + // Service data UUIDs to match. Appended last on purpose: pigeon encodes + // fields positionally, so inserting this anywhere else would shift the + // indices of the fields above and break against a stale native binary. + const ::flutter::EncodableList& with_service_data() const; + void set_with_service_data(const ::flutter::EncodableList& value_arg); + bool operator==(const UniversalScanFilter& other) const; bool operator!=(const UniversalScanFilter& other) const; /// Returns a hash code value for the object. This method is supported for the benefit of hash tables. @@ -703,6 +710,7 @@ class UniversalScanFilter { ::flutter::EncodableList with_services_; ::flutter::EncodableList with_name_prefix_; ::flutter::EncodableList with_manufacturer_data_; + ::flutter::EncodableList with_service_data_; }; diff --git a/windows/src/universal_ble_filter_util.cpp b/windows/src/universal_ble_filter_util.cpp index 7ccb98ad..7b9f1861 100644 --- a/windows/src/universal_ble_filter_util.cpp +++ b/windows/src/universal_ble_filter_util.cpp @@ -7,6 +7,7 @@ namespace universal_ble { std::vector manufacturerScanFilter = std::vector(); std::vector serviceFilterUUIDS = std::vector(); +std::vector serviceDataFilterUUIDS = std::vector(); std::vector namePrefixFilter = std::vector(); void setScanFilter(const UniversalScanFilter filter) { @@ -24,6 +25,13 @@ void setScanFilter(const UniversalScanFilter filter) { serviceFilterUUIDS.push_back(uuid_to_guid(std::get(uuid))); } } + // Set Service Data Filter + if (!filter.with_service_data().empty()) { + for (const auto &uuid : filter.with_service_data()) { + serviceDataFilterUUIDS.push_back( + uuid_to_guid(std::get(uuid))); + } + } // Set Names Filter if (!filter.with_name_prefix().empty()) { for (const auto &name : filter.with_name_prefix()) { @@ -35,6 +43,7 @@ void setScanFilter(const UniversalScanFilter filter) { void resetScanFilter() { manufacturerScanFilter.clear(); serviceFilterUUIDS.clear(); + serviceDataFilterUUIDS.clear(); namePrefixFilter.clear(); } @@ -70,6 +79,33 @@ bool isServicesMatchingFilters(const flutter::EncodableList *services) { }); } +/// Matches a device by the Service Data it advertises (the `0x16`/`0x20`/`0x21` +/// advertising data types). This is a different set from the advertised service +/// UUID lists matched by isServicesMatchingFilters: beacons such as Eddystone +/// (`0xFEAA`) or the ATC_MiThermometer (`0x181A`) carry the UUID only there. +bool isServiceDataMatchingFilters(const flutter::EncodableMap *serviceData) { + if (serviceDataFilterUUIDS.empty()) + return true; + if (serviceData == nullptr || serviceData->empty()) + return false; + + std::unordered_set serviceDataGUIDs; + serviceDataGUIDs.reserve(serviceData->size()); + for (const auto &entry : *serviceData) { + if (const auto *uuid = std::get_if(&entry.first)) { + serviceDataGUIDs.insert(uuid_to_guid(*uuid)); + } + } + + // Check if any of the filter UUIDs are in the set of Service Data UUIDs + return std::any_of(serviceDataFilterUUIDS.begin(), + serviceDataFilterUUIDS.end(), + [&serviceDataGUIDs](const winrt::guid &filterUUID) { + return serviceDataGUIDs.find(filterUUID) != + serviceDataGUIDs.end(); + }); +} + bool isManufacturerDataMatchingFilters( const flutter::EncodableList *manufacturerDataList) { if (manufacturerScanFilter.empty()) @@ -119,10 +155,12 @@ bool isManufacturerDataMatchingFilters( bool filterDevice(UniversalBleScanResult scanResult) { bool hasNamePrefixFilter = !namePrefixFilter.empty(); bool hasServiceFilter = !serviceFilterUUIDS.empty(); + bool hasServiceDataFilter = !serviceDataFilterUUIDS.empty(); bool hasManufacturerDataFilter = !manufacturerScanFilter.empty(); // If there is no filter at all, then allow device - if (!hasNamePrefixFilter && !hasServiceFilter && !hasManufacturerDataFilter) { + if (!hasNamePrefixFilter && !hasServiceFilter && !hasServiceDataFilter && + !hasManufacturerDataFilter) { return true; } @@ -130,6 +168,8 @@ bool filterDevice(UniversalBleScanResult scanResult) { return (hasNamePrefixFilter && isNameMatchingFilters(scanResult.name())) || (hasServiceFilter && isServicesMatchingFilters(scanResult.services())) || + (hasServiceDataFilter && + isServiceDataMatchingFilters(scanResult.service_data())) || (hasManufacturerDataFilter && isManufacturerDataMatchingFilters( scanResult.manufacturer_data_list())); From 239a8c6aa80e705b8bf099cbf5d71976ce66416c Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Thu, 15 Oct 2026 23:27:09 +0200 Subject: [PATCH 2/4] Refactor the scan filter path and pin its semantics with a shared fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- android/build.gradle | 4 + .../universal_ble/UniversalBleFilterUtil.kt | 79 +++--- .../universal_ble/UniversalBlePlugin.kt | 9 +- .../UniversalBleFilterConformanceTest.kt | 70 ++++++ .../UniversalBleFilterUtilTest.kt | 6 +- .../UniversalBleFilterUtil.swift | 83 +++---- .../universal_ble/UniversalBlePlugin.swift | 8 +- example/macos/Podfile.lock | 2 +- .../universal_ble_linux.dart | 26 +- .../universal_ble_web/universal_ble_web.dart | 77 +++--- lib/src/utils/universal_ble_filter_util.dart | 52 ++-- test/fixtures/scan_filter_cases.tsv | 45 ++++ test/manufacturer_data_filter_test.dart | 7 +- test/scan_filter_conformance_test.dart | 74 ++++++ test/scan_filter_test.dart | 20 +- windows/CMakeLists.txt | 2 + windows/src/scan_filter_matcher.cpp | 124 ++++++++++ windows/src/scan_filter_matcher.h | 60 +++++ windows/src/universal_ble_filter_util.cpp | 231 ++++++------------ windows/test/CMakeLists.txt | 14 ++ windows/test/scan_filter_matcher_test.cpp | 178 ++++++++++++++ 21 files changed, 833 insertions(+), 338 deletions(-) create mode 100644 android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterConformanceTest.kt create mode 100644 test/fixtures/scan_filter_cases.tsv create mode 100644 test/scan_filter_conformance_test.dart create mode 100644 windows/src/scan_filter_matcher.cpp create mode 100644 windows/src/scan_filter_matcher.h create mode 100644 windows/test/scan_filter_matcher_test.cpp diff --git a/android/build.gradle b/android/build.gradle index 410d6ac5..536f2b5e 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -36,6 +36,10 @@ android { sourceSets { main.java.srcDirs += 'src/main/kotlin' test.java.srcDirs += 'src/test/kotlin' + // The scan filter conformance fixture is shared with the Dart and C++ + // tests (test/fixtures/scan_filter_cases.tsv), so it lives outside this + // module and is exposed to the JVM tests as a resource. + test.resources.srcDirs += '../test/fixtures' } defaultConfig { diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleFilterUtil.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleFilterUtil.kt index 8f40cd5c..f43aeade 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleFilterUtil.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBleFilterUtil.kt @@ -11,9 +11,21 @@ private const val TAG = "UniversalBlePlugin" @SuppressLint("MissingPermission") class UniversalBleFilterUtil { - var scanFilter: UniversalScanFilter? = null - var serviceFilterUUIDS: List = emptyList() - var serviceDataFilterUUIDS: List = emptyList() + private var scanFilter: UniversalScanFilter? = null + private var serviceFilters: List = emptyList() + private var serviceDataFilters: List = emptyList() + + /// Single entry point for the filter state. + /// + /// Pass `null` when the platform applies the filter itself (native + /// `ScanFilter`), which makes [filterDevice] accept every device. Deriving the + /// UUID lists here, instead of letting callers assign them alongside + /// [scanFilter], keeps the three from drifting apart. + fun configure(filter: UniversalScanFilter?) { + scanFilter = filter + serviceFilters = filter?.withServices?.toUUIDList().orEmpty() + serviceDataFilters = filter?.withServiceData?.toUUIDList().orEmpty() + } fun filterDevice( name: String?, @@ -23,51 +35,38 @@ class UniversalBleFilterUtil { ): Boolean { val filter = scanFilter ?: return true - val hasNamePrefixFilter = filter.withNamePrefix.isNotEmpty() - val hasServiceFilter = filter.withServices.isNotEmpty() - val hasServiceDataFilter = filter.withServiceData.isNotEmpty() - val hasManufacturerDataFilter = filter.withManufacturerData.isNotEmpty() - - // If there is no filter at all, then allow device - if (!hasNamePrefixFilter && - !hasServiceFilter && - !hasServiceDataFilter && - !hasManufacturerDataFilter - ) { + // The conditions are in OR relation. Each check below is a strict + // predicate answering "does this dimension match?", so an unfiltered + // dimension contributes nothing and needs no guard of its own. + val hasAnyFilter = + filter.withNamePrefix.isNotEmpty() || + serviceFilters.isNotEmpty() || + serviceDataFilters.isNotEmpty() || + filter.withManufacturerData.isNotEmpty() + if (!hasAnyFilter) { return true } - // For not, we only have DeviceName filter - return hasNamePrefixFilter && isNameMatchingFilters(filter, name) || - hasServiceFilter && isServicesMatchingFilters(serviceUuids) || - hasServiceDataFilter && isServiceDataMatchingFilters(serviceDataUuids) || - hasManufacturerDataFilter && isManufacturerDataMatchingFilters( - filter, - manufacturerDataList - ) + return isNameMatchingFilters(filter, name) || + isServicesMatchingFilters(serviceUuids) || + isServiceDataMatchingFilters(serviceDataUuids) || + isManufacturerDataMatchingFilters( + filter, + manufacturerDataList + ) } private fun isNameMatchingFilters(scanFilter: UniversalScanFilter, name: String?): Boolean { - val namePrefixFilter = scanFilter.withNamePrefix - if (namePrefixFilter.isEmpty()) { - return true - } if (name.isNullOrEmpty()) { return false } - return namePrefixFilter.any { name.startsWith(it) } + return scanFilter.withNamePrefix.any { name.startsWith(it) } } private fun isServicesMatchingFilters( serviceUuids: Array, ): Boolean { - if (serviceFilterUUIDS.isEmpty()) { - return true - } - if (serviceUuids.isEmpty()) { - return false - } - return serviceFilterUUIDS.any { + return serviceFilters.any { serviceUuids.contains(it) } } @@ -80,13 +79,7 @@ class UniversalBleFilterUtil { private fun isServiceDataMatchingFilters( serviceDataUuids: List, ): Boolean { - if (serviceDataFilterUUIDS.isEmpty()) { - return true - } - if (serviceDataUuids.isEmpty()) { - return false - } - return serviceDataFilterUUIDS.any { + return serviceDataFilters.any { serviceDataUuids.contains(it) } } @@ -95,11 +88,9 @@ class UniversalBleFilterUtil { scanFilter: UniversalScanFilter, manufacturerDataList: List, ): Boolean { - val filters = scanFilter.withManufacturerData - if (filters.isEmpty()) return true if (manufacturerDataList.isEmpty()) return false return manufacturerDataList.any { msd -> - filters.any { filter -> + scanFilter.withManufacturerData.any { filter -> msd.companyIdentifier == filter.companyIdentifier && isDataMatching(filter.payloadPrefix, msd.data, filter.payloadMask) } diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt index 0300599f..4bea74ed 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -272,19 +272,14 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), try { val filterServices = filter?.withServices?.toUUIDList() ?: emptyList() - val filterServiceData = filter?.withServiceData?.toUUIDList() ?: emptyList() var scanFilters = emptyList() // Set custom scan filter only if required if (usesCustomFilters) { UniversalBleLogger.logError("Using Custom Filters") - universalBleFilterUtil.scanFilter = filter - universalBleFilterUtil.serviceFilterUUIDS = filterServices - universalBleFilterUtil.serviceDataFilterUUIDS = filterServiceData + universalBleFilterUtil.configure(filter) } else { - universalBleFilterUtil.scanFilter = null - universalBleFilterUtil.serviceFilterUUIDS = emptyList() - universalBleFilterUtil.serviceDataFilterUUIDS = emptyList() + universalBleFilterUtil.configure(null) scanFilters = filter?.toScanFilters(filterServices) ?: emptyList() } diff --git a/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterConformanceTest.kt b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterConformanceTest.kt new file mode 100644 index 00000000..1765d4f6 --- /dev/null +++ b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterConformanceTest.kt @@ -0,0 +1,70 @@ +package com.navideck.universal_ble + +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Drives [UniversalBleFilterUtil] from the shared conformance fixture + * (`test/fixtures/scan_filter_cases.tsv`, exposed as a test resource by + * `android/build.gradle`), so its semantics stay identical to the Dart, Swift and + * C++ implementations. + * + * The fixture is language-neutral: its UUIDs are written the way the platforms + * receive them (full 128-bit, lowercase), since short/`0x` forms are normalised in + * the Dart layer before they reach the channel. + */ +internal class UniversalBleFilterConformanceTest { + + private class Row(val id: String, val columns: List, val expected: Boolean) + + private fun loadRows(): List { + val stream = javaClass.getResourceAsStream("/scan_filter_cases.tsv") + ?: error("Fixture not found on the test classpath") + val rows = stream.bufferedReader().readLines() + .filter { it.isNotBlank() && !it.startsWith("#") } + .map { it.split("|") } + .also { it.first() } // header is dropped below + .drop(1) + return rows.map { columns -> + assertEquals(10, columns.size, "Bad fixture row: ${columns.joinToString("|")}") + Row(columns[0], columns, columns[9] == "1") + } + } + + private fun values(row: Row, index: Int): List = + row.columns[index].split(";").filter { it.isNotEmpty() } + + private fun companyIds(row: Row, index: Int): List = + values(row, index).map { it.toLong() } + + private fun filterOf(row: Row): UniversalScanFilter = UniversalScanFilter( + withServices = values(row, 1), + withNamePrefix = values(row, 3), + withManufacturerData = companyIds(row, 4).map { + ManufacturerDataFilter(companyIdentifier = it, payloadPrefix = null, payloadMask = null) + }, + withServiceData = values(row, 2), + ) + + @Test + fun matchesTheSharedConformanceFixture() { + val rows = loadRows() + assertTrue(rows.isNotEmpty(), "Fixture should contain cases") + + for (row in rows) { + val util = UniversalBleFilterUtil().apply { configure(filterOf(row)) } + val matched = util.filterDevice( + name = row.columns[5].ifEmpty { null }, + manufacturerDataList = companyIds(row, 8).map { + UniversalManufacturerData(companyIdentifier = it, data = ByteArray(0)) + }, + serviceUuids = values(row, 6).map { UUID.fromString(it) }.toTypedArray(), + serviceDataUuids = values(row, 7).map { UUID.fromString(it) }, + ) + + assertEquals(row.expected, matched, "Fixture case '${row.id}'") + } + } +} diff --git a/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterUtilTest.kt b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterUtilTest.kt index 568dd814..54482345 100644 --- a/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterUtilTest.kt +++ b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterUtilTest.kt @@ -17,11 +17,7 @@ internal class UniversalBleFilterUtilTest { ) = UniversalScanFilter(withServices, withNamePrefix, emptyList(), withServiceData) private fun filterUtil(filter: UniversalScanFilter): UniversalBleFilterUtil { - return UniversalBleFilterUtil().apply { - scanFilter = filter - serviceFilterUUIDS = filter.withServices.map { UUID.fromString(it) } - serviceDataFilterUUIDS = filter.withServiceData.map { UUID.fromString(it) } - } + return UniversalBleFilterUtil().apply { configure(filter) } } @Test diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBleFilterUtil.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBleFilterUtil.swift index 482d5f1e..a13b7bc1 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBleFilterUtil.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBleFilterUtil.swift @@ -9,58 +9,62 @@ import CoreBluetooth import Foundation public class UniversalBleFilterUtil { - var scanFilter: UniversalScanFilter? - var scanFilterServicesUUID: [CBUUID] = [] + private var scanFilter: UniversalScanFilter? + private var serviceFilters: Set = [] + private var serviceDataFilters: Set = [] - /// Service Data UUIDs to match, normalised to 128-bit lowercase form. - var scanFilterServiceDataUUID: [String] = [] + /// Single entry point for the filter state. + /// + /// Pass `nil` when CoreBluetooth applies the filter itself + /// (`scanForPeripherals(withServices:)`), which makes [filterDevice] accept + /// every peripheral. The UUID sets are normalised to the 128-bit lowercase + /// form CoreBluetooth does not guarantee, and derived here so they cannot + /// drift from [scanFilter]. + func configure(scanFilter: UniversalScanFilter?) { + self.scanFilter = scanFilter + serviceFilters = Set(scanFilter?.withServices.map { normalize(uuid: $0) } ?? []) + serviceDataFilters = Set(scanFilter?.withServiceData.map { normalize(uuid: $0) } ?? []) + } + + /// Expands a short UUID to the canonical 128-bit lowercase form the advertised + /// UUIDs are normalised to before comparison. + private func normalize(uuid: String) -> String { + CBUUID(string: uuid).uuid128Str + } func filterDevice(name: String?, manufacturerData: UniversalManufacturerData?, services: [CBUUID]?, serviceData: [CBUUID]? = nil) -> Bool { guard let filter = scanFilter else { return true } - let hasNamePrefixFilter = !filter.withNamePrefix.isEmpty - let hasServiceFilter = !filter.withServices.isEmpty - let hasServiceDataFilter = !filter.withServiceData.isEmpty - let hasManufacturerDataFilter = !filter.withManufacturerData.isEmpty - - // If there is no filter at all, then allow device - if !hasNamePrefixFilter && !hasServiceFilter && !hasServiceDataFilter && !hasManufacturerDataFilter { + // The conditions are in OR relation. Each check below is a strict + // predicate answering "does this dimension match?", so an unfiltered + // dimension contributes nothing and needs no guard of its own. + let hasAnyFilter = !filter.withNamePrefix.isEmpty || + !serviceFilters.isEmpty || + !serviceDataFilters.isEmpty || + !filter.withManufacturerData.isEmpty + if !hasAnyFilter { return true } - // Else check one of the filter passes - return hasNamePrefixFilter && isNameMatchingFilters(filter: filter, name: name) || - hasServiceFilter && isServicesMatchingFilters(services: services) || - hasServiceDataFilter && isServiceDataMatchingFilters(serviceData: serviceData) || - hasManufacturerDataFilter && isManufacturerDataMatchingFilters(scanFilter: filter, msd: manufacturerData) + return isNameMatchingFilters(filter: filter, name: name) || + isServicesMatchingFilters(services: services) || + isServiceDataMatchingFilters(serviceData: serviceData) || + isManufacturerDataMatchingFilters(scanFilter: filter, msd: manufacturerData) } func isNameMatchingFilters(filter: UniversalScanFilter, name: String?) -> Bool { - let prefixFilters = filter.withNamePrefix.compactMap { $0 }.filter { !$0.isEmpty } - - guard !prefixFilters.isEmpty else { - return true - } - guard let name = name, !name.isEmpty else { return false } - return prefixFilters.contains { name.hasPrefix($0) } + return filter.withNamePrefix.compactMap { $0 }.contains { name.hasPrefix($0) } } - + /// Compared as 128-bit strings: CoreBluetooth reports a 16-bit advertised + /// service UUID as its short form, which is not equal to the full form the + /// Dart layer sends. func isServicesMatchingFilters(services: [CBUUID]?) -> Bool { - // Compared as 128-bit strings: CoreBluetooth reports a 16-bit advertised - // service UUID as its short form, which is not equal to the full form the - // Dart layer sends. - let serviceFilters = Set(scanFilterServicesUUID.map { $0.uuid128Str }) - - guard !serviceFilters.isEmpty else { - return true - } - guard let services = services, !services.isEmpty else { return false } @@ -75,12 +79,6 @@ public class UniversalBleFilterUtil { /// [isServicesMatchingFilters]: beacons such as Eddystone (`0xFEAA`) or the /// ATC_MiThermometer (`0x181A`) carry the UUID only inside their Service Data. func isServiceDataMatchingFilters(serviceData: [CBUUID]?) -> Bool { - let serviceDataFilters = Set(scanFilterServiceDataUUID) - - guard !serviceDataFilters.isEmpty else { - return true - } - guard let serviceData = serviceData, !serviceData.isEmpty else { return false } @@ -89,16 +87,11 @@ public class UniversalBleFilterUtil { } func isManufacturerDataMatchingFilters(scanFilter: UniversalScanFilter, msd: UniversalManufacturerData?) -> Bool { - let filters = scanFilter.withManufacturerData.compactMap { $0 } - if filters.isEmpty { - return true - } - guard let msd = msd else { return false } - for filter in filters { + for filter in scanFilter.withManufacturerData.compactMap({ $0 }) { let companyIdentifier: Int64 = filter.companyIdentifier if msd.companyIdentifier == companyIdentifier && findData(find: filter.payloadPrefix?.toData(), inData: msd.data.toData(), usingMask: filter.payloadMask?.toData()) { return true diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift index 78a47465..cb69ce7b 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift @@ -182,14 +182,10 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral if usesCustomFilters { UniversalBleLogger.shared.logInfo("Using Custom Filters") - universalBleFilterUtil.scanFilter = filter - universalBleFilterUtil.scanFilterServicesUUID = withServices - universalBleFilterUtil.scanFilterServiceDataUUID = filter?.withServiceData.map { $0.lowercased() } ?? [] + universalBleFilterUtil.configure(scanFilter: filter) withServices = [] } else { - universalBleFilterUtil.scanFilter = nil - universalBleFilterUtil.scanFilterServicesUUID = [] - universalBleFilterUtil.scanFilterServiceDataUUID = [] + universalBleFilterUtil.configure(scanFilter: nil) } // When the caller opts into de-duplication, omit the allow-duplicates key so diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index a515762b..0a997dc0 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -13,4 +13,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 1e95c36afbfd1cb6423ceca4de7a8e1b256fb6ac -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/lib/src/universal_ble_linux/universal_ble_linux.dart b/lib/src/universal_ble_linux/universal_ble_linux.dart index 93010111..639f6d30 100644 --- a/lib/src/universal_ble_linux/universal_ble_linux.dart +++ b/lib/src/universal_ble_linux/universal_ble_linux.dart @@ -653,13 +653,22 @@ class UniversalBleLinux extends UniversalBlePlatform { } } - void _onDeviceAdd(BlueZDevice device) { - BleDevice bleDevice = device.toBleDevice(); - - // Update scan results only if rssi is available - if (device.rssi != 0 && _bleFilter.shouldAcceptDevice(bleDevice)) { + /// Emits the device as a scan result when the current filter accepts it. + /// + /// Runs again on every advertisement rather than trusting the properties seen + /// when BlueZ added the device: a peripheral may only start matching once it + /// advertises Service Data or UUIDs in a later packet (#57). + void _reportIfAccepted(BlueZDevice device, {bool requireRssi = true}) { + if (requireRssi && device.rssi == 0) return; + + final bleDevice = device.toBleDevice(); + if (_bleFilter.shouldAcceptDevice(bleDevice)) { updateScanResult(bleDevice); } + } + + void _onDeviceAdd(BlueZDevice device) { + _reportIfAccepted(device); // Setup Cache. Devices are cached (and their listeners attached) even when // the current filter rejects them: a device that has not advertised any @@ -675,12 +684,7 @@ class UniversalBleLinux extends UniversalBlePlatform { e.contains(BluezProperty.uuids) || e.contains(BluezProperty.serviceData); }).listen((_) { - // Re-evaluate the filter on the current properties, not on the snapshot - // taken when the device was added. - final updatedBleDevice = device.toBleDevice(); - if (_bleFilter.shouldAcceptDevice(updatedBleDevice)) { - updateScanResult(updatedBleDevice); - } + _reportIfAccepted(device, requireRssi: false); }); // Setup update listener diff --git a/lib/src/universal_ble_web/universal_ble_web.dart b/lib/src/universal_ble_web/universal_ble_web.dart index aecb9180..9f2a22b5 100644 --- a/lib/src/universal_ble_web/universal_ble_web.dart +++ b/lib/src/universal_ble_web/universal_ble_web.dart @@ -737,19 +737,13 @@ class UniversalBleWeb extends UniversalBlePlatform { ); } - RequestOptionsBuilder _getRequestOptionBuilder( - ScanFilter? scanFilter, - WebOptions? webOptions, - ) { - List filters = []; - List exclusionFilters = []; - List optionalManufacturerData = []; - List optionalServices = []; - - // The browser chooser can only filter on advertised service UUIDs and - // manufacturer data, so a Service Data match cannot be expressed (and the - // chooser's result is the only scan result web reports). Fail loudly instead - // of silently returning unrelated devices. + /// Rejects filters the browser's device chooser cannot express. + /// + /// The chooser can only filter on advertised service UUIDs, name prefixes and + /// manufacturer data, and its result is the only scan result Web reports, so an + /// unsatisfiable filter must fail loudly rather than silently return unrelated + /// devices. + void _validateScanFilter(ScanFilter? scanFilter) { if (scanFilter != null && scanFilter.withServiceData.isNotEmpty) { throw UniversalBleException( code: UniversalBleErrorCode.notSupported, @@ -760,6 +754,42 @@ class UniversalBleWeb extends UniversalBlePlatform { "from the advertisement events instead.", ); } + } + + List _getExclusionFilters(ScanFilter? scanFilter) { + final exclusionFilters = []; + for (var exclusionFilter in scanFilter?.exclusionFilters ?? const []) { + exclusionFilters.add( + RequestFilterBuilder( + services: exclusionFilter.services.isEmpty + ? null + : exclusionFilter.services.toValidUUIDList(), + namePrefix: exclusionFilter.namePrefix, + manufacturerData: exclusionFilter.manufacturerDataFilter.isEmpty + ? null + : exclusionFilter.manufacturerDataFilter + .map((e) => ManufacturerDataFilterBuilder( + companyIdentifier: e.companyIdentifier, + dataPrefix: e.payloadPrefix, + mask: e.payloadMask, + )) + .toList(), + ), + ); + } + return exclusionFilters; + } + + RequestOptionsBuilder _getRequestOptionBuilder( + ScanFilter? scanFilter, + WebOptions? webOptions, + ) { + _validateScanFilter(scanFilter); + + List filters = []; + List optionalManufacturerData = []; + List optionalServices = []; + final exclusionFilters = _getExclusionFilters(scanFilter); if (webOptions != null) { optionalServices.addAll(webOptions.optionalServices.toValidUUIDList()); @@ -799,27 +829,6 @@ class UniversalBleWeb extends UniversalBlePlatform { for (var name in scanFilter.withNamePrefix) { filters.add(RequestFilterBuilder(namePrefix: name)); } - - // Add exclusion filters - for (var exclusionFilter in scanFilter.exclusionFilters) { - exclusionFilters.add( - RequestFilterBuilder( - services: exclusionFilter.services.isEmpty - ? null - : exclusionFilter.services.toValidUUIDList(), - namePrefix: exclusionFilter.namePrefix, - manufacturerData: exclusionFilter.manufacturerDataFilter.isEmpty - ? null - : exclusionFilter.manufacturerDataFilter.map((e) { - return ManufacturerDataFilterBuilder( - companyIdentifier: e.companyIdentifier, - dataPrefix: e.payloadPrefix, - mask: e.payloadMask, - ); - }).toList(), - ), - ); - } } if (optionalServices.isEmpty) { diff --git a/lib/src/utils/universal_ble_filter_util.dart b/lib/src/utils/universal_ble_filter_util.dart index bbee2c1b..0f0d3e4e 100644 --- a/lib/src/utils/universal_ble_filter_util.dart +++ b/lib/src/utils/universal_ble_filter_util.dart @@ -5,6 +5,11 @@ import 'package:universal_ble/universal_ble.dart'; // A default Filter on dart side // Used on Linux only class UniversalBleFilterUtil { + /// The filter of the current scan session. + /// + /// Kept as a plain field rather than derived state: unlike the native filter + /// utils, there is nothing here to keep in sync with it. Every platform + /// assigns it in `startScan`, so a previous scan's filter cannot leak in. ScanFilter? scanFilter; bool shouldAcceptDevice(BleDevice device) => @@ -32,7 +37,9 @@ class UniversalBleFilterUtil { continue; } // Check manufacturer data - if (!manufacturerDataMatches(filter.manufacturerDataFilter, device)) { + final manufacturerFilters = filter.manufacturerDataFilter; + if (manufacturerFilters.isNotEmpty && + !manufacturerDataMatches(manufacturerFilters, device)) { continue; } // Device matches an exclusion filter @@ -58,40 +65,29 @@ class UniversalBleFilterUtil { return false; } - final hasNamePrefixFilter = filter.withNamePrefix.isNotEmpty; - final hasServiceFilter = filter.withServices.isNotEmpty; - final hasServiceDataFilter = filter.withServiceData.isNotEmpty; - final hasManufacturerDataFilter = filter.withManufacturerData.isNotEmpty; - - // If there is no filter at all, then allow device - if (!hasNamePrefixFilter && - !hasServiceFilter && - !hasServiceDataFilter && - !hasManufacturerDataFilter) { - return true; - } - - // Else check one of the filter passes - return (hasNamePrefixFilter && nameMatches(filter, device)) || - (hasServiceFilter && servicesMatch(filter, device)) || - (hasServiceDataFilter && serviceDataMatch(filter, device)) || - (hasManufacturerDataFilter && - manufacturerDataMatches(filter.withManufacturerData, device)); + // The conditions are in OR relation. Each check below is a strict predicate + // answering "does this dimension match?", so an unfiltered dimension + // contributes nothing and needs no `has…Filter &&` guard of its own. + final hasAnyFilter = filter.withNamePrefix.isNotEmpty || + filter.withServices.isNotEmpty || + filter.withServiceData.isNotEmpty || + filter.withManufacturerData.isNotEmpty; + if (!hasAnyFilter) return true; + + return nameMatches(filter, device) || + servicesMatch(filter, device) || + serviceDataMatch(filter, device) || + manufacturerDataMatches(filter.withManufacturerData, device); } bool nameMatches(ScanFilter scanFilter, BleDevice device) { - final namePrefixFilter = scanFilter.withNamePrefix; - if (namePrefixFilter.isEmpty) return true; - final name = device.name; if (name == null || name.isEmpty) return false; - return namePrefixFilter.any(name.startsWith); + return scanFilter.withNamePrefix.any(name.startsWith); } bool servicesMatch(ScanFilter scanFilter, BleDevice device) { final serviceFilters = scanFilter.withServices.toValidUUIDList(); - if (serviceFilters.isEmpty) return true; - final serviceUuids = device.services.toValidUUIDList(); if (serviceUuids.isEmpty) { return false; @@ -109,8 +105,6 @@ class UniversalBleFilterUtil { /// scan. bool serviceDataMatch(ScanFilter scanFilter, BleDevice device) { final serviceDataFilters = scanFilter.withServiceData.toValidUUIDList(); - if (serviceDataFilters.isEmpty) return true; - final advertisedUuids = device.serviceData.keys .map(BleUuidParser.stringOrNull) .whereType() @@ -122,8 +116,6 @@ class UniversalBleFilterUtil { List filters, BleDevice device, ) { - if (filters.isEmpty) return true; - final deviceDataList = device.manufacturerDataList; if (deviceDataList.isEmpty) return false; diff --git a/test/fixtures/scan_filter_cases.tsv b/test/fixtures/scan_filter_cases.tsv new file mode 100644 index 00000000..7235009f --- /dev/null +++ b/test/fixtures/scan_filter_cases.tsv @@ -0,0 +1,45 @@ +# Shared scan-filter conformance cases. +# +# Consumed by: +# * test/scan_filter_conformance_test.dart (Dart) +# * android/src/test/.../UniversalBleFilterConformanceTest.kt (Kotlin) +# * windows/test/scan_filter_matcher_test.cpp (C++) +# so the platform filter implementations are held to one semantics instead of +# four hand-maintained copies of the same rule. +# +# Columns, '|'-separated (exactly nine separators per row): +# id | withServices | withServiceData | withNamePrefix | withManufacturerIds +# | deviceName | deviceServices | deviceServiceData | deviceManufacturerIds +# | expected +# +# Multi-value columns are ';'-separated. An empty column means "none", and a +# filter whose columns are all empty accepts every device. +# +# UUIDs are written in the full 128-bit lowercase form the platforms receive. +# Short (`181A`) and `0x`-prefixed forms are a Dart-layer convenience handled by +# BleUuidParser, covered by test/scan_filter_test.dart and +# test/scan_filter_channel_test.dart. +# +# Manufacturer payload prefix/mask matching is covered by the per-platform unit +# tests; this fixture compares company identifiers only. +id|withServices|withServiceData|withNamePrefix|withManufacturerIds|deviceName|deviceServices|deviceServiceData|deviceManufacturerIds|expected +no-filter|||||ATC_DD7A81|0000181a-0000-1000-8000-00805f9b34fb|0000181a-0000-1000-8000-00805f9b34fb|76|1 +services-match|0000181a-0000-1000-8000-00805f9b34fb||||ATC|0000181a-0000-1000-8000-00805f9b34fb|||1 +services-mismatch|0000181a-0000-1000-8000-00805f9b34fb||||ATC|00002a6e-0000-1000-8000-00805f9b34fb|||0 +services-multiple-any|0000181a-0000-1000-8000-00805f9b34fb;00002a6e-0000-1000-8000-00805f9b34fb||||ATC|00002a6e-0000-1000-8000-00805f9b34fb|||1 +service-data-match||0000181a-0000-1000-8000-00805f9b34fb|||ATC_DD7A81||0000181a-0000-1000-8000-00805f9b34fb||1 +service-data-mismatch||0000181a-0000-1000-8000-00805f9b34fb|||ATC_DD7A81||00002a6e-0000-1000-8000-00805f9b34fb||0 +service-data-is-not-advertised-services||0000181a-0000-1000-8000-00805f9b34fb|||ATC|0000181a-0000-1000-8000-00805f9b34fb|||0 +advertised-services-are-not-service-data|0000181a-0000-1000-8000-00805f9b34fb||||ATC||0000181a-0000-1000-8000-00805f9b34fb||0 +or-services-hit|0000181a-0000-1000-8000-00805f9b34fb|00002a6e-0000-1000-8000-00805f9b34fb|||ATC|0000181a-0000-1000-8000-00805f9b34fb|||1 +or-service-data-hit|0000181a-0000-1000-8000-00805f9b34fb|00002a6e-0000-1000-8000-00805f9b34fb|||ATC||00002a6e-0000-1000-8000-00805f9b34fb||1 +or-no-hit|0000181a-0000-1000-8000-00805f9b34fb|00002a6e-0000-1000-8000-00805f9b34fb|||ATC|00002a6e-0000-1000-8000-00805f9b34fb|||0 +name-prefix-match|||ATC||ATC_DD7A81||||1 +name-prefix-mismatch|||ATC||OtherDevice||||0 +name-prefix-is-case-sensitive|||atc||ATC_DD7A81||||0 +manufacturer-match||||76|ATC|||76|1 +manufacturer-mismatch||||76|ATC|||117|0 +manufacturer-multiple-any||||76;117|ATC|||117|1 +or-mixed||0000181a-0000-1000-8000-00805f9b34fb|ATC||Other||0000181a-0000-1000-8000-00805f9b34fb||1 +empty-advertisement||0000181a-0000-1000-8000-00805f9b34fb|||||||0 +service-data-128bit||0000feaa-0000-1000-8000-00805f9b34fb|||Eddystone||0000feaa-0000-1000-8000-00805f9b34fb||1 diff --git a/test/manufacturer_data_filter_test.dart b/test/manufacturer_data_filter_test.dart index 39c75a18..4bf74d97 100644 --- a/test/manufacturer_data_filter_test.dart +++ b/test/manufacturer_data_filter_test.dart @@ -19,7 +19,7 @@ void main() { ); }); - test('should match when no manufacturer filters are present', () { + test('should ignore a filter with no manufacturer filters', () { device = BleDevice( deviceId: '1', name: 'Test Device', @@ -27,10 +27,13 @@ void main() { services: [], ); + // The predicate answers "does this dimension match?" and is strict... expect( filterUtil.manufacturerDataMatches( scanFilter.withManufacturerData, device), - true); + false); + // ...so an unfiltered dimension does not narrow the scan on its own. + expect(filterUtil.matchedScanFilter(device), true); }); test('should not match when device has no manufacturer data', () { diff --git a/test/scan_filter_conformance_test.dart b/test/scan_filter_conformance_test.dart new file mode 100644 index 00000000..0008768e --- /dev/null +++ b/test/scan_filter_conformance_test.dart @@ -0,0 +1,74 @@ +// Drives the Dart filter util from the shared conformance fixture +// (test/fixtures/scan_filter_cases.tsv), so its semantics stay identical to the +// Kotlin, Swift and C++ implementations. +// +// Skipped on the web test run: the fixture is read from disk. +@TestOn('vm') +library; + +import 'dart:io'; +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'; + +const _fixturePath = 'test/fixtures/scan_filter_cases.tsv'; + +final _filterUtil = UniversalBleFilterUtil(); + +/// The fixture is language-neutral, so its UUIDs are written the way the +/// platforms receive them (full 128-bit, lowercase). Short/`0x` forms are a +/// Dart-layer convenience covered by `test/scan_filter_test.dart`. +List _values(String column) => + column.isEmpty ? const [] : column.split(';'); + +void main() { + final file = File(_fixturePath); + if (!file.existsSync()) { + throw StateError( + 'Fixture not found at $_fixturePath (run tests from the package root)', + ); + } + + final rows = file + .readAsLinesSync() + .where((line) => line.isNotEmpty && !line.startsWith('#')) + .toList(); + rows.removeAt(0); // header + + for (final row in rows) { + final columns = row.split('|'); + if (columns.length != 10) { + throw StateError('Fixture row "$row" must have 10 columns'); + } + + final id = columns[0]; + final expected = columns[9] == '1'; + + final filter = ScanFilter( + withServices: _values(columns[1]), + withServiceData: _values(columns[2]), + withNamePrefix: _values(columns[3]), + withManufacturerData: _values(columns[4]) + .map((id) => ManufacturerDataFilter(companyIdentifier: int.parse(id))) + .toList(), + ); + final device = BleDevice( + deviceId: id, + name: columns[5].isEmpty ? null : columns[5], + services: _values(columns[6]), + serviceData: { + for (final uuid in _values(columns[7])) uuid: Uint8List.fromList([0]), + }, + manufacturerDataList: _values(columns[8]) + .map((id) => ManufacturerData(int.parse(id), Uint8List(0))) + .toList(), + ); + + test('fixture: $id', () { + _filterUtil.scanFilter = filter; + expect(_filterUtil.matchedScanFilter(device), expected); + }); + } +} diff --git a/test/scan_filter_test.dart b/test/scan_filter_test.dart index 3274a05c..1c0abae4 100644 --- a/test/scan_filter_test.dart +++ b/test/scan_filter_test.dart @@ -5,7 +5,14 @@ import 'package:universal_ble/src/utils/universal_ble_filter_util.dart'; import 'package:universal_ble/universal_ble.dart'; void main() { - final universalBleFilter = UniversalBleFilterUtil(); + // A fresh util per test: the filter is mutable state, so sharing one instance + // makes a test's outcome depend on the tests before it. + late UniversalBleFilterUtil universalBleFilter; + + setUp(() { + universalBleFilter = UniversalBleFilterUtil(); + }); + var device1 = BleDevice( deviceId: '1', name: '1_device', @@ -62,8 +69,14 @@ void main() { universalBleFilter.servicesMatch(scanFilter, device2), isFalse, ); + // A services-only filter constrains nothing in the name dimension. expect( universalBleFilter.nameMatches(scanFilter, device3), + isFalse, + ); + expect( + universalBleFilter.nameMatches( + ScanFilter(withNamePrefix: ['3']), device3), isTrue, ); }); @@ -399,10 +412,11 @@ void main() { isFalse); }); - test('Test serviceDataMatch: empty filter matches everything', () { + test('Test serviceDataMatch: unset service data filter does not narrow', + () { expect( universalBleFilter.serviceDataMatch(ScanFilter(), serviceDataBeacon), - isTrue, + isFalse, ); }); diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 4afcff4b..5c5d2f75 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -45,6 +45,8 @@ list(APPEND PLUGIN_SOURCES "src/generated/universal_ble.g.h" "src/pin_entry.h" "src/scan_result_merge.h" + "src/scan_filter_matcher.cpp" + "src/scan_filter_matcher.h" "src/universal_ble_filter_util.cpp" "src/universal_ble_filter_util.h" "src/universal_ble_thread_safe.h" diff --git a/windows/src/scan_filter_matcher.cpp b/windows/src/scan_filter_matcher.cpp new file mode 100644 index 00000000..84622f41 --- /dev/null +++ b/windows/src/scan_filter_matcher.cpp @@ -0,0 +1,124 @@ +#include "scan_filter_matcher.h" + +#include +#include + +namespace universal_ble { +namespace { + +std::string ToLower(const std::string &value) { + std::string result = value; + std::transform(result.begin(), result.end(), result.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return result; +} + +bool NameMatches(const std::vector &prefixes, + const std::string &name) { + if (name.empty()) { + return false; + } + return std::any_of(prefixes.begin(), prefixes.end(), + [&name](const std::string &prefix) { + return name.rfind(prefix, 0) == 0; + }); +} + +bool ContainsAny(const std::vector &filters, + const std::vector &advertised) { + for (const auto &filter : filters) { + if (std::find(advertised.begin(), advertised.end(), filter) != + advertised.end()) { + return true; + } + } + return false; +} + +/// Compares the advertised bytes against the filter's prefix, honouring the mask +/// when one is given (one bit set to 1 per prefix bit that must match). +bool DataMatches(const std::optional> &prefix, + const std::vector &device_data, + const std::optional> &mask) { + if (!prefix.has_value()) { + return true; + } + if (prefix->size() > device_data.size()) { + return false; + } + if (mask.has_value() && mask->size() != prefix->size()) { + return false; + } + + for (size_t i = 0; i < prefix->size(); i++) { + const uint8_t mask_byte = mask.has_value() ? (*mask)[i] : 0xFF; + if ((mask_byte & (*prefix)[i]) != (mask_byte & device_data[i])) { + return false; + } + } + return true; +} + +bool ManufacturerDataMatches( + const std::vector &filters, + const std::vector &advertised) { + for (const auto &filter : filters) { + for (const auto &data : advertised) { + if (data.company_identifier == filter.company_identifier && + DataMatches(filter.payload_prefix, data.data, filter.payload_mask)) { + return true; + } + } + } + return false; +} + +/// Normalises a whole list, so both sides of a comparison use one form. +std::vector NormalizeAll(const std::vector &uuids) { + std::vector normalized; + normalized.reserve(uuids.size()); + for (const auto &uuid : uuids) { + normalized.push_back(NormalizeUuid(uuid)); + } + return normalized; +} + +} // namespace + +std::string NormalizeUuid(const std::string &uuid) { + std::string hex; + hex.reserve(uuid.size()); + for (const char c : uuid) { + if (c != '-') { + hex.push_back(static_cast( + std::tolower(static_cast(c)))); + } + } + + if (hex.size() == 4) { + hex = "0000" + hex + "0000" + "1000" + "8000" + "00805f9b34fb"; + } else if (hex.size() == 8) { + hex = hex + "0000" + "1000" + "8000" + "00805f9b34fb"; + } + return hex; +} + +bool HasAnyFilter(const ScanFilterValues &filter) { + return !filter.name_prefix.empty() || !filter.services.empty() || + !filter.service_data.empty() || !filter.manufacturer_data.empty(); +} + +bool MatchesScanFilter(const ScanFilterValues &filter, + const AdvertisementValues &advertisement) { + const auto filter_services = NormalizeAll(filter.services); + const auto filter_service_data = NormalizeAll(filter.service_data); + + return NameMatches(filter.name_prefix, advertisement.name) || + ContainsAny(filter_services, NormalizeAll(advertisement.services)) || + ContainsAny(filter_service_data, + NormalizeAll(advertisement.service_data_uuids)) || + ManufacturerDataMatches(filter.manufacturer_data, + advertisement.manufacturer_data); +} + +} // namespace universal_ble diff --git a/windows/src/scan_filter_matcher.h b/windows/src/scan_filter_matcher.h new file mode 100644 index 00000000..b96b8068 --- /dev/null +++ b/windows/src/scan_filter_matcher.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include + +namespace universal_ble { + +/// A manufacturer-data filter in plain types. +/// +/// Deliberately free of flutter/winrt types: the matching rules live here so they +/// can be unit tested without a Flutter engine (see +/// windows/test/scan_filter_matcher_test.cpp, which is driven by the shared +/// fixture in test/fixtures/scan_filter_cases.tsv). +struct ManufacturerDataFilterValue { + int64_t company_identifier = 0; + std::optional> payload_prefix; + std::optional> payload_mask; +}; + +/// One manufacturer-data field of an advertisement. +struct ManufacturerDataValue { + int64_t company_identifier = 0; + std::vector data; +}; + +/// The caller's ScanFilter, reduced to what matching needs. +struct ScanFilterValues { + std::vector services; + std::vector service_data; + std::vector name_prefix; + std::vector manufacturer_data; +}; + +/// One advertisement, reduced to what matching needs. +struct AdvertisementValues { + std::string name; + std::vector services; + std::vector service_data_uuids; + std::vector manufacturer_data; +}; + +/// Normalises a UUID for comparison: lowercase, dashes removed, and short +/// (16-/32-bit) forms expanded to their 128-bit equivalent. +std::string NormalizeUuid(const std::string &uuid); + +/// True when the filter constrains at least one dimension. +/// +/// A filter that constrains nothing accepts every advertisement. +bool HasAnyFilter(const ScanFilterValues &filter); + +/// True when the advertisement matches at least one filter dimension. +/// +/// The dimensions are in OR relation, and each one is a strict predicate: an +/// unfiltered dimension (empty list) matches nothing and therefore contributes +/// nothing. Callers must check [HasAnyFilter] first. +bool MatchesScanFilter(const ScanFilterValues &filter, + const AdvertisementValues &advertisement); +} // namespace universal_ble diff --git a/windows/src/universal_ble_filter_util.cpp b/windows/src/universal_ble_filter_util.cpp index 7b9f1861..5d3126a2 100644 --- a/windows/src/universal_ble_filter_util.cpp +++ b/windows/src/universal_ble_filter_util.cpp @@ -1,178 +1,109 @@ +#include "universal_ble_filter_util.h" + +#include #include + #include "helper/utils.h" -#include "generated/universal_ble.g.h" -#include +#include "scan_filter_matcher.h" namespace universal_ble { -std::vector manufacturerScanFilter = - std::vector(); -std::vector serviceFilterUUIDS = std::vector(); -std::vector serviceDataFilterUUIDS = std::vector(); -std::vector namePrefixFilter = std::vector(); -void setScanFilter(const UniversalScanFilter filter) { - // Set ManufacturerData filter - const auto &manufacturerData = filter.with_manufacturer_data(); - for (const flutter::EncodableValue &data : manufacturerData) { - ManufacturerDataFilter manufacturerDataFilter = - std::any_cast( - std::get(data)); - manufacturerScanFilter.push_back(manufacturerDataFilter); - } - // Set Services Filter - if (!filter.with_services().empty()) { - for (const auto &uuid : filter.with_services()) { - serviceFilterUUIDS.push_back(uuid_to_guid(std::get(uuid))); - } - } - // Set Service Data Filter - if (!filter.with_service_data().empty()) { - for (const auto &uuid : filter.with_service_data()) { - serviceDataFilterUUIDS.push_back( - uuid_to_guid(std::get(uuid))); - } - } - // Set Names Filter - if (!filter.with_name_prefix().empty()) { - for (const auto &name : filter.with_name_prefix()) { - namePrefixFilter.push_back(std::get(name)); +// The filter of the current scan, plus the last one it was set from. The matching +// rules themselves live in scan_filter_matcher.cpp; this file only translates +// the pigeon/flutter types into the plain ones it works with. +namespace { +ScanFilterValues scanFilterValues; +} // namespace + +namespace { + +std::vector ToStringList(const flutter::EncodableList &list) { + std::vector result; + result.reserve(list.size()); + for (const auto &value : list) { + if (const auto *str = std::get_if(&value)) { + result.push_back(*str); } } + return result; } -void resetScanFilter() { - manufacturerScanFilter.clear(); - serviceFilterUUIDS.clear(); - serviceDataFilterUUIDS.clear(); - namePrefixFilter.clear(); -} - -bool isNameMatchingFilters(const std::string *name) { - if (namePrefixFilter.empty()) - return true; - if (name == nullptr || name->empty()) - return false; - return std::any_of( - namePrefixFilter.begin(), namePrefixFilter.end(), - [&name](const std::string &prefix) { return name->find(prefix) == 0; }); -} - -bool isServicesMatchingFilters(const flutter::EncodableList *services) { - if (serviceFilterUUIDS.empty()) - return true; - if (services == nullptr || services->empty()) - return false; - - std::unordered_set serviceGUIDs; - serviceGUIDs.reserve(services->size()); - for (const auto &service : *services) { - if (const auto *str = std::get_if(&service)) { - serviceGUIDs.insert(uuid_to_guid(*str)); - } +std::vector ToBytes(const flutter::EncodableValue &value) { + if (const auto *bytes = std::get_if>(&value)) { + return *bytes; } - - // Check if any of the filter UUIDs are in the set of service GUIDs - return std::any_of(serviceFilterUUIDS.begin(), serviceFilterUUIDS.end(), - [&serviceGUIDs](const winrt::guid &filterUUID) { - return serviceGUIDs.find(filterUUID) != - serviceGUIDs.end(); - }); + return {}; } -/// Matches a device by the Service Data it advertises (the `0x16`/`0x20`/`0x21` -/// advertising data types). This is a different set from the advertised service -/// UUID lists matched by isServicesMatchingFilters: beacons such as Eddystone -/// (`0xFEAA`) or the ATC_MiThermometer (`0x181A`) carry the UUID only there. -bool isServiceDataMatchingFilters(const flutter::EncodableMap *serviceData) { - if (serviceDataFilterUUIDS.empty()) - return true; - if (serviceData == nullptr || serviceData->empty()) - return false; - - std::unordered_set serviceDataGUIDs; - serviceDataGUIDs.reserve(serviceData->size()); - for (const auto &entry : *serviceData) { - if (const auto *uuid = std::get_if(&entry.first)) { - serviceDataGUIDs.insert(uuid_to_guid(*uuid)); +std::vector ToManufacturerFilters( + const flutter::EncodableList &list) { + std::vector result; + result.reserve(list.size()); + for (const auto &value : list) { + const auto &filter = std::any_cast( + std::get(value)); + ManufacturerDataFilterValue plain; + plain.company_identifier = filter.company_identifier(); + if (const auto *prefix = filter.payload_prefix()) { + plain.payload_prefix = *prefix; } + if (const auto *mask = filter.payload_mask()) { + plain.payload_mask = *mask; + } + result.push_back(plain); } - - // Check if any of the filter UUIDs are in the set of Service Data UUIDs - return std::any_of(serviceDataFilterUUIDS.begin(), - serviceDataFilterUUIDS.end(), - [&serviceDataGUIDs](const winrt::guid &filterUUID) { - return serviceDataGUIDs.find(filterUUID) != - serviceDataGUIDs.end(); - }); + return result; } -bool isManufacturerDataMatchingFilters( - const flutter::EncodableList *manufacturerDataList) { - if (manufacturerScanFilter.empty()) - return true; - if (manufacturerDataList == nullptr || manufacturerDataList->empty()) - return false; - - for (const auto &filter : manufacturerScanFilter) { - const auto *data_filter = filter.payload_prefix(); - const auto *mask = filter.payload_mask(); - - for (const auto &value : *manufacturerDataList) { - const auto &deviceManufacturerData = - std::any_cast( - std::get(value)); - - if (deviceManufacturerData.company_identifier() != - filter.company_identifier()) - continue; - - // If no data filter, all data matches - if (data_filter == nullptr) - return true; - - const auto &deviceData = deviceManufacturerData.data(); - if (deviceData.size() < data_filter->size()) - continue; - - bool isMatch = true; - for (size_t i = 0; i < data_filter->size(); ++i) { - const bool hasMask = mask != nullptr && i < mask->size(); - const uint8_t maskByte = hasMask ? (*mask)[i] : 0xFF; - if ((maskByte & (*data_filter)[i]) != (maskByte & deviceData[i])) { - isMatch = false; - break; - } +AdvertisementValues ToAdvertisementValues( + const UniversalBleScanResult &scanResult) { + AdvertisementValues advertisement; + if (const auto *name = scanResult.name()) { + advertisement.name = *name; + } + if (const auto *services = scanResult.services()) { + advertisement.services = ToStringList(*services); + } + if (const auto *service_data = scanResult.service_data()) { + advertisement.service_data_uuids.reserve(service_data->size()); + for (const auto &entry : *service_data) { + if (const auto *uuid = std::get_if(&entry.first)) { + advertisement.service_data_uuids.push_back(*uuid); } - - if (isMatch) - return true; } } + if (const auto *manufacturer_data = scanResult.manufacturer_data_list()) { + advertisement.manufacturer_data.reserve(manufacturer_data->size()); + for (const auto &value : *manufacturer_data) { + const auto &data = std::any_cast( + std::get(value)); + ManufacturerDataValue plain; + plain.company_identifier = data.company_identifier(); + plain.data = data.data(); + advertisement.manufacturer_data.push_back(plain); + } + } + return advertisement; +} - return false; +} // namespace + +void setScanFilter(const UniversalScanFilter filter) { + scanFilterValues = ScanFilterValues(); + scanFilterValues.services = ToStringList(filter.with_services()); + scanFilterValues.service_data = ToStringList(filter.with_service_data()); + scanFilterValues.name_prefix = ToStringList(filter.with_name_prefix()); + scanFilterValues.manufacturer_data = + ToManufacturerFilters(filter.with_manufacturer_data()); } -bool filterDevice(UniversalBleScanResult scanResult) { - bool hasNamePrefixFilter = !namePrefixFilter.empty(); - bool hasServiceFilter = !serviceFilterUUIDS.empty(); - bool hasServiceDataFilter = !serviceDataFilterUUIDS.empty(); - bool hasManufacturerDataFilter = !manufacturerScanFilter.empty(); +void resetScanFilter() { scanFilterValues = ScanFilterValues(); } - // If there is no filter at all, then allow device - if (!hasNamePrefixFilter && !hasServiceFilter && !hasServiceDataFilter && - !hasManufacturerDataFilter) { +bool filterDevice(UniversalBleScanResult scanResult) { + if (!HasAnyFilter(scanFilterValues)) { return true; } - - // Check each filter condition - return (hasNamePrefixFilter && isNameMatchingFilters(scanResult.name())) || - (hasServiceFilter && - isServicesMatchingFilters(scanResult.services())) || - (hasServiceDataFilter && - isServiceDataMatchingFilters(scanResult.service_data())) || - (hasManufacturerDataFilter && - isManufacturerDataMatchingFilters( - scanResult.manufacturer_data_list())); + return MatchesScanFilter(scanFilterValues, ToAdvertisementValues(scanResult)); } } // namespace universal_ble diff --git a/windows/test/CMakeLists.txt b/windows/test/CMakeLists.txt index dc89a352..6a4fbcc3 100644 --- a/windows/test/CMakeLists.txt +++ b/windows/test/CMakeLists.txt @@ -15,6 +15,20 @@ target_include_directories(scan_result_merge_test PRIVATE ../src) add_test(NAME scan_result_merge_test COMMAND scan_result_merge_test) +# Drives the platform-agnostic filter core from the shared Dart/Android/C++ +# conformance fixture. +add_executable(scan_filter_matcher_test + scan_filter_matcher_test.cpp + ../src/scan_filter_matcher.cpp +) +target_compile_features(scan_filter_matcher_test PRIVATE cxx_std_20) +target_include_directories(scan_filter_matcher_test PRIVATE ../src) +target_compile_definitions(scan_filter_matcher_test PRIVATE + SCAN_FILTER_FIXTURE_PATH="${CMAKE_CURRENT_SOURCE_DIR}/../../test/fixtures/scan_filter_cases.tsv" +) + +add_test(NAME scan_filter_matcher_test COMMAND scan_filter_matcher_test) + if(WIN32) add_executable(cleanup_drain_test cleanup_drain_test.cpp) target_compile_features(cleanup_drain_test PRIVATE cxx_std_20) diff --git a/windows/test/scan_filter_matcher_test.cpp b/windows/test/scan_filter_matcher_test.cpp new file mode 100644 index 00000000..c8c8d3bd --- /dev/null +++ b/windows/test/scan_filter_matcher_test.cpp @@ -0,0 +1,178 @@ +// Drives the Windows filter core from the shared conformance fixture +// (test/fixtures/scan_filter_cases.tsv), so its semantics stay identical to the +// Dart, Kotlin and Swift implementations. Builds and runs without a Flutter +// engine or WinRT: see windows/src/scan_filter_matcher.h. +#include +#include +#include +#include +#include + +#include "scan_filter_matcher.h" + +namespace { + +using universal_ble::AdvertisementValues; +using universal_ble::HasAnyFilter; +using universal_ble::ManufacturerDataValue; +using universal_ble::MatchesScanFilter; +using universal_ble::NormalizeUuid; +using universal_ble::ScanFilterValues; + +#ifndef SCAN_FILTER_FIXTURE_PATH +#error "SCAN_FILTER_FIXTURE_PATH must point at test/fixtures/scan_filter_cases.tsv" +#endif + +std::vector Split(const std::string &value, char separator) { + std::vector parts; + std::stringstream stream(value); + std::string part; + while (std::getline(stream, part, separator)) { + if (!part.empty()) { + parts.push_back(part); + } + } + return parts; +} + +struct Case { + std::string id; + std::vector with_services; + std::vector with_service_data; + std::vector with_name_prefix; + std::vector with_manufacturer_ids; + std::string device_name; + std::vector device_services; + std::vector device_service_data; + std::vector device_manufacturer_ids; + bool expected = false; +}; + +std::vector SplitRow(const std::string &line) { + std::vector columns; + std::stringstream stream(line); + std::string column; + while (std::getline(stream, column, '|')) { + columns.push_back(column); + } + return columns; +} + +std::vector ToCompanyIds(const std::vector &values, + const std::string &row_id) { + std::vector ids; + ids.reserve(values.size()); + for (const auto &value : values) { + try { + ids.push_back(std::stoll(value)); + } catch (const std::exception &) { + std::cerr << "Bad company identifier '" << value << "' in fixture row '" + << row_id << "' (columns shifted?)\n"; + std::exit(2); + } + } + return ids; +} + +std::vector LoadCases(const std::string &path) { + std::ifstream file(path); + if (!file.is_open()) { + std::cerr << "Failed to open fixture: " << path << "\n"; + std::exit(2); + } + + std::vector cases; + std::string line; + while (std::getline(file, line)) { + if (line.empty() || line[0] == '#' || line.rfind("id|", 0) == 0) { + continue; + } + const auto columns = SplitRow(line); + if (columns.size() != 10) { + std::cerr << "Bad fixture row: " << line << "\n"; + std::exit(2); + } + + Case test_case; + test_case.id = columns[0]; + test_case.with_services = Split(columns[1], ';'); + test_case.with_service_data = Split(columns[2], ';'); + test_case.with_name_prefix = Split(columns[3], ';'); + test_case.with_manufacturer_ids = Split(columns[4], ';'); + test_case.device_name = columns[5]; + test_case.device_services = Split(columns[6], ';'); + test_case.device_service_data = Split(columns[7], ';'); + test_case.device_manufacturer_ids = Split(columns[8], ';'); + test_case.expected = columns[9] == "1"; + cases.push_back(test_case); + } + return cases; +} + +ScanFilterValues BuildFilter(const Case &test_case) { + ScanFilterValues filter; + filter.services = test_case.with_services; + filter.service_data = test_case.with_service_data; + filter.name_prefix = test_case.with_name_prefix; + for (const auto company_id : ToCompanyIds(test_case.with_manufacturer_ids, test_case.id)) { + universal_ble::ManufacturerDataFilterValue value; + value.company_identifier = company_id; + filter.manufacturer_data.push_back(value); + } + return filter; +} + +AdvertisementValues BuildAdvertisement(const Case &test_case) { + AdvertisementValues advertisement; + advertisement.name = test_case.device_name; + advertisement.services = test_case.device_services; + advertisement.service_data_uuids = test_case.device_service_data; + for (const auto company_id : ToCompanyIds(test_case.device_manufacturer_ids, test_case.id)) { + ManufacturerDataValue value; + value.company_identifier = company_id; + advertisement.manufacturer_data.push_back(value); + } + return advertisement; +} + +int Failures = 0; + +void Expect(bool condition, const std::string &what) { + if (!condition) { + std::cerr << "FAILED: " << what << "\n"; + Failures++; + } +} + +} // namespace + +int main() { + const auto cases = LoadCases(SCAN_FILTER_FIXTURE_PATH); + Expect(!cases.empty(), "fixture should contain cases"); + + for (const auto &test_case : cases) { + const auto filter = BuildFilter(test_case); + const auto advertisement = BuildAdvertisement(test_case); + + if (!HasAnyFilter(filter)) { + Expect(test_case.expected, test_case.id + ": empty filter should accept"); + continue; + } + Expect(MatchesScanFilter(filter, advertisement) == test_case.expected, + test_case.id + ": unexpected match result"); + } + + // UUID normalisation, which every comparison above relies on. + Expect(NormalizeUuid("181A") == "0000181a00001000800000805f9b34fb", + "short UUID expands to the 128-bit form"); + Expect(NormalizeUuid("0000181A-0000-1000-8000-00805F9B34FB") == + "0000181a00001000800000805f9b34fb", + "dashes and case are ignored"); + Expect(NormalizeUuid("181a") == NormalizeUuid("181A"), + "normalisation is case-insensitive"); + + if (Failures == 0) { + std::cout << "All " << cases.size() << " scan filter cases passed\n"; + } + return Failures == 0 ? 0 : 1; +} From 941b7a292b986681212d6339c30a5340c4ead402 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Thu, 15 Oct 2026 23:40:06 +0200 Subject: [PATCH 3/4] 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 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 --- README.md | 2 +- .../universal_ble/UniversalBleFilterConformanceTest.kt | 3 +-- example/macos/Podfile.lock | 2 +- test/manufacturer_data_filter_test.dart | 1 + windows/src/scan_filter_matcher.cpp | 7 ------- windows/src/universal_ble_filter_util.cpp | 8 -------- 6 files changed, 4 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f321e950..cbb8c60a 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ You still need to explicitly [connect](#connecting) to them before being able to #### Scan Filter -You can optionally set a filter when scanning. A filter can have multiple conditions (services, manufacturerData, namePrefix) and all conditions are in `OR` relation, returning results that match any of the given conditions. +You can optionally set a filter when scanning. A filter can have multiple conditions (services, serviceData, manufacturerData, namePrefix) and all conditions are in `OR` relation, returning results that match any of the given conditions. ##### With Services diff --git a/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterConformanceTest.kt b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterConformanceTest.kt index 1765d4f6..a5ed9284 100644 --- a/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterConformanceTest.kt +++ b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterConformanceTest.kt @@ -25,8 +25,7 @@ internal class UniversalBleFilterConformanceTest { val rows = stream.bufferedReader().readLines() .filter { it.isNotBlank() && !it.startsWith("#") } .map { it.split("|") } - .also { it.first() } // header is dropped below - .drop(1) + .drop(1) // header row return rows.map { columns -> assertEquals(10, columns.size, "Bad fixture row: ${columns.joinToString("|")}") Row(columns[0], columns, columns[9] == "1") diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 0a997dc0..a515762b 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -13,4 +13,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 1e95c36afbfd1cb6423ceca4de7a8e1b256fb6ac -COCOAPODS: 1.17.0 +COCOAPODS: 1.16.2 diff --git a/test/manufacturer_data_filter_test.dart b/test/manufacturer_data_filter_test.dart index 4bf74d97..606ca4be 100644 --- a/test/manufacturer_data_filter_test.dart +++ b/test/manufacturer_data_filter_test.dart @@ -33,6 +33,7 @@ void main() { scanFilter.withManufacturerData, device), false); // ...so an unfiltered dimension does not narrow the scan on its own. + filterUtil.scanFilter = scanFilter; expect(filterUtil.matchedScanFilter(device), true); }); diff --git a/windows/src/scan_filter_matcher.cpp b/windows/src/scan_filter_matcher.cpp index 84622f41..40531159 100644 --- a/windows/src/scan_filter_matcher.cpp +++ b/windows/src/scan_filter_matcher.cpp @@ -6,13 +6,6 @@ namespace universal_ble { namespace { -std::string ToLower(const std::string &value) { - std::string result = value; - std::transform(result.begin(), result.end(), result.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - return result; -} - bool NameMatches(const std::vector &prefixes, const std::string &name) { if (name.empty()) { diff --git a/windows/src/universal_ble_filter_util.cpp b/windows/src/universal_ble_filter_util.cpp index 5d3126a2..8f0d3149 100644 --- a/windows/src/universal_ble_filter_util.cpp +++ b/windows/src/universal_ble_filter_util.cpp @@ -1,6 +1,5 @@ #include "universal_ble_filter_util.h" -#include #include #include "helper/utils.h" @@ -28,13 +27,6 @@ std::vector ToStringList(const flutter::EncodableList &list) { return result; } -std::vector ToBytes(const flutter::EncodableValue &value) { - if (const auto *bytes = std::get_if>(&value)) { - return *bytes; - } - return {}; -} - std::vector ToManufacturerFilters( const flutter::EncodableList &list) { std::vector result; From 89ba74a92659ed096459cac694e2d370b8df1a7e Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Fri, 16 Oct 2026 08:08:30 +0200 Subject: [PATCH 4/4] Normalize BleDevice.services to 128-bit UUIDs (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- AGENTS.md | 12 ++ CHANGELOG.md | 1 + README.md | 2 +- lib/src/models/ble_device.dart | 19 ++- .../universal_ble_pigeon_channel.dart | 5 +- lib/src/utils/universal_ble_filter_util.dart | 17 ++- skills/universal-ble-migrate-2-to-3/SKILL.md | 66 ++++++++-- test/ble_device_services_test.dart | 122 ++++++++++++++++++ 8 files changed, 223 insertions(+), 21 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/README.md b/README.md index cbb8c60a..b4edbfbf 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); + }); + }); +}