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 Field Logger 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 } }