Skip to content

Commit

Permalink
Enhanced Scan Filtering (#695)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidtaylor-juul authored Jul 10, 2024
1 parent d2617f9 commit 4b0842a
Show file tree
Hide file tree
Showing 22 changed files with 822 additions and 210 deletions.
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

0 comments on commit 4b0842a

Please sign in to comment.