How to Perform a BLE Scan on Android

Pt 3 System Planning Collaboration Cropped

Getting a BLE scan working on Android is one of those things that looks straightforward until you’re staring at the permission model. The API itself is well-structured, but the permission requirements have changed across enough Android versions that there’s no single “just do this” answer. What you declare in your manifest and what you request at runtime depends on what you’re targeting. 

We’ll walk through the whole setup from permissions through displaying results, using Kotlin and a minimum API level of 21. If you need the full picture beyond scanning, the Android BLE: The Ultimate Guide has you covered.


Android BLE Scanning Classes in the Android SDK

Before diving into the implementation, it helps to know the main classes involved in a BLE scan. You’ll encounter all of these as you work through this guide.

ClassPurpose
BluetoothAdapterA representation of the Android device’s Bluetooth hardware. An instance is provided by the BluetoothManager class. BluetoothAdapter tells you whether Bluetooth is on or off and lets you start BLE scans.
BluetoothLeScannerProvided by BluetoothAdapter, this is the class that actually starts and stops BLE scans. Note that ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION is required for BLE scans from Android 6.0 through Android 11, and BLUETOOTH_SCAN is required from Android 12 and above.
ScanFilterAllows you to narrow down scan results to target specific devices. The most common use case is filtering by advertised service UUID.
ScanResultRepresents a single BLE scan result. Contains the device’s MAC address, RSSI, and advertisement data, plus a BluetoothDevice handle for connecting.
BluetoothDeviceRepresents a physical Bluetooth device. Provides the device name, MAC address, bond state, and the entry point for initiating a connection.

Declaring Permissions in AndroidManifest.xml

The first thing to sort out is permissions. Navigate to your project’s AndroidManifest.xml and add the following inside the <manifest> tag:

<!-- Request legacy Bluetooth permissions on versions older than API 31 (Android 12). -->
<uses-permission android:name="android.permission.BLUETOOTH"
    android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
    android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
    android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
    android:maxSdkVersion="30" />

<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation"
    tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

Here’s what each one does:

  • BLUETOOTH — Legacy permission allowing the app to connect to Bluetooth devices. Required below API 31.
  • BLUETOOTH_ADMIN — Legacy permission allowing the app to scan for and bond with Bluetooth devices. Required below API 31.
  • ACCESS_FINE_LOCATION — Between Android 6 and Android 11 (inclusive), location permission is required to receive BLE scan results. This exists to protect user privacy — a BLE scan can unintentionally reveal location via beacon proximity. Before Android 10, ACCESS_COARSE_LOCATION was sufficient, but we recommend ACCESS_FINE_LOCATION since it covers all versions.
  • ACCESS_COARSE_LOCATION — Also required for apps targeting Android 12 and above, in addition to ACCESS_FINE_LOCATION.
  • BLUETOOTH_SCAN — For apps targeting Android 12 and above, this replaces the location permission requirement for scanning. Using neverForLocation tells the system your app won’t use scan results to derive location.
  • BLUETOOTH_CONNECT — For apps targeting Android 12 and above, required to connect to Bluetooth peripherals currently bonded to the device.

If BLE hardware is a hard requirement for your app and you want to prevent it from appearing in the Play Store on devices without BLE support, add this below your <uses-permission> tags:

<uses-feature
    android:name="android.hardware.bluetooth_le"
    android:required="true" />

Requesting Bluetooth Runtime Permissions on Android

Declaring permissions in the manifest isn’t enough — ACCESS_FINE_LOCATION, BLUETOOTH_SCAN, and BLUETOOTH_CONNECT are runtime permissions, meaning the user has to explicitly grant them while the app is running. BLUETOOTH and BLUETOOTH_ADMIN are granted automatically at install time on older versions.

Start by adding a couple of helper extension functions on Context that check whether the required permissions have been granted:

/**
 * Determine whether the current [Context] has been granted the relevant [Manifest.permission].
 */
fun Context.hasPermission(permissionType: String): Boolean {
    return ContextCompat.checkSelfPermission(this, permissionType) ==
        PackageManager.PERMISSION_GRANTED
}

/**
 * Determine whether the current [Context] has been granted the relevant permissions to perform
 * Bluetooth operations depending on the mobile device's Android version.
 */
