Permissions and setup
Bluetooth fails silently without the right declarations. Do this before anything else.
Install#
flutter pub add flutter_classic_bluetoothimport 'package:flutter_classic_bluetooth/flutter_classic_bluetooth.dart';Android#
Android split its Bluetooth permissions at API 31, so a build that covers both old and new devices needs both sets. You do not have to add them: they ship in the plugin's own manifest and are merged into your app for you, already scoped so an app targeting Android 12 or later requests no location permission at all. This is what gets merged in.
<!-- 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" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="30" />
<!-- Android 12 (API 31) and above -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />Two things trip people up here. On Android 11 and below, scanning requires location permission, because a nearby-device list can be used to infer where someone is. And on Android 12 and above, BLUETOOTH_SCAN and BLUETOOTH_CONNECT are runtime permissions, so declaring them is not enough. The plugin requests those for you when you call a method that needs one, or you can ask up front.
neverForLocation tells Android you are not using scan results to derive location, which keeps the permission prompt narrower. Bluetooth Classic serial never does, so it is set for you.
Re-declaring these in your own manifest is unnecessary. If you do declare one, your value is what ends up in the merged manifest, so declare it capped or leave it out. A permission only the plugin declares flows through exactly as the plugin declares it, which is why ACCESS_COARSE_LOCATION used to reach every app uncapped even when the app had scoped its own location permission correctly. If your app genuinely does derive location from scans, drop the flag on your own declaration with tools:remove="android:usesPermissionFlags".
iOS#
iOS only reaches MFi certified accessories, and only ones whose protocol string you declare up front. Add to ios/Runner/Info.plist:
<key>UISupportedExternalAccessoryProtocols</key>
<array>
<string>com.example.spp</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app communicates with Bluetooth accessories.</string>Replace com.example.spp with the protocol string your accessory vendor gave you. An accessory whose string is not listed is invisible to your app, and there is no way around that from Dart. A generic HC-05 or ESP32 will not work on iOS at all, because neither is MFi certified.
macOS#
Add the entitlement to both macos/Runner/DebugProfile.entitlements and Release.entitlements:
<key>com.apple.security.device.bluetooth</key>
<true/>Sandboxed builds silently see no devices without it.
Linux#
The native plugin compiles against GTK and BlueZ. Missing headers show up as a CMake error mentioning gtk+-3.0 or bluetooth/bluetooth.h, not as a runtime failure.
sudo apt-get install -y libgtk-3-dev libbluetooth-dev ninja-build cmake pkg-config clangOn Fedora: gtk3-devel bluez-libs-devel ninja-build cmake clang. On Arch: gtk3 bluez-libs ninja cmake clang.
Windows#
Nothing to declare. The plugin uses Winsock2 AF_BTH sockets, which need no manifest entry or capability.
Asking at a moment that makes sense#
Every call that needs a permission requests it on its own, so the plugin works with no permission code at all. The cost is that the first scan raises a system dialog out of nowhere, with no explanation of why the app wants it, which is the version users refuse.
Ask on your own terms instead.
switch (await bluetooth.checkPermissions()) {
case BtcPermissionStatus.granted:
case BtcPermissionStatus.notRequired:
startScanning();
case BtcPermissionStatus.denied:
// The system will still prompt. Explain why, then ask.
if (await bluetooth.requestPermissions() == BtcPermissionStatus.granted) {
startScanning();
}
case BtcPermissionStatus.permanentlyDenied:
// The system has stopped asking. Only settings can change it.
await bluetooth.openAppSettings();
}checkPermissions never prompts, so it is safe to call while building a screen.
Ask only for what you use#
Android 12 replaced one Bluetooth permission with three, each covering different calls. An app that talks to a device the user already paired does not need permission to scan, and asking for it means a broader prompt than the app has earned.
// A printer app that works from the paired list.
await bluetooth.checkPermissions(permissions: {BtcPermission.connect});
// A scanner that never connects.
await bluetooth.requestPermissions(permissions: {BtcPermission.scan});The default is {BtcPermission.scan, BtcPermission.connect}, which is what a discover-then-connect app needs.
| Android 12+ | Android 7 to 11 | iOS | Windows, macOS, Linux | |
|---|---|---|---|---|
scan | BLUETOOTH_SCAN | location permission, plus the toggle | one Bluetooth grant | notRequired |
connect | BLUETOOTH_CONNECT | nothing at runtime | one Bluetooth grant | notRequired |
advertise | BLUETOOTH_ADVERTISE | nothing at runtime | one Bluetooth grant | notRequired |
Below Android 12 only scanning was ever gated, and it was gated by location rather than by Bluetooth. Connecting and advertising were granted at install time, so on those versions asking for them is a no-op that correctly reports granted.
The location toggle, and why a scan finds nothing#
This is the least obvious way Bluetooth fails on Android. On Android 11 and below, discovery needs the system location toggle switched on, over and above the permission. With the permission granted and the toggle off, startDiscovery succeeds, reports no error, and simply never finds a device.
There is no in-app prompt for it, because it is a system-wide setting rather than an app permission. All you can do is detect it and point the user at the screen.
if (await bluetooth.isLocationServiceRequired() &&
!await bluetooth.isLocationServiceEnabled()) {
// Explain first, then offer the settings screen.
await bluetooth.openLocationSettings();
}isLocationServiceRequired is false on Android 12 and above and on every other platform, and isLocationServiceEnabled reports true wherever the setting does not apply, so that check reads correctly everywhere without a platform test.
The four statuses#
| Status | Meaning | What to do |
|---|---|---|
granted | Everything asked for is held | Carry on |
denied | Not held, but the system will still prompt | Show a reason, then request |
permanentlyDenied | The system has stopped prompting | Offer openAppSettings |
notRequired | No runtime permission exists here | Treat as granted |
Only Android and iOS report the first three. Windows, macOS and Linux always report notRequired, because they decide Bluetooth access at build time through a manifest entry, an entitlement or the system's D-Bus policy. Writing against the status rather than against the platform name means one code path covers all five.
Android is where this matters most: a refusal there blocks scanning and connecting outright. On iOS the permission governs CoreBluetooth, which this plugin uses only to read adapter state, so a refusal makes adapterState report unauthorized but does not stop you reaching an accessory. MFi access is gated by the protocol strings you declared, not by this permission.
Permanent denial#
Android reaches permanentlyDenied after a second refusal, or one with "don't ask again" selected. iOS reaches it on any refusal, since it never re-prompts. From there requestPermissions returns immediately without showing anything, so a retry button is a dead end.
final status = await bluetooth.requestPermissions();
if (status == BtcPermissionStatus.permanentlyDenied) {
await bluetooth.openAppSettings();
}openAppSettings backgrounds your app, and the user can come back without having changed anything, so re-check on resume rather than assuming the trip worked.
@override
void didChangeAppLifecycleState(AppLifecycleState state) async {
if (state != AppLifecycleState.resumed) return;
final status = await bluetooth.checkPermissions();
if (mounted) setState(() => permission = status);
}Check before you call#
Once setup is done, confirm the adapter is actually usable rather than assuming it.
final bluetooth = FlutterClassicBluetooth();
if (!await bluetooth.isSupported()) {
// No Bluetooth Classic radio, or an unsupported platform.
return;
}
if (!await bluetooth.isEnabled()) {
final caps = await bluetooth.getPlatformCapabilities();
if (caps.canEnableBluetooth) {
await bluetooth.enableBluetooth(); // Android and Linux
} else {
// Ask the user to turn it on in system settings.
}
}