DEV Community

Ble Advertiser
Ble Advertiser

Posted on

Mastering Android 12+ BLE Permissions in Kotlin: The `BLUETOOTH_SCAN`/`CONNECT` Dance and Common Gotchas

Have you ever deployed a perfectly functional BLE application, only for users on newer Android versions (especially Android 12/API 31+) to report that your app suddenly "lost" its ability to scan or connect, even though you meticulously requested ACCESS_FINE_LOCATION? If so, you've encountered the Android 12+ BLE permission paradigm shift, a necessary evolution that often leaves developers scrambling. This article cuts through the noise, providing a direct, no-fluff guide to navigating the new BLUETOOTH_SCAN and BLUETOOTH_CONNECT permissions in Kotlin, ensuring your BLE applications function reliably on modern Android devices.

Core Concepts: The Shift to Granular BLE Permissions

Prior to Android 12 (API level 31), the ACCESS_FINE_LOCATION permission was a ubiquitous, if often misunderstood, requirement for nearly all BLE operations. This was primarily due to the potential for BLE scans to infer a user's location. While this blanket approach simplified things in some ways, it also forced users to grant a highly sensitive location permission even when an app's BLE functionality had no actual need for location data.

Android 12 addressed this by introducing a set of new, more granular Bluetooth permissions, decoupling BLE functionality from location access. These permissions are:

  • BLUETOOTH_SCAN: Required for your app to find nearby Bluetooth devices. This replaces the need for ACCESS_FINE_LOCATION for most BLE scanning scenarios.
  • BLUETOOTH_CONNECT: Required for your app to connect to already paired Bluetooth devices or initiate new connections.
  • BLUETOOTH_ADVERTISE: Required for your app to make itself discoverable to other Bluetooth devices and broadcast advertisements.

These new permissions are runtime permissions, meaning you must declare them in your AndroidManifest.xml and then explicitly request them from the user at runtime, just like ACCESS_FINE_LOCATION.

Why the Change?

The primary motivations behind this shift are user privacy and control:

  1. Granular Control: Users can now grant specific Bluetooth capabilities (scan, connect, advertise) without exposing their precise location.
  2. Improved Transparency: It's clearer to users exactly what permissions an app needs for Bluetooth operations.
  3. Reduced Friction: For apps that truly don't need location for BLE, users are less likely to deny permissions.

Permission Mapping and Dependencies

It's critical to understand that these new permissions only apply when your app targets Android 12 (API 31) or higher (targetSdk >= 31). If your targetSdk is 30 or lower, the system still uses ACCESS_FINE_LOCATION for BLE scanning, even on an Android 12+ device. This is a common point of confusion and a significant gotcha.

Here's a simplified mapping of old vs. new permissions and their purpose:

Operation Android 11 (API 30) & below (if targetSdk <= 30) Android 12 (API 31) & above (if targetSdk >= 31) Notes
BLE Scanning ACCESS_FINE_LOCATION BLUETOOTH_SCAN ACCESS_FINE_LOCATION might still be needed if your app requires location for other features or if specific BLE scan results require location information (e.g., ScanFilter for device name filtering in some specific OS versions/device manufacturers). Also, turning on location services may still be required on some devices to enable BLE scanning, even with BLUETOOTH_SCAN granted.
BLE Connecting BLUETOOTH BLUETOOTH_CONNECT BLUETOOTH is a normal permission, automatically granted. BLUETOOTH_CONNECT is runtime.
BLE Advertising BLUETOOTH BLUETOOTH_ADVERTISE BLUETOOTH_ADVERTISE is runtime.
Managing Paired Devices BLUETOOTH BLUETOOTH_CONNECT

Important Nuance: Even with BLUETOOTH_SCAN granted and targetSdk >= 31, a user might still need to have location services enabled on their device for BLE scanning to function correctly. This is a device-level setting, not an app-level permission, and its necessity can vary by Android OEM implementation. Always prompt the user to enable location services if scanning fails, even if BLUETOOTH_SCAN is granted.

