How to Use Android BLE Scan Filter

Developer debugging code across multiple monitors at a modern workspace, focused on resolving software issues.

You’ve got scanning working. Results are coming back. The problem is that there are a lot of them. Your own device is buried in a list of headphones, fitness trackers, and whatever your neighbor left on. Or maybe you’re building something with custom firmware and you want your app to only ever see devices running that firmware, nothing else. Either way, scanning without filtering is the wrong default for most apps, and Android gives you the tools to fix it.

This article covers how to use Android’s ScanFilter API to narrow scan results down to exactly what you care about, whether that’s service UUID, device name, MAC address, or manufacturer data, and how to tune scan behavior once filtering is in place. If you haven’t gotten scanning up and running yet, start with How to Perform a BLE Scan on Android first and come back here when results are flowing.


What You Can Filter On with ScanFilter

Before starting a scan, it’s worth asking whether the devices you’re looking for have any distinctive traits that can help the scanner narrow things down. Android’s ScanFilter class lets you filter incoming ScanResults based on:

  • Advertised service UUIDs
  • Service data for the advertised services
  • Name of the advertising BLE device
  • MAC address of the advertising BLE device
  • Additional manufacturer-specific data

While some apps do have use cases for scanning without a ScanFilter — our own LightBlue app is one of them — most apps use BLE to connect to a specific type of device because they are designed to perform certain meaningful tasks with only that type of device. For example, an app that shows the current temperature, humidity, and air pressure would only try to connect to BLE devices with those capabilities, like devices that advertise the Environmental Sensing Service.


Filtering by Service UUID

You can create a ScanFilter using the ScanFilter.Builder class, calling its methods to set the filtering criteria before calling build():

val filter = ScanFilter.Builder().setServiceUuid(
    ParcelUuid.fromString(ENVIRONMENTAL_SERVICE_UUID.toString())
).build()

Based on our experience developing BLE-intensive apps for our clients, if custom firmware is involved in any way, the easiest way to make sure an app only ever picks up devices running that firmware is to generate a random UUID and have the firmware advertise it. The app then filters on that UUID. Since UUIDs are unique enough for practical purposes, you can expect the scan to only surface the devices you care about.


Parsing What Comes Back

A ScanResult object gets surfaced as part of ScanCallback‘s onScanResult(...) method. The things you’ll generally want from it are:

  • The MAC address of the device that identifies the advertising scan result. Obtained via getDevice() followed by getAddress(), or simply device.address in Kotlin. One important caveat: a device implementing Bluetooth 4.2’s LE Privacy feature will randomize its public MAC address periodically, so a MAC address obtained via scanning should not generally be used as a long-term means to identify a device — unless the firmware guarantees it’s not rotating MAC addresses, there’s an out-of-band way to communicate the current public MAC address, or the use case involves bonding, which allows Android to derive the device’s current address.
  • The device name, obtained via getDevice() followed by getName(), or device.name in Kotlin. Not all BLE devices advertise a name, so this may be null.
  • RSSI, or signal strength of the advertising device, measured in dBm. Obtained via getRssi(), or simply rssi in Kotlin. Sorting by descending signal strength is a reasonable way to surface the nearest peripheral, but it’s not a guarantee — RSSI is affected by the transmission power of the advertising device’s antenna and environmental factors like nearby metallic objects. The decibel values are also relative, not absolute, so an RSSI reading of -42 dBm might be close range on one Android phone and medium range on another. Mapping RSSI to real-world physical distance isn’t recommended in the general case.
  • The BluetoothDevice handle, needed to connect to the device, accessed via getDevice().
  • Extra advertisement data in the ScanResult‘s ScanRecord, accessed via getScanRecord(). ScanRecord conveniently parses out manufacturer-specific data and service data, accessible via getManufacturerSpecificData(...) and getServiceData(...). The raw scan record bytes are available via getBytes().

Tuning Scan Behavior with ScanSettings

Alongside filtering, Android lets you specify scan behavior through the ScanSettings class and its own ScanSettings.Builder. A few practical settings worth knowing:

Scan mode controls the power and latency tradeoff:

  • SCAN_MODE_BALANCED is right for most foreground scans lasting longer than 30 seconds.
  • SCAN_MODE_LOW_LATENCY is recommended when the app is only scanning briefly, typically to find a specific device.
  • SCAN_MODE_LOW_POWER is for long-duration scans or background scans (with the user’s permission). The high latency of this mode can result in missed advertisement packets if the device has a high enough advertising interval that it doesn’t overlap with the app’s scan frequency.