fun Context.hasRequiredBluetoothPermissions(): Boolean {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        hasPermission(Manifest.permission.BLUETOOTH_SCAN) &&
            hasPermission(Manifest.permission.BLUETOOTH_CONNECT)
    } else {
        hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)
    }
}

These can live in a separate Kotlin file so other Activities can use them too. Now add a constant for the permission request code — any positive integer works:

private const val PERMISSION_REQUEST_CODE = 1

Since permissions are only needed when scanning, it makes sense to request them at that moment rather than at launch. Assuming you have a button to start a scan, hook it up like this:

scanButton.setOnClickListener { startBleScan() }

startBleScan() checks for permissions and requests them if they’re missing:

private fun startBleScan() {
    if (!hasRequiredBluetoothPermissions()) {
        requestRelevantRuntimePermissions()
    } else { /* TODO: Actually perform scan */ }
}

private fun Activity.requestRelevantRuntimePermissions() {
    if (hasRequiredBluetoothPermissions()) { return }
    when {
        Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> {
            requestLocationPermission()
        }
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            requestBluetoothPermissions()
        }
    }
}

private fun requestLocationPermission() = runOnUiThread {
    AlertDialog.Builder(this)
        .setTitle("Location permission required")
        .setMessage(
            "Starting from Android M (6.0), the system requires apps to be granted " +
            "location access in order to scan for BLE devices."
        )
        .setCancelable(false)
        .setPositiveButton(android.R.string.ok) { _, _ ->
            ActivityCompat.requestPermissions(
                this,
                arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
                PERMISSION_REQUEST_CODE
            )
        }
        .show()
}

@RequiresApi(Build.VERSION_CODES.S)
private fun requestBluetoothPermissions() = runOnUiThread {
    AlertDialog.Builder(this)
        .setTitle("Bluetooth permission required")
        .setMessage(
            "Starting from Android 12, the system requires apps to be granted " +
                "Bluetooth access in order to scan for and connect to BLE devices."
        )
        .setCancelable(false)
        .setPositiveButton(android.R.string.ok) { _, _ ->
            ActivityCompat.requestPermissions(
                this,
                arrayOf(
                    Manifest.permission.BLUETOOTH_SCAN,
                    Manifest.permission.BLUETOOTH_CONNECT
                ),
                PERMISSION_REQUEST_CODE
            )
        }
        .show()
    }
}

Once the user responds to the permission dialog, handle the result by overriding onRequestPermissionsResult():

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    if (requestCode != PERMISSION_REQUEST_CODE) return

    val containsPermanentDenial = permissions.zip(grantResults.toTypedArray()).any {
        it.second == PackageManager.PERMISSION_DENIED &&
            !ActivityCompat.shouldShowRequestPermissionRationale(this, it.first)
    }
    val containsDenial = grantResults.any { it == PackageManager.PERMISSION_DENIED }
    val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
    when {
        containsPermanentDenial -> {
            // TODO: Handle permanent denial (e.g., show AlertDialog with justification)
            // Note: The user will need to navigate to App Settings and manually grant
            // permissions that were permanently denied
        }
        containsDenial -> {
            requestRelevantRuntimePermissions()
        }
        allGranted && hasRequiredBluetoothPermissions() -> {
            startBleScan()
        }
        else -> {
            // Unexpected scenario encountered when handling permissions
            recreate()
        }
    }
}

If the user denies permanently, they’ll need to go to app settings to grant permissions manually — surface a clear message to help them get there. If they deny without permanent denial, the dialog re-prompts. If everything is granted, startBleScan() proceeds.

A Note on Newer Permission APIs

The permission handling above uses onRequestPermissionsResult(), which works fine but isn’t where Android’s permission APIs have landed. If you’re starting a new project, registerForActivityResult() is the current recommended approach. You register a launcher up front, then call it when you need to request permissions, instead of overriding a callback method on the Activity. The logic for checking which permissions to request based on SDK version stays the same, only the mechanism for triggering the request and handling the result changes.

We kept this guide on the older pattern to match the rest of the series, but if you’re building fresh, it’s worth setting up with the newer API from the start.


Setting Up BluetoothLeScanner