Implementation: The Step-by-Step Walkthrough

Let's dive into integrating these new permissions into your Kotlin BLE application.

Prerequisites

  • Android Studio Arctic Fox (or newer)
  • Kotlin 1.5.0 (or newer)
  • targetSdk set to 31 or higher in your build.gradle (module-level). This is critical.

    android {
        compileSdk 33 // Or higher
        defaultConfig {
            minSdk 21
            targetSdk 33 // This is the key
            // ...
        }
    }
    

1. Manifest Declarations

You need to declare the new Bluetooth permissions in your AndroidManifest.xml. To maintain backward compatibility with devices running Android 11 (API 30) or lower, and to conditionally request ACCESS_FINE_LOCATION only when needed for older OS versions, you'll typically declare both the old and new permissions.

Crucially, for BLUETOOTH_SCAN, you'll use the android:usesPermissionFlags attribute with neverForLocation. This flag explicitly tells the system that your app does not use the permission for location features, which is key to avoiding location permission requests on Android 12+.

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

    <!-- Permissions for Android 11 (API 30) and below -->
    <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_FINE_LOCATION" android:maxSdkVersion="30" />

    <!-- Permissions for Android 12 (API 31) and above -->
    <!-- BLUETOOTH_SCAN: Needed to discover devices. -->
    <uses-permission android:name="android.permission.BLUETOOTH_SCAN"
        android:usesPermissionFlags="neverForLocation"
        tools:targetApi="31" />

    <!-- BLUETOOTH_CONNECT: Needed to connect to devices. -->
    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" tools:targetApi="31" />

    <!-- BLUETOOTH_ADVERTISE: Needed to advertise as a peripheral. -->
    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" tools:targetApi="31" />

    <!-- Optionally, if your app truly *needs* location for other features or older Android versions -->
    <!-- If your minSdk < 31, ACCESS_FINE_LOCATION is still relevant for BLE scanning on older OS versions. -->
    <!-- If your app specifically needs to derive location from BLE scans (e.g., for indoor positioning) -->
    <!-- then you might still need ACCESS_FINE_LOCATION even on API 31+ for those specific scenarios, -->
    <!-- but for standard BLE proximity scans, neverForLocation covers it. -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

    <!-- If you target API 31+, and *only* need coarse location (e.g., for location-based services that don't need fine accuracy) -->
    <!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> -->

    <application
        <!-- ... your application settings ... -->
    </application>
</manifest>
Enter fullscreen mode Exit fullscreen mode

Explanation of Manifest Declarations:

  • android:maxSdkVersion="30": This attribute limits the BLUETOOTH, BLUETOOTH_ADMIN, and ACCESS_FINE_LOCATION permissions to devices running API level 30 or lower. When your app runs on API 31+, these permissions are ignored by the system, preventing conflicts.
  • tools:targetApi="31": This attribute is a lint directive that tells Android Studio that this permission is only relevant for API 31 and above. It helps prevent lint warnings about unused permissions on older Android versions and improves readability.
  • android:usesPermissionFlags="neverForLocation": This is the most crucial flag for BLUETOOTH_SCAN. It explicitly tells Android that your app does not intend to use the Bluetooth scan results to derive the user's physical location. If you omit this, even on Android 12+, the system might still require ACCESS_FINE_LOCATION for scanning.

2. Runtime Permission Request

Now that the permissions are declared, you need to request them at runtime. You should check the Android version and request the appropriate permissions.

Helper function for permission checks:

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat

object BluetoothPermissions {

    // Permissions required for BLE scanning on Android 12+
    val bluetoothScanPermissions = arrayOf(
        Manifest.permission.BLUETOOTH_SCAN
    )

    // Permissions required for BLE connecting on Android 12+
    val bluetoothConnectPermissions = arrayOf(
        Manifest.permission.BLUETOOTH_CONNECT
    )

    // Permissions required for BLE advertising on Android 12+
    val bluetoothAdvertisePermissions = arrayOf(
        Manifest.permission.BLUETOOTH_ADVERTISE
    )

    // Permissions required for BLE scanning on Android 11 and below
    val bluetoothLegacyScanPermissions = arrayOf(
        Manifest.permission.ACCESS_FINE_LOCATION
    )

    /**
     * Checks if the required BLE scanning permissions are granted.
     * On Android 12+, this means BLUETOOTH_SCAN.
     * On Android 11 and below, this means ACCESS_FINE_LOCATION.
     *
     * @param context The application context.
     * @return True if all necessary scanning permissions are granted, false otherwise.
     */
    fun hasScanPermissions(context: Context): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12 (API 31)
            bluetoothScanPermissions.all { permission ->
                ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
            }
        } else { // Android 11 and below
            bluetoothLegacyScanPermissions.all { permission ->
                ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
            }
        }
    }

    /**
     * Checks if the required BLE connection permissions are granted.
     * On Android 12+, this means BLUETOOTH_CONNECT.
     * On Android 11 and below, BLUETOOTH (normal permission) is usually sufficient.
     *
     * @param context The application context.
     * @return True if all necessary connection permissions are granted, false otherwise.
     */
    fun hasConnectPermissions(context: Context): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12 (API 31)
            bluetoothConnectPermissions.all { permission ->
                ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
            }
        } else { // Android 11 and below
            // On older versions, BLUETOOTH is a normal permission and granted automatically.
            // No runtime request needed for connection.
            true
        }
    }

    /**
     * Checks if the required BLE advertising permissions are granted.
     * On Android 12+, this means BLUETOOTH_ADVERTISE.
     * On Android 11 and below, BLUETOOTH (normal permission) is usually sufficient.
     *
     * @param context The application context.
     * @return True if all necessary advertising permissions are granted, false otherwise.
     */
    fun hasAdvertisePermissions(context: Context): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12 (API 31)
            bluetoothAdvertisePermissions.all { permission ->
                ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
            }
        } else { // Android 11 and below
            // On older versions, BLUETOOTH is a normal permission and granted automatically.
            // No runtime request needed for advertising.
            true
        }
    }

    /**
     * Determines the set of permissions required for BLE scanning based on the current Android version.
     *
     * @return An array of string permissions.
     */
    fun getScanPermissions(): Array<String> {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            bluetoothScanPermissions
        } else {
            bluetoothLegacyScanPermissions
        }
    }

    /**
     * Determines the set of permissions required for BLE connection based on the current Android version.
     *
     * @return An array of string permissions.
     */
    fun getConnectPermissions(): Array<String> {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            bluetoothConnectPermissions
        } else {
            // No runtime permission needed for connect on older Android versions beyond
            // the BLUETOOTH normal permission which is auto-granted.
            // Returning an empty array or a dummy is fine here if you only check for runtime permissions.
            emptyArray()
        }
    }

    /**
     * Determines the set of permissions required for BLE advertising based on the current Android version.
     *
     * @return An array of string permissions.
     */
    fun getAdvertisePermissions(): Array<String> {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            bluetoothAdvertisePermissions
        } else {
            // No runtime permission needed for advertising on older Android versions beyond
            // the BLUETOOTH normal permission which is auto-granted.
            emptyArray()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, integrate this into your Activity or Fragment:

import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Intent
import android.content.pm.PackageManager
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.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity

class BleScannerActivity : AppCompatActivity() {

    private val TAG = "BleScannerActivity"
    private val REQUEST_ENABLE_BT = 1

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

    // Register for permission results using the new Activity Result API
    private val requestPermissionLauncher =
        registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissionsGranted ->
            if (permissionsGranted.all { it.value }) {
                Log.d(TAG, "All required BLE permissions granted.")
                // Permissions granted, proceed with BLE operations (e.g., start scan)
                startBleOperations()
            } else {
                Log.w(TAG, "Not all required BLE permissions were granted.")
                Toast.makeText(this, "BLE permissions are required to scan for devices.", Toast.LENGTH_LONG).show()
                // Handle denied permissions, perhaps disable BLE features or show a persistent rationale
            }
        }

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

        findViewById<Button>(R.id.start_scan_button).setOnClickListener {
            checkPermissionsAndStartScan()
        }
    }

    private fun checkPermissionsAndStartScan() {
        if (!isBluetoothEnabled()) {
            promptEnableBluetooth()
            return
        }

        // Determine which permissions are needed based on OS version
        val requiredPermissions = BluetoothPermissions.getScanPermissions()
            .filter { permission -> // Filter out permissions already granted
                ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED
            }.toTypedArray()

        if (requiredPermissions.isNotEmpty()) {
            // Request permissions if not all are granted
            Log.d(TAG, "Requesting BLE permissions: ${requiredPermissions.joinToString()}")
            requestPermissionLauncher.launch(requiredPermissions)
        } else {
            // All necessary permissions are already granted, proceed
            Log.d(TAG, "All necessary BLE permissions already granted. Starting BLE operations.")
            startBleOperations()
        }
    }

    private fun startBleOperations() {
        // Double-check for location services enablement, especially on older Android versions or some OEMs.
        // Even with BLUETOOTH_SCAN, location services might need to be ON.
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S && !isLocationEnabled(this)) {
            promptEnableLocationServices()
            return
        }

        // Your BLE scanning logic goes here
        Log.i(TAG, "Starting BLE scan...")
        Toast.makeText(this, "Starting BLE scan...", Toast.LENGTH_SHORT).show()
        // Example: bluetoothAdapter?.bluetoothLeScanner?.startScan(yourScanCallback)
    }

    private fun isBluetoothEnabled(): Boolean {
        return bluetoothAdapter?.isEnabled == true
    }

    private fun promptEnableBluetooth() {
        if (bluetoothAdapter?.isEnabled == false) {
            val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
            startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) // Use old API for simplicity, or Activity Result API for modern approach
        }
    }

    private fun isLocationEnabled(context: Context): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            // On Android 9 (API 28) and above, use LocationManager
            val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
            locationManager.isLocationEnabled
        } else {
            // On older versions, check modes (HIGH_ACCURACY, BATTERY_SAVING, DEVICE_ONLY)
            val mode = Settings.Secure.getInt(context.contentResolver, Settings.Secure.LOCATION_MODE, Settings.Secure.LOCATION_MODE_OFF)
            mode != Settings.Secure.LOCATION_MODE_OFF
        }
    }

    private fun promptEnableLocationServices() {
        AlertDialog.Builder(this)
            .setTitle("Location Services Required")
            .setMessage("For BLE scanning on this device, please enable Location Services.")
            .setPositiveButton("Enable") { _, _ ->
                val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
                startActivity(intent)
            }
            .setNegativeButton("Cancel") { dialog, _ ->
                dialog.dismiss()
                Toast.makeText(this, "BLE scanning disabled without Location Services.", Toast.LENGTH_LONG).show()
            }
            .show()
    }

    // You would override onActivityResult if using startActivityForResult,
    // or integrate into the Activity Result API for enabling Bluetooth.
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == REQUEST_ENABLE_BT) {
            if (resultCode == RESULT_OK) {
                Log.d(TAG, "Bluetooth enabled by user.")
                checkPermissionsAndStartScan() // Retry permission check and start scan
            } else {
                Log.w(TAG, "User denied enabling Bluetooth.")
                Toast.makeText(this, "Bluetooth must be enabled to use BLE features.", Toast.LENGTH_LONG).show()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This comprehensive example demonstrates:

  1. Conditional Permission Checks: Uses Build.VERSION.SDK_INT to determine which set of permissions to check and request.
  2. Activity Result API: Modern way to handle permission requests with registerForActivityResult.
  3. Rationale: Implicitly, if a permission is denied, a Toast is shown. For a production app, you'd show a more detailed rationale via shouldShowRequestPermissionRationale() before requesting again.
  4. Bluetooth and Location Service Checks: Essential checks for basic Bluetooth adapter state and device-level location services.

Best Practices & Common Gotchas

Navigating Android permissions, especially with BLE, comes with its share of tricky situations. Here are some best practices and common pitfalls to avoid:

  1. Over-requesting Permissions:

    • Pitfall: Declaring and requesting BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and BLUETOOTH_ADVERTISE when your app only performs, say, scanning. This exposes your app to unnecessary scrutiny from users and increases the chances of denial.
    • Fix: Only declare and request the specific Bluetooth permissions your app absolutely needs for its current operation. If your app only scans for devices, only request BLUETOOTH_SCAN. If it later needs to connect, request BLUETOOTH_CONNECT at that point, perhaps after a successful scan. This aligns with the principle of least privilege. Use the BluetoothPermissions helper to precisely determine the required permissions.
  2. Forgetting neverForLocation:

    • Pitfall: Declaring BLUETOOTH_SCAN in your AndroidManifest.xml without the android:usesPermissionFlags="neverForLocation" attribute. Even with targetSdk >= 31, the system might still interpret your BLE scanning as a potential source of location information and demand ACCESS_FINE_LOCATION at runtime.
    • Fix: Always include android:usesPermissionFlags="neverForLocation" for BLUETOOTH_SCAN if your app does not derive location from BLE scans. This is the cornerstone of decoupling BLE from location on Android 12+.
  3. Ignoring targetSdk:

    • Pitfall: Keeping your targetSdk below 31. If your targetSdk is 30 or lower, even if your app runs on Android 12+, the system will not use BLUETOOTH_SCAN or BLUETOOTH_CONNECT. Instead, it will still rely on ACCESS_FINE_LOCATION for scanning and the normal BLUETOOTH permission for connecting. This leads to the "my app broke on Android 12" scenario if you then try to request BLUETOOTH_SCAN.
    • Fix: Update your targetSdk to 31 (Android 12) or higher as soon as possible. This opts your app into the new permission model and allows you to leverage the granular controls. Incrementing your targetSdk requires careful testing, as it can introduce other behavioral changes.
  4. Inadequate Rationale and UX:

    • Pitfall: Prompting for permissions without explaining why they are needed, or failing to guide users when permissions are denied (especially "Don't ask again").
    • Fix:
      • Before Requesting: Use shouldShowRequestPermissionRationale() to check if the user has previously denied the permission. If true, display a user-friendly dialog explaining why the permission is crucial for your app's functionality before making the actual permission request.
      • After Denial (Persistent): If the user denies a critical permission and checks "Don't ask again," your app won't be able to request it again. Detect this state (e.g., checkSelfPermission returns PERMISSION_DENIED and shouldShowRequestPermissionRationale returns false) and gracefully guide the user to your app's settings page (via Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)) to manually grant the permission. Clear communication here is key to a good user experience.
  5. Forgetting Location Services (even with BLUETOOTH_SCAN):

    • Pitfall: Assuming that BLUETOOTH_SCAN alone is sufficient to enable BLE scanning on all Android 12+ devices. Some OEMs or even specific Android versions (especially prior to S) might still require the device's Location Services to be enabled (the toggle in quick settings), even if your app has the BLUETOOTH_SCAN permission.
    • Fix: Always include a check for the device's location services status (LocationManager.isLocationEnabled on API 28+ or Settings.Secure.LOCATION_MODE on older versions) before starting a scan. If location services are off, politely prompt the user to enable them, perhaps with an Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).

Conclusion

The Android 12+ BLE permission changes, while initially challenging, represent a significant step forward for user privacy and control. By understanding the shift from ACCESS_FINE_LOCATION to BLUETOOTH_SCAN/CONNECT, correctly implementing conditional manifest declarations with neverForLocation, and rigorously requesting permissions at runtime, you can ensure your BLE applications are robust and user-friendly. Always prioritize clear user communication and thorough testing across different Android versions to catch these nuanced behavioral changes. Your next step should be to audit your existing BLE applications, update their targetSdk to 31+, and refactor your permission handling to embrace this new, more granular model.


TAGS: android, kotlin, ble, permissions

Top comments (0)