Hardware companion apps are one of the most common requests we get that nobody writes tutorials about. A client has a sensor, a medical device, a lock, a scale, an agricultural controller — and they need a phone app that pairs with it, streams readings, pushes firmware, and keeps working in a warehouse with no signal. Flutter is a good fit: one codebase, native BLE stacks underneath, and a UI layer that handles streaming data well.
It is also the area where teams lose the most time, because Bluetooth Low Energy on mobile is not an API problem — it is a state machine and permissions problem. iOS and Android disagree about scanning, permissions, reconnection, and what your app is allowed to do in the background. This tutorial walks the whole path: permissions on Android 12+ and iOS 17+, scanning without burning battery, a connection state machine that survives a device walking out of range, GATT reads/writes/notifications, MTU and throughput, background behaviour, and how to test any of it in CI when the hardware lives on someone's desk.
The code targets flutter_blue_plus 1.3x on Flutter 3.4x. The architecture applies equally if you are on universal_ble, a vendor SDK wrapped in platform channels, or dart:ffi bindings to a native stack.
Pick the plugin before you design the architecture
Three realistic options in 2026:
| Option | Use when |
|---|---|
flutter_blue_plus | Generic GATT peripherals. Best maintained general-purpose choice; central (client) role only. |
universal_ble | You also need Flutter web/desktop BLE from the same code. |
| Vendor SDK + platform channels | The device maker ships an Android/iOS SDK that does encryption, OTA, or proprietary pairing. Do not reimplement their protocol over raw GATT. |
If your device needs the phone to advertise (peripheral role), no mainstream Flutter plugin covers it well — budget native code.
dependencies:
flutter_blue_plus: ^1.34.5
permission_handler: ^11.3.1
rxdart: ^0.28.0
Permissions: the part that fails in review, not in dev
Android
Android 12 (API 31) split Bluetooth permissions and introduced neverForLocation. Declare both the legacy and modern sets or you will crash on old OS versions:
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- 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" />
Two traps:
- If you claim
neverForLocationyou must actually not derive location from beacons — and you must not filter on iBeacon-style manufacturer data that encodes position. In exchange you skip the location permission prompt, which materially improves opt-in rates. - If you do need location (beacon ranging), Play Store review will ask you to justify it in the data safety form. Get that answer from the client in writing before you ship.
iOS
<!-- ios/Runner/Info.plist -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>PumpLink connects to your device to read live sensor data and install updates.</string>
Write a real sentence naming the device and the benefit. Generic strings ("This app uses Bluetooth") are a routine App Review rejection. NSBluetoothPeripheralUsageDescription is only needed if you support iOS 12 or earlier.
Requesting at runtime
Ask at the moment of value — when the user taps "Connect device" — not on first launch.
Future<bool> ensureBlePermissions() async {
if (Platform.isAndroid) {
final statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
].request();
return statuses.values.every((s) => s.isGranted);
}
// iOS: the system prompts on first CBCentralManager use.
final adapterState = await FlutterBluePlus.adapterState
.firstWhere((s) => s != BluetoothAdapterState.unknown);
return adapterState == BluetoothAdapterState.on;
}
Always render a path for "permanently denied" that deep-links into Settings. On a hardware app, a denied Bluetooth permission is a dead app, not a degraded one.
Scanning: filter in the OS, not in Dart
The single biggest battery and reliability win is letting the platform filter advertisements by service UUID. Unfiltered scans wake your process for every phone, TV, and pair of earbuds in the room.
final serviceUuid = Guid('0000fee0-0000-1000-8000-00805f9b34fb');
Stream<List<ScanResult>> scanForDevices() {
FlutterBluePlus.startScan(
withServices: [serviceUuid],
timeout: const Duration(seconds: 15),
androidScanMode: AndroidScanMode.lowLatency,
);
return FlutterBluePlus.scanResults;
}
Rules we apply on every engagement:
- Always set a timeout. A scan with no timeout is a battery bug waiting for a one-star review.
lowLatencyonly while a scan sheet is visible. Usebalancedfor background or passive discovery.- Stop scanning before connecting. On Android, connecting while a scan is running is a well-known source of
GATT_ERROR(133). - De-duplicate and sort by RSSI so the device on the bench appears at the top rather than the neighbour's.
If the device is already known, skip discovery entirely. On iOS you reconnect by the system-assigned identifier; on Android by MAC address. Persist whichever you get and treat it as opaque:
final saved = prefs.getString('device_id');
if (saved != null) {
final device = BluetoothDevice.fromId(saved);
await device.connect(timeout: const Duration(seconds: 10));
}
A connection state machine you can reason about
Most BLE bugs come from code that treats "connected" as a boolean. Real life has at least six states, and the transitions are what the UI needs to show.
enum LinkState {
idle, // nothing attempted
scanning,
connecting,
discovering, // GATT service discovery
ready, // services resolved, notifications subscribed
reconnecting, // dropped, trying again with backoff
failed, // needs user action
}
class DeviceLink {
DeviceLink(this._device);
final BluetoothDevice _device;
final _state = BehaviorSubject<LinkState>.seeded(LinkState.idle);
StreamSubscription<BluetoothConnectionState>? _connSub;
int _attempt = 0;
Stream<LinkState> get state => _state.stream;
Future<void> open() async {
_connSub ??= _device.connectionState.listen(_onConnectionState);
await _connect();
}
Future<void> _connect() async {
_state.add(_attempt == 0 ? LinkState.connecting : LinkState.reconnecting);
try {
await _device.connect(
timeout: const Duration(seconds: 10),
autoConnect: false,
);
} catch (_) {
_scheduleRetry();
}
}
Future<void> _onConnectionState(BluetoothConnectionState s) async {
if (s == BluetoothConnectionState.connected) {
_attempt = 0;
_state.add(LinkState.discovering);
await _negotiate();
_state.add(LinkState.ready);
} else if (s == BluetoothConnectionState.disconnected) {
_scheduleRetry();
}
}
void _scheduleRetry() {
if (_attempt >= 6) {
_state.add(LinkState.failed);
return;
}
final delay = Duration(milliseconds: 500 * (1 << _attempt));
_attempt++;
_state.add(LinkState.reconnecting);
Timer(delay, _connect);
}
Future<void> dispose() async {
await _connSub?.cancel();
await _device.disconnect();
await _state.close();
}
}
Exponential backoff with a cap and a terminal failed state is the whole trick. Tight retry loops drain the battery and, on Android, get your app throttled by the scan/connect rate limiter.
autoConnect: true deserves a note: it is slow to establish (the radio waits for a low-duty-cycle advertisement) but survives the device being out of range for hours, and on Android it resumes after Bluetooth is toggled. We use autoConnect: false for the first user-initiated connect and true for long-lived background links.
GATT: discover once, cache the characteristics
Service discovery is expensive — hundreds of milliseconds to several seconds. Do it once per connection and keep references.
late BluetoothCharacteristic _measurement; // notify
late BluetoothCharacteristic _command; // write
Future<void> _negotiate() async {
if (Platform.isAndroid) {
await _device.requestMtu(247); // ATT payload becomes 244 bytes
await _device.requestConnectionPriority(
connectionPriorityRequest: ConnectionPriority.high,
);
}
final services = await _device.discoverServices();
final svc = services.firstWhere((s) => s.uuid == serviceUuid);
_measurement = svc.characteristics
.firstWhere((c) => c.uuid == Guid('0000fee1-...'));
_command = svc.characteristics
.firstWhere((c) => c.uuid == Guid('0000fee2-...'));
await _measurement.setNotifyValue(true);
}
Four details that separate a demo from a shippable app:
- MTU. The default 23-byte ATT MTU gives you 20 usable bytes per packet. Negotiating 247 on Android roughly 12×'s throughput. iOS negotiates automatically; never hardcode 244 as an assumption — read the resulting MTU and chunk accordingly.
- Write type.
write(value, withoutResponse: true)is dramatically faster but unacknowledged. Use it for streaming and commanded writes for anything that changes device state. - Notify vs. indicate. Notifications are fire-and-forget; indications are acknowledged. Firmware usually dictates which, and if you subscribe to the wrong one you get silence.
- Serialize everything. Android's BLE stack processes one GATT operation at a time. Concurrent reads and writes produce
GATT_ERROR. Put every operation behind a queue:
final _gattLock = Lock(); // package:synchronized
Future<T> _gatt<T>(Future<T> Function() op) => _gattLock.synchronized(op);
Turn the byte stream into typed domain events
Never let widgets see List<int>. Parse at the edge, emit domain objects, and fail loudly on malformed frames.
class Reading {
const Reading(this.timestamp, this.celsius, this.batteryPct);
final DateTime timestamp;
final double celsius;
final int batteryPct;
/// Frame: [0]=version, [1..2]=temp centi-degrees (LE int16), [3]=battery
static Reading? parse(List<int> raw) {
if (raw.length < 4 || raw[0] != 0x01) return null;
final bytes = Uint8List.fromList(raw).buffer.asByteData();
return Reading(
DateTime.now(),
bytes.getInt16(1, Endian.little) / 100.0,
raw[3].clamp(0, 100),
);
}
}
Stream<Reading> get readings => _measurement.onValueReceived
.map(Reading.parse)
.whereType<Reading>();
Endianness is the number one interop bug with embedded firmware. BLE is little-endian by convention; ByteData defaults to big-endian. Write a unit test per frame type against real captured bytes — those tests cost minutes and catch field failures.
High-rate notifications (100 Hz accelerometer data, for example) will also melt your UI if each one triggers a rebuild. Buffer before you render:
readings.bufferTime(const Duration(milliseconds: 100))
.where((batch) => batch.isNotEmpty)
.listen(_store.appendBatch);
Persist batches to SQLite and let the widget tree stream from the database. That pairs exactly with the offline-first pattern: the device fills a local store, and sync uploads it later.
Background behaviour: what each platform actually allows
This is where expectations need managing early, ideally in the proposal.
iOS. Add the background mode and you keep a connection alive and receive notifications while backgrounded or even after the app is killed by the system:
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
Limits: you cannot scan for arbitrary devices in the background with any useful duty cycle; background scans must specify service UUIDs, are heavily throttled, and your process is relaunched with limited time to act. Long transfers in the background are unreliable.
Android. A connection survives backgrounding, but Doze and app standby will eventually suspend your process. For anything that must keep running — a continuous logging session, a firmware update — run a foreground service with a visible notification and the connectedDevice type:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
Android 14+ requires the typed permission and a declared type on the service, and Play reviewers check that the type matches actual behaviour.
Either way: design so that a dropped background connection is recoverable, not lossy. The device should buffer readings in its own flash and replay them on reconnect. If the firmware cannot do that, raise it as a requirement while the hardware is still changeable.
Firmware updates (OTA/DFU)
If the client mentions OTA, scope it separately. Real DFU protocols — Nordic DFU, Silicon Labs, ESP32 OTA — are stateful multi-stage transfers with bootloader mode switches, and a failed update can brick a unit.
Practical guidance:
- Use the vendor's library (
nordic_dfuwraps the official Android/iOS libs) rather than pushing packets over raw GATT. - Require battery above a threshold on both phone and device before starting.
- Keep the screen awake (
WakelockPlus.enable()) and show real progress; a transfer can take minutes. - Make the operation idempotent and resumable, and always verify the reported firmware version after reboot rather than assuming success.
Testing without the hardware on your desk
Hardware is scarce, CI has no radio, and QA is in another city. Three layers solve this.
1. Abstract the transport. Your app should depend on an interface, not on flutter_blue_plus:
abstract interface class DeviceTransport {
Stream<LinkState> get state;
Stream<List<int>> get inbound;
Future<void> send(List<int> frame);
}
2. Write a fake peripheral. A few hundred lines of Dart that emits realistic frames — plus the nasty cases: mid-session disconnects, truncated frames, wrong version bytes, 30-second silences. Every bug found in the field becomes a new scenario in the fake. This suite runs in CI on every PR with no radio at all.
class FakePump implements DeviceTransport {
final _in = StreamController<List<int>>.broadcast();
@override Stream<List<int>> get inbound => _in.stream;
void emitReading(double c, {int battery = 90}) {
final b = ByteData(4)
..setUint8(0, 0x01)
..setInt16(1, (c * 100).round(), Endian.little)
..setUint8(3, battery);
_in.add(b.buffer.asUint8List());
}
void dropLink() => _state.add(LinkState.reconnecting);
}
3. Keep a small hardware smoke test. A scripted 15-minute manual pass — pair, stream, walk out of range, return, background for five minutes, force-quit, relaunch — run against real units before each release. Automating this is rarely worth it; skipping it always costs more.
Log every GATT operation with timestamps, and ship those logs with crash reports. When a client says "it disconnects sometimes", a timeline of connect/discover/notify/disconnect events with error codes is the difference between a fix and a week of guessing.
A pre-ship checklist
- Android 12+ and legacy permission sets both declared;
neverForLocationclaim is honest. - iOS usage string names the device and the benefit.
- Every scan has a timeout; scanning stops before connecting.
- Reconnection uses capped exponential backoff and a terminal failed state with a clear UI.
- All GATT operations serialized through one queue.
- MTU negotiated, resulting value read, payloads chunked to it.
- Frame parsers unit-tested against captured bytes, including malformed frames.
- High-rate notifications buffered; persistence off the UI thread.
- Background strategy chosen per platform and documented; data loss on a dropped link is recoverable.
- Fake peripheral suite in CI; hardware smoke test scripted for release.
Where teams get stuck
The pattern we see is that the Flutter work is fine and the contract with the firmware is not: undocumented frame layouts, characteristics that behave differently across firmware revisions, no buffering on the device, no version negotiation. Write the protocol down — characteristic table, frame layouts, endianness, error codes, version handshake — and get the firmware team to sign it. That document saves more schedule than any Dart refactor.
If you are building a BLE companion app and want a team that has shipped this stack before — including the App Review and Play data-safety conversations — get in touch. We also place senior Flutter developers directly with product teams and agencies who need the capacity for a hardware engagement.