Skip to content
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

Enhanced Scan Filtering #695

Merged
merged 15 commits into from
Jul 10, 2024
Merged
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ The [`Scanner`] may be configured via the following DSL (shown are defaults, whe

```kotlin
val scanner = Scanner {
filters = null
filters {
match {
name = "My device"
}
}
logging {
engine = SystemLogEngine
level = Warnings
Expand All @@ -30,7 +34,8 @@ val scanner = Scanner {
}
```

Scan results can be filtered by providing a list of [`Filter`]s. The following filters are supported:
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 |
|--------------------|:-------:|:-----:|:----------:|
Expand Down Expand Up @@ -60,10 +65,14 @@ To have peripherals D1 and D3 emitted during a scan, you could use the following

```kotlin
val scanner = Scanner {
filters = listOf(
Filter.Service(uuidFrom("0000aa80-0000-1000-8000-00805f9b34fb")), // SensorTag
Filter.NamePrefix("Ex"),
)
filters {
match {
services = listOf(uuidFrom("0000aa80-0000-1000-8000-00805f9b34fb")) // SensorTag
}
match {
name = Filter.Name.Prefix("Ex")
}
}
}
```

Expand All @@ -73,7 +82,11 @@ found matching the specified filters:

```kotlin
val advertisement = Scanner {
filters = listOf(Filter.Name("Example"))
filters {
match {
name = Filter.Name.Exact("Example")
}
}
}.advertisements.first()
```

Expand Down Expand Up @@ -281,9 +294,11 @@ user is then returned (as a [`Peripheral`] object).

```kotlin
val options = Options(
filters = listOf(
Filter.NamePrefix("Example"),
),
filters {
match {
name = Filter.Name.Prefix("Example")
}
},
optionalServices = listOf(
uuidFrom("f000aa80-0451-4000-b000-000000000000"),
uuidFrom("f000aa81-0451-4000-b000-000000000000"),
Expand Down
33 changes: 26 additions & 7 deletions core/api/android/core.api
Original file line number Diff line number Diff line change
Expand Up @@ -170,22 +170,25 @@ public final class com/juul/kable/Filter$ManufacturerData : com/juul/kable/Filte
public final fun getId ()I
}

public final class com/juul/kable/Filter$Name : com/juul/kable/Filter {
public abstract class com/juul/kable/Filter$Name : com/juul/kable/Filter {
}

public final class com/juul/kable/Filter$Name$Exact : com/juul/kable/Filter$Name {
public fun <init> (Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;)Lcom/juul/kable/Filter$Name;
public static synthetic fun copy$default (Lcom/juul/kable/Filter$Name;Ljava/lang/String;ILjava/lang/Object;)Lcom/juul/kable/Filter$Name;
public final fun copy (Ljava/lang/String;)Lcom/juul/kable/Filter$Name$Exact;
public static synthetic fun copy$default (Lcom/juul/kable/Filter$Name$Exact;Ljava/lang/String;ILjava/lang/Object;)Lcom/juul/kable/Filter$Name$Exact;
public fun equals (Ljava/lang/Object;)Z
public final fun getName ()Ljava/lang/String;
public final fun getExact ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/juul/kable/Filter$NamePrefix : com/juul/kable/Filter {
public final class com/juul/kable/Filter$Name$Prefix : com/juul/kable/Filter$Name {
public fun <init> (Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;)Lcom/juul/kable/Filter$NamePrefix;
public static synthetic fun copy$default (Lcom/juul/kable/Filter$NamePrefix;Ljava/lang/String;ILjava/lang/Object;)Lcom/juul/kable/Filter$NamePrefix;
public final fun copy (Ljava/lang/String;)Lcom/juul/kable/Filter$Name$Prefix;
public static synthetic fun copy$default (Lcom/juul/kable/Filter$Name$Prefix;Ljava/lang/String;ILjava/lang/Object;)Lcom/juul/kable/Filter$Name$Prefix;
public fun equals (Ljava/lang/Object;)Z
public final fun getPrefix ()Ljava/lang/String;
public fun hashCode ()I
Expand All @@ -203,6 +206,21 @@ public final class com/juul/kable/Filter$Service : com/juul/kable/Filter {
public fun toString ()Ljava/lang/String;
}

public final class com/juul/kable/FilterPredicateBuilder {
public final fun getAddress ()Ljava/lang/String;
public final fun getManufacturerData ()Ljava/util/List;
public final fun getName ()Lcom/juul/kable/Filter$Name;
public final fun getServices ()Ljava/util/List;
public final fun setAddress (Ljava/lang/String;)V
public final fun setManufacturerData (Ljava/util/List;)V
public final fun setName (Lcom/juul/kable/Filter$Name;)V
public final fun setServices (Ljava/util/List;)V
}

public final class com/juul/kable/FiltersBuilder {
public final fun match (Lkotlin/jvm/functions/Function1;)V
}

public class com/juul/kable/GattRequestRejectedException : com/juul/kable/BluetoothException {
public fun <init> ()V
}
Expand Down Expand Up @@ -371,6 +389,7 @@ public abstract interface class com/juul/kable/Scanner {

public final class com/juul/kable/ScannerBuilder {
public fun <init> ()V
public final fun filters (Lkotlin/jvm/functions/Function1;)V
public final fun getFilters ()Ljava/util/List;
public final fun getPreConflate ()Z
public final fun getScanSettings ()Landroid/bluetooth/le/ScanSettings;
Expand Down
33 changes: 26 additions & 7 deletions core/api/jvm/core.api
Original file line number Diff line number Diff line change
Expand Up @@ -111,22 +111,25 @@ public final class com/juul/kable/Filter$ManufacturerData : com/juul/kable/Filte
public final fun getId ()I
}

public final class com/juul/kable/Filter$Name : com/juul/kable/Filter {
public abstract class com/juul/kable/Filter$Name : com/juul/kable/Filter {
}

public final class com/juul/kable/Filter$Name$Exact : com/juul/kable/Filter$Name {
public fun <init> (Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;)Lcom/juul/kable/Filter$Name;
public static synthetic fun copy$default (Lcom/juul/kable/Filter$Name;Ljava/lang/String;ILjava/lang/Object;)Lcom/juul/kable/Filter$Name;
public final fun copy (Ljava/lang/String;)Lcom/juul/kable/Filter$Name$Exact;
public static synthetic fun copy$default (Lcom/juul/kable/Filter$Name$Exact;Ljava/lang/String;ILjava/lang/Object;)Lcom/juul/kable/Filter$Name$Exact;
public fun equals (Ljava/lang/Object;)Z
public final fun getName ()Ljava/lang/String;
public final fun getExact ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/juul/kable/Filter$NamePrefix : com/juul/kable/Filter {
public final class com/juul/kable/Filter$Name$Prefix : com/juul/kable/Filter$Name {
public fun <init> (Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;)Lcom/juul/kable/Filter$NamePrefix;
public static synthetic fun copy$default (Lcom/juul/kable/Filter$NamePrefix;Ljava/lang/String;ILjava/lang/Object;)Lcom/juul/kable/Filter$NamePrefix;
public final fun copy (Ljava/lang/String;)Lcom/juul/kable/Filter$Name$Prefix;
public static synthetic fun copy$default (Lcom/juul/kable/Filter$Name$Prefix;Ljava/lang/String;ILjava/lang/Object;)Lcom/juul/kable/Filter$Name$Prefix;
public fun equals (Ljava/lang/Object;)Z
public final fun getPrefix ()Ljava/lang/String;
public fun hashCode ()I
Expand All @@ -144,6 +147,21 @@ public final class com/juul/kable/Filter$Service : com/juul/kable/Filter {
public fun toString ()Ljava/lang/String;
}

public final class com/juul/kable/FilterPredicateBuilder {
public final fun getAddress ()Ljava/lang/String;
public final fun getManufacturerData ()Ljava/util/List;
public final fun getName ()Lcom/juul/kable/Filter$Name;
public final fun getServices ()Ljava/util/List;
public final fun setAddress (Ljava/lang/String;)V
public final fun setManufacturerData (Ljava/util/List;)V
public final fun setName (Lcom/juul/kable/Filter$Name;)V
public final fun setServices (Ljava/util/List;)V
}

public final class com/juul/kable/FiltersBuilder {
public final fun match (Lkotlin/jvm/functions/Function1;)V
}

public final class com/juul/kable/IdentifierKt {
public static final fun toIdentifier (Ljava/lang/String;)Ljava/lang/String;
}
Expand Down Expand Up @@ -247,6 +265,7 @@ public abstract interface class com/juul/kable/Scanner {

public final class com/juul/kable/ScannerBuilder {
public fun <init> ()V
public final fun filters (Lkotlin/jvm/functions/Function1;)V
public final fun getFilters ()Ljava/util/List;
public final fun logging (Lkotlin/jvm/functions/Function1;)V
public final fun setFilters (Ljava/util/List;)V
Expand Down
64 changes: 45 additions & 19 deletions core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import android.os.ParcelUuid
import com.juul.kable.Filter.Address
import com.juul.kable.Filter.ManufacturerData
import com.juul.kable.Filter.Name
import com.juul.kable.Filter.NamePrefix
import com.juul.kable.Filter.Service
import com.juul.kable.logs.Logger
import com.juul.kable.logs.Logging
Expand All @@ -22,15 +21,15 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filter

internal class BluetoothLeScannerAndroidScanner(
private val filters: List<Filter>,
private val 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 namePrefixFilters = filters.filterIsInstance<NamePrefix>()
private val scanFilters = filters.toNativeScanFilters()

override val advertisements: Flow<PlatformAdvertisement> = callbackFlow {
val scanner = getBluetoothAdapter().bluetoothLeScanner ?: throw BluetoothDisabledException()
Expand Down Expand Up @@ -61,18 +60,6 @@ internal class BluetoothLeScannerAndroidScanner(
}
}

val scanFilters = filters.map { filter ->
ScanFilter.Builder().apply {
when (filter) {
is Name -> setDeviceName(filter.name)
is NamePrefix -> {} // No-op: Filtering performed via flow.
is Address -> setDeviceAddress(filter.address)
is ManufacturerData -> setManufacturerData(filter.id, filter.data, filter.dataMask)
is Service -> setServiceUuid(ParcelUuid(filter.uuid)).build()
}
}.build()
}

logger.info {
message = logMessage("Starting", preConflate, scanFilters)
}
Expand All @@ -91,11 +78,16 @@ internal class BluetoothLeScannerAndroidScanner(
}
}
}.filter { advertisement ->
// Short-circuit (i.e. don't filter) if no `Filter.NamePrefix` filters were provided.
if (namePrefixFilters.isEmpty()) return@filter true
// Short-circuit (i.e. don't filter) if native scan filters were applied.
if (scanFilters.isNotEmpty()) return@filter true

// Perform `Filter.NamePrefix` filtering here, since it isn't supported natively.
namePrefixFilters.any { filter -> filter.matches(advertisement.name) }
// 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,
)
}
}

Expand All @@ -112,3 +104,37 @@ private fun logMessage(prefix: String, preConflate: Boolean, scanFilters: List<S
append("with ${scanFilters.size} filter(s)")
}
}

private fun List<FilterPredicate>.toNativeScanFilters(): List<ScanFilter> =
if (all(FilterPredicate::supportsNativeScanFiltering)) {
map(FilterPredicate::toNativeScanFilter)
} else {
emptyList()
}

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

// Scan filter does not support name prefix filtering, and only allows at most one service uuid
// and one manufacturer data.
private fun FilterPredicate.supportsNativeScanFiltering(): Boolean =
!containsNamePrefix() && serviceCount() <= 1 && manufacturerDataCount() <= 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 }
13 changes: 12 additions & 1 deletion core/src/androidMain/kotlin/ScannerBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,19 @@ import kotlinx.coroutines.runBlocking

public actual class ScannerBuilder {

@Deprecated(
message = "Use filters(FiltersBuilder.() -> Unit)",
replaceWith = ReplaceWith("filters { }"),
level = DeprecationLevel.WARNING,
)
public actual var filters: List<Filter>? = null

private var filterPredicates: List<FilterPredicate> = emptyList()

public actual fun filters(builderAction: FiltersBuilder.() -> Unit) {
filterPredicates = FiltersBuilder().apply(builderAction).build()
}

/**
* Allows for the [Scanner] to be configured via Android's [ScanSettings].
*
Expand Down Expand Up @@ -41,7 +52,7 @@ public actual class ScannerBuilder {

@OptIn(ObsoleteKableApi::class)
internal actual fun build(): PlatformScanner = BluetoothLeScannerAndroidScanner(
filters = filters.orEmpty(),
filters = filters?.convertDeprecatedFilters() ?: filterPredicates,
scanSettings = scanSettings,
logging = logging,
preConflate = preConflate,
Expand Down
Loading