BluetoothLeScanner is an Android class for scanning Bluetooth Low Energy devices, available since API 21 (Android 5.0). BluetoothLeScanner replaced the deprecated startLeScan method on BluetoothAdapter, providing a flexible API with scan configuration (ScanSettings), filtering (ScanFilter), and background mode support (PendingIntent). The instance is obtained via BluetoothAdapter.getBluetoothLeScanner(). According to Android Developers, 2026, BluetoothLeScanner supports three power modes and allows scanning BLE advertising packets with filtering by service UUID, device name, or MAC address.
Key Takeaways
BluetoothLeScanner is a system class for managing BLE scanning on Android. Unlike BluetoothAdapter.startLeScan(), which accepts a simple LeScanCallback, BluetoothLeScanner provides an object-oriented API with settings, filters, and advanced error handling. This class was introduced in API 21 (Android 5.0) alongside BLE 4.2 support and remains the primary BLE scanning method on all modern Android versions.
Obtaining a BluetoothLeScanner instance is done via BluetoothAdapter.getBluetoothLeScanner(). The method returns null if the Bluetooth adapter is unavailable (Bluetooth disabled or device does not support BLE). Before obtaining it, check BluetoothAdapter.isEnabled() and the presence of FEATURE_BLUETOOTH_LE via PackageManager. Once the scanner is obtained, scanning can be started on any thread — Android schedules BLE operations on an internal Bluetooth stack thread.
// Getting BluetoothLeScanner
class BLEScannerManager(context: Context) {
private val bluetoothManager: BluetoothManager =
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val adapter: BluetoothAdapter? = bluetoothManager.adapter
private var scanner: BluetoothLeScanner? = null
fun initScanner(): Boolean {
// Check BLE availability
if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
return false
}
// Check Bluetooth enabled
if (adapter?.isEnabled != true) {
return false
}
// Get scanner
scanner = adapter?.bluetoothLeScanner
return scanner != null
}
// Check scanner availability
val isAvailable: Boolean
get() = scanner != null
// Start basic scan without filters
fun startBasicScan() {
scanner?.startScan(object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
handleResult(result)
}
})
}
private fun handleResult(result: ScanResult) {
val device = result.device
print("Device: ${device.name ?: "Unnamed"}, RSSI: ${result.rssi}, address: ${device.address}")
}
}
The BLEScannerManager class demonstrates safe obtaining and initialization of BluetoothLeScanner. initScanner checks BLE availability via hasSystemFeature, enabled Bluetooth, and successful scanner retrieval. startBasicScan starts scanning without settings and filters — discovers all BLE devices in range. handleResult parses ScanResult: BluetoothDevice (name, address), RSSI (signal strength), scanRecord (advertising data).
ScanSettings is a class for BLE scan configuration. The main parameter is the scan mode (scanMode), which determines the trade-off between power consumption and discovery latency. ScanSettings.Builder allows you to configure: scanMode, callbackType (CALLBACK_TYPE_ALL_MATCHES, CALLBACK_TYPE_FIRST_MATCH, CALLBACK_TYPE_MATCH_LOST), matchMode (MATCH_MODE_AGGRESSIVE, MATCH_MODE_STICKY), reportDelay (batch delivery delay), and phy (PHY_LE_1M, PHY_LE_2M, PHY_LE_CODED).
Three scan modes: SCAN_MODE_LOW_POWER (0) — background scanning with low power consumption, discovery delay of several seconds. SCAN_MODE_BALANCED (1) — balanced mode for most scenarios. SCAN_MODE_LOW_LATENCY (2) — minimum discovery delay (about 100 ms), maximum power consumption. For active device discovery use LOW_LATENCY, for background monitoring use LOW_POWER.
reportDelay — delay in milliseconds before batch delivery of results. If reportDelay = 0, results are sent immediately upon discovery. If > 0, Android accumulates results and sends a batch via onBatchScanResults. Batch delivery reduces callback invocations and power consumption, suitable for low-priority background scanning.
// ScanSettings configuration for different scenarios
class ScanSettingsProvider {
// 1. Fast scan (active search)
fun lowLatencyScan(): ScanSettings {
return ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setReportDelay(0)
.setPhy(ScanSettings.PHY_LE_ALL_SUPPORTED)
.build()
}
// 2. Power efficient scan (background monitoring)
fun lowPowerScan(): ScanSettings {
return ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH)
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
.setReportDelay(2000) // batch every 2 seconds
.build()
}
// 3. BLE Long Range scan (Coded PHY)
fun longRangeScan(): ScanSettings {
return ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setPhy(ScanSettings.PHY_LE_CODED)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.build()
}
// 4. Scan only on 2M PHY (BLE 5.0+)
fun highSpeedScan(): ScanSettings {
return ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setPhy(ScanSettings.PHY_LE_2M)
.build()
}
}
The ScanSettingsProvider class contains typical configurations. lowLatencyScan — for UI scanning (search “here and now”). lowPowerScan — for background monitoring with batch every 2 seconds and callbackType FIRST_MATCH (triggers only on first discovery). longRangeScan uses PHY_LE_CODED (BLE Long Range, up to 1 km). highSpeedScan — PHY_LE_2M (2 Mbit/s, BLE 5.0+ devices only).
ScanFilter is a class for filtering BLE scan results. Without a filter, BluetoothLeScanner returns all BLE devices in range — in a dense BLE environment this can be hundreds of packets per minute. ScanFilter narrows results to the desired devices, reducing power consumption and app load. Filters are applied at the Bluetooth stack level — unsuitable packets are discarded before reaching the app.
Filter types: setServiceUuid — service UUID (full 128-bit format required). setDeviceName — device name substring (case-sensitive, exact substring match). setDeviceAddress — exact MAC address. setManufacturerData — manufacturer data (company ID + mask). Multiple filters can be set for a single scan — the device must match all (AND logic). For OR logic, start multiple scans.
// ScanFilter creation for different scenarios
class ScanFilterFactory {
// 1. Filter by service UUID (Heart Rate Monitor)
fun byHeartRateService(): ScanFilter {
return ScanFilter.Builder()
.setServiceUuid(
ParcelUuid.fromString("0000180D-0000-1000-8000-00805F9B34FB")
)
.build()
}
// 2. Filter by device name ( "iBeacon*")
fun byDeviceName(): ScanFilter {
return ScanFilter.Builder()
.setDeviceName("Sensor")
.build()
}
// 3. MAC- ( devices)
fun byMacAddress(mac: String): ScanFilter {
return ScanFilter.Builder()
.setDeviceAddress(mac)
.build()
}
// 4. Combined filter (UUID + )
fun combinedFilter(): List<ScanFilter> {
return listOf(
ScanFilter.Builder()
.setServiceUuid(
ParcelUuid.fromString("0000A001-0000-1000-8000-00805F9B34FB")
)
.setDeviceName("MyDevice")
.build()
)
}
// 5. manufacturer data
fun byManufacturer(companyId: Int, data: ByteArray, mask: ByteArray): ScanFilter {
return ScanFilter.Builder()
.setManufacturerData(companyId, data, mask)
.build()
}
}
The ScanFilterFactory class shows all filter types. byHeartRateService filters devices with the heart rate service 0x180D. byDeviceName finds devices containing “Sensor” in the name (Apple recommends unique names for filtering). byMacAddress — exact search for a specific device. combinedFilter — AND filter by UUID and name. byManufacturer — filter by manufacturer data (e.g., for iBeacon, Apple company ID 0x004C is used).
ScanCallback is an abstract class for receiving BLE scan results. It contains three methods: onScanResult — single result (callback type, ScanResult), onBatchScanResults — batch results for reportDelay > 0, onScanFailed — error code. All methods are called on the Android main thread. For long processing in onScanResult, use coroutines or HandlerThread.
ScanResult contains: BluetoothDevice device, int rssi (signal level in dBm), ScanRecord scanRecord (advertising data), long timestampNanos (discovery time since system boot). ScanRecord provides: getServiceData() — UUID + custom data, getManufacturerSpecificData() — manufacturer data, getAdvertiseFlags() — BLE flags. The callback type indicates: CALLBACK_TYPE_ALL_MATCHES — match with filter, CALLBACK_TYPE_FIRST_MATCH — first discovery, CALLBACK_TYPE_MATCH_LOST — device lost.
onScanFailed error codes: SCAN_FAILED_ALREADY_STARTED (1) — scanning already started, SCAN_FAILED_APPLICATION_REGISTRATION_FAILED (2) — app registration in Bluetooth stack failed, SCAN_FAILED_INTERNAL_ERROR (3) — internal stack error, SCAN_FAILED_FEATURE_UNSUPPORTED (4) — BLE scanning not supported on device.
// Full scan results and error handling
class ScanResultHandler {
private val results = mutableListOf<ScanResult>()
val scanCallback = object : ScanCallback() {
// 1. Single result
override fun onScanResult(callbackType: Int, result: ScanResult) {
// callbackType: 1 = ALL_MATCHES, 2 = FIRST_MATCH, 4 = MATCH_LOST
if (callbackType == ScanSettings.CALLBACK_TYPE_MATCH_LOST) {
onDeviceLost(result)
return
}
// Add to list (dedup by address)
val existingIndex = results.indexOfFirst {
it.device.address == result.device.address
}
if (existingIndex >= 0) {
results[existingIndex] = result // update RSSI
} else {
results.add(result)
}
// Extract data from advertising packet
val record = result.scanRecord
val serviceData = record?.serviceData
val manufacturerData = record?.manufacturerSpecificData
print("Found: ${result.device.name ?: "N/A"}, RSSI: ${result.rssi}")
}
// 2. Batch results (reportDelay > 0)
override fun onBatchScanResults(results: MutableList<ScanResult>?) {
results?.let { batch ->
print("Batch: ${batch.size} devices")
}
}
// 3. Scan error
override fun onScanFailed(errorCode: Int) {
val error = when (errorCode) {
ScanCallback.SCAN_FAILED_ALREADY_STARTED -> "Already scanning"
ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "Registration failed"
ScanCallback.SCAN_FAILED_INTERNAL_ERROR -> "Internal error"
ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED -> "BLE not supported"
else -> "Unknown error: $errorCode"
}
print("Error: $error")
}
}
private fun onDeviceLost(result: ScanResult) {
results.removeAll { it.device.address == result.device.address }
print("Device lost: ${result.device.address}")
}
}
The ScanResultHandler class handles all BluetoothLeScanner callback types. onScanResult updates the device list with deduplication by MAC address — RSSI is updated for already found devices. CALLBACK_TYPE_MATCH_LOST signals device loss (removal from list). onBatchScanResults processes batch results for reportDelay > 0. onScanFailed maps error codes to human-readable messages — critical for BLE scanning debugging.
PendingIntent scanning is a BluetoothLeScanner mechanism for BLE scanning that works even when the app is in the background (with Android 8+ restrictions). Instead of ScanCallback, a PendingIntent is used, which sends a Broadcast to the system BroadcastReceiver when a BLE device is discovered. This allows the app to receive BLE device notifications without staying in memory (the system creates the process upon receiving the broadcast).
Background scanning limitations: On Android 8+ (API 26), background services are restricted — PendingIntent scanning bypasses this limitation via BroadcastReceiver, which the system can start upon receiving a BLE event. On Android 10+ (API 29), background BLE scanning is further restricted by manufacturer power-saving policies (Xiaomi, Huawei, Samsung block background BLE operations). For critical BLE scenarios, a foreground service notification is required.
// Background BLE scanning via PendingIntent
class BackgroundBLEScanner(private val context: Context) {
private val scanner: BluetoothLeScanner? by lazy {
val adapter = BluetoothAdapter.getDefaultAdapter()
adapter?.bluetoothLeScanner
}
fun startBackgroundScan() {
// Create PendingIntent for BroadcastReceiver
val intent = Intent(context, BLEBroadcastReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Background scan settings
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH)
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
.build()
// Start background scan
scanner?.startScan(
null, // filters
settings,
pendingIntent
)
}
fun stopBackgroundScan() {
val intent = Intent(context, BLEBroadcastReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
scanner?.stopScan(pendingIntent)
}
}
// BroadcastReceiver BLE-
class BLEBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Get scan results
val results = BluetoothLeScanner.getPendingIntentScanResults(intent)
results?.let { scanResults ->
for (result in scanResults) {
// Send notification to user
showNotification(context, result.device.name ?: " ")
}
}
}
private fun showNotification(context: Context, name: String) {
val notification = Notification.Builder(context, "ble_channel")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("BLE devices")
.setContentText("Found: $name")
.setAutoCancel(true)
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
manager.notify(System.currentTimeMillis().toInt(), notification)
}
}
The BackgroundBLEScanner class starts background BLE scanning via PendingIntent. startBackgroundScan creates a PendingIntent that, upon BLE device discovery, sends a Broadcast to BLEBroadcastReceiver. The BroadcastReceiver extracts ScanResult via getPendingIntentScanResults() and can show a notification or send data to the server. This approach works even if the app was terminated by the system — Android restarts the BroadcastReceiver upon receiving the broadcast.
Complete example of a BLE scanner in Kotlin using BluetoothLeScanner with ScanSettings, ScanFilter and ScanCallback to find Heart Rate Monitor devices. The scanner shows a list of discovered devices with RSSI and service UUIDs, with the ability to connect via BluetoothGatt.
// Full BLE scanner with coroutines in Kotlin
class DeviceScanner(private val context: Context) {
private val adapter: BluetoothAdapter? by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE)
as BluetoothManager
manager.adapter
}
private val scanner: BluetoothLeScanner? by lazy {
adapter?.bluetoothLeScanner
}
fun startScan(duration: Long = 10000): Flow<ScanResult> = callbackFlow {
// Check Bluetooth state
if (adapter?.isEnabled != true) {
close(IllegalStateException("Bluetooth disabled"))
return@callbackFlow
}
// Scan configuration
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
val filters = listOf(
ScanFilter.Builder()
.setServiceUuid(ParcelUuid.fromString("0000180D-0000-1000-8000-00805F9B34FB"))
.build()
)
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
trySend(result)
}
override fun onScanFailed(errorCode: Int) {
close(BLEException("Scan failed: $errorCode"))
}
}
// Start scanning
scanner?.startScan(filters, settings, callback)
// Auto-stop after duration
delay(duration)
scanner?.stopScan(callback)
close()
}.flowOn(Dispatchers.IO)
fun stop() {
scanner?.stopScan(object : ScanCallback() {})
}
}
class BLEException(message: String) : Exception(message)
The DeviceScanner class uses Kotlin Flow (callbackFlow) for reactive BLE scanning. Scanning starts with LOW_LATENCY settings and a Heart Rate Service UUID filter. Results are emitted via onScanResult into a Flow. Auto-stop after a given duration (10 seconds by default). FlowOn(Dispatchers.IO) offloads BLE operations to a background thread. This approach enables BLE scanning in MVVM architecture via viewModelScope.launch and collect.
Frequently Asked Questions
BluetoothLeScanner is an Android class (API 21+) for BLE scanning. It is obtained via BluetoothAdapter.getBluetoothLeScanner(). It supports three scan modes (LOW_POWER, BALANCED, LOW_LATENCY), filtering by UUID, name and MAC address, batch results, and PendingIntent for background scanning. It replaces the deprecated BluetoothAdapter.startLeScan() method.
SCAN_MODE_LOW_POWER — background mode with 5–10 second discovery delay, minimal power consumption. SCAN_MODE_LOW_LATENCY — active mode with about 100 ms delay, maximum power consumption. SCAN_MODE_BALANCED — compromise (~2 second delay). Use LOW_LATENCY for UI scanning, LOW_POWER with PendingIntent for background monitoring.
Reasons: Bluetooth disabled (check adapter.isEnabled), missing permissions (BLUETOOTH_SCAN on API 31+, ACCESS_FINE_LOCATION on API 23–30), scanner = null (adapter unavailable), device out of range, or incorrect filter. Also check onScanFailed — the error code indicates the cause: SCAN_FAILED_ALREADY_STARTED (1) or SCAN_FAILED_APPLICATION_REGISTRATION_FAILED (2).
Use the PendingIntent version of startScan() — pass a PendingIntent instead of ScanCallback. When a BLE device is discovered, Android sends a Broadcast to the BroadcastReceiver, which can be started by the system even if the app is in the background. For Android 8+, add the BroadcastReceiver to the manifest. On Android 10+, consider manufacturer power-saving restrictions.
BluetoothLeScanner has no limit on the number of discovered devices — the limitation depends on BLE saturation of the environment. An office may have 20–50 active BLE devices, a shopping center hundreds. Use ScanFilter (by UUID, name) for filtering. Without filtering, process results asynchronously — onScanResult may be called dozens of times per second.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also