Scanning¶
blueLib.scan(
ScanRequest(
serviceUuids = listOf(BluetoothUuid.HEART_RATE),
mode = ScanMode.LOW_LATENCY,
timeoutMillis = 15_000,
namePrefixes = listOf("HR-"),
),
).collect { event -> … }
One scan at a time, and it stops when you stop¶
Android allows one LE scan per app per BluetoothLeScanner, and a second startScan silently replaces
the callback of the first. BlueLib makes that an error:
- a second concurrent
scan()emitsScanEvent.Failed(BlueLibError.ScanAlreadyActive); - cancelling the collecting coroutine stops the platform scan (
stopScanplusflushPendingScanResults); ScanRequest.timeoutMillisstops the scan on its own, which matters when the collector is a long-lived scope that nobody cancels.
blueLib.isScanning tells you whether the platform scan is currently running.
The 5-per-30-seconds quota¶
Android throttles startScan calls to five per app per 30 seconds and reports
SCAN_FAILED_SCANNING_TOO_FREQUENTLY (0x05) when you exceed it. OEM builds sometimes throttle harder.
ScanQuotaGovernor keeps the budget in the domain layer: the sixth start inside the window is rejected
before the platform call with the exact delay to wait.
is ScanEvent.Failed -> when (val error = event.error) {
is BlueLibError.ScanThrottled -> delay(error.retryAfterMillis)
is BlueLibError.ScanFailed -> when (error.reason) {
ScanFailureReason.SCANNING_TOO_FREQUENTLY -> delay(1_000)
else -> report(error)
}
else -> report(error)
}
Tune the window through BlueLibConfig(scanStartsPerWindow = …, scanQuotaWindowMillis = …) only if an OEM
documents different limits. Restarting a scan is what costs budget — keep one scan running and filter in
software instead.
Filters: platform first, software second¶
ScanRequest splits filters into the two kinds that exist for real:
| Filter | Where it runs | Notes |
|---|---|---|
serviceUuids |
platform | Offloaded to the controller when isOffloadedFilteringSupported |
deviceAddresses |
platform | Address filters |
manufacturerData |
platform | Manufacturer-specific data filters |
namePrefixes / nameExact |
BlueLib | Software filter, because Android cannot express name matching in a ScanFilter and silently ignoring it is worse |
adTypeFilters (Android 13+) |
platform | ScanFilter AD-type matching on API 33+ |
rssiAtLeast |
BlueLib | Software, applied to every result |
Every observation carries the raw payload as well as the parsed form, so a consumer never loses data on OEM stacks that drop part of the parsed record:
data class ScanObservation(
val deviceId: BluetoothDeviceId,
val rssi: Int,
val txPower: Int?,
val connectable: Boolean?,
val name: String?,
val serviceUuids: List<BluetoothUuid>,
val manufacturerData: Map<Int, ByteArray>,
val serviceData: Map<BluetoothUuid, ByteArray>,
val rawBytes: ByteArray?, // the complete advertising payload
val phy: Phy?, // reported on API 26+
)
Presence tracking¶
ScanEvent.Lost(observation, reason) is emitted when a device stops being seen — the reason is either
NOT_SEEN_FOR_A_WHILE (ScanRequest.DEFAULT_LOST_AFTER_MILLIS, 10 s) or SCAN_STOPPED. This is what a
device-list UI wants; the alternative (an ever-growing list) is the most common scanning bug in BLE apps.
Background scanning¶
On Android 8+ the platform offers a PendingIntent based scan that delivers results to a broadcast
receiver, which is the supported way to scan without holding a foreground service. On Android 14+
a long-running scan in the background generally needs:
- the
connectedDeviceforeground service type, declared and with the matching permission (FOREGROUND_SERVICE_CONNECTED_DEVICE) and a justification when the service starts; - or a
PendingIntentscan if the app is exempt from background scan restrictions.
BlueLib keeps the platform scan alive for as long as the flow is collected, so the correct pattern is:
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
scope.launch { blueLib.scan(request).collect { … } }
// and later
scope.cancel() // stops the platform scan
Do not start a scan from a broadcast receiver that lives for milliseconds; the scan dies with the receiver.
Common traps¶
- Scanning while connecting. Discovery and an LE connection compete for the radio: a scan running during a connection attempt raises the failure rate for both. Stop scanning before connecting.
- Trusting
rssias a distance. It is a rough signal strength; use it for ordering, not for centimetres (see the Roadmap for Channel Sounding, which is the real answer). - Ignoring
SCAN_FAILED_APP_REGISTRATION_FAILED. It means the app's scan registration was rejected — usually too many registered scanners from the same process, and it never recovers without restarting the scan.