Flutter Bluetooth Permissions on Android 12+ for Printers

BLUETOOTH_CONNECT is always required, because it is what allows communication with an already-paired device. BLUETOOTH_SCAN is needed only if the app discovers devices, and should carry the neverForLocation flag. BLUETOOTH_ADVERTISE is not needed by a printer app, and ACCESS_FINE_LOCATION should be capped at API 30.
It declares that your app does not derive physical location from Bluetooth scan results, which keeps BLUETOOTH_SCAN out of the location permission bucket. Users then see a Nearby devices prompt instead of a location prompt, and Play Console does not ask you to justify a location feature your printing app does not have.
Almost always because the app targets API 31 or higher while declaring only the legacy BLUETOOTH permissions. Scanning then returns nothing and connecting throws a SecurityException, with no permission prompt visible anywhere. Add the runtime permissions and cap the legacy ones at API 30.
At the moment the user taps something obviously about the printer, such as connect printer in a settings screen — never at app launch. A prompt with no visible cause is the biggest source of permanent denials, and once a permission is permanently denied the system will not show the dialog again.
Entirely. iOS has no equivalent split because it does not expose Bluetooth Classic serial port profile to third-party apps at all; accessories reach it through MFi certification and the External Accessory framework, and everything else uses Bluetooth Low Energy with an Info.plist usage description. That makes iOS printing a product decision, not a permissions one.

Key Takeaway
Android 12 replaced the old BLUETOOTH permissions with runtime BLUETOOTH_SCAN and BLUETOOTH_CONNECT, shown to users as Nearby devices. A receipt-printer app should cap the legacy and location permissions at API 30, declare BLUETOOTH_SCAN with neverForLocation, and request at the moment the cashier connects a printer.
A Flutter POS that printed fine on an older tablet stops finding any printer on a new one, throws a SecurityException in a stack trace nobody reads, and the shop concludes the app is broken. Nine times out of ten the cause is the Android 12 permission split, and the fix is twenty lines of manifest plus one well-timed request.
This is the whole model in one place — what changed, what a printer app actually needs, and why asking for location will cost you time in Play Console review.
Before API 31, Bluetooth was covered by install-time permissions plus a location permission, because scan results could reveal where a user was. From API 31 the capabilities were split into runtime permissions that map to what an app really does.
| Permission | When you need it | Notes for a printer app |
|---|---|---|
| BLUETOOTH_CONNECT | Communicating with an already-paired device | Always required — this is the one that lets you open the connection and print |
| BLUETOOTH_SCAN | Discovering nearby devices | Needed only if you scan; declare it with neverForLocation so it is not a location request |
| BLUETOOTH_ADVERTISE | Making the phone discoverable to others | Not needed by a printer app — do not declare it |
| ACCESS_FINE_LOCATION | Deriving physical location from scan results | Cap at API 30. A receipt printer is not a location feature and Play will ask you to justify it |
The user-visible difference matters as much as the technical one. On Android 12 and later the prompt says Nearby devices, which a cashier understands in the context of a printer. The old flow asked for location, which is exactly the kind of request that gets denied and then permanently denied.
You still need the legacy permissions for older devices, and you need them not to apply on new ones. That is what maxSdkVersion is for.
<!-- android/app/src/main/AndroidManifest.xml -->
<!-- Legacy permissions: capped at API 30 so Android 12+ ignores them -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- Android 12+ runtime permissions -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<!-- A receipt printer is not a location feature. Cap location at 30 so
Play Console never asks you to justify a location permission you
do not use, and users see "Nearby devices" instead. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />Two lines carry most of the value. The neverForLocation flag on BLUETOOTH_SCAN tells Android your scan results are not being used to derive location, which keeps the request out of the location bucket. Capping ACCESS_FINE_LOCATION at API 30 means modern devices never see a location request from a printing app at all.
Declaring ACCESS_FINE_LOCATION without a cap is the most common mistake in this space and the most expensive. It triggers a location-permission review question on Play Console for an app that has no location feature, and it presents cashiers with a prompt whose relationship to printing is invisible.

Permission requests are a user-experience problem disguised as an API. The rule that has worked for me is to request only in direct response to an action whose purpose is obviously the printer, and to handle permanent denial as a distinct state rather than looping.
import 'package:permission_handler/permission_handler.dart';
/// Ask at the moment the cashier taps "connect printer", never at
/// launch. A permission dialog with no visible cause is the single
/// biggest source of permanent denials.
Future<bool> ensureBluetoothPermissions() async {
final statuses = await [
Permission.bluetoothConnect,
Permission.bluetoothScan,
].request();
final granted = statuses.values.every((s) => s.isGranted);
if (granted) return true;
// Permanently denied cannot be re-prompted: the only path forward is
// the system settings screen, so say that plainly instead of looping.
if (statuses.values.any((s) => s.isPermanentlyDenied)) {
await openAppSettings();
}
return false;
}Note what the code does not do: it does not request at app launch, it does not request before the printer settings screen, and it does not retry the request after a permanent denial. Once a permission is permanently denied the system will not show the dialog again, so the only honest response is to explain and offer to open settings.
When a shop reports that the printer cannot be found, these are the causes in the order I check them.
All four produce the same complaint from the shop and have completely different fixes, which is why a printer settings screen that reports each condition separately saves more support time than any amount of retry logic.

On iOS there is no equivalent permission split, because there is no equivalent capability. Apple does not expose Bluetooth Classic serial port profile to third-party apps; accessories reach it through MFi certification and the External Accessory framework, and everything else goes through low energy with an Info.plist usage description.
For a POS product the practical consequence is a product decision, not a code decision: either you require printers that expose a low-energy interface, or you support Android for printing and treat iOS as a view-only client. Deciding that early avoids promising a feature the platform will not grant.
Put a diagnostics row in your printer settings screen showing four booleans: Bluetooth on, permissions granted, device paired, last connection succeeded. Support conversations collapse from twenty minutes to one screenshot.
Before shipping any release that touches Bluetooth, I run these four checks on a physical device.
The Android 12 permission model is friendlier than what it replaced: a printer app can now ask for exactly what a printer needs, and users see a prompt about nearby devices instead of one about their location. Declaring the right four lines and asking at the right moment turns the most common Bluetooth support call into a non-event.