flutter_uwb — Ultra-wideband proximity for Flutter

flutter_uwb pub.dev pub.dev points platforms license

Features

  • Distance + direction — centimeter-level distance and azimuth/elevation when the hardware supports it.
  • Discovery + pairing built in — peers find each other over BLE / MultipeerConnectivity, and the plugin runs the UWB token exchange end-to-end. No QR codes, no hand-rolled signalling.
  • One Dart API, two platforms — same surface for Android (androidx.core.uwb) and iOS (NearbyInteraction).
  • Apple FiRa accessories — talk to Qorvo, NXP, and other certified UWB tags from iOS out of the box.
  • End-to-end encrypted — every Android↔Android session uses a fresh per-pair key; iOS rides Apple's protected discovery channels.
  • Production-ready — readiness checks tell you what's supported and which permissions to ask for; typed error codes tell you exactly why something failed, so apps can recover gracefully instead of showing platform stack traces.
  • Streams everywhere — discovery, ranging samples, errors, and lifecycle events as Streams you can plug into any state-management solution.

What ranges against what

Pair Status
iPhone ↔ iPhone ✅ Stable
Android ↔ Android ✅ Stable
iPhone ↔ Apple-FiRa accessory (Qorvo, NXP, MFi tag) ✅ Stable
iPhone ↔ Android (cross-OS) ❌ Not supported (see Architecture)

Platform support

Platform Minimum hardware Notes
Android Pixel 6 Pro+, Galaxy S21 Ultra+, or any device exposing FEATURE_UWB Android 14+ recommended for stable UwbAvailabilityCallback behavior.
iOS iPhone with U1/U2 chip (iPhone 11+, excluding SE 2/3) on iOS 16+ Camera assist & extended distance gated by RangingOptions. iOS 14/15 hosts must pin to 0.3.1.

isUwbAvailable() returns false on emulators, the iOS simulator, and devices without a UWB chip — always check it before calling discovery.

iOS 26 / U2 chip caveat. Apple disabled supportsDirectionMeasurement for the U2 chip on iOS 26, so iPhone 15 Pro / Pro Max and the iPhone 16 series report null for azimuthDegrees and elevationDegrees. Distance is unaffected.

Installation

flutter pub add flutter_uwb

Requires Flutter >=3.22 and Dart >=3.3.

Quick start

Both peers run the same code. Pick a unique localName for each side.

import 'package:flutter_uwb/flutter_uwb.dart';

final uwb = FlutterUwb.instance;

if (!await uwb.isUwbAvailable()) return;

// Acceptor side — auto-respond when a peer initiates pairing.
uwb.incomingRequests.listen((req) async {
  final myToken = await uwb.getLocalToken(UwbRole.controlee);
  await uwb.acceptRequest(req.device.id, myToken);
  await uwb.startRanging(req.device.id);
});

uwb.rangingSamples.listen((s) {
  print('${s.distanceMeters.toStringAsFixed(2)} m  '
        '${s.azimuthDegrees?.toStringAsFixed(1)}°');
});

await uwb.startDiscovery('phone-A');

// Initiator side — call from your UI when the user picks a peer.
Future<void> pairAndRange(UwbDevice device) async {
  // Accessories handshake via the Apple NI Accessory Protocol — skip pairWith.
  if (!device.platform.startsWith('accessory')) {
    await uwb.pairWith(device.id);
  }
  await uwb.startRanging(device.id);
}

When you're done:

await uwb.stopRanging();
await uwb.stopDiscovery();

Pairing is asymmetric: one side calls pairWith (the initiator); the other side's incomingRequests stream fires and that side calls acceptRequest. Both sides then call startRanging. Trigger the initiator from your own UI — a button, a QR scan, a server event, whatever fits.

Apple-FiRa accessories (iOS only)

To range against a Qorvo, NXP, or third-party Apple-FiRa tag, register the vendor's BLE service triplet before startDiscovery:

await uwb.registerAccessoryProfile(
  serviceUuid: '<accessory service UUID>',
  rxUuid:      '<accessory rx UUID>',
  txUuid:      '<accessory tx UUID>',
  vendorTag:   'my-tag', // optional — surfaces as `accessory:my-tag`
);

The accessory shows up in deviceFound with device.platform == 'accessory:my-tag'. The Quick Start's pairAndRange already handles it — the platform check skips pairWith and lets startRanging drive Apple's NI Accessory Protocol. Calling registerAccessoryProfile on Android throws UwbException (iOS-only in 1.0.0).

See example/lib/main.dart for a working Qorvo DWM3001CDK profile.

A complete runnable demo lives in example/.

API

