vital_bluetooth
Bluetooth LE for Flutter, built for wearables and healthcare devices that have to stay connected and keep streaming data, even while the app is in the background or its screen has been closed.
- Background mode. Links survive the app going to the background, the screen turning off, and the app's UI being closed. A dropped link is restored in native code and notifications are turned back on. Values that arrive while no Dart code runs are kept for you.
- Automatic reconnect. Dropped links come back with backoff, and every notification you turned on is turned on again.
- Queued GATT operations. Reads, writes and notification changes run one at a time per device, so concurrent calls never collide.
- Android quirks handled. GATT status 133 is retried, every connection is closed properly, and location being off on Android 7–11 is reported instead of returning an empty scan.
- Typed errors. A sealed exception hierarchy you can
switchover, with the raw native code kept for logging. - State-management friendly. Adapter state, scanning, connection state,
services, MTU and names are
ValueListenables; scans and values areStreams.
Platform support
| Android | iOS | macOS | Windows | |
|---|---|---|---|---|
| Scan, connect, GATT, notifications | ✅ | ✅ | ✅ | ✅ |
| Background mode | ✅ | ✅ | ✅ | ✅ |
| Bonding from the app | ✅ | – | – | ✅ |
| Read RSSI of a connected device | ✅ | ✅ | ✅ | – |
| MTU request | ✅ | auto | auto | auto |
| Connection priority, PHY | ✅ | – | – | – |
| Turn Bluetooth on | ✅ | – | – | ✅ |
– means the platform doesn't let apps do it. "auto" means the system
negotiates the MTU itself; requestMtu returns the current value.
Bluetooth Classic (SPP) is planned.
Minimum versions: Android 7.0 (API 24), iOS 15, macOS 12, Windows 10.
Installation
dependencies:
vital_bluetooth: ^0.1.0
Android
Nothing to add. The plugin's manifest declares:
- Android 12+:
BLUETOOTH_SCAN(withneverForLocation) andBLUETOOTH_CONNECT - Android 11 and lower:
BLUETOOTH,BLUETOOTH_ADMINandACCESS_FINE_LOCATION - Background mode:
FOREGROUND_SERVICE,FOREGROUND_SERVICE_CONNECTED_DEVICE,POST_NOTIFICATIONSand the foreground service
If your app needs to derive location from scans (beacons), remove the
neverForLocation flag in your own manifest:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
tools:remove="android:usesPermissionFlags" />
If your app never uses background mode, you can remove its permissions and service so Google Play doesn't ask you to declare a foreground service:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"
tools:node="remove" />
<service android:name="dev.goodluckcy.vital_bluetooth.VitalBluetoothService"
tools:node="remove" />
The background-mode notification uses a drawable named
vital_bluetooth_notification if your app has one, and the app icon
otherwise.
iOS
Add to ios/Runner/Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Connects to your heart rate monitor.</string>
<!-- Only for background mode: -->
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
macOS
Add NSBluetoothAlwaysUsageDescription to macos/Runner/Info.plist, and
to both DebugProfile.entitlements and Release.entitlements:
<key>com.apple.security.device.bluetooth</key>
<true/>
Windows
Nothing to add.
Quick start
import 'package:vital_bluetooth/vital_bluetooth.dart';
final bluetooth = VitalBluetooth();
// 1. Permissions
final status = await bluetooth.requestPermissions();
if (!status.isGranted) return;
// 2. Scan for heart rate monitors
final result = await bluetooth
.scan(withServices: ['180d'], timeout: const Duration(seconds: 10))
.first;
// 3. Connect (opens the link and discovers services, with retries)
final device = result.device;
await device.connect();
// 4. Subscribe to heart rate measurements
final heartRate = device.characteristic('180d', '2a37')!;
heartRate.values.listen((bytes) => print('HR packet: $bytes'));
await heartRate.setNotify(true);
UUIDs can be written as '180d', '0x180D' or the full 128-bit form.
Staying connected in the background
final bluetooth = VitalBluetooth();
// Keep reconnecting while the app is open, with no attempt limit.
bluetooth.defaultOptions = const ConnectionOptions(
reconnect: ReconnectPolicy.unlimited(),
);
// When the app starts: pick up links that were kept alive while it was
// closed, and store what arrived meanwhile.
final devices = await bluetooth.restoreDevices();
final missed = await bluetooth.takeBackgroundValues();
for (final v in missed) {
saveReading(v.deviceId, v.characteristicUuid, v.value, v.timestamp);
}
// Turn it on while the app is in the foreground.
await bluetooth.enableBackgroundMode(
title: 'Heart rate monitor',
text: 'Connected',
);
What it does on each platform:
- Android runs a foreground service of type
connectedDevicewith an ongoing notification (the system requires one). The process is kept alive, links outlive the app's screen (back button, swiping the app away from recents), and while no screen is open a dropped link is reconnected natively withautoConnect, with no time limit. Values that arrive with no screen open are buffered (the latest 10,000). - iOS suspends apps in the background, so Dart timers stop. A dropped link is therefore reconnected natively right away: iOS keeps the connect pending and wakes the app when the device is back. With state restoration, iOS also relaunches the app in the background for a device event after it was terminated for memory. iOS doesn't relaunch an app the user swiped away. Restoration starts at the launch after background mode was first turned on.
- macOS and Windows don't suspend apps, so links already live as long as the app does.
Usage
Adapter and permissions
ValueListenableBuilder(
valueListenable: bluetooth.adapterState,
builder: (context, state, _) => Text(state.isOn ? 'On' : 'Off'),
);
await bluetooth.turnOn(); // Android shows the system prompt
Scanning
scan() starts when you listen and stops when you cancel, when the timeout
passes, or when the platform ends it. Only one scan runs at a time; starting
another ends the first.
final subscription = bluetooth
.scan(mode: ScanMode.lowLatency, allowDuplicates: true)
.listen((r) => print('${r.name} ${r.rssi} dBm'));
await subscription.cancel(); // stops the scan
Connection state
ValueListenableBuilder(
valueListenable: device.connectionState,
builder: (context, state, _) => Text(state.status.name),
);
Statuses: disconnected, connecting, discovering, ready,
reconnecting, disconnecting.
bluetooth.device(id) returns the same object for the same ID, so a screen
can be closed and opened again without losing the connection. Keep devices
in your state-management layer and call disconnect() only when the user
asks.
Reading and writing
final location = device.characteristic('180d', '2a38')!;
final value = await location.read();
final control = device.characteristic(serviceUuid, controlUuid)!;
await control.write([0x01, 0xA0], withResponse: true);
Every value from valuesWithTime carries the time the platform received
it, which matters for signals like ECG because values reach Dart in
batches:
heartRate.valuesWithTime.listen((v) => print('${v.timestamp}: ${v.value}'));
Reconnect and timeouts
final device = bluetooth.device(
id,
options: const ConnectionOptions(
connectTimeout: Duration(seconds: 15),
connectAttempts: 3,
reconnect: ReconnectPolicy.unlimited(),
),
);
ReconnectPolicy() tries 5 times with exponential backoff (1 s up to
30 s). ReconnectPolicy.none() turns reconnecting off. Options apply when
a device object is first created; set bluetooth.defaultOptions before
scanning to cover devices created from scan results.
MTU, RSSI, bonding
await device.requestMtu(247);
final rssi = await device.readRssi();
await device.createBond();
await device.requestConnectionPriority(ConnectionPriority.high);
Check await bluetooth.getCapabilities() to see what the current platform
supports before offering a feature.
Errors
All errors extend the sealed VitalBluetoothException:
try {
await device.connect();
} on VitalBluetoothException catch (e) {
switch (e) {
case PermissionDeniedException(:final permanentlyDenied):
// ask the user, or send them to the settings
case LocationServicesDisabledException():
// Android 7–11: ask the user to turn location on
case GattException(requiresBonding: true):
await device.createBond();
case _ when e.isRetryable:
// try again later
default:
print(e); // includes the native code, e.g. GATT status 133
}
}
Example app
The example scans for nearby devices, connects, lists services, lets you read, write (hex) and subscribe to any characteristic, and has a background-mode switch that shows what arrived while the app was closed.
License
MIT
Libraries
- vital_bluetooth
- Bluetooth LE for wearables and healthcare devices that stays connected, including in the background.