With permissions handled, set up the bleScanner as a lazy property:

private val bleScanner by lazy {
    bluetoothAdapter.bluetoothLeScanner
}

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

Both properties are deferred because bluetoothAdapter relies on getSystemService(), which is only available after onCreate() has returned. Initializing either one before that point would crash the app.

Next, define your scan settings:

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

SCAN_MODE_LOW_LATENCY is the right choice when the app is scanning briefly in the foreground to find a specific device. For longer-running or background scans, SCAN_MODE_BALANCED or SCAN_MODE_LOW_POWER are better fits.


Implementing ScanCallback and Starting the Scan

Create a ScanCallback object to receive results:

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")
        }
    }

    override fun onScanFailed(errorCode: Int) {
        Log.e("ScanCallback", "onScanFailed: code $errorCode")
    }
}

Now update startBleScan() to actually kick off the scan:

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

The null first argument means no filter — the scan will return every BLE device in range. Build and run the app on a physical Android device and tap your scan button. You should start seeing results in Logcat almost immediately if there are any BLE devices advertising nearby.


Stopping the Scan

BluetoothLeScanner exposes a stopScan() method, but since it doesn’t expose its own scanning state, you’ll want to track that yourself:

private var isScanning = false
    set(value) {
        field = value
        runOnUiThread { scanButton.text = if (value) "Stop Scan" else "Start Scan" }
    }

The setter override keeps the button label in sync with the scanning state. Now implement stopBleScan() and update the button’s OnClickListener to toggle:

private fun stopBleScan() {
    bleScanner.stopScan(scanCallback)
    isScanning = false
}

scanButton.setOnClickListener {
    if (isScanning) { stopBleScan() } else { startBleScan() }
}

Displaying Results

Logging to Logcat confirms scanning is working, but you’ll want to surface results in the UI. The most straightforward approach is a RecyclerView — or a LazyColumn if your project uses Jetpack Compose.

The RecyclerView.Adapter implementation is specific to your layout, so we won’t reproduce it here — you can find our example in our open-source GitHub repo. The rest of this section assumes you’ve set up your adapter and RecyclerView similarly.

Back in your Activity, add properties for the results list and adapter:

private val scanResults = mutableListOf<ScanResult>()
private val scanResultAdapter: ScanResultAdapter by lazy {
    ScanResultAdapter(scanResults) {
        // TODO: Implement click handler
    }
}

Update startBleScan() to clear stale results each time a new scan starts:

private fun startBleScan() {
    if (!hasRequiredBluetoothPermissions()) {
        requestRelevantRuntimePermissions()
    } else {
        scanResults.clear()
        scanResultAdapter.notifyDataSetChanged()
        bleScanner.startScan(null, scanSettings, scanCallback)
        isScanning = true
    }
}

Then update scanCallback to populate the list and keep it current. Since CALLBACK_TYPE_FIRST_MATCH wasn’t specified in ScanSettings, onScanResult will fire repeatedly for the same device with updated RSSI readings. The check below handles that cleanly:

private val scanCallback = object : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult) {
        val indexQuery = scanResults.indexOfFirst { it.device.address == result.device.address }
        if (indexQuery != -1) { // A scan result already exists with the same address
            scanResults[indexQuery] = result
            scanResultAdapter.notifyItemChanged(indexQuery)
        } else {
            with(result.device) {
                Log.i("ScanCallback", "Found BLE device! Name: ${name ?: "Unnamed"}, address: $address")
            }
            scanResults.add(result)
            scanResultAdapter.notifyItemInserted(scanResults.size - 1)
        }
    }

    override fun onScanFailed(errorCode: Int) {
        Log.e("ScanCallback", "onScanFailed: code $errorCode")
    }
}

Build and run. Your RecyclerView should populate with nearby BLE devices and update RSSI readings as new packets come in.


Where to Go From Here

At this point you have a working BLE scan, devices are showing up and results are flowing. If you’re building an app that talks to a specific device rather than every BLE device in the room, the next step is filtering. How to Filter Results from Android BLE Scan covers how to use Android’s ScanFilter API to narrow results down to exactly what you care about. From there, the Android BLE Ultimate Guide picks up with connecting, service discovery, and working with characteristics.

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.