Your BLE scan throws a SecurityException on Android 12 and not on 11
By Shailendra Kumar Ram · · 7 min read
The version boundary is the whole diagnosis. If scanning works on Android 11 and throws on 12, nothing is wrong with your Bluetooth code. API level 31 replaced one permission model with another, and the old one stops applying the moment you raise targetSdk. Everything below follows from that single change.
java.lang.SecurityException: Need android.permission.BLUETOOTH_SCAN
permission for AttributionSource { ... }: GattService registerScannerThat message is precise and it is worth reading literally. It does not say the permission is missing from your manifest. It says the caller does not hold it, which on Android 12 means something different from what it meant before.
What Android 12 actually changed
Before API 31, Bluetooth needed BLUETOOTH and BLUETOOTH_ADMIN, which were install-time permissions and therefore invisible, plus a location permission because a scan can be used to infer where someone is. The location requirement confused everyone and users refused it, which is the problem Android 12 set out to fix.
It split the old permissions into three that describe what your app is doing, all of them runtime permissions granted by the user:
| Permission | What it covers | When you need it |
|---|---|---|
| BLUETOOTH_SCAN | Discovering nearby devices | Any startScan call |
| BLUETOOTH_CONNECT | Talking to an already-paired device | connectGatt, bonding, reading device name |
| BLUETOOTH_ADVERTISE | Making this device discoverable | Peripheral mode only |
The trap is that BLUETOOTH_CONNECT is separate from BLUETOOTH_SCAN, and the second crash usually arrives earlier than people expect. Reading device.name to render the scan result list needs BLUETOOTH_CONNECT, even though the user has not connected to anything and is still scanning. So an app that requests only the scan permission crashes while drawing its own device picker. Request both together; there is no realistic flow that needs one without the other.
The manifest that works on both
You need the new permissions unconditionally and the old ones capped at API 30, so they apply on older devices and are ignored on newer ones:
<!-- Android 12 and above -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Android 11 and below only -->
<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 maxSdkVersion on ACCESS_FINE_LOCATION is the line most people leave off, and leaving it off is why an app that has migrated correctly still asks a user on Android 14 for their location before it will scan. The permission is no longer needed there, but an uncapped declaration still triggers the prompt, and the prompt is the thing users decline.
neverForLocation is a product decision
That flag is an assertion to the system that your app will not derive physical location from scan results. Declare it and you are exempt from the location permission entirely, which is the outcome almost everyone wants.
It is not free. Android filters some beacons out of your scan results when the flag is present, because those advertisements are the ones most usable for positioning. If your product does anything with iBeacon or Eddystone payloads, or infers proximity from a fixed installation, test that path specifically before you ship the flag. For everyone connecting to their own peripheral by service UUID, it costs nothing and saves you a permission dialog.
What the flag is not is optional-with-no-consequence. Leave it off and the system assumes you might be deriving location, which puts ACCESS_FINE_LOCATION back in the required set for scanning to return results. That is the position most half-migrated apps end up in: new permissions declared, old location permission uncapped, and a location prompt still standing between the user and a scan.
Requesting at runtime, which is the actual fix
Most crash reports on this end here. The manifest is correct, the flag is set, and nothing ever asked the user:
private val bluetoothPermissions =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
)
} else {
// Pre-12 the location permission is what gates scanning.
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
private val requestPermissions = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted ->
if (granted.values.all { it }) startScan()
else showWhyBluetoothIsNeeded()
}
private fun ensurePermissionsThenScan() {
val missing = bluetoothPermissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) startScan() else requestPermissions.launch(missing.toTypedArray())
}Two details in there matter. The permission array is chosen by build version, because requesting BLUETOOTH_SCAN on Android 11 silently does nothing and requesting ACCESS_FINE_LOCATION on Android 14 prompts for something you do not need. And the result is checked before scanning rather than after, because a denial is a normal outcome rather than an error case.
The one that is not a permission problem at all
If permissions are granted and startScan still returns nothing on Android 12 while returning results on 11, you have hit a different rule that arrived around the same time. A scan with no ScanFilter is stopped by the OS after roughly thirty seconds when the app is not in the foreground, with no callback and no error. Attach a filter on your service UUID and the limit does not apply.
This is worth separating from the permission story because the symptom overlaps and the fix does not. One throws, the other goes quiet. The iOS mirror of the same silent-empty-scan problem has its own causes, covered in why your CoreBluetooth scan finds nothing.
Diagnostic order
- Read the exception. SCAN and CONNECT are different grants, and the message names which one is missing.
- Check targetSdk. Below 31 the old model still applies and none of this is your problem yet. Play Store target requirements will move you eventually.
- Check the runtime request, not the manifest. Declaring is not granting, and this is where most of these end.
- Check for maxSdkVersion="30" on ACCESS_FINE_LOCATION. Its absence is why a correctly migrated app still asks for location.
- If nothing throws but nothing arrives, attach a ScanFilter. That is the thirty second rule, not a permission.
- If beacons specifically vanished, remove neverForLocation and retest. That is the flag doing exactly what it promises, and removing it puts the location permission back in play.
A permission in the manifest is a request for the right to ask. It is not the answer.
Behaviour here moves with each Android release and with each manufacturer's additions on top, so confirm against a real Samsung and a real Pixel rather than an emulator. If you have a Bluetooth app that behaves differently across Android versions and you want a second pair of eyes on it, send over the manifest and a logcat trace and we can work through it.