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.
What Android 12 changed
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.
The manifest that works on every Android version
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.

Requesting at the right moment
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.
Four failures that look like a broken printer
When a shop reports that the printer cannot be found, these are the causes in the order I check them.
- The app targets API 31 or higher but only declares the legacy permissions. Scanning returns nothing and connecting throws, with no visible prompt anywhere.
- Permissions were denied once during onboarding, before the user knew what the app did. Everything after that looks like a hardware fault.
- The printer is not paired at the operating-system level. Classic serial devices must be bonded in Android settings; the app can only connect to what is already there.
- Bluetooth itself is off. Check adapter state explicitly and say so, rather than reporting a generic connection failure.
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.

iOS is a different question entirely
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.
A pre-release checklist
Before shipping any release that touches Bluetooth, I run these four checks on a physical device.
- Install fresh on an Android 12 or newer device, deny the permission once, and confirm the app explains rather than crashes.
- Deny permanently, then confirm the app offers to open settings instead of silently failing.
- Run on an Android 11 device and confirm the legacy path still pairs and prints.
- Check the merged manifest in the build output, not your source manifest — a dependency can add a permission you never declared.
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.

