BLE Scanning on Android 12+: New Permission Model and Migration Guide
Android 12 (API level 31) introduced the most significant change to Bluetooth permissions since BLE was first supported on the platform. The old model, where BLE scanning required ACCESS_FINE_LOCATION, has been replaced with a set of granular Bluetooth-specific permissions: BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and BLUETOOTH_ADVERTISE. This is genuinely good for user privacy. It is also a migration headache that has caused crashes in thousands of production apps.
Working from our Toronto office on BLE companion apps for neurotechnology companies like CLEIO and RE-AK Technologies, we had to migrate multiple production apps to the new permission model while maintaining compatibility with Android 10 and 11 devices still active in the field. In the Canadian healthcare and research market, many institutions run devices on older Android versions, so dropping backwards compatibility was not an option. This guide covers everything we learned during those migrations.
The Old Model: Why Location Permission Was Required for BLE
Before Android 12, any app that wanted to scan for BLE devices needed ACCESS_FINE_LOCATION (or ACCESS_COARSE_LOCATION on Android 9 and below). This was not an oversight. BLE scan results include device addresses and advertising data that can be used for indoor positioning and location tracking. Google classified BLE scanning as a location-sensitive operation.
The practical impact was that every BLE app had to ask users for location permission, even if the app had nothing to do with location. A neurotechnology app that connects to an EEG headset needed the same permission as a navigation app. Users saw "Allow this app to access your device's location" and either denied it (breaking the app) or granted it reluctantly (eroding trust).
The manifest declarations for the old model looked like this:
<!-- Android 11 and below -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Required on Android 10+ for background scanning -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
And the runtime permission request:
// Old approach (Android 11 and below)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
REQUEST_CODE_LOCATION
)
}
The New Permission Model: BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and BLUETOOTH_ADVERTISE
Android 12 splits Bluetooth operations into three distinct permissions:
BLUETOOTH_SCAN- Required to discover nearby BLE devices. This replaces the location permission requirement for scanning.BLUETOOTH_CONNECT- Required to connect to paired or bonded devices, read/write characteristics, and manage GATT connections.BLUETOOTH_ADVERTISE- Required for apps that act as BLE peripherals (advertising). Most companion apps do not need this.
These are runtime permissions in the NEARBY_DEVICES permission group. The user sees a dialog that says "Allow [App] to find, connect to, and determine the relative position of nearby devices" instead of a location permission dialog.
Here is the updated manifest for an app targeting Android 12+:
<!-- Android 12+ (API 31+) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Legacy permissions for Android 11 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" />
The android:maxSdkVersion="30" attribute is critical. It tells Android 12+ devices that the legacy permissions are not needed, so the system will not show them in the app's permission settings. Without this attribute, users on Android 12+ might see both location and Bluetooth permissions listed, which is confusing.
The neverForLocation Flag: When You Do Not Need Location
There is a subtle but important detail in the BLUETOOTH_SCAN permission. By default, even on Android 12+, the system assumes that BLE scan results might be used to derive location. If you declare BLUETOOTH_SCAN without additional flags, ACCESS_FINE_LOCATION is still implicitly required.
To fully decouple scanning from location, you must add the usesPermissionFlags attribute with neverForLocation:
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
When you set neverForLocation, the system enforces a restriction: your scan results will not include the physical address of the device if the user has not granted location permission. In practice, this means:
- You can scan for devices and see their names, service UUIDs, and advertising data.
- You can filter scans by service UUID, which is the normal approach for finding a specific peripheral.
- The
ScanResultobjects will still contain the device's Bluetooth address, which you need for connecting. - You do not get the device's physical location or signal strength-based positioning data (though
getRssi()still works for basic proximity detection).
For neurotechnology and medical device companion apps, neverForLocation is almost always the right choice. You are scanning for a specific device by service UUID, not performing location tracking. Using this flag means your users never see a location permission dialog, which significantly improves permission grant rates.
When You Still Need Location Permission
Do not use neverForLocation if your app:
- Uses BLE scan results to determine the user's indoor position
- Logs BLE device locations for analytics or tracking purposes
- Needs to scan for BLE beacons used in a location-aware feature
- Uses the physical address of BLE devices in any location-related computation
If you set neverForLocation and Google determines during review that your app uses scan results for location purposes, your app may be removed from the Play Store.
Runtime Permission Flow for Android 12+
Here is the complete runtime permission flow that handles both Android 12+ and older versions. This is the pattern we use across all of our BLE apps:
class BlePermissionManager(private val activity: ComponentActivity) {
private val requiredPermissions: Array<String>
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
} else {
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION
)
}
private val permissionLauncher = activity.registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.all { it.value }
if (allGranted) {
callback?.onPermissionsGranted()
} else {
val denied = permissions.filter { !it.value }.keys
val permanentlyDenied = denied.any { permission ->
!ActivityCompat.shouldShowRequestPermissionRationale(
activity, permission
)
}
callback?.onPermissionsDenied(permanentlyDenied)
}
}
private var callback: PermissionCallback? = null
fun checkAndRequestPermissions(callback: PermissionCallback) {
this.callback = callback
val missingPermissions = requiredPermissions.filter {
ContextCompat.checkSelfPermission(activity, it) !=
PackageManager.PERMISSION_GRANTED
}
when {
missingPermissions.isEmpty() -> {
callback.onPermissionsGranted()
}
missingPermissions.any { permission ->
ActivityCompat.shouldShowRequestPermissionRationale(
activity, permission
)
} -> {
callback.onShouldShowRationale {
permissionLauncher.launch(missingPermissions.toTypedArray())
}
}
else -> {
permissionLauncher.launch(missingPermissions.toTypedArray())
}
}
}
fun hasAllPermissions(): Boolean {
return requiredPermissions.all {
ContextCompat.checkSelfPermission(activity, it) ==
PackageManager.PERMISSION_GRANTED
}
}
interface PermissionCallback {
fun onPermissionsGranted()
fun onPermissionsDenied(permanentlyDenied: Boolean)
fun onShouldShowRationale(proceed: () -> Unit)
}
}
Usage in an Activity or Fragment:
class DeviceScanActivity : ComponentActivity() {
private lateinit var permissionManager: BlePermissionManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
permissionManager = BlePermissionManager(this)
}
private fun startScanning() {
permissionManager.checkAndRequestPermissions(
object : BlePermissionManager.PermissionCallback {
override fun onPermissionsGranted() {
// Safe to start BLE scanning
bleScanner.startScan(scanFilters, scanSettings, scanCallback)
}
override fun onPermissionsDenied(permanentlyDenied: Boolean) {
if (permanentlyDenied) {
// User selected "Don't ask again"
// Guide them to app settings
showSettingsDialog()
} else {
showPermissionExplanation()
}
}
override fun onShouldShowRationale(proceed: () -> Unit) {
// Show explanation, then call proceed()
MaterialAlertDialogBuilder(this@DeviceScanActivity)
.setTitle("Bluetooth Permission Required")
.setMessage(
"This app needs Bluetooth access to " +
"find and connect to your device."
)
.setPositiveButton("Grant") { _, _ -> proceed() }
.setNegativeButton("Cancel", null)
.show()
}
}
)
}
private fun showSettingsDialog() {
MaterialAlertDialogBuilder(this)
.setTitle("Permission Required")
.setMessage(
"Bluetooth permission was permanently denied. " +
"Please enable it in app settings."
)
.setPositiveButton("Open Settings") { _, _ ->
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null)
)
startActivity(intent)
}
.setNegativeButton("Cancel", null)
.show()
}
}
Backwards Compatibility: Supporting Android 10 Through 15
Most production BLE apps need to support a range of Android versions. As of early 2026, the Android version distribution for BLE-capable devices in Canada shows significant usage from Android 11 through Android 15, with a long tail back to Android 10. Dropping support for pre-12 devices is not realistic for most apps.
The key to backwards compatibility is a combination of manifest configuration and runtime checks. Here is the complete manifest setup:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- BLE hardware feature -->
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<!-- Android 12+ permissions -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission
android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Legacy permissions, capped at API 30 -->
<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" />
<!-- Only if you need background scanning on Android 10-11 -->
<uses-permission
android:name="android.permission.ACCESS_BACKGROUND_LOCATION"
android:maxSdkVersion="30" />
...
</manifest>
Runtime Permission Branching
Every BLE operation in your code needs to check the correct permission based on the runtime Android version. Here is a utility class that centralizes these checks:
object BlePermissionUtils {
fun hasScanPermission(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(
context, Manifest.permission.BLUETOOTH_SCAN
) == PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
}
fun hasConnectPermission(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(
context, Manifest.permission.BLUETOOTH_CONNECT
) == PackageManager.PERMISSION_GRANTED
} else {
// BLUETOOTH permission is normal (auto-granted) on API 30 and below
true
}
}
fun getScanPermissions(): Array<String> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
fun getConnectPermissions(): Array<String> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_CONNECT)
} else {
emptyArray() // Auto-granted on older versions
}
}
fun getAllBlePermissions(): Array<String> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
}
Common Permission-Related Crashes and How to Fix Them
The migration to Android 12 permissions introduced several new crash scenarios. Here are the ones we see most frequently in crash reports, along with their solutions.
Crash 1: SecurityException on startScan()
The most common crash after targeting SDK 31 is a SecurityException when calling BluetoothLeScanner.startScan() without the BLUETOOTH_SCAN permission:
java.lang.SecurityException: Need android.permission.BLUETOOTH_SCAN permission
for AttributionSource { uid = 10234, packageName = com.example.app, ...}
This happens when you update targetSdkVersion to 31 or higher but forget to add the new permission to the manifest, or when you add it to the manifest but forget to request it at runtime.
The fix is straightforward: ensure the permission is both declared in the manifest and granted at runtime before calling any scan method. Wrap scan calls with a permission check:
fun startBleScan(
scanner: BluetoothLeScanner,
filters: List<ScanFilter>,
settings: ScanSettings,
callback: ScanCallback
) {
if (!BlePermissionUtils.hasScanPermission(context)) {
Log.e(TAG, "Missing scan permission, cannot start scan")
listener?.onScanError(ScanError.PERMISSION_MISSING)
return
}
try {
scanner.startScan(filters, settings, callback)
} catch (e: SecurityException) {
// Defensive catch for edge cases (permission revoked mid-operation)
Log.e(TAG, "SecurityException during scan", e)
listener?.onScanError(ScanError.SECURITY_EXCEPTION)
}
}
Crash 2: SecurityException on connectGatt()
The second most common crash occurs when calling BluetoothDevice.connectGatt() without BLUETOOTH_CONNECT:
java.lang.SecurityException: Need android.permission.BLUETOOTH_CONNECT permission
for AttributionSource { uid = 10234, packageName = com.example.app, ...}
This is easy to miss because on Android 11 and below, connectGatt() did not require any runtime permission (the BLUETOOTH permission was a normal permission, auto-granted at install). Many developers migrated the scan permission but forgot that connection operations also need a new runtime permission.
Every call to the following methods requires BLUETOOTH_CONNECT on Android 12+:
BluetoothDevice.connectGatt()BluetoothDevice.createBond()BluetoothDevice.getName()BluetoothGatt.discoverServices()BluetoothGatt.readCharacteristic()BluetoothGatt.writeCharacteristic()BluetoothGatt.setCharacteristicNotification()BluetoothGatt.readDescriptor()BluetoothGatt.writeDescriptor()BluetoothGatt.requestMtu()BluetoothGatt.disconnect()BluetoothGatt.close()
Yes, even getName() requires BLUETOOTH_CONNECT. If your scan result adapter calls device.name to display device names in a list, and the user has granted BLUETOOTH_SCAN but not BLUETOOTH_CONNECT, you get a crash. Request both permissions together to avoid this.
Crash 3: IllegalArgumentException from neverForLocation Conflict
If you declare BLUETOOTH_SCAN with neverForLocation in your manifest but a library dependency also declares BLUETOOTH_SCAN without the flag, the manifest merger can produce an inconsistent result. On some devices this causes an IllegalArgumentException at startup.
Check your merged manifest (build/intermediates/merged_manifests/) and use the tools:replace attribute if needed:
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:replace="android:usesPermissionFlags" />
Crash 4: Missing Permission Check in Foreground Services
Foreground services that perform BLE operations need the same runtime permissions. A common pattern is to start scanning in a foreground service that was created before the permission model changed. After updating targetSdkVersion to 31, the service crashes because it was never designed to check for runtime Bluetooth permissions.
Always verify permissions at the start of your service's BLE operations, not just in the Activity that starts the service:
class BleScanService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!BlePermissionUtils.hasScanPermission(this)) {
Log.e(TAG, "BLE scan permission not granted, stopping service")
stopSelf()
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, createNotification())
startScanning()
return START_STICKY
}
}
Targeting SDK 31+: The Upgrade Checklist
When you update your app's targetSdkVersion from 30 to 31 or higher, use this checklist to ensure a smooth transition:
- Add new permissions to the manifest. Declare
BLUETOOTH_SCAN(withneverForLocationif applicable) andBLUETOOTH_CONNECT. - Cap legacy permissions with maxSdkVersion. Add
android:maxSdkVersion="30"toBLUETOOTH,BLUETOOTH_ADMIN, andACCESS_FINE_LOCATION(only if location is not needed for other features). - Update runtime permission requests. Branch on
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Sto request the correct permissions for each Android version. - Audit every BLE API call. Search your codebase for all usages of
BluetoothLeScanner,BluetoothDevice,BluetoothGatt, andBluetoothAdapter. Ensure each call is preceded by the appropriate permission check. - Check library dependencies. Some BLE libraries (especially older versions of RxAndroidBle, SweetBlue, and Nordic's libraries) may not handle the new permissions correctly. Update to the latest versions.
- Update your foreground service declarations. If you use foreground services for BLE operations, ensure they declare the
connectedDeviceforeground service type (required on Android 14+):
<service
android:name=".BleConnectionService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
- Test on both old and new Android versions. Run your full BLE flow on an Android 11 device (to verify the legacy permission path) and an Android 12+ device (to verify the new permission path). Do not skip this step.
- Test the "Don't allow" path. Deny the Bluetooth permission on Android 12+ and verify that your app degrades gracefully rather than crashing.
- Test permission revocation. Grant the permission, connect to a device, then revoke the permission in system settings. Your app should handle the resulting
SecurityExceptionwithout crashing.
Android 13, 14, and 15: Incremental Changes
The core permission model introduced in Android 12 has remained stable through subsequent releases, but there are a few incremental changes worth noting:
Android 13 (API 33)
No changes to BLE permissions. However, Android 13 introduced the POST_NOTIFICATIONS runtime permission, which affects BLE apps that use foreground services with persistent notifications. If your foreground service notification is not showing on Android 13+, check that you are requesting POST_NOTIFICATIONS.
Android 14 (API 34)
Android 14 requires foreground services to declare a specific type. BLE services must use foregroundServiceType="connectedDevice" in the manifest. Without this declaration, starting the foreground service throws an IllegalArgumentException.
Additionally, Android 14 enforces that BLUETOOTH_CONNECT is requested at runtime before calling BluetoothAdapter.getProfileProxy(). Some apps used getProfileProxy() for HFP or A2DP integration alongside BLE and did not realize it now requires a runtime permission check.
Android 15 (API 35)
Android 15 added stricter enforcement around background BLE scanning. Apps that start a BLE scan from a foreground service must now have the FOREGROUND_SERVICE_CONNECTED_DEVICE permission in their manifest (in addition to the foreground service type declaration). This permission is auto-granted for most apps, but if you are using a custom permission setup or your app has been restricted by the user, the scan will fail silently.
Also in Android 15, the Bluetooth permission dialog was updated to show more detail about what the app will be able to do with Bluetooth access. This is a visual change only and does not affect the API.
Testing Permissions Across Android Versions
Thorough permission testing requires physical devices or reliable emulators running different Android versions. Here is the testing matrix we use for BLE apps:
- Android 10 (API 29): Verify
ACCESS_FINE_LOCATIONis requested. Verify background scanning works withACCESS_BACKGROUND_LOCATION. - Android 11 (API 30): Same as Android 10. Verify the "only while using the app" location permission option works correctly with BLE scanning.
- Android 12 (API 31): Verify
BLUETOOTH_SCANandBLUETOOTH_CONNECTare requested instead of location. VerifyneverForLocationworks. Verify legacy permissions are not shown. - Android 13 (API 33): Same as Android 12 for BLE permissions. Verify foreground service notification appears (requires
POST_NOTIFICATIONS). - Android 14 (API 34): Verify foreground service type is declared. Verify all GATT operations have connect permission checks.
- Android 15 (API 35): Verify background scanning from foreground services. Verify the
FOREGROUND_SERVICE_CONNECTED_DEVICEpermission is present.
For each version, test the following scenarios:
- Fresh install, grant all permissions on first prompt
- Fresh install, deny permissions, verify graceful degradation
- Fresh install, deny permanently ("Don't ask again"), verify settings redirect
- Grant permissions, use the app, revoke permissions in settings, return to app
- Upgrade from an older targetSdkVersion to 31+ (tests the migration path for existing users)
The Android 12 permission model is a significant improvement for user privacy. The migration requires careful attention to detail, especially around backwards compatibility and the many BLE API calls that now require BLUETOOTH_CONNECT. But once the migration is complete, your users get a cleaner permission experience, and your app no longer needs to explain why a Bluetooth device needs location access.
Need help migrating your BLE app to the latest Android permission model? Talk to DEVSFLOW Neuro. We build BLE-connected mobile apps for neurotechnology and medical device companies across Canada.