Callback type controls what triggers a result:

  • CALLBACK_TYPE_ALL_MATCHES notifies you about all incoming packets. It’s the default and what apps like LightBlue use for continuous updating.
  • CALLBACK_TYPE_FIRST_MATCH is useful when you only want a single callback per matching device — a natural pairing with filtering when you just need to find and connect to one thing.
  • CALLBACK_TYPE_MATCH_LOST is a bit of an odd one. We’ve had mixed experience with it and generally don’t recommend it. You’re better off implementing a timer yourself that periodically reviews your list of ScanResults and removes outdated ones — for example, anything not seen in the last 10 seconds — based on ScanResult‘s getTimestampNanos() method. Note that this method provides a timestamp since Android’s system boot time, not since Epoch time.

Match mode controls the signal threshold for surfacing results:

  • MATCH_MODE_STICKY requires a higher signal strength threshold and more sightings before surfacing a device. Useful for filtering out devices that are technically visible but too far away to be relevant.
  • MATCH_MODE_AGGRESSIVE is the opposite — it surfaces everything in range, near or far.

Scan Errors Worth Knowing About

The most common error you’ll run into during BLE scans is the undocumented “App is scanning too frequently” error. Android has an internal limit of five startScan(...) calls every 30 seconds per app on a BluetoothLeScanner object, and going beyond that doesn’t trigger any error callbacks — just an obscure Logcat entry saying your app is scanning too frequently. What makes this particularly tricky is that the entry won’t show up unless you have no filtering active in your Logcat window, because it has an unknown package name of “?” associated with it.

If your app exceeds this limit, the scan will appear to have started but no results will be delivered to your callback. After 30 seconds, you should call stopScan() followed by startScan(...) again to resume receiving results — the original call that triggered the error doesn’t automatically recover once the cooldown completes. Most apps won’t hit this limit in normal use, but it’s worth keeping in mind if your UI lets users start and stop scans repeatedly.

Another error you may encounter is SCAN_FAILED_APPLICATION_REGISTRATION_FAILED, surfaced by ScanCallback‘s onScanFailed(...) method. It’s vaguely described as the app failing to register with the BLE scanner. The undocumented solution is to ask the user to disable and re-enable Bluetooth. If that doesn’t work (and it mostly doesn’t) the next step is a device reboot. Oh, joy.

These are the two errors you’re most likely to hit while getting scanning and filtering working. For a more complete look at what can go wrong during Android BLE scans and how to handle it, we’ve covered the full range in Android BLE Scan Errors.


Implementing BLE Scan Filtering in Android

First, set up the bleScanner as a lazy property:

private val bleScanner by lazy {
    bluetoothAdapter.bluetoothLeScanner
}

// From the previous section:
private val bluetoothAdapter: BluetoothAdapter by lazy {
    val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
    bluetoothManager.adapter
}

The bleScanner property is initialized only when needed. The reason for deferring it rather than declaring it directly as bluetoothAdapter.bluetoothLeScanner is that bluetoothAdapter itself relies on getSystemService(), which is only available after onCreate() has returned. Deferring both avoids a crash that would happen if the BluetoothAdapter was initialized too early.

Next, set up scan settings:

private val scanSettings = ScanSettings.Builder()
    .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
    .build()

Build a filter for the service UUID you’re targeting:

val filter = ScanFilter.Builder().setServiceUuid(
    ParcelUuid.fromString(YOUR_SERVICE_UUID.toString())
).build()

Set up a ScanCallback:

private val scanCallback = object : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult) {
        with(result.device) {
            Log.i("ScanCallback", "Found BLE device! Name: ${name ?: "Unnamed"}, address: $address")
        }
    }
}

Then pass the filter into startScan(...):

private fun startBleScan() {
    if (!hasRequiredBluetoothPermissions()) {
        requestRelevantRuntimePermissions()
    } else {
        bleScanner.startScan(listOf(filter), scanSettings, scanCallback)
        isScanning = true
    }
}

The key difference from an unfiltered scan is the first argument — instead of passing null, you’re passing a list containing your filter. Android will handle the rest, and your callback will only fire for devices that match.


Where to Go From Here

Once you’ve filtered down to the device you’re looking for, the next step is connecting to it and starting to interact with its GATT profile. The Android BLE Ultimate Guide walks through everything you need from there including connecting, service discovery, reading and writing characteristics, and setting up notifications. From there, once you’re chaining multiple BLE operations together, Building a Reliable Android BLE Operation Queue covers how to handle them without running into Android’s lack of internal queuing.

Share:

Punch Through
Punch Through
We’re a team of engineers who obsess over making connected things actually work — reliably, securely, and without the handwaving. From BLE to backend, we build the software and systems behind connected medical devices and custom connected products that can’t afford to fail.

Subscribe to stay up-to-date with our latest articles and resources.