Skip to content

Optimize scan filtering #890

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version = "0.7.0" }
mockk = { module = "io.mockk:mockk", version = "1.13.17" }
robolectric = { module = "org.robolectric:robolectric", version = "4.14.1" }
tuulbox-collections = { module = "com.juul.tuulbox:collections", version.ref = "tuulbox" }
tuulbox-coroutines = { module = "com.juul.tuulbox:coroutines", version.ref = "tuulbox" }
wrappers-bom = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", version = "2025.4.3" }
Expand Down
1 change: 1 addition & 0 deletions kable-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ kotlin {
androidUnitTest.dependencies {
implementation(libs.equalsverifier)
implementation(libs.mockk)
implementation(libs.robolectric)
}

jsMain.dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,29 @@ import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filter
import kotlin.reflect.KClass
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid

internal data class ScanFilters(

/** [ScanFilter]s applied using Android's native filtering. */
val native: List<ScanFilter>,

/** [FilterPredicate]s applied via flow [filter][Flow.filter] operator. */
val flow: List<FilterPredicate>,
)

internal class BluetoothLeScannerAndroidScanner(
private val filters: List<FilterPredicate>,
filters: List<FilterPredicate>,
private val scanSettings: ScanSettings,
private val preConflate: Boolean,
logging: Logging,
) : PlatformScanner {

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

private val scanFilters = filters.toNativeScanFilters()
private val scanFilters = filters.toScanFilters()

override val advertisements: Flow<PlatformAdvertisement> = callbackFlow {
logger.debug { message = "Initializing scan" }
Expand Down Expand Up @@ -87,7 +97,7 @@ internal class BluetoothLeScannerAndroidScanner(
logger.info {
message = logMessage("Starting", preConflate, scanFilters)
}
scanner.startScan(scanFilters, scanSettings, callback)
scanner.startScan(scanFilters.native, scanSettings, callback)

awaitClose {
logger.info {
Expand All @@ -102,76 +112,112 @@ internal class BluetoothLeScannerAndroidScanner(
}
}
}.filter { advertisement ->
// Short-circuit (i.e. don't filter) if native scan filters were applied.
if (scanFilters.isNotEmpty()) return@filter true

// Perform filtering here, since we were not able to use native scan filters.
filters.matches(
services = advertisement.uuids,
name = advertisement.name,
address = advertisement.address,
manufacturerData = advertisement.manufacturerData,
serviceData = advertisement.serviceData?.mapKeys { (key) -> key.uuid.toKotlinUuid() },
)
if (scanFilters.flow.isEmpty()) {
true
} else {
scanFilters.flow.matches(
services = advertisement.uuids,
name = advertisement.name,
address = advertisement.address,
manufacturerData = advertisement.manufacturerData,
serviceData = advertisement.serviceData?.mapKeys { (key) -> key.uuid.toKotlinUuid() },
)
}
}
}

private fun logMessage(
prefix: String,
preConflate: Boolean,
scanFilters: List<ScanFilter>,
scanFilters: ScanFilters,
) = buildString {
append(prefix)
append(' ')
append("scan ")
if (preConflate) {
append("pre-conflated ")
}
if (scanFilters.isEmpty()) {
if (scanFilters.native.isEmpty() && scanFilters.flow.isEmpty()) {
append("without filters")
} else {
append("with ${scanFilters.size} filter(s)")
append("with ${scanFilters.native.count()} native and ${scanFilters.flow.count()} flow filter(s)")
}
}

private fun List<FilterPredicate>.toNativeScanFilters(): List<ScanFilter> =
internal fun List<FilterPredicate>.toScanFilters(): ScanFilters =
if (all(FilterPredicate::supportsNativeScanFiltering)) {
map(FilterPredicate::toNativeScanFilter)
ScanFilters(
native = map(FilterPredicate::toNativeScanFilter),
flow = emptyList(),
)
} else if (count() == 1) {
val nativeFilters = mutableMapOf<KClass<*>, Filter>()
val flowFilters = mutableListOf<Filter>()
single().filters.forEach { filter ->
if (filter.canFilterNatively && filter::class !in nativeFilters) {
nativeFilters[filter::class] = filter
} else {
flowFilters += filter
}
}
ScanFilters(
native = listOf(nativeFilters.values.toList().toNativeScanFilter()),
flow = listOf(FilterPredicate(flowFilters)),
)
} else {
emptyList()
ScanFilters(
native = emptyList(),
flow = this,
)
}

private fun FilterPredicate.toNativeScanFilter(): ScanFilter =
// Android's `ScanFilter` does not support name prefix filtering, and only allows at most one of each filter type.
private val FilterPredicate.supportsNativeScanFiltering: Boolean
get() {
var service = 0
var nameExact = 0
var address = 0
var manufacturerData = 0
var serviceData = 0
filters.forEach { filter ->
when (filter) {
is Service -> if (++service > 1) return false
is Name.Exact -> if (++nameExact > 1) return false
is Name.Prefix -> return false
is Address -> if (++address > 1) return false
is ManufacturerData -> if (++manufacturerData > 1) return false
is ServiceData -> if (++serviceData > 1) return false
}
}
return true
}

private val Filter.canFilterNatively: Boolean
get() = when (this) {
is Service -> true
is Name.Exact -> true
is Address -> true
is ManufacturerData -> true
is ServiceData -> true
else -> false
}

private fun FilterPredicate.toNativeScanFilter(): ScanFilter = filters.toNativeScanFilter()

private fun List<Filter>.toNativeScanFilter(): ScanFilter =
ScanFilter.Builder().apply {
filters.map { filter ->
onEach { filter ->
when (filter) {
is Service -> setServiceUuid(ParcelUuid(filter.uuid.toJavaUuid()))
is Name.Exact -> setDeviceName(filter.exact)
is Address -> setDeviceAddress(filter.address)
is ManufacturerData -> setManufacturerData(filter.id, filterDataCompat(filter.data), filter.dataMask)
is ServiceData -> setServiceData(ParcelUuid(filter.uuid.toJavaUuid()), filterDataCompat(filter.data), filter.dataMask)
is Service -> setServiceUuid(ParcelUuid(filter.uuid.toJavaUuid()))
else -> throw AssertionError("Unsupported filter element")
}
}
}.build()

// Scan filter does not support name prefix filtering, and only allows at most one each of the
// following: service uuid, manufacturer data, service data.
private fun FilterPredicate.supportsNativeScanFiltering(): Boolean =
!containsNamePrefix() && serviceCount() <= 1 && manufacturerDataCount() <= 1 && serviceDataCount() <= 1

private fun FilterPredicate.containsNamePrefix(): Boolean =
filters.any { it is Name.Prefix }

private fun FilterPredicate.serviceCount(): Int =
filters.count { it is Service }

private fun FilterPredicate.manufacturerDataCount(): Int =
filters.count { it is ManufacturerData }

private fun FilterPredicate.serviceDataCount(): Int =
filters.count { it is ServiceData }

// Android doesn't properly check for nullness of manufacturer or service data until Android 16.
// See https://github.com/JuulLabs/kable/issues/854 for more details.
private fun filterDataCompat(data: ByteArray?): ByteArray? =
Expand Down
Loading