Cross-platform Bluetooth Low Energy for Dart and Flutter, wrapping the Rust
btleplug host/central library. The
native build requires Rust in addition to Dart or Flutter. Precompiled
binaries are planned to remove that build requirement.
This is a melos monorepo:
| Package | What it is |
|---|---|
packages/bluetooth_dart |
Pure Dart (no Flutter); works in CLI tools. Reaches native BLE over dart:ffi and, on the web, uses Web Bluetooth instead, behind a pluggable backend registry. |
packages/bluetooth_flutter |
Flutter plugin. Builds the native library automatically (via cargokit) and re-exports the bluetooth_dart API. |
packages/bluetooth_flutter/rust |
The bluetooth_core Rust crate (the btleplug wrapper behind the FFI), kept inside the plugin so the published package can build it. Owns a tokio runtime, drains BLE events into a pollable queue, and exposes a small C ABI. |
native/bluetooth_cli |
Thin Rust binary over bluetooth_core for driving real hardware directly (scan, permission). |
BLE only: btleplug is host/central mode and does not support Bluetooth
Classic.
import 'package:bluetooth_dart/bluetooth_dart.dart';
final bt = Bluetooth.instance;
await bt.requestPermission();
final scan = await bt.startScan();
scan.devices.listen((d) => print('${d.id} ${d.name ?? '(unknown)'} ${d.rssi} dBm'));
await Future<void>.delayed(const Duration(seconds: 5));
await scan.stop();Build the native library first:
cargo build --manifest-path packages/bluetooth_flutter/rust/Cargo.toml.
| Host | Status |
|---|---|
| macOS | Tested |
| Ubuntu 24.04 | Tested |
| Android | Tested |
| iOS | Tested |
| iOS Simulator | Untested |
| Android Emulator | Untested |
| Windows 11 | Tested |
| Web (Chromium) | Tested |
| Web (Firefox/Safari) | Unsupported |
macOS, Ubuntu 24.04, Android, iOS, and Windows 11 are tested. On the web, BLE
goes through Web Bluetooth, which only Chromium browsers (Chrome/Edge/Brave/Opera)
implement; Firefox and Safari do not expose navigator.bluetooth at all. See
Web below for the per-OS browser flags Web Bluetooth needs.
bluetooth_core (Rust) owns a tokio runtime and the BLE adapter, drains
btleplug's event stream into a queue, and exposes a small synchronous, pollable
C ABI. bluetooth_dart binds that ABI over dart:ffi and drains it with a poll
timer into Dart streams, behind a pluggable backend registry.
bluetooth_flutter builds the Rust crate automatically (cargokit) and re-exports
the Dart API. bluetooth_cli is a thin binary over the same core for testing on
real hardware. BLE payloads cross the ABI as JSON.
The web is the exception: there is no Rust/FFI there. bluetooth_dart registers
a Web Bluetooth backend (over navigator.bluetooth via dart:js_interop) behind
the same backend registry, so the FFI layer is never compiled into web builds.
Beyond Dart/Flutter the only requirement is Rust (cargokit builds the native
library from source until precompiled binaries are published). On Apple
platforms the plugin links the CoreBluetooth framework; declare
NSBluetoothAlwaysUsageDescription (and the macOS Bluetooth entitlement) in your
app, as the example does.
On Android the plugin bundles btleplug's droidplug Java/JNI companion and
declares the Bluetooth permissions, but scanning needs them granted at runtime:
BLUETOOTH_SCAN and BLUETOOTH_CONNECT on API 31+, or ACCESS_FINE_LOCATION
(with location services enabled) on API 30 and below. Request them before
scanning, as the example does with permission_handler.
The toolchain works from a Windows host as well: the Rust core builds with the
MSVC toolchain, the Windows desktop target links via Visual Studio's CMake, and
Android builds cross-compile through cargokit using the Android NDK (cargokit
adds the Rust Android targets through rustup as needed).
Web builds use Web Bluetooth instead of the native library, so they need no Rust.
Building and running is flutter build web or flutter run -d chrome. Web is on
by default in current Flutter; if flutter devices does not list Chrome, enable
it once in the SDK with flutter config --enable-web. A few properties of Web
Bluetooth shape how this works.
Only Chromium browsers (Chrome, Edge, Brave, Opera) implement it. Firefox and
Safari do not expose navigator.bluetooth, and neither does any browser on iOS
(they are all WebKit underneath), so on those the registry falls back to the
unsupported floor and scans yield nothing. The API is also restricted to
secure contexts (HTTPS or http://localhost), and scanning has to begin from a
user gesture, so call startScan from a tap or click handler. The example does
this from its Scan button.
Because what the user must do differs by browser (usually nothing in
Chrome/Edge/Opera, enable a flag on Linux or in Brave, switch browsers from
Firefox, Safari, or iOS), bluetooth_dart exposes a small helper for tailoring
guidance: detectWebBrowser() identifies the WebBrowser, and
webBluetoothGuidance(status) returns a per-browser message, a settings URL
where one applies (e.g. Brave's flag), and ready. Gate scanning on ready
rather than status.isUsable: when the page has no Web Bluetooth the registry
falls back to the unsupported floor, whose status also counts as usable. Off the
web both degrade to a generic/unknown result, so they are safe to call from
cross-platform code. The example renders this in its permission banner.
The default scan uses the browser's device chooser (requestDevice). The browser
shows its own native picker, which scans internally, and returns whichever device
the user selects; the scan session ends when the chooser closes. That is the path
Chromium supports best across platforms. To collect several devices, scan once
per device.
The browser only grants GATT access to services named in the chooser request.
Pass the ones you need as serviceUuids (which also filter the chooser), and
services a device has but does not advertise as optionalServices on a
WebBluetoothBackend you register yourself (as in the preferLeScan snippet
below). Short (180f) and full UUIDs, in any case, and GATT names such as
battery_service are all accepted. Unlike the native backends, a read of a
characteristic you are subscribed to also arrives on its notification stream.
Web Bluetooth is enabled by default on Android Chrome and on Chrome OS. On desktop Linux it sits behind a flag; on Windows and macOS it is usually on already but can be turned back on the same way.
| Need | Flag |
|---|---|
Device chooser (requestDevice), the default here |
chrome://flags/#enable-web-bluetooth, or launch with --enable-features=WebBluetooth |
Streaming advertisement scan (requestLEScan), opt-in |
chrome://flags/#enable-experimental-web-platform-features |
# Run the example on Linux Chrome (chooser-based scan):
flutter run -d chrome --web-browser-flag=--enable-features=WebBluetoothThe streaming requestLEScan API is experimental. On Linux/BlueZ it reports the
scan as active yet never delivers advertisements, which is why the chooser is the
default. If you still want streaming where it works, register a backend that
prefers it:
// WebBluetoothBackend is exported on every platform, but off the web it is a
// stub that is never available, so guard this with `kIsWeb` in an app that
// also targets native.
Bluetooth.instance.registerBackend(
WebBluetoothBackend(preferLeScan: true), makeActive: true,
);Driven by melos (config lives in the root
pubspec.yaml). After dart pub get (or flutter pub get):
dart run melos run native:build # build the Rust shared library
dart run melos run native:scan # scan for nearby BLE devices (real hardware)
dart run melos run analyze # analyze bluetooth_dart + bluetooth_flutter
dart run melos run format # check Dart formatting (format:fix to apply)
dart run melos run test # Dart + Flutter tests
dart run melos run native:test # Rust crate testsThe unit tests run without hardware. To validate the full GATT loop
(scan -> connect -> discover -> subscribe -> write -> notify) against a real
peripheral, see the hardware integration test in
packages/bluetooth_dart/test/integration;
it is skipped unless BLE_TEST_DEVICE is set.