Navigating Android 12+ BLE Permissions: A Kotlin Developer's Checklist for BLUETOOTH_SCAN, CONNECT, and ADVERTISE

# android# kotlin# bluetooth# permissions
Navigating Android 12+ BLE Permissions: A Kotlin Developer's Checklist for BLUETOOTH_SCAN, CONNECT, and ADVERTISEBle Advertiser

Master Android 12+ BLE permissions! This guide details `BLUETOOTH_SCAN`, `CONNECT`, `ADVERTISE` in Kotlin, covering manifest, runtime, and critical best prac...

Did your rock-solid BLE app suddenly stop discovering devices, connecting, or advertising services on Android 12 (API 31) or later? You're not alone. Google's ongoing commitment to user privacy and granular control introduced a significant overhaul to how Bluetooth permissions are handled, moving away from the broad strokes of BLUETOOTH_ADMIN and ACCESS_FINE_LOCATION to a much more precise model. This change is mandatory, not optional, and understanding it is critical to delivering a reliable BLE experience.

This article cuts through the noise, providing a direct, actionable guide for Android developers using Kotlin. We’ll break down the new BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and BLUETOOTH_ADVERTISE permissions, show you exactly how to implement them, and arm you with best practices to avoid common pitfalls.

Core Concepts: The Shift in Android 12+ BLE Permissions

Before Android 12 (API 31), BLE operations primarily relied on two install-time permissions: BLUETOOTH and BLUETOOTH_ADMIN. While BLUETOOTH_ADMIN granted broad control over Bluetooth functions like scanning and connecting, the critical piece for scanning nearby devices was the runtime permission ACCESS_FINE_LOCATION. This was a point of frequent user friction; many users questioned why a flashlight app needed their precise location, simply because it also wanted to scan for a nearby BLE lightbulb.

With Android 12, Google addressed this by decoupling location from fundamental BLE operations. The new model introduces three distinct runtime permissions, giving users more granular control over what specific Bluetooth capabilities your app can access.

Here's the breakdown of the new permissions introduced in API 31+:

BLUETOOTH_SCAN

  • Purpose: This permission is required if your app needs to discover nearby Bluetooth devices, including BLE peripherals, or determine their non-identifying information (e.g., device name, service UUIDs in advertisement data).
  • Operations: Any operation that initiates a scan for Bluetooth devices, such as calling BluetoothAdapter.startLeScan() or BluetoothLeScanner.startScan().
  • Crucial Distinction: On Android 12 (API 31) and higher, BLUETOOTH_SCAN does NOT inherently require ACCESS_FINE_LOCATION just to discover devices. If your app uses scan results only to find a device for connection and doesn't derive physical location from these results, you do not need to request ACCESS_FINE_LOCATION alongside BLUETOOTH_SCAN. This is a significant privacy improvement. You should also include android:usesPermissionFlags="neverForLocation" in your manifest for BLUETOOTH_SCAN to explicitly signal this intent to the OS.

BLUETOOTH_CONNECT

  • Purpose: This permission is required if your app needs to communicate with already-paired Bluetooth devices, initiate connections, or accept incoming connections.
  • Operations:
    • Connecting to a GATT server (BluetoothDevice.connectGatt()).
    • Reading/writing characteristics or descriptors.
    • Communicating with a remote device after a connection has been established.
    • Accepting incoming connections (BluetoothAdapter.listenUsingRfcommWithServiceRecord(), BluetoothGattServer.accept()).
    • Accessing the local Bluetooth adapter's profile (e.g., BluetoothAdapter.getProfileProxy()).

BLUETOOTH_ADVERTISE

  • Purpose: This permission is required if your app needs to make its device discoverable to other Bluetooth devices and broadcast advertisement packets.
  • Operations: Starting a BLE advertisement session (BluetoothLeAdvertiser.startAdvertising()).

The Location Conundrum (Revisited for API 31+)

