Skip to content

Commit 63df3e3

Browse files
authored
Optimize scan filtering (#890)
1 parent af43b56 commit 63df3e3

4 files changed

Lines changed: 296 additions & 40 deletions

File tree

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
1919
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
2020
kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version = "0.7.0" }
2121
mockk = { module = "io.mockk:mockk", version = "1.13.17" }
22+
robolectric = { module = "org.robolectric:robolectric", version = "4.14.1" }
2223
tuulbox-collections = { module = "com.juul.tuulbox:collections", version.ref = "tuulbox" }
2324
tuulbox-coroutines = { module = "com.juul.tuulbox:coroutines", version.ref = "tuulbox" }
2425
wrappers-bom = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", version = "2025.4.3" }

kable-core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ kotlin {
5858
androidUnitTest.dependencies {
5959
implementation(libs.equalsverifier)
6060
implementation(libs.mockk)
61+
implementation(libs.robolectric)
6162
}
6263

6364
jsMain.dependencies {

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

Lines changed: 86 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,29 @@ import kotlinx.coroutines.channels.trySendBlocking
2626
import kotlinx.coroutines.flow.Flow
2727
import kotlinx.coroutines.flow.callbackFlow
2828
import kotlinx.coroutines.flow.filter
29+
import kotlin.reflect.KClass
2930
import kotlin.uuid.toJavaUuid
3031
import kotlin.uuid.toKotlinUuid
3132

33+
internal data class ScanFilters(
34+
35+
/** [ScanFilter]s applied using Android's native filtering. */
36+
val native: List<ScanFilter>,
37+
38+
/** [FilterPredicate]s applied via flow [filter][Flow.filter] operator. */
39+
val flow: List<FilterPredicate>,
40+
)
41+
3242
internal class BluetoothLeScannerAndroidScanner(
33-
private val filters: List<FilterPredicate>,
43+
filters: List<FilterPredicate>,
3444
private val scanSettings: ScanSettings,
3545
private val preConflate: Boolean,
3646
logging: Logging,
3747
) : PlatformScanner {
3848

3949
private val logger = Logger(logging, tag = "Kable/Scanner", identifier = null)
4050

41-
private val scanFilters = filters.toNativeScanFilters()
51+
private val scanFilters = filters.toScanFilters()
4252

4353
override val advertisements: Flow<PlatformAdvertisement> = callbackFlow {
4454
logger.debug { message = "Initializing scan" }
@@ -87,7 +97,7 @@ internal class BluetoothLeScannerAndroidScanner(
8797
logger.info {
8898
message = logMessage("Starting", preConflate, scanFilters)
8999
}
90-
scanner.startScan(scanFilters, scanSettings, callback)
100+
scanner.startScan(scanFilters.native, scanSettings, callback)
91101

92102
awaitClose {
93103
logger.info {
@@ -102,76 +112,112 @@ internal class BluetoothLeScannerAndroidScanner(
102112
}
103113
}
104114
}.filter { advertisement ->
105-
// Short-circuit (i.e. don't filter) if native scan filters were applied.
106-
if (scanFilters.isNotEmpty()) return@filter true
107-
108-
// Perform filtering here, since we were not able to use native scan filters.
109-
filters.matches(
110-
services = advertisement.uuids,
111-
name = advertisement.name,
112-
address = advertisement.address,
113-
manufacturerData = advertisement.manufacturerData,
114-
serviceData = advertisement.serviceData?.mapKeys { (key) -> key.uuid.toKotlinUuid() },
115-
)
115+
if (scanFilters.flow.isEmpty()) {
116+
true
117+
} else {
118+
scanFilters.flow.matches(
119+
services = advertisement.uuids,
120+
name = advertisement.name,
121+
address = advertisement.address,
122+
manufacturerData = advertisement.manufacturerData,
123+
serviceData = advertisement.serviceData?.mapKeys { (key) -> key.uuid.toKotlinUuid() },
124+
)
125+
}
116126
}
117127
}
118128

119129
private fun logMessage(
120130
prefix: String,
121131
preConflate: Boolean,
122-
scanFilters: List<ScanFilter>,
132+
scanFilters: ScanFilters,
123133
) = buildString {
124134
append(prefix)
125135
append(' ')
126136
append("scan ")
127137
if (preConflate) {
128138
append("pre-conflated ")
129139
}
130-
if (scanFilters.isEmpty()) {
140+
if (scanFilters.native.isEmpty() && scanFilters.flow.isEmpty()) {
131141
append("without filters")
132142
} else {
133-
append("with ${scanFilters.size} filter(s)")
143+
append("with ${scanFilters.native.count()} native and ${scanFilters.flow.count()} flow filter(s)")
134144
}
135145
}
136146

137-
private fun List<FilterPredicate>.toNativeScanFilters(): List<ScanFilter> =
147+
internal fun List<FilterPredicate>.toScanFilters(): ScanFilters =
138148
if (all(FilterPredicate::supportsNativeScanFiltering)) {
139-
map(FilterPredicate::toNativeScanFilter)
149+
ScanFilters(
150+
native = map(FilterPredicate::toNativeScanFilter),
151+
flow = emptyList(),
152+
)
153+
} else if (count() == 1) {
154+
val nativeFilters = mutableMapOf<KClass<*>, Filter>()
155+
val flowFilters = mutableListOf<Filter>()
156+
single().filters.forEach { filter ->
157+
if (filter.canFilterNatively && filter::class !in nativeFilters) {
158+
nativeFilters[filter::class] = filter
159+
} else {
160+
flowFilters += filter
161+
}
162+
}
163+
ScanFilters(
164+
native = listOf(nativeFilters.values.toList().toNativeScanFilter()),
165+
flow = listOf(FilterPredicate(flowFilters)),
166+
)
140167
} else {
141-
emptyList()
168+
ScanFilters(
169+
native = emptyList(),
170+
flow = this,
171+
)
142172
}
143173

144-
private fun FilterPredicate.toNativeScanFilter(): ScanFilter =
174+
// Android's `ScanFilter` does not support name prefix filtering, and only allows at most one of each filter type.
175+
private val FilterPredicate.supportsNativeScanFiltering: Boolean
176+
get() {
177+
var service = 0
178+
var nameExact = 0
179+
var address = 0
180+
var manufacturerData = 0
181+
var serviceData = 0
182+
filters.forEach { filter ->
183+
when (filter) {
184+
is Service -> if (++service > 1) return false
185+
is Name.Exact -> if (++nameExact > 1) return false
186+
is Name.Prefix -> return false
187+
is Address -> if (++address > 1) return false
188+
is ManufacturerData -> if (++manufacturerData > 1) return false
189+
is ServiceData -> if (++serviceData > 1) return false
190+
}
191+
}
192+
return true
193+
}
194+
195+
private val Filter.canFilterNatively: Boolean
196+
get() = when (this) {
197+
is Service -> true
198+
is Name.Exact -> true
199+
is Address -> true
200+
is ManufacturerData -> true
201+
is ServiceData -> true
202+
else -> false
203+
}
204+
205+
private fun FilterPredicate.toNativeScanFilter(): ScanFilter = filters.toNativeScanFilter()
206+
207+
private fun List<Filter>.toNativeScanFilter(): ScanFilter =
145208
ScanFilter.Builder().apply {
146-
filters.map { filter ->
209+
onEach { filter ->
147210
when (filter) {
211+
is Service -> setServiceUuid(ParcelUuid(filter.uuid.toJavaUuid()))
148212
is Name.Exact -> setDeviceName(filter.exact)
149213
is Address -> setDeviceAddress(filter.address)
150214
is ManufacturerData -> setManufacturerData(filter.id, filterDataCompat(filter.data), filter.dataMask)
151215
is ServiceData -> setServiceData(ParcelUuid(filter.uuid.toJavaUuid()), filterDataCompat(filter.data), filter.dataMask)
152-
is Service -> setServiceUuid(ParcelUuid(filter.uuid.toJavaUuid()))
153216
else -> throw AssertionError("Unsupported filter element")
154217
}
155218
}
156219
}.build()
157220

158-
// Scan filter does not support name prefix filtering, and only allows at most one each of the
159-
// following: service uuid, manufacturer data, service data.
160-
private fun FilterPredicate.supportsNativeScanFiltering(): Boolean =
161-
!containsNamePrefix() && serviceCount() <= 1 && manufacturerDataCount() <= 1 && serviceDataCount() <= 1
162-
163-
private fun FilterPredicate.containsNamePrefix(): Boolean =
164-
filters.any { it is Name.Prefix }
165-
166-
private fun FilterPredicate.serviceCount(): Int =
167-
filters.count { it is Service }
168-
169-
private fun FilterPredicate.manufacturerDataCount(): Int =
170-
filters.count { it is ManufacturerData }
171-
172-
private fun FilterPredicate.serviceDataCount(): Int =
173-
filters.count { it is ServiceData }
174-
175221
// Android doesn't properly check for nullness of manufacturer or service data until Android 16.
176222
// See https://github.com/JuulLabs/kable/issues/854 for more details.
177223
private fun filterDataCompat(data: ByteArray?): ByteArray? =

0 commit comments

Comments
 (0)