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 c4f21429..67ebf504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 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. +* 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..b4edbfbf 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, 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 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. @@ -198,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 @@ -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/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/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..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,71 +11,86 @@ private const val TAG = "UniversalBlePlugin" @SuppressLint("MissingPermission") class UniversalBleFilterUtil { - var scanFilter: UniversalScanFilter? = null - var serviceFilterUUIDS: 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?, manufacturerDataList: List, serviceUuids: Array, + serviceDataUuids: List, ): Boolean { val filter = scanFilter ?: return true - val hasNamePrefixFilter = filter.withNamePrefix.isNotEmpty() - val hasServiceFilter = filter.withServices.isNotEmpty() - val hasManufacturerDataFilter = filter.withManufacturerData.isNotEmpty() - - // If there is no filter at all, then allow device - if (!hasNamePrefixFilter && - !hasServiceFilter && - !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) || - 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) } } + /// 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 { + return serviceDataFilters.any { + serviceDataUuids.contains(it) + } + } + private fun isManufacturerDataMatchingFilters( 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) } @@ -100,8 +115,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..4bea74ed 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -277,10 +277,9 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), // Set custom scan filter only if required if (usesCustomFilters) { UniversalBleLogger.logError("Using Custom Filters") - universalBleFilterUtil.scanFilter = filter - universalBleFilterUtil.serviceFilterUUIDS = filterServices + universalBleFilterUtil.configure(filter) } else { - universalBleFilterUtil.scanFilter = null + universalBleFilterUtil.configure(null) scanFilters = filter?.toScanFilters(filterServices) ?: emptyList() } @@ -1674,7 +1673,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/UniversalBleFilterConformanceTest.kt b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterConformanceTest.kt new file mode 100644 index 00000000..a5ed9284 --- /dev/null +++ b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterConformanceTest.kt @@ -0,0 +1,69 @@ +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("|") } + .drop(1) // header row + 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 new file mode 100644 index 00000000..54482345 --- /dev/null +++ b/android/src/test/kotlin/com/navideck/universal_ble/UniversalBleFilterUtilTest.kt @@ -0,0 +1,93 @@ +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 { configure(filter) } + } + + @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..a13b7bc1 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBleFilterUtil.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBleFilterUtil.swift @@ -9,68 +9,89 @@ 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 = [] + + /// 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]?) -> Bool { + 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 hasManufacturerDataFilter = !filter.withManufacturerData.isEmpty - - // If there is no filter at all, then allow device - if !hasNamePrefixFilter && !hasServiceFilter && !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) || - 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 { - let serviceFilters = Set(scanFilterServicesUUID.compactMap { $0 }) - - guard !serviceFilters.isEmpty else { - return true - } - guard let services = services, !services.isEmpty else { return false } - return !Set(services).isDisjoint(with: serviceFilters) + return !Set(services.map { $0.uuid128Str }).isDisjoint(with: serviceFilters) } - func isManufacturerDataMatchingFilters(scanFilter: UniversalScanFilter, msd: UniversalManufacturerData?) -> Bool { - let filters = scanFilter.withManufacturerData.compactMap { $0 } - if filters.isEmpty { - return true + /// 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 { + 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 { 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 @@ -102,6 +123,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..cb69ce7b 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBlePlugin.swift @@ -182,12 +182,10 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral if usesCustomFilters { UniversalBleLogger.shared.logInfo("Using Custom Filters") - universalBleFilterUtil.scanFilter = filter - universalBleFilterUtil.scanFilterServicesUUID = withServices + universalBleFilterUtil.configure(scanFilter: filter) withServices = [] } else { - universalBleFilterUtil.scanFilter = nil - universalBleFilterUtil.scanFilterServicesUUID = [] + universalBleFilterUtil.configure(scanFilter: nil) } // When the caller opts into de-duplication, omit the allow-duplicates key so @@ -674,7 +672,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/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/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..639f6d30 100644 --- a/lib/src/universal_ble_linux/universal_ble_linux.dart +++ b/lib/src/universal_ble_linux/universal_ble_linux.dart @@ -653,18 +653,27 @@ 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) { + /// 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 + // 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,9 +684,7 @@ class UniversalBleLinux extends UniversalBlePlatform { e.contains(BluezProperty.uuids) || e.contains(BluezProperty.serviceData); }).listen((_) { - if (_bleFilter.shouldAcceptDevice(bleDevice)) { - updateScanResult(device.toBleDevice()); - } + _reportIfAccepted(device, requireRssi: false); }); // Setup update listener 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..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 @@ -412,6 +415,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..9f2a22b5 100644 --- a/lib/src/universal_ble_web/universal_ble_web.dart +++ b/lib/src/universal_ble_web/universal_ble_web.dart @@ -737,14 +737,59 @@ class UniversalBleWeb extends UniversalBlePlatform { ); } + /// 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, + 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.", + ); + } + } + + 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 exclusionFilters = []; List optionalManufacturerData = []; List optionalServices = []; + final exclusionFilters = _getExclusionFilters(scanFilter); if (webOptions != null) { optionalServices.addAll(webOptions.optionalServices.toValidUUIDList()); @@ -784,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 c771b378..24f7d527 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) => @@ -15,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; @@ -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,50 +65,62 @@ class UniversalBleFilterUtil { return false; } - final hasNamePrefixFilter = filter.withNamePrefix.isNotEmpty; - final hasServiceFilter = filter.withServices.isNotEmpty; - final hasManufacturerDataFilter = filter.withManufacturerData.isNotEmpty; - - // If there is no filter at all, then allow device - if (!hasNamePrefixFilter && - !hasServiceFilter && - !hasManufacturerDataFilter) { - return true; - } - - // Else check one of the filter passes - return (hasNamePrefixFilter && nameMatches(filter, device)) || - (hasServiceFilter && servicesMatch(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(); + final serviceUuids = _normalizedUuids(device.services); if (serviceUuids.isEmpty) { return false; } 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(); + 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, ) { - if (filters.isEmpty) return true; - final deviceDataList = device.manufacturerDataList; if (deviceDataList.isEmpty) return false; 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/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); + }); + }); +} 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..606ca4be 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,14 @@ 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. + filterUtil.scanFilter = scanFilter; + expect(filterUtil.matchedScanFilter(device), true); }); test('should not match when device has no manufacturer data', () { 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_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 2f523878..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, ); }); @@ -312,24 +325,142 @@ 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: unset service data filter does not narrow', + () { + expect( + universalBleFilter.serviceDataMatch(ScanFilter(), serviceDataBeacon), + isFalse, + ); + }); + + 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/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/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/scan_filter_matcher.cpp b/windows/src/scan_filter_matcher.cpp new file mode 100644 index 00000000..40531159 --- /dev/null +++ b/windows/src/scan_filter_matcher.cpp @@ -0,0 +1,117 @@ +#include "scan_filter_matcher.h" + +#include +#include + +namespace universal_ble { +namespace { + +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 7ccb98ad..8f0d3149 100644 --- a/windows/src/universal_ble_filter_util.cpp +++ b/windows/src/universal_ble_filter_util.cpp @@ -1,138 +1,101 @@ +#include "universal_ble_filter_util.h" + #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 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))); +// 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); } } - // Set Names Filter - if (!filter.with_name_prefix().empty()) { - for (const auto &name : filter.with_name_prefix()) { - namePrefixFilter.push_back(std::get(name)); - } - } -} - -void resetScanFilter() { - manufacturerScanFilter.clear(); - serviceFilterUUIDS.clear(); - namePrefixFilter.clear(); + return result; } -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 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 GUIDs - return std::any_of(serviceFilterUUIDS.begin(), serviceFilterUUIDS.end(), - [&serviceGUIDs](const winrt::guid &filterUUID) { - return serviceGUIDs.find(filterUUID) != - serviceGUIDs.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; +} + +} // namespace - return false; +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 hasManufacturerDataFilter = !manufacturerScanFilter.empty(); +void resetScanFilter() { scanFilterValues = ScanFilterValues(); } - // If there is no filter at all, then allow device - if (!hasNamePrefixFilter && !hasServiceFilter && !hasManufacturerDataFilter) { +bool filterDevice(UniversalBleScanResult scanResult) { + if (!HasAnyFilter(scanFilterValues)) { return true; } - - // Check each filter condition - return (hasNamePrefixFilter && isNameMatchingFilters(scanResult.name())) || - (hasServiceFilter && - isServicesMatchingFilters(scanResult.services())) || - (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; +}