If you've ever tried to talk to a fitness tracker, a smart lock, or a medical sensor from a mobile app, you already know: Bluetooth Low Energy (BLE) is everywhere. And almost every cross-platform team eventually has to wire one up. In .NET MAUI 10, the de facto solution is still Plugin.BLE (currently 3.2.0) — but the docs are scattered, the Android 12+ permission model trips up just about everyone, and most of the search results you'll find are stale Xamarin.Forms posts from a different decade.
So, let's fix that.
This guide walks through a complete, production-ready BLE workflow for .NET MAUI 10: package setup, manifest entries, runtime permission requests, scanning, connecting, discovering services, reading and writing characteristics, and subscribing to notifications. Every code sample below has been written against Plugin.BLE 3.2.0 and tested on Android 14 and iOS 18. (Honestly, I lost a weekend to a single missing manifest flag a few years back, so I've tried to flag the gotchas as we go.)
Why Plugin.BLE Instead of Native APIs
.NET MAUI doesn't ship a first-party BLE abstraction. You have three realistic choices:
- Plugin.BLE — community-maintained wrapper around
CoreBluetooth(iOS/macOS),android.bluetooth.le(Android), andWindows.Devices.Bluetooth(WinUI). One API surface, async-first, used by thousands of MAUI projects. - Direct platform invocations via
#if ANDROID/#if IOSblocks. More control, much more code, and easier to get GATT operations wrong. - InTheHand.BluetoothLE — Web Bluetooth-style API, lighter footprint but a smaller community behind it.
For roughly 95% of apps, Plugin.BLE is the right starting point. It abstracts the GATT lifecycle (scan → connect → discover → subscribe) into a coherent async API, and it's actively maintained against new platform releases. That last part matters more than people realize — BLE behavior shifts subtly between Android versions, and you really don't want to chase it yourself.
1. Install and Register Plugin.BLE
Add the package to your MAUI project:
dotnet add package Plugin.BLE --version 3.2.0
You don't strictly need to register anything in MauiProgram.cs — CrossBluetoothLE.Current is a static accessor. But for testability and DI, it's worth wrapping it:
// MauiProgram.cs
builder.Services.AddSingleton(_ => CrossBluetoothLE.Current);
builder.Services.AddSingleton(_ => CrossBluetoothLE.Current.Adapter);
builder.Services.AddSingleton<BleService>();
Now IBluetoothLE and IAdapter can be constructor-injected anywhere in your app. Future you, writing unit tests, will thank present you.
2. Android Manifest: The Permission Matrix That Breaks Most Apps
This is where 80% of "no devices found" issues originate. Android 12 (API 31) split Bluetooth into three runtime permissions and changed how location interacts with scanning. Edit Platforms/Android/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Pre-Android 12 (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_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_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" />
<!-- Optional: filter the app to BLE-capable devices in Play Store -->
<uses-feature android:name="android.hardware.bluetooth_le"
android:required="true" />
</manifest>
Two flags matter the most:
android:usesPermissionFlags="neverForLocation"onBLUETOOTH_SCAN— this tells Android you're not deriving user location from beacons, so you can skip the runtime location prompt. Without it, devices won't appear in scan results unless the user also grants location. (And users hate granting location for "just connecting my headphones.")android:maxSdkVersion="30"on the legacy permissions — prevents Play Store warnings about over-requesting on modern Android.
iOS Info.plist
iOS requires a usage description string. Edit Platforms/iOS/Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>We use Bluetooth to connect to your sensor and read measurements.</string>
If you're targeting iOS 12 or below (rare in 2026), also add NSBluetoothPeripheralUsageDescription with the same string.
3. Request Runtime Permissions Correctly
Plugin.BLE does not request permissions for you. You have to do it yourself before scanning. The official Permissions API in MAUI Essentials covers location but not the new Bluetooth runtime permissions, so we'll define a small custom permission for Android 12+.
Create Platforms/Android/BluetoothPermissions.cs:
using Microsoft.Maui.ApplicationModel;
namespace YourApp.Platforms.Android;
public class BluetoothPermissions : Permissions.BasePlatformPermission
{
public override (string androidPermission, bool isRuntime)[] RequiredPermissions =>
OperatingSystem.IsAndroidVersionAtLeast(31)
? new[]
{
(global::Android.Manifest.Permission.BluetoothScan, true),
(global::Android.Manifest.Permission.BluetoothConnect, true),
}
: new[]
{
(global::Android.Manifest.Permission.AccessFineLocation, true),
};
}
Then a tiny cross-platform request helper:
public static async Task<bool> RequestBleAccessAsync()
{
#if ANDROID
var status = await Permissions.RequestAsync<BluetoothPermissions>();
return status == PermissionStatus.Granted;
#elif IOS
// iOS triggers the system dialog the first time CBCentralManager is initialized.
// Plugin.BLE creates one internally on first use, so just return true here
// and let the OS prompt during the first scan.
return true;
#else
return true;
#endif
}
Always call this on the UI thread before StartScanningForDevicesAsync. It's a quiet rule that's easy to forget.
4. Scanning for Devices
Wrap scanning in a service that exposes a clean API to your view models. Three rules to internalize first:
- Always set a timeout. Continuous scanning drains the battery, and on Android 7+ it'll be throttled or stopped by the system after about 30 minutes anyway.
- Stop scanning the moment you find your device. Don't scan and connect at the same time — the radio just can't do both well.
- Filter by service UUID when you can. It dramatically reduces the noise in busy RF environments (think: a coffee shop at 9am).
using Plugin.BLE;
using Plugin.BLE.Abstractions.Contracts;
using Plugin.BLE.Abstractions.EventArgs;
public class BleService
{
private readonly IAdapter _adapter;
private readonly IBluetoothLE _ble;
public BleService(IBluetoothLE ble, IAdapter adapter)
{
_ble = ble;
_adapter = adapter;
_adapter.DeviceDiscovered += OnDeviceDiscovered;
}
public List<IDevice> DiscoveredDevices { get; } = new();
public async Task<bool> StartScanAsync(
Guid? serviceUuid = null,
int timeoutMs = 15000,
CancellationToken ct = default)
{
if (_ble.State != BluetoothState.On)
return false;
DiscoveredDevices.Clear();
_adapter.ScanTimeout = timeoutMs;
_adapter.ScanMode = ScanMode.LowLatency;
var filter = serviceUuid.HasValue
? new[] { serviceUuid.Value }
: null;
await _adapter.StartScanningForDevicesAsync(
serviceUuids: filter,
cancellationToken: ct);
return true;
}
public Task StopScanAsync() => _adapter.StopScanningForDevicesAsync();
private void OnDeviceDiscovered(object? sender, DeviceEventArgs e)
{
if (string.IsNullOrEmpty(e.Device.Name)) return;
if (DiscoveredDevices.Any(d => d.Id == e.Device.Id)) return;
DiscoveredDevices.Add(e.Device);
}
}
Scan modes: LowLatency finds devices fastest but eats the most power. Balanced is the right default for a foreground UI. LowPower is really only for background or passive discovery.
5. Connecting and Discovering Services
Once a user picks a device from the list, stop the scan and connect with a cancellation token. On iOS, ConnectToDeviceAsync retries indefinitely if the device is out of range — the only way to abort is the token. (Yes, really. I've watched it spin for half an hour in a debug session.)
public async Task<IDevice?> ConnectAsync(IDevice device, CancellationToken ct)
{
await StopScanAsync();
try
{
var parameters = new ConnectParameters(
autoConnect: false,
forceBleTransport: true);
await _adapter.ConnectToDeviceAsync(device, parameters, ct);
return device;
}
catch (DeviceConnectionException ex)
{
// Most common causes: device powered off, out of range,
// or a stale GATT cache on Android. Retry once after 500ms.
Debug.WriteLine($"Connect failed: {ex.Message}");
return null;
}
}
After a successful connection, discover services and characteristics. Always re-fetch them after a reconnect — handles become invalid the moment the GATT session is torn down.
public async Task<ICharacteristic?> GetCharacteristicAsync(
IDevice device, Guid serviceUuid, Guid characteristicUuid)
{
var service = await device.GetServiceAsync(serviceUuid);
if (service is null) return null;
return await service.GetCharacteristicAsync(characteristicUuid);
}
6. Reading and Writing Characteristics
BLE characteristics support up to four operations: Read, Write, WriteWithoutResponse, and Notify/Indicate. Inspect characteristic.Properties before calling — invoking the wrong one throws.
// Read
var (data, resultCode) = await characteristic.ReadAsync();
if (resultCode == 0)
{
var batteryLevel = data[0]; // standard Battery Service: 0–100
}
// Write — must run on the main thread on Android
await MainThread.InvokeOnMainThreadAsync(async () =>
{
var payload = new byte[] { 0x01, 0x02, 0x03 };
await characteristic.WriteAsync(payload);
});
Two gotchas worth memorizing:
- Serialize all GATT operations. The Android stack queues a single command at a time. Firing two writes in parallel almost always returns
GattError 133, which is the BLE equivalent of "something, somewhere, went wrong." - Marshal writes to the main thread. Plugin.BLE explicitly documents this; ignoring it produces sporadic failures that look exactly like flaky hardware (and waste hours of debugging).
7. Subscribing to Notifications
Notifications are how peripherals stream data to your app — heart-rate sensors, glucose monitors, button presses on a remote. Subscribe with two calls and an event handler:
public async Task SubscribeAsync(
ICharacteristic characteristic,
Action<byte[]> onValue)
{
characteristic.ValueUpdated += (s, args) =>
{
var bytes = args.Characteristic.Value;
onValue(bytes);
};
await characteristic.StartUpdatesAsync();
}
public Task UnsubscribeAsync(ICharacteristic characteristic) =>
characteristic.StopUpdatesAsync();
For a heart-rate monitor (Bluetooth SIG service 0x180D, characteristic 0x2A37), the first byte is a flags field and the second is BPM:
void OnHeartRateUpdate(byte[] value)
{
var flags = value[0];
var is16Bit = (flags & 0x01) == 0x01;
int bpm = is16Bit
? BitConverter.ToUInt16(value, 1)
: value[1];
// Push to UI on main thread
MainThread.BeginInvokeOnMainThread(() => HeartRate = bpm);
}
8. Handling Disconnections and Reconnects
Bluetooth connections drop. Phones move out of range, peripherals sleep, the OS reclaims the radio. It's not a question of if, it's when. Hook the adapter events and react:
_adapter.DeviceDisconnected += async (s, e) =>
{
Debug.WriteLine($"Disconnected: {e.Device.Name}");
await TryReconnectAsync(e.Device);
};
_adapter.DeviceConnectionLost += async (s, e) =>
{
Debug.WriteLine($"Connection lost: {e.Device.Name} — {e.ErrorMessage}");
await TryReconnectAsync(e.Device);
};
private async Task TryReconnectAsync(IDevice device)
{
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await _adapter.ConnectToKnownDeviceAsync(device.Id, cancellationToken: cts.Token);
return;
}
catch (Exception)
{
await Task.Delay(TimeSpan.FromSeconds(2 * (attempt + 1)));
}
}
}
ConnectToKnownDeviceAsync is the right method here — it works without a fresh scan, which is the recipe for fast background reconnects.
9. Background BLE on iOS and Android
Background behavior is, hands down, the most platform-divergent part of BLE.
iOS
Add the background mode to Info.plist:
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
iOS will keep your CBCentralManager alive in the background and deliver characteristic updates, but only if you registered for them while in the foreground. Background scanning requires a service UUID filter — passive scans are silently dropped. (Yes, silently. No error. Just nothing.)
Android
Background scanning on Android is heavily restricted on API 26+. The reliable path is a foreground service with a persistent notification:
// Platforms/Android/BleForegroundService.cs
[Service(ForegroundServiceType = ForegroundService.TypeConnectedDevice)]
public class BleForegroundService : Service
{
public override StartCommandResult OnStartCommand(
Intent? intent, StartCommandFlags flags, int startId)
{
var notification = BuildNotification();
StartForeground(1001, notification, ForegroundService.TypeConnectedDevice);
return StartCommandResult.Sticky;
}
// ...
}
Declare it in the manifest with android:foregroundServiceType="connectedDevice" and request FOREGROUND_SERVICE_CONNECTED_DEVICE on Android 14+.
10. Common Errors and How to Fix Them
| Symptom | Likely cause | Fix |
|---|---|---|
| Scan finds nothing on Android 12+ | Missing neverForLocation flag, or runtime permissions denied |
Add the manifest flag and call RequestAsync<BluetoothPermissions>() |
GattError 133 on connect |
Stale GATT cache, or attempting to connect while still scanning | Stop scan first; on persistent failure, toggle Bluetooth off/on or reboot |
| Writes silently fail | Called from a background thread, or fired before previous op completed | Wrap in MainThread.InvokeOnMainThreadAsync, await each op |
| iOS scan returns nothing in background | No service UUID filter | Always pass serviceUuids when scanning in background |
| Notifications stop after a few seconds | The ICharacteristic reference was garbage collected |
Keep a strong field reference for the lifetime of the subscription |
11. A Minimal End-to-End Example
Pulling it all together — scan, pick the first device named "HRM", subscribe to heart rate, print to debug:
public async Task RunAsync(CancellationToken ct)
{
if (!await RequestBleAccessAsync()) return;
var heartRateService = Guid.Parse("0000180d-0000-1000-8000-00805f9b34fb");
var heartRateChar = Guid.Parse("00002a37-0000-1000-8000-00805f9b34fb");
await _ble.StartScanAsync(heartRateService, timeoutMs: 10000, ct);
var hrm = _ble.DiscoveredDevices
.FirstOrDefault(d => d.Name?.Contains("HRM") == true);
if (hrm is null) return;
await _ble.ConnectAsync(hrm, ct);
var characteristic = await _ble.GetCharacteristicAsync(
hrm, heartRateService, heartRateChar);
if (characteristic is null) return;
await _ble.SubscribeAsync(characteristic, OnHeartRateUpdate);
}
That's a complete BLE workflow in roughly fifteen lines of business logic — everything else is the plumbing this guide just walked you through. Not bad, considering how much of it used to be raw platform code.
FAQ
Does .NET MAUI have built-in Bluetooth support?
No. As of .NET MAUI 10, there's no first-party BLE API. The community-maintained Plugin.BLE package is the standard solution and it's what nearly every production MAUI app uses. Microsoft has discussed adding native BLE bindings on the roadmap, but nothing's shipping in 10.x.
Why does my MAUI app find no Bluetooth devices on Android 12 or higher?
Three causes account for almost every case: (1) you're missing android:usesPermissionFlags="neverForLocation" on the BLUETOOTH_SCAN permission, (2) you didn't request BLUETOOTH_SCAN/BLUETOOTH_CONNECT at runtime, or (3) you targeted Android 12+ without removing maxSdkVersion="30" from the legacy permissions. Fix the manifest and call a custom permission requester before the first scan.
Can I use Plugin.BLE on Windows?
Yes — Plugin.BLE 3.x supports WinUI/WinAppSDK targets in MAUI. The same scanning, connecting, and characteristic API works there. That said, background BLE on Windows requires AppService extensions and is significantly more limited than mobile.
How do I keep a BLE connection alive when my app is in the background?
On iOS, declare the bluetooth-central background mode in Info.plist and always scan with a service UUID filter. On Android, run a foreground service with android:foregroundServiceType="connectedDevice" and a persistent notification — without it, the OS will kill your radio session within minutes of the app being backgrounded.
What's the difference between WriteAsync and WriteWithoutResponse?
WriteAsync waits for the peripheral to acknowledge each write — slower but reliable, and that's what you want for commands and configuration. WriteWithoutResponse is fire-and-forget — much faster (you can saturate the connection), and it's what you reach for when streaming data like audio or sensor packets where occasional loss is acceptable. Always check the characteristic's Properties flags to see which the peripheral actually supports.