Skip to content

Commit

Permalink
Allow filtering by company ID only (#855)
Browse files Browse the repository at this point in the history
  • Loading branch information
twyatt authored Feb 7, 2025
1 parent 44f18ea commit 98acf44
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 20 deletions.
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | | ✓<sup>2</sup> | |
| `Name` | | ✓<sup>1</sup> | |
| `NamePrefix` | ✓<sup>1</sup> | ✓<sup>1</sup> | |
| `Address` | | | |
| `ManufacturerData` | | ✓<sup>1</sup> | |

&nbsp; Supported natively
<sup>1</sup> Support provided by Kable via flow filter
<sup>2</sup> 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:
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[versions]
android-compile = "34"
android-compile = "35"
android-min = "21"
atomicfu = "0.27.0"
coroutines = "1.10.1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand All @@ -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
14 changes: 10 additions & 4 deletions kable-core/src/commonMain/kotlin/Filter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()})"
}
}

Expand All @@ -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() }
Expand Down
114 changes: 114 additions & 0 deletions kable-core/src/commonTest/kotlin/FilterPredicateTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -244,6 +245,119 @@ class FilterPredicateTests {
),
)
}

@Test
fun manufacturerDataFilter_nullDataWithNullDataMask_isAllowed() {
ManufacturerDataFilter(
id = 1,
data = null,
dataMask = null,
)
}

@Test
fun manufacturerDataFilter_nullDataWithEmptyDataMask_throwsIllegalArgumentException() {
assertFailsWith<IllegalArgumentException> {
ManufacturerDataFilter(
id = 1,
data = null,
dataMask = byteArrayOf(),
)
}
}

@Test
fun manufacturerDataFilter_nullDataWithNonNullDataMask_throwsIllegalArgumentException() {
assertFailsWith<IllegalArgumentException> {
ManufacturerDataFilter(
id = 1,
data = null,
dataMask = byteArrayOf(0xFF.toByte(), 0xFF.toByte()),
)
}
}

@Test
fun manufacturerDataFilter_emptyData_throwsIllegalArgumentException() {
assertFailsWith<IllegalArgumentException> {
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))
4 changes: 3 additions & 1 deletion kable-core/src/jsMain/kotlin/BluetoothLEScanOptions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ private fun FilterPredicate.toBluetoothLEScanFilterInit(): BluetoothLEScanFilter
private fun toBluetoothManufacturerDataFilterInit(filter: Filter.ManufacturerData) =
jso<BluetoothManufacturerDataFilterInit> {
companyIdentifier = filter.id
dataPrefix = filter.data
if (filter.data != null) {
dataPrefix = filter.data
}
if (filter.dataMask != null) {
mask = filter.dataMask
}
Expand Down

0 comments on commit 98acf44

Please sign in to comment.