Skip to content

Commit 98acf44

Browse files
authored
Allow filtering by company ID only (#855)
1 parent 44f18ea commit 98acf44

6 files changed

Lines changed: 149 additions & 20 deletions

File tree

README.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,21 +82,21 @@ val scanner = Scanner {
8282
Scan results can be filtered by providing a list of [`Filter`]s via the `filters` DSL.
8383
The following filters are supported:
8484

85-
| Filter | Android | Apple | JavaScript |
86-
|--------------------|:-------:|:-----:|:----------:|
87-
| `Service` | ✓✓ | ✓✓* | |
88-
| `Name` | ✓✓ | | ✓✓ |
89-
| `NamePrefix` | || |
90-
| `Address` | ✓✓ | | |
91-
| `ManufacturerData` | ✓✓ | | ✓✓ |
92-
93-
✓ = Supported natively
94-
= Support provided by Kable via flow filter
95-
* = Supported natively if the only filter type used, otherwise falls back to flow filter
85+
| Filter | Android | Apple | JavaScript |
86+
|--------------------|:-------------:|:-------------:|:----------:|
87+
| `Service` | | ✓<sup>2</sup> | |
88+
| `Name` | | ✓<sup>1</sup> | |
89+
| `NamePrefix` | ✓<sup>1</sup> | ✓<sup>1</sup> | |
90+
| `Address` | | | |
91+
| `ManufacturerData` | | ✓<sup>1</sup> | |
92+
93+
&nbsp; Supported natively
94+
<sup>1</sup> Support provided by Kable via flow filter
95+
<sup>2</sup> Supported natively if the only filter type used, otherwise falls back to flow filter
9696

9797
> [!TIP]
98-
> _When a filter is supported natively, the system will often be able to perform scan optimizations. If feasible, it is
99-
> recommended to provide only `Filter.Service` filters (and at least one) — as it is natively supported on all platforms._
98+
> When a filter is supported natively, the system will often be able to perform scan optimizations. If feasible, it is
99+
> recommended to provide only `Filter.Service` filters (and at least one) — as it is natively supported on all platforms.
100100
101101
When filters are specified, only [`Advertisement`]s that match at least one [`Filter`] will be emitted. For example, if
102102
you had the following peripherals nearby when performing a scan:

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[versions]
2-
android-compile = "34"
2+
android-compile = "35"
33
android-min = "21"
44
atomicfu = "0.27.0"
55
coroutines = "1.10.1"

kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import android.bluetooth.le.ScanCallback
44
import android.bluetooth.le.ScanFilter
55
import android.bluetooth.le.ScanResult
66
import android.bluetooth.le.ScanSettings
7+
import android.os.Build.VERSION.SDK_INT
8+
import android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM
79
import android.os.ParcelUuid
810
import com.juul.kable.Filter.Address
911
import com.juul.kable.Filter.ManufacturerData
@@ -142,7 +144,7 @@ private fun FilterPredicate.toNativeScanFilter(): ScanFilter =
142144
when (filter) {
143145
is Name.Exact -> setDeviceName(filter.exact)
144146
is Address -> setDeviceAddress(filter.address)
145-
is ManufacturerData -> setManufacturerData(filter.id, filter.data, filter.dataMask)
147+
is ManufacturerData -> setManufacturerData(filter.id, filterDataCompat(filter.data), filter.dataMask)
146148
is Service -> setServiceUuid(ParcelUuid(filter.uuid.toJavaUuid()))
147149
else -> throw AssertionError("Unsupported filter element")
148150
}
@@ -162,3 +164,8 @@ private fun FilterPredicate.serviceCount(): Int =
162164

163165
private fun FilterPredicate.manufacturerDataCount(): Int =
164166
filters.count { it is ManufacturerData }
167+
168+
// Android doesn't properly check for nullness of manufacturer data until Android 16.
169+
// See https://github.com/JuulLabs/kable/issues/854 for more details.
170+
private fun filterDataCompat(data: ByteArray?): ByteArray? =
171+
if (data == null && SDK_INT <= VANILLA_ICE_CREAM) byteArrayOf() else data

kable-core/src/commonMain/kotlin/Filter.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ public sealed class Filter {
103103
*/
104104
public val id: Int,
105105

106-
public val data: ByteArray,
106+
/** Must be non-`null` if [dataMask] is non-`null`. */
107+
public val data: ByteArray? = null,
107108

108109
/**
109110
* 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 {
112113
public val dataMask: ByteArray? = null,
113114
) : Filter() {
114115

115-
public constructor(id: ByteArray, data: ByteArray, dataMask: ByteArray? = null) : this(id.toShort(), data, dataMask)
116+
public constructor(id: ByteArray, data: ByteArray? = null, dataMask: ByteArray? = null) : this(id.toShort(), data, dataMask)
116117

117118
init {
118119
require(id >= 0) { "Company identifier cannot be negative, was $id" }
119120
require(id <= 65535) { "Company identifier cannot be more than 16-bits (65535), was $id" }
120-
if (dataMask != null) requireDataAndMaskHaveSameLength(data, dataMask)
121+
if (data != null && data.isEmpty()) throw IllegalArgumentException("If data is present (non-null), it must be non-empty")
122+
if (dataMask != null) {
123+
requireNotNull(data) { "Data is null but must be non-null when dataMask is non-null" }
124+
requireDataAndMaskHaveSameLength(data, dataMask)
125+
}
121126
}
122127

123128
override fun toString(): String =
124-
"ManufacturerData(id=$id, data=${data.toHexString()}, dataMask=${dataMask?.toHexString()})"
129+
"ManufacturerData(id=$id, data=${data?.toHexString()}, dataMask=${dataMask?.toHexString()})"
125130
}
126131
}
127132

@@ -144,6 +149,7 @@ internal fun Filter.Name.matches(name: String?): Boolean {
144149
}
145150

146151
internal fun Filter.ManufacturerData.matches(data: ByteArray?): Boolean {
152+
if (this.data == null) return true
147153
if (data == null) return false
148154
if (dataMask == null) return this.data.contentEquals(data)
149155
val lastMaskIndex = dataMask.indexOfLast { it != 0.toByte() }

kable-core/src/commonTest/kotlin/FilterPredicateTests.kt

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.juul.kable
33
import com.juul.kable.Filter.Name.Exact
44
import com.juul.kable.Filter.Name.Prefix
55
import kotlin.test.Test
6+
import kotlin.test.assertFailsWith
67
import kotlin.test.assertFalse
78
import kotlin.test.assertTrue
89
import kotlin.uuid.Uuid
@@ -244,6 +245,119 @@ class FilterPredicateTests {
244245
),
245246
)
246247
}
248+
249+
@Test
250+
fun manufacturerDataFilter_nullDataWithNullDataMask_isAllowed() {
251+
ManufacturerDataFilter(
252+
id = 1,
253+
data = null,
254+
dataMask = null,
255+
)
256+
}
257+
258+
@Test
259+
fun manufacturerDataFilter_nullDataWithEmptyDataMask_throwsIllegalArgumentException() {
260+
assertFailsWith<IllegalArgumentException> {
261+
ManufacturerDataFilter(
262+
id = 1,
263+
data = null,
264+
dataMask = byteArrayOf(),
265+
)
266+
}
267+
}
268+
269+
@Test
270+
fun manufacturerDataFilter_nullDataWithNonNullDataMask_throwsIllegalArgumentException() {
271+
assertFailsWith<IllegalArgumentException> {
272+
ManufacturerDataFilter(
273+
id = 1,
274+
data = null,
275+
dataMask = byteArrayOf(0xFF.toByte(), 0xFF.toByte()),
276+
)
277+
}
278+
}
279+
280+
@Test
281+
fun manufacturerDataFilter_emptyData_throwsIllegalArgumentException() {
282+
assertFailsWith<IllegalArgumentException> {
283+
ManufacturerDataFilter(
284+
id = 1,
285+
data = byteArrayOf(),
286+
)
287+
}
288+
}
289+
290+
@Test
291+
fun matches_manufacturerDataFilterWithNullDataVsEmptyData_isTrue() {
292+
val predicate = ManufacturerDataFilter(
293+
id = 1,
294+
data = null,
295+
dataMask = null,
296+
).toPredicate()
297+
298+
assertTrue(
299+
predicate.matches(
300+
manufacturerData = ManufacturerData(
301+
code = 1,
302+
data = byteArrayOf(),
303+
),
304+
),
305+
)
306+
}
307+
308+
@Test
309+
fun matches_manufacturerDataFilterWithNullDataVsData_isTrue() {
310+
val predicate = ManufacturerDataFilter(
311+
id = 1,
312+
data = null,
313+
dataMask = null,
314+
).toPredicate()
315+
316+
assertTrue(
317+
predicate.matches(
318+
manufacturerData = ManufacturerData(
319+
code = 1,
320+
data = byteArrayOf(0xFF.toByte(), 0xFF.toByte()),
321+
),
322+
),
323+
)
324+
}
325+
326+
@Test
327+
fun matches_manufacturerDataFilterWithoutDataMaskVsData_isTrue() {
328+
val predicate = ManufacturerDataFilter(
329+
id = 1,
330+
data = byteArrayOf(0xFF.toByte(), 0xFF.toByte()),
331+
dataMask = null,
332+
).toPredicate()
333+
334+
assertTrue(
335+
predicate.matches(
336+
manufacturerData = ManufacturerData(
337+
code = 1,
338+
data = byteArrayOf(0xFF.toByte(), 0xFF.toByte()),
339+
),
340+
),
341+
)
342+
}
343+
344+
@Test
345+
fun matches_manufacturerDataFilterWithoutDataMaskVsDifferentData_isFalse() {
346+
val predicate = ManufacturerDataFilter(
347+
id = 1,
348+
data = byteArrayOf(0xF0.toByte(), 0x0D.toByte()),
349+
dataMask = null,
350+
).toPredicate()
351+
352+
assertFalse(
353+
predicate.matches(
354+
manufacturerData = ManufacturerData(
355+
code = 1,
356+
data = byteArrayOf(0x12, 0x34),
357+
),
358+
),
359+
)
360+
}
247361
}
248362

249363
private fun Filter.toPredicate() = FilterPredicate(listOf(this))

kable-core/src/jsMain/kotlin/BluetoothLEScanOptions.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ private fun FilterPredicate.toBluetoothLEScanFilterInit(): BluetoothLEScanFilter
4646
private fun toBluetoothManufacturerDataFilterInit(filter: Filter.ManufacturerData) =
4747
jso<BluetoothManufacturerDataFilterInit> {
4848
companyIdentifier = filter.id
49-
dataPrefix = filter.data
49+
if (filter.data != null) {
50+
dataPrefix = filter.data
51+
}
5052
if (filter.dataMask != null) {
5153
mask = filter.dataMask
5254
}

0 commit comments

Comments
 (0)