While BLUETOOTH_SCAN now largely stands alone for discovery, there are specific scenarios where ACCESS_FINE_LOCATION is still required for BLE operations, even on Android 12+:

  1. Location Inference: If your app uses the BLE scan results (e.g., RSSI values from multiple beacons, or specific beacon types like iBeacons/Eddystones whose profiles are often associated with location) to infer the physical location of the user or a device, then ACCESS_FINE_LOCATION is still necessary. The system needs to ensure the user is aware their location is being derived.
  2. Compatibility (Pre-API 31 logic): If you target API 30 or lower, and your app runs on an Android 12+ device, the system might grant the new Bluetooth permissions if BLUETOOTH_ADMIN and ACCESS_FINE_LOCATION were granted. However, this is a compatibility bridge, and you must update your targetSdkVersion to 31 or higher and request the new permissions directly.
  3. Global Bluetooth/Wi-Fi scanning setting: Prior to API 31, if the user globally disabled "Wi-Fi and Bluetooth scanning" in their device settings (a system setting separate from app permissions), apps could not perform BLE scans even with ACCESS_FINE_LOCATION. On API 31+, BLUETOOTH_SCAN (especially with neverForLocation) allows an app to bypass this global setting for app-initiated scans if the app doesn't derive location from scan results.

Summary Table: Permissions Pre- vs. Post-API 31

Operation Android 11 (API 30) & Lower Android 12 (API 31) & Higher Notes
Scanning BLUETOOTH_ADMIN + ACCESS_FINE_LOCATION (runtime) BLUETOOTH_SCAN (runtime) ACCESS_FINE_LOCATION no longer required for mere discovery on API 31+ if not inferring location.
Connecting BLUETOOTH_ADMIN BLUETOOTH_CONNECT (runtime) No runtime permission for connection prior to API 31.
Advertising BLUETOOTH_ADMIN BLUETOOTH_ADVERTISE (runtime) No runtime permission for advertising prior to API 31.
Location Use ACCESS_FINE_LOCATION (runtime) ACCESS_FINE_LOCATION (runtime) Still required if your app uses scan results to derive physical location.

Implementation: A Step-by-Step Guide for Kotlin

Implementing the new BLE permissions correctly involves two main steps: declaring them in your AndroidManifest.xml and then requesting them at runtime using modern Activity Result APIs.

Step 1: Update Your AndroidManifest.xml

You need to declare the new Bluetooth permissions in your AndroidManifest.xml. It's crucial to also include maxSdkVersion for older permissions if you still need to support pre-API 31 devices, ensuring the system selects the correct permissions based on the device's OS version.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!-- REQUIRED for devices running Android 12 (API level 31) or higher -->
    <!-- For discovering nearby BLE devices -->
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN"
        android:usesPermissionFlags="neverForLocation"
        tools:targetApi="s" />
    <!-- For connecting to already-paired BLE devices and communicating -->
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
        tools:targetApi="s" />
    <!-- For making your device discoverable as a BLE peripheral -->
    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"
        tools:targetApi="s" />

    <!-- REQUIRED for devices running Android 11 (API level 30) or lower -->
    <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />

    <!--
        REQUIRED if your app still needs to derive physical location from BLE scan results,
        OR if targeting API 30 or lower and performing BLE scans.
        For API 31+, BLUETOOTH_SCAN with "neverForLocation" flag covers most scan use cases.
    -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

    <!-- Declares that your app uses BLE and should only be installed on devices that support it. -->
    <uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.BlePermissionsGuide"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
Enter fullscreen mode Exit fullscreen mode

Key Points in Manifest:

  • android:usesPermissionFlags="neverForLocation": Crucial for BLUETOOTH_SCAN. This flag explicitly tells the system that your app does not intend to use Bluetooth scan results to derive physical location. This can help prevent unnecessary location permission prompts and streamline the user experience.
  • tools:targetApi="s": The tools:targetApi attribute (from xmlns:tools) helps lint and other build tools understand that these permissions are specific to certain API levels, preventing warnings if your minSdkVersion is lower than 31.
  • android:maxSdkVersion="30": This ensures that older permissions like BLUETOOTH and BLUETOOTH_ADMIN are only requested on devices running Android 11 or lower, preventing redundant or incorrect permission requests on newer OS versions.
  • ACCESS_FINE_LOCATION: Keep this if you truly need location for other features or if you are inferring location from BLE scans. Otherwise, consider removing it for maximum privacy.

Step 2: Request Permissions at Runtime (Kotlin & Activity Result API)

For modern Android development, you should use the registerForActivityResult API (part of the Activity Result APIs) to handle runtime permission requests. This replaces the deprecated onRequestPermissionsResult callback, leading to cleaner and more lifecycle-aware code.

First, define a launcher for requesting multiple permissions in your Activity or Fragment:

import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.widget.Button
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat

class MainActivity : AppCompatActivity() {

    private val TAG = "BLEPermissionsGuide"
    private var bluetoothAdapter: BluetoothAdapter? = null

    // Register ActivityResultLauncher for requesting multiple permissions
    private val requestBlePermissions: ActivityResultLauncher<Array<String>> =
        registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
            val allGranted = permissions.entries.all { it.value }
            if (allGranted) {
                // All required BLE permissions are granted. Proceed with BLE operations.
                Log.d(TAG, "All required BLE permissions granted.")
                ensureBluetoothEnabled()
            } else {
                // At least one BLE permission was denied.
                Log.w(TAG, "One or more BLE permissions denied.")
                showPermissionDeniedDialog()
            }
        }

    // Register ActivityResultLauncher for enabling Bluetooth
    private val enableBluetoothLauncher: ActivityResultLauncher<Intent> =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
            if (result.resultCode == RESULT_OK) {
                Log.d(TAG, "Bluetooth enabled by user.")
                startBleOperationsIfReady()
            } else {
                Log.w(TAG, "Bluetooth not enabled by user.")
                Toast.makeText(this, "Bluetooth is required for this app.", Toast.LENGTH_SHORT).show()
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Initialize BluetoothAdapter
        val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        bluetoothAdapter = bluetoothManager.adapter

        findViewById<Button>(R.id.start_scan_button).setOnClickListener {
            // Initiate the permission check and request flow
            checkAndRequestBlePermissions()
        }
    }

    private fun checkAndRequestBlePermissions() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12 (API 31) and higher
            val permissionsToRequest = mutableListOf<String>()

            // Check BLUETOOTH_SCAN
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
                permissionsToRequest.add(Manifest.permission.BLUETOOTH_SCAN)
            }
            // Check BLUETOOTH_CONNECT
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
                permissionsToRequest.add(Manifest.permission.BLUETOOTH_CONNECT)
            }
            // Check BLUETOOTH_ADVERTISE (only add if your app needs to advertise)
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_ADVERTISE) != PackageManager.PERMISSION_GRANTED) {
                // If you need advertising, uncomment the line below:
                // permissionsToRequest.add(Manifest.permission.BLUETOOTH_ADVERTISE)
            }

            // If ACCESS_FINE_LOCATION is truly needed for location inference from BLE scans (not just discovery)
            // if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
            //     permissionsToRequest.add(Manifest.permission.ACCESS_FINE_LOCATION)
            // }

            if (permissionsToRequest.isNotEmpty()) {
                // Request the missing permissions
                requestBlePermissions.launch(permissionsToRequest.toTypedArray())
            } else {
                // All necessary permissions already granted for API 31+
                Log.d(TAG, "All API 31+ BLE permissions already granted.")
                ensureBluetoothEnabled()
            }
        } else { // Android 11 (API 30) and lower
            // For older Android versions, we primarily need ACCESS_FINE_LOCATION for scanning.
            // BLUETOOTH and BLUETOOTH_ADMIN are install-time permissions.
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
                // Request ACCESS_FINE_LOCATION using the same ActivityResultLauncher
                requestBlePermissions.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))
            } else {
                Log.d(TAG, "ACCESS_FINE_LOCATION permission already granted for older API.")
                ensureBluetoothEnabled()
            }
        }
    }

    private fun ensureBluetoothEnabled() {
        bluetoothAdapter?.let {
            if (!it.isEnabled) {
                Log.d(TAG, "Bluetooth is disabled. Requesting user to enable it.")
                val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
                enableBluetoothLauncher.launch(enableBtIntent)
            } else {
                Log.d(TAG, "Bluetooth is already enabled.")
                startBleOperationsIfReady()
            }
        } ?: run {
            Log.e(TAG, "Bluetooth not supported on this device.")
            Toast.makeText(this, "Bluetooth not supported on this device.", Toast.LENGTH_LONG).show()
        }
    }

    private fun startBleOperationsIfReady() {
        // Double-check if all conditions (permissions, Bluetooth enabled) are met.
        // This method can be called after permissions are granted or Bluetooth is enabled.
        if (bluetoothAdapter?.isEnabled == true && hasRequiredBlePermissions()) {
            Toast.makeText(this, "Starting BLE scan...", Toast.LENGTH_SHORT).show()
            Log.d(TAG, "BLE scan initiated.")
            // --- Your BLE scanning or connection logic goes here ---
            // Example:
            // val scanner = bluetoothAdapter?.bluetoothLeScanner
            // scanner?.startScan( /* Scan Filters and Settings */ )
        } else {
            Log.w(TAG, "Cannot start BLE operations: Permissions or Bluetooth not ready.")
        }
    }

    private fun hasRequiredBlePermissions(): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
            ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
            // Add BLUETOOTH_ADVERTISE if required for your app
            // && ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED
            // Add ACCESS_FINE_LOCATION if required
            // && ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
        } else {
            // For API < 31, ensure ACCESS_FINE_LOCATION (if scanning)
            ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
        }
    }

    private fun showPermissionDeniedDialog() {
        AlertDialog.Builder(this)
            .setTitle("Permissions Required")
            .setMessage("BLE operations require Bluetooth permissions. Please grant them in your device settings.")
            .setPositiveButton("Go to Settings") { dialog, _ ->
                val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
                val uri = Uri.fromParts("package", packageName, null)
                intent.data = uri
                startActivity(intent)
                dialog.dismiss()
            }
            .setNegativeButton("Cancel") { dialog, _ ->
                dialog.dismiss()
            }
            .show()
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation of the Code:

  1. requestBlePermissions Launcher: This ActivityResultLauncher is initialized once. When requestBlePermissions.launch() is called with an array of permissions, it triggers the system permission dialog. The lambda then receives a map of permission names to boolean (granted/denied) results.
  2. enableBluetoothLauncher Launcher: Separate launcher to handle the result of ACTION_REQUEST_ENABLE which prompts the user to turn on Bluetooth.
  3. checkAndRequestBlePermissions():
    • It uses Build.VERSION.SDK_INT to conditionally handle permissions based on the Android version.
    • For API 31+, it explicitly checks BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and optionally BLUETOOTH_ADVERTISE and ACCESS_FINE_LOCATION.
    • For API 30 and lower, it primarily checks ACCESS_FINE_LOCATION (since BLUETOOTH and BLUETOOTH_ADMIN are install-time).
    • If any required permission is missing, it adds it to permissionsToRequest and then launches the requestBlePermissions launcher.
  4. ensureBluetoothEnabled(): Permissions are just one part. You must also ensure the Bluetooth adapter is actually enabled. This function checks bluetoothAdapter.isEnabled and, if necessary, launches an ACTION_REQUEST_ENABLE intent.
  5. startBleOperationsIfReady(): This is your entry point for BLE functionality. It's called only after permissions are granted AND Bluetooth is enabled.
  6. showPermissionDeniedDialog(): A critical UX component. If permissions are permanently denied (user checks "Don't ask again"), you cannot re-request them programmatically. This dialog informs the user and provides a direct link to your app's settings page, where they can manually grant permissions.
  7. hasRequiredBlePermissions(): A utility function to quickly check the current state of necessary permissions.

Best Practices: Avoiding Common BLE Permission Headaches

Even with the correct code, nuanced issues can arise. Here are crucial best practices derived from real-world BLE development.

  1. Request Permissions Contextually, Not All at Once:

    • Pitfall: Dumping all three new Bluetooth permissions (BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE) to the user at app launch can be overwhelming and lead to denials. If your app only scans occasionally, asking for BLUETOOTH_CONNECT and BLUETOOTH_ADVERTISE upfront is unnecessary and bad UX.
    • Fix: Request permissions just before the operation that requires them. For instance, request BLUETOOTH_SCAN when the user taps a "Start Scan" button. Request BLUETOOTH_CONNECT when they select a device to connect to. This provides clear rationale and increases the likelihood of permission grants.
    // Example: Only request BLUETOOTH_ADVERTISE when user explicitly enables advertising
    fun enableAdvertisingClicked() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_ADVERTISE) != PackageManager.PERMISSION_GRANTED) {
                requestBlePermissions.launch(arrayOf(Manifest.permission.BLUETOOTH_ADVERTISE))
            } else {
                startAdvertising() // Proceed with advertising
            }
        } else {
            startAdvertising() // For older APIs, BLUETOOTH_ADMIN handles this
        }
    }
    
  2. Understand and Utilize android:usesPermissionFlags="neverForLocation":

    • Pitfall: Omitting android:usesPermissionFlags="neverForLocation" with BLUETOOTH_SCAN can inadvertently prompt the user for location permission, even if your app doesn't need it for location inference. This happens if ACCESS_FINE_LOCATION is also declared in your manifest for other purposes.
    • Fix: Always include android:usesPermissionFlags="neverForLocation" when declaring BLUETOOTH_SCAN in your AndroidManifest.xml if your app does not derive physical location from BLE scan results. This tells the OS to handle BLUETOOTH_SCAN independently of ACCESS_FINE_LOCATION. Double-check your app's logic; if you're truly not inferring location, this flag is your friend.
  3. Provide Clear Rationale and Handle Persistent Denials Gracefully:

    • Pitfall: Simply showing the system permission dialog, and then crashing or failing silently if the user denies it, leads to a poor user experience. Users need to understand why you need these permissions. If they deny persistently (checking "Don't ask again"), your app might get stuck.
    • Fix:
      • shouldShowRequestPermissionRationale(): Before requesting a permission, check shouldShowRequestPermissionRationale(). If it returns true, it means the user has denied the permission at least once, and you should show a custom UI (like an AlertDialog) explaining why the permission is needed before re-requesting it.
      • Direct to Settings: For persistent denials (when shouldShowRequestPermissionRationale() returns false after multiple denials), your app cannot request the permission again. Your only recourse is to guide the user to your app's settings page, where they can manually enable the permission. The showPermissionDeniedDialog() in the example code demonstrates this.
  4. Manage Bluetooth State Independently:

    • Pitfall: Assuming that granting permissions is enough to start BLE operations. Bluetooth can be disabled by the user even if permissions are granted.
    • Fix: Always check BluetoothAdapter.isEnabled() before initiating any BLE operation (scan, connect, advertise). If it's disabled, prompt the user to enable it using an Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE). Your enableBluetoothLauncher example covers this.
  5. Backward Compatibility for Older Devices:

    • Pitfall: Only implementing the API 31+ permission model and breaking functionality for users on Android 11 or lower.
    • Fix: Use Build.VERSION.SDK_INT checks, as shown in the checkAndRequestBlePermissions() example. Ensure your AndroidManifest.xml correctly uses maxSdkVersion for older permissions and includes ACCESS_FINE_LOCATION for scanning on pre-API 31 devices if necessary. The system will then handle the permission model appropriate for the device's OS version.

Conclusion

Navigating Android 12+ BLE permissions might seem daunting at first glance, but it's a critical step towards building more secure and privacy-respecting applications. By understanding the distinct roles of BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and BLUETOOTH_ADVERTISE, correctly declaring them in your manifest, and requesting them at runtime with the Activity Result APIs, you empower your users with granular control while ensuring your app functions reliably. Remember to prioritize user experience by requesting permissions contextually, clarifying their necessity, and providing clear paths to resolution if permissions are denied. Now, go forth and refactor your BLE apps with confidence. Review your existing BLE applications today and update their permission handling to embrace the modern Android permission model.