From 98acf44b4f8d715bdd7bc838cf2ceefa4d7b7f5c Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Fri, 7 Feb 2025 11:25:31 -0800 Subject: [PATCH] Allow filtering by company ID only (#855) --- README.md | 26 ++-- gradle/libs.versions.toml | 2 +- .../BluetoothLeScannerAndroidScanner.kt | 9 +- kable-core/src/commonMain/kotlin/Filter.kt | 14 ++- .../commonTest/kotlin/FilterPredicateTests.kt | 114 ++++++++++++++++++ .../jsMain/kotlin/BluetoothLEScanOptions.kt | 4 +- 6 files changed, 149 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a2db34813..91f7e24df 100644 --- a/README.md +++ b/README.md @@ -82,21 +82,21 @@ val scanner = Scanner { Scan results can be filtered by providing a list of [`Filter`]s via the `filters` DSL. The following filters are supported: -| Filter | Android | Apple | JavaScript | -|--------------------|:-------:|:-----:|:----------:| -| `Service` | ✓✓ | ✓✓* | ✓✓ | -| `Name` | ✓✓ | ✓ | ✓✓ | -| `NamePrefix` | ✓ | ✓ | ✓✓ | -| `Address` | ✓✓ | | | -| `ManufacturerData` | ✓✓ | ✓ | ✓✓ | - -✓✓ = Supported natively -✓ = Support provided by Kable via flow filter -✓✓* = Supported natively if the only filter type used, otherwise falls back to flow filter +| Filter | Android | Apple | JavaScript | +|--------------------|:-------------:|:-------------:|:----------:| +| `Service` | ✓ | ✓2 | ✓ | +| `Name` | ✓ | ✓1 | ✓ | +| `NamePrefix` | ✓1 | ✓1 | ✓ | +| `Address` | ✓ | | | +| `ManufacturerData` | ✓ | ✓1 | ✓ | + +✓  Supported natively +✓1 Support provided by Kable via flow filter +✓2 Supported natively if the only filter type used, otherwise falls back to flow filter > [!TIP] -> _When a filter is supported natively, the system will often be able to perform scan optimizations. If feasible, it is -> recommended to provide only `Filter.Service` filters (and at least one) — as it is natively supported on all platforms._ +> When a filter is supported natively, the system will often be able to perform scan optimizations. If feasible, it is +> recommended to provide only `Filter.Service` filters (and at least one) — as it is natively supported on all platforms. When filters are specified, only [`Advertisement`]s that match at least one [`Filter`] will be emitted. For example, if you had the following peripherals nearby when performing a scan: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 63075d2be..7734947bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -android-compile = "34" +android-compile = "35" android-min = "21" atomicfu = "0.27.0" coroutines = "1.10.1" diff --git a/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt b/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt index 56107613b..b5e4106da 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt @@ -4,6 +4,8 @@ import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM import android.os.ParcelUuid import com.juul.kable.Filter.Address import com.juul.kable.Filter.ManufacturerData @@ -142,7 +144,7 @@ private fun FilterPredicate.toNativeScanFilter(): ScanFilter = when (filter) { is Name.Exact -> setDeviceName(filter.exact) is Address -> setDeviceAddress(filter.address) - is ManufacturerData -> setManufacturerData(filter.id, filter.data, filter.dataMask) + is ManufacturerData -> setManufacturerData(filter.id, filterDataCompat(filter.data), filter.dataMask) is Service -> setServiceUuid(ParcelUuid(filter.uuid.toJavaUuid())) else -> throw AssertionError("Unsupported filter element") } @@ -162,3 +164,8 @@ private fun FilterPredicate.serviceCount(): Int = private fun FilterPredicate.manufacturerDataCount(): Int = filters.count { it is ManufacturerData } + +// Android doesn't properly check for nullness of manufacturer data until Android 16. +// See https://github.com/JuulLabs/kable/issues/854 for more details. +private fun filterDataCompat(data: ByteArray?): ByteArray? = + if (data == null && SDK_INT <= VANILLA_ICE_CREAM) byteArrayOf() else data diff --git a/kable-core/src/commonMain/kotlin/Filter.kt b/kable-core/src/commonMain/kotlin/Filter.kt index ae27384c0..1973c195d 100644 --- a/kable-core/src/commonMain/kotlin/Filter.kt +++ b/kable-core/src/commonMain/kotlin/Filter.kt @@ -103,7 +103,8 @@ public sealed class Filter { */ public val id: Int, - public val data: ByteArray, + /** Must be non-`null` if [dataMask] is non-`null`. */ + public val data: ByteArray? = null, /** * For any bit in the mask, set it to 1 if advertisement manufacturer data needs to match the corresponding bit @@ -112,16 +113,20 @@ public sealed class Filter { public val dataMask: ByteArray? = null, ) : Filter() { - public constructor(id: ByteArray, data: ByteArray, dataMask: ByteArray? = null) : this(id.toShort(), data, dataMask) + public constructor(id: ByteArray, data: ByteArray? = null, dataMask: ByteArray? = null) : this(id.toShort(), data, dataMask) init { require(id >= 0) { "Company identifier cannot be negative, was $id" } require(id <= 65535) { "Company identifier cannot be more than 16-bits (65535), was $id" } - if (dataMask != null) requireDataAndMaskHaveSameLength(data, dataMask) + if (data != null && data.isEmpty()) throw IllegalArgumentException("If data is present (non-null), it must be non-empty") + if (dataMask != null) { + requireNotNull(data) { "Data is null but must be non-null when dataMask is non-null" } + requireDataAndMaskHaveSameLength(data, dataMask) + } } override fun toString(): String = - "ManufacturerData(id=$id, data=${data.toHexString()}, dataMask=${dataMask?.toHexString()})" + "ManufacturerData(id=$id, data=${data?.toHexString()}, dataMask=${dataMask?.toHexString()})" } } @@ -144,6 +149,7 @@ internal fun Filter.Name.matches(name: String?): Boolean { } internal fun Filter.ManufacturerData.matches(data: ByteArray?): Boolean { + if (this.data == null) return true if (data == null) return false if (dataMask == null) return this.data.contentEquals(data) val lastMaskIndex = dataMask.indexOfLast { it != 0.toByte() } diff --git a/kable-core/src/commonTest/kotlin/FilterPredicateTests.kt b/kable-core/src/commonTest/kotlin/FilterPredicateTests.kt index 88a784526..1cca55193 100644 --- a/kable-core/src/commonTest/kotlin/FilterPredicateTests.kt +++ b/kable-core/src/commonTest/kotlin/FilterPredicateTests.kt @@ -3,6 +3,7 @@ package com.juul.kable import com.juul.kable.Filter.Name.Exact import com.juul.kable.Filter.Name.Prefix import kotlin.test.Test +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.uuid.Uuid @@ -244,6 +245,119 @@ class FilterPredicateTests { ), ) } + + @Test + fun manufacturerDataFilter_nullDataWithNullDataMask_isAllowed() { + ManufacturerDataFilter( + id = 1, + data = null, + dataMask = null, + ) + } + + @Test + fun manufacturerDataFilter_nullDataWithEmptyDataMask_throwsIllegalArgumentException() { + assertFailsWith { + ManufacturerDataFilter( + id = 1, + data = null, + dataMask = byteArrayOf(), + ) + } + } + + @Test + fun manufacturerDataFilter_nullDataWithNonNullDataMask_throwsIllegalArgumentException() { + assertFailsWith { + ManufacturerDataFilter( + id = 1, + data = null, + dataMask = byteArrayOf(0xFF.toByte(), 0xFF.toByte()), + ) + } + } + + @Test + fun manufacturerDataFilter_emptyData_throwsIllegalArgumentException() { + assertFailsWith { + ManufacturerDataFilter( + id = 1, + data = byteArrayOf(), + ) + } + } + + @Test + fun matches_manufacturerDataFilterWithNullDataVsEmptyData_isTrue() { + val predicate = ManufacturerDataFilter( + id = 1, + data = null, + dataMask = null, + ).toPredicate() + + assertTrue( + predicate.matches( + manufacturerData = ManufacturerData( + code = 1, + data = byteArrayOf(), + ), + ), + ) + } + + @Test + fun matches_manufacturerDataFilterWithNullDataVsData_isTrue() { + val predicate = ManufacturerDataFilter( + id = 1, + data = null, + dataMask = null, + ).toPredicate() + + assertTrue( + predicate.matches( + manufacturerData = ManufacturerData( + code = 1, + data = byteArrayOf(0xFF.toByte(), 0xFF.toByte()), + ), + ), + ) + } + + @Test + fun matches_manufacturerDataFilterWithoutDataMaskVsData_isTrue() { + val predicate = ManufacturerDataFilter( + id = 1, + data = byteArrayOf(0xFF.toByte(), 0xFF.toByte()), + dataMask = null, + ).toPredicate() + + assertTrue( + predicate.matches( + manufacturerData = ManufacturerData( + code = 1, + data = byteArrayOf(0xFF.toByte(), 0xFF.toByte()), + ), + ), + ) + } + + @Test + fun matches_manufacturerDataFilterWithoutDataMaskVsDifferentData_isFalse() { + val predicate = ManufacturerDataFilter( + id = 1, + data = byteArrayOf(0xF0.toByte(), 0x0D.toByte()), + dataMask = null, + ).toPredicate() + + assertFalse( + predicate.matches( + manufacturerData = ManufacturerData( + code = 1, + data = byteArrayOf(0x12, 0x34), + ), + ), + ) + } } private fun Filter.toPredicate() = FilterPredicate(listOf(this)) diff --git a/kable-core/src/jsMain/kotlin/BluetoothLEScanOptions.kt b/kable-core/src/jsMain/kotlin/BluetoothLEScanOptions.kt index 20630a933..0ade6ebdd 100644 --- a/kable-core/src/jsMain/kotlin/BluetoothLEScanOptions.kt +++ b/kable-core/src/jsMain/kotlin/BluetoothLEScanOptions.kt @@ -46,7 +46,9 @@ private fun FilterPredicate.toBluetoothLEScanFilterInit(): BluetoothLEScanFilter private fun toBluetoothManufacturerDataFilterInit(filter: Filter.ManufacturerData) = jso { companyIdentifier = filter.id - dataPrefix = filter.data + if (filter.data != null) { + dataPrefix = filter.data + } if (filter.dataMask != null) { mask = filter.dataMask }