Stream Fires when
deviceFound A new peer is discovered via BLE / MPC
deviceLost A previously-discovered peer disappears
incomingRequests A peer sends us their UWB token; reply with acceptRequest or declineRequest
rangingSamples A new RangingSample arrives from the active session
peerLost The ranging peer disconnects mid-session
rangingErrors A platform error occurs inside the active session

RangingSample exposes distanceMeters, azimuthDegrees, elevationDegrees, elapsedRealtimeNanos and the originating deviceId. All mutating methods throw UwbException on failure.

startRanging accepts an optional RangingOptions(cameraAssist, extendedDistance) for iOS opt-ins. Use getDeviceCapabilities() to gate the toggles in your UI.

checkReadiness() returns a snapshot of the UWB radio, Bluetooth, and runtime-permission state — use it before startDiscovery / startRanging to drive an onboarding flow without trying to range first and catching the failure.

Full API docs: pub.dev/documentation/flutter_uwb/latest/

Permissions

Android

The plugin manifest already declares the required <uses-permission> entries. Your app only needs to request them at runtime:

API level Runtime permissions
31+ BLUETOOTH_SCAN, BLUETOOTH_ADVERTISE, BLUETOOTH_CONNECT
≤ 30 ACCESS_FINE_LOCATION
33+ additionally UWB_RANGING
import android.Manifest
import android.os.Build
import androidx.core.app.ActivityCompat

private val perms: Array<String> = buildList {
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
    add(Manifest.permission.BLUETOOTH_SCAN)
    add(Manifest.permission.BLUETOOTH_ADVERTISE)
    add(Manifest.permission.BLUETOOTH_CONNECT)
  } else {
    add(Manifest.permission.ACCESS_FINE_LOCATION)
  }
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    add(Manifest.permission.UWB_RANGING)
  }
}.toTypedArray()

ActivityCompat.requestPermissions(this, perms, /*requestCode*/ 1)
iOS

Add to ios/Runner/Info.plist:

<key>NSNearbyInteractionUsageDescription</key>
<string>Used to measure precise distance to nearby devices over UWB.</string>

<key>NSBluetoothAlwaysUsageDescription</key>
<string>Used to discover nearby devices for UWB ranging.</string>

<!-- Required for iOS↔iOS pairing on iOS 17+ (keeps the AWDL sidechannel alive). -->
<key>NSLocalNetworkUsageDescription</key>
<string>Used to coordinate UWB ranging with nearby iPhones.</string>
<key>NSBonjourServices</key>
<array>
  <string>_flutteruwb-uwb._tcp</string>
  <string>_flutteruwb-uwb._udp</string>
</array>

The Bonjour service names must match exactly. If you only target FiRa accessories, the local-network keys are optional but harmless.

Example app

A runnable demo lives in example/. It wires up discovery, pairing, and a live distance/azimuth readout for same-OS pairs and (on iOS) Apple-FiRa accessories.

flutter_uwb example app

Troubleshooting

startDiscovery succeeds on Android but no peers appear

Almost always missing runtime permissions. Android 12+ requires the user to grant BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE and UWB_RANGING at runtime — declaring them in the manifest is not enough. Use uwb.checkReadiness() and request anything in missingPermissions (typically with permission_handler) before calling startDiscovery.

If permissions are granted and you still see nothing, check that Bluetooth is actually powered on (r.bluetoothEnabled) and that the peer is also running 1.0.x — wire-format compatibility with 0.3.x peers ended in 0.4.0.

UwbErrorCode.regionalRestriction on first ranging call

UWB is regulated and the OS disables ranging in some jurisdictions even on hardware that ships with the radio. There's no programmatic recovery — surface a region-restriction notice to the user.

iOS ranging starts then stops "randomly"

If the host app backgrounds during a session, the plugin tears down the active NISession and fires onPeerLost so the app can react (the alternative is undefined behaviour from NISession + ARSession left running across suspension). The host app should re-call startRanging on foreground if it wants ranging back.

If you see this without backgrounding, the most likely cause is camera-assist on a session whose ARSession hasn't received its first frame yet — that surfaces as NIErrorCodeInvalidARConfiguration (-5883). Disable cameraAssist in RangingOptions and retry.

How to enable verbose plugin logs
import 'package:flutter_uwb/flutter_uwb.dart';

void main() {
  if (kDebugMode) UwbLog.setLevel(UwbLogLevel.debug);
  UwbLog.setHandler((level, msg) => debugPrint('[uwb] [$level] $msg'));
  runApp(const MyApp());
}

Native logs: adb logcat | grep flutter_uwb on Android, Xcode console filtered by subsystem flutter_uwb on iOS.

Architecture

For protocol details, token format, BLE/UWB topology, and the ECDH-keyed Provisioned STS handshake on Android, see doc/architecture.md.

License

MIT

Libraries

flutter_uwb