using Plugin.BLE;
using Plugin.BLE.Abstractions.Contracts;
using Plugin.BLE.Abstractions.EventArgs;
namespace FieldLogger.Services.Ble;
/// A device found during a BLE scan.
public sealed record DiscoveredDevice(Guid Id, string Name, int Rssi);
/// Thin scan wrapper over Plugin.BLE with per-platform permission handling.
public sealed class BleScanner
{
private readonly IBluetoothLE _bluetooth = CrossBluetoothLE.Current;
private readonly IAdapter _adapter = CrossBluetoothLE.Current.Adapter;
public event EventHandler? DeviceDiscovered;
public bool IsScanning => _adapter.IsScanning;
///
/// Scans for BLE devices for the given duration, raising DeviceDiscovered as devices appear.
/// Only named devices are reported.
///
public async Task ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default)
{
await EnsurePermissionsAsync();
await EnsureBluetoothReadyAsync(cancellationToken);
void OnDiscovered(object? sender, DeviceEventArgs e)
{
if (!string.IsNullOrWhiteSpace(e.Device.Name))
DeviceDiscovered?.Invoke(this, new DiscoveredDevice(e.Device.Id, e.Device.Name, e.Device.Rssi));
}
_adapter.DeviceDiscovered += OnDiscovered;
_adapter.DeviceAdvertised += OnDiscovered;
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(duration);
_adapter.ScanTimeout = (int)duration.TotalMilliseconds;
try
{
await _adapter.StartScanningForDevicesAsync(cancellationToken: timeout.Token);
}
catch (OperationCanceledException)
{
// Scan window elapsed or caller cancelled - both are normal.
}
}
finally
{
_adapter.DeviceDiscovered -= OnDiscovered;
_adapter.DeviceAdvertised -= OnDiscovered;
if (_adapter.IsScanning)
await _adapter.StopScanningForDevicesAsync();
}
}
private async Task EnsureBluetoothReadyAsync(CancellationToken cancellationToken)
{
// CoreBluetooth commonly starts in Unknown while CBCentralManager is being
// initialized. IsOn is false for every state except On, so checking it
// immediately incorrectly reports that Bluetooth is switched off on macOS.
if (_bluetooth.State is BluetoothState.Unknown or BluetoothState.TurningOn)
{
var stateChanged = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
void OnStateChanged(object? sender, BluetoothStateChangedArgs args)
{
if (args.NewState is not (BluetoothState.Unknown or BluetoothState.TurningOn))
stateChanged.TrySetResult(args.NewState);
}
_bluetooth.StateChanged += OnStateChanged;
try
{
// Recheck after subscribing so a transition cannot be missed.
var currentState = _bluetooth.State;
if (currentState is BluetoothState.Unknown or BluetoothState.TurningOn)
{
using var initializationTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, initializationTimeout.Token);
try
{
await stateChanged.Task.WaitAsync(linkedCancellation.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new InvalidOperationException(
"Bluetooth did not finish initializing. Try scanning again.");
}
}
}
finally
{
_bluetooth.StateChanged -= OnStateChanged;
}
}
if (_bluetooth.State == BluetoothState.On)
return;
throw _bluetooth.State switch
{
BluetoothState.Off or BluetoothState.TurningOff =>
new InvalidOperationException("Bluetooth is turned off."),
BluetoothState.Unauthorized =>
new PermissionException(
"Bluetooth access is denied. Enable UM Trace in System Settings > Privacy & Security > Bluetooth."),
BluetoothState.Unavailable =>
new InvalidOperationException("Bluetooth is not available on this Mac."),
_ => new InvalidOperationException($"Bluetooth is not ready (state: {_bluetooth.State}).")
};
}
/// Requests the runtime permissions BLE scanning needs (Android only; no-op elsewhere).
public static async Task EnsurePermissionsAsync()
{
#if ANDROID
if (OperatingSystem.IsAndroidVersionAtLeast(31))
{
var status = await Permissions.RequestAsync();
if (status != PermissionStatus.Granted)
throw new PermissionException("Bluetooth permission was denied.");
}
else
{
var status = await Permissions.RequestAsync();
if (status != PermissionStatus.Granted)
throw new PermissionException("Location permission (required for BLE scanning) was denied.");
}
#else
await Task.CompletedTask;
#endif
}
}