feat: Linux BLE pairing support via bluetoothctl

Add Linux BLE pairing helper that drives bluetoothctl for pair/trust/PIN
entry, with Completer-based flow control, explicit retry loop, and named
timeout constants.

- LinuxBlePairingService: pair-and-trust with up to 2 retries
- LinuxBleErrorClassifier: map bluetoothctl stderr to user-facing errors
- Conditional import stub for web builds (dart.library.io gate)
- Scanner screen: PIN dialog integration for Linux pairing flow
- MeshCoreConnector: Linux pairing/recovery/reconnect wiring
- l10n: 4 new pairing keys across all 14 locales
- 12 unit tests (pairing service + error classifier)
This commit is contained in:
just-stuff-tm
2026-03-15 16:28:57 -04:00
parent cb63b48b78
commit 29660d520e
39 changed files with 2031 additions and 40 deletions
+442 -22
View File
@@ -5,6 +5,7 @@ import 'package:crypto/crypto.dart' as crypto;
import 'package:pointycastle/export.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
@@ -15,6 +16,9 @@ import '../helpers/reaction_helper.dart';
import '../helpers/smaz.dart';
import '../services/app_debug_log_service.dart';
import '../services/ble_debug_log_service.dart';
import '../services/linux_ble_error_classifier.dart';
import '../services/linux_ble_pairing_service_stub.dart'
if (dart.library.io) '../services/linux_ble_pairing_service.dart';
import '../services/message_retry_service.dart';
import '../services/path_history_service.dart';
import '../services/app_settings_service.dart';
@@ -116,11 +120,14 @@ class MeshCoreConnector extends ChangeNotifier {
String? _lastDeviceDisplayName;
bool _manualDisconnect = false;
final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
final LinuxBlePairingService _linuxBlePairingService =
LinuxBlePairingService();
StreamSubscription<Uint8List>? _usbFrameSubscription;
final MeshCoreTcpConnector _tcpConnector = MeshCoreTcpConnector();
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
final List<ScanResult> _scanResults = [];
final List<ScanResult> _linuxSystemScanResults = [];
final List<Contact> _contacts = [];
final List<Contact> _discoveredContacts = [];
final List<Channel> _channels = [];
@@ -269,6 +276,8 @@ class MeshCoreConnector extends ChangeNotifier {
bool get isUsbTransportConnected =>
_state == MeshCoreConnectionState.connected &&
_activeTransport == MeshCoreTransportType.usb;
bool get isAutoReconnectScheduled =>
_shouldAutoReconnect && (_reconnectTimer?.isActive ?? false);
String? get activeTcpEndpoint => _tcpConnector.activeEndpoint;
bool get isTcpTransportConnected =>
_state == MeshCoreConnectionState.connected &&
@@ -933,6 +942,7 @@ class MeshCoreConnector extends ChangeNotifier {
if (_state == MeshCoreConnectionState.scanning) return;
_scanResults.clear();
_linuxSystemScanResults.clear();
_setState(MeshCoreConnectionState.scanning);
// Ensure any previous scan is fully stopped. Guard with isScanningNow to
@@ -971,9 +981,15 @@ class MeshCoreConnector extends ChangeNotifier {
await Future.delayed(const Duration(milliseconds: 300));
}
if (PlatformInfo.isLinux) {
await _loadLinuxSystemDevicesForScan();
}
_scanSubscription = FlutterBluePlus.scanResults.listen((results) {
_scanResults.clear();
_scanResults.addAll(results);
_scanResults
..clear()
..addAll(results);
_mergeLinuxSystemScanResults();
notifyListeners();
});
@@ -994,6 +1010,62 @@ class MeshCoreConnector extends ChangeNotifier {
await stopScan();
}
Future<void> _loadLinuxSystemDevicesForScan() async {
try {
final systemDevices = await FlutterBluePlus.systemDevices([
Guid(MeshCoreUuids.service),
]);
_linuxSystemScanResults
..clear()
..addAll(
systemDevices
.where(
(device) =>
device.platformName.startsWith('MeshCore-') ||
device.platformName.startsWith('Whisper-'),
)
.map(
(device) => ScanResult(
device: device,
advertisementData: AdvertisementData(
advName: device.platformName,
txPowerLevel: null,
appearance: null,
connectable: true,
manufacturerData: const <int, List<int>>{},
serviceData: const <Guid, List<int>>{},
serviceUuids: <Guid>[Guid(MeshCoreUuids.service)],
),
rssi: 0,
timeStamp: DateTime.now(),
),
),
);
_mergeLinuxSystemScanResults();
notifyListeners();
} catch (error) {
_appDebugLogService?.warn(
'Failed loading Linux paired/system BLE devices: $error',
tag: 'BLE Scan',
);
}
}
void _mergeLinuxSystemScanResults() {
if (!PlatformInfo.isLinux || _linuxSystemScanResults.isEmpty) {
return;
}
final existingIds = _scanResults
.map((result) => result.device.remoteId.str)
.toSet();
for (final result in _linuxSystemScanResults) {
if (existingIds.contains(result.device.remoteId.str)) {
continue;
}
_scanResults.add(result);
}
}
Future<void> stopScan() async {
// Only call FlutterBluePlus.stopScan() when a scan is actually running.
// Calling it when idle triggers a native BLE completion callback even
@@ -1250,7 +1322,11 @@ class MeshCoreConnector extends ChangeNotifier {
activeTransport == MeshCoreTransportType.tcp;
}
Future<void> connect(BluetoothDevice device, {String? displayName}) async {
Future<void> connect(
BluetoothDevice device, {
String? displayName,
Future<String?> Function()? linuxPairingPinProvider,
}) async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
return;
@@ -1295,22 +1371,149 @@ class MeshCoreConnector extends ChangeNotifier {
}
});
try {
await device.connect(
timeout: const Duration(seconds: 15),
mtu: null,
license: License.free,
);
} catch (error) {
_appDebugLogService?.error(
'device.connect() failure: $error',
if (PlatformInfo.isLinux) {
final remoteId = device.remoteId.str;
_appDebugLogService?.info(
'Linux pre-connect BlueZ disconnect for $remoteId',
tag: 'BLE Connect',
);
rethrow;
await _linuxBlePairingService.disconnectDevice(
remoteId,
onLog: (message) {
_appDebugLogService?.info(message, tag: 'BLE Pair');
},
);
}
// Request larger MTU only on native platforms; web does not support it.
if (!PlatformInfo.isWeb) {
final connectTimeout = PlatformInfo.isLinux
? const Duration(seconds: 6)
: const Duration(seconds: 15);
_appDebugLogService?.info(
'device.connect timeout set to ${connectTimeout.inSeconds}s',
tag: 'BLE Connect',
);
if (PlatformInfo.isLinux) {
Future<void> attemptConnect() {
return device
.connect(
timeout: connectTimeout,
mtu: null,
license: License.free,
)
.timeout(
connectTimeout + const Duration(seconds: 2),
onTimeout: () {
throw TimeoutException(
'Linux connect hard-timeout after ${connectTimeout.inSeconds + 2}s',
);
},
);
}
try {
await attemptConnect();
} catch (error) {
_appDebugLogService?.error(
'device.connect() failure: $error',
tag: 'BLE Connect',
);
final remoteId = device.remoteId.str;
_appDebugLogService?.warn(
'Linux immediate retry: forcing BlueZ disconnect before second connect attempt',
tag: 'BLE Connect',
);
await _linuxBlePairingService.disconnectDevice(
remoteId,
onLog: (message) {
_appDebugLogService?.info(message, tag: 'BLE Pair');
},
);
await Future<void>.delayed(const Duration(milliseconds: 700));
try {
await attemptConnect();
_appDebugLogService?.info(
'Linux immediate retry connect succeeded',
tag: 'BLE Connect',
);
} catch (retryError, retryStackTrace) {
Object finalConnectError = retryError;
StackTrace finalConnectStackTrace = retryStackTrace;
final retryErrorText = retryError.toString().toLowerCase();
final isAbortByLocal = retryErrorText.contains(
'le-connection-abort-by-local',
);
var recoveredOnThirdAttempt = false;
if (isAbortByLocal) {
_appDebugLogService?.warn(
'Linux immediate retry aborted by local stack; waiting and retrying once more',
tag: 'BLE Connect',
);
await Future<void>.delayed(const Duration(milliseconds: 1200));
try {
await attemptConnect();
_appDebugLogService?.info(
'Linux third-attempt connect succeeded after local abort',
tag: 'BLE Connect',
);
recoveredOnThirdAttempt = true;
} catch (thirdError, thirdStackTrace) {
finalConnectError = thirdError;
finalConnectStackTrace = thirdStackTrace;
_appDebugLogService?.error(
'device.connect() third-attempt failure: $thirdError',
tag: 'BLE Connect',
);
}
}
if (!recoveredOnThirdAttempt) {
final recoveredByPairing = await _recoverLinuxConnectFailure(
device,
attemptConnect: attemptConnect,
onRequestPin: linuxPairingPinProvider,
);
if (recoveredByPairing) {
_appDebugLogService?.info(
'Linux connect succeeded after pairing/trust recovery',
tag: 'BLE Connect',
);
} else {
_appDebugLogService?.error(
'device.connect() retry failure: $finalConnectError',
tag: 'BLE Connect',
);
Error.throwWithStackTrace(
_wrapLinuxConnectStageError(finalConnectError),
finalConnectStackTrace,
);
}
}
}
}
} else {
try {
await device.connect(
timeout: connectTimeout,
mtu: null,
license: License.free,
);
} catch (error) {
_appDebugLogService?.error(
'device.connect() failure: $error',
tag: 'BLE Connect',
);
rethrow;
}
}
if (PlatformInfo.isLinux) {
await _ensureLinuxBleBond(
device,
onRequestPin: linuxPairingPinProvider,
);
}
// Request larger MTU only where the platform path supports it.
if (!PlatformInfo.isWeb && !PlatformInfo.isLinux) {
try {
final mtu = await device.requestMtu(185);
_appDebugLogService?.info('MTU set to: $mtu', tag: 'BLE Connect');
@@ -1320,6 +1523,11 @@ class MeshCoreConnector extends ChangeNotifier {
tag: 'BLE Connect',
);
}
} else if (PlatformInfo.isLinux) {
_appDebugLogService?.info(
'Skipping MTU request on Linux; flutter_blue_plus only supports requestMtu on Android',
tag: 'BLE Connect',
);
}
late final List<BluetoothService> services;
@@ -1433,11 +1641,213 @@ class MeshCoreConnector extends ChangeNotifier {
await _startBleInitialSync();
} catch (e) {
_appDebugLogService?.error('Connection error: $e', tag: 'BLE Connect');
await disconnect(manual: false);
final errorText = e.toString();
final lowerErrorText = errorText.toLowerCase();
final isLinuxPairingFailure =
PlatformInfo.isLinux && isLinuxBlePairingFailureText(errorText);
final isLikelyPairingTimeout = isLikelyLinuxBlePairingTimeoutText(
errorText,
);
final isConnectFailure = isLinuxBleConnectFailureText(errorText);
final isConnectTimeoutFailure =
isConnectFailure && lowerErrorText.contains('timed out');
final isLinuxConnectFailure = PlatformInfo.isLinux && isConnectFailure;
// Linux pairing failures should not enter auto-reconnect loops; user
// needs to retry manually so they can re-enter PIN / resolve pairing.
if (isLinuxPairingFailure) {
_appDebugLogService?.warn(
isLikelyPairingTimeout
? 'Linux pairing timed out: stopping reconnect until user retries manually'
: 'Linux pairing failure: stopping reconnect until user retries manually',
tag: 'BLE Connect',
);
await disconnect(manual: true);
} else if (isLinuxConnectFailure) {
_appDebugLogService?.warn(
isConnectTimeoutFailure
? 'Linux connect timeout: issuing BlueZ disconnect before reconnect'
: 'Linux connect failure: issuing BlueZ disconnect before reconnect',
tag: 'BLE Connect',
);
final remoteId = _device?.remoteId.str;
if (remoteId != null) {
await _linuxBlePairingService.disconnectDevice(
remoteId,
onLog: (message) {
_appDebugLogService?.info(message, tag: 'BLE Pair');
},
);
}
await disconnect(manual: false, skipBleDeviceDisconnect: true);
} else {
await disconnect(manual: false);
}
rethrow;
}
}
Future<bool> _recoverLinuxConnectFailure(
BluetoothDevice device, {
required Future<void> Function() attemptConnect,
Future<String?> Function()? onRequestPin,
}) async {
if (!PlatformInfo.isLinux ||
!await _linuxBlePairingService.isBluetoothctlAvailable()) {
return false;
}
final remoteId = device.remoteId.str;
final pluginBondState = await _getLinuxPluginBondState(device);
final trustedByBluez = await _linuxBlePairingService.isPairedAndTrusted(
remoteId,
);
final needsBondRecovery =
pluginBondState != BmBondStateEnum.bonded || !trustedByBluez;
if (!needsBondRecovery) {
return false;
}
_appDebugLogService?.warn(
pluginBondState == BmBondStateEnum.bonded
? 'Linux connect failed with an untrusted bond; attempting trust/pair recovery'
: 'Linux connect failed before bond completed; attempting pairing fallback',
tag: 'BLE Connect',
);
await _ensureLinuxBleBond(device, onRequestPin: onRequestPin);
_appDebugLogService?.info(
'Resetting BlueZ connection after Linux pairing/trust recovery',
tag: 'BLE Connect',
);
await _linuxBlePairingService.disconnectDevice(
remoteId,
onLog: (message) {
_appDebugLogService?.info(message, tag: 'BLE Pair');
},
);
await Future<void>.delayed(const Duration(milliseconds: 700));
try {
await attemptConnect();
} catch (error, stackTrace) {
Error.throwWithStackTrace(_wrapLinuxConnectStageError(error), stackTrace);
}
return true;
}
Object _wrapLinuxConnectStageError(Object error) {
final errorText = error.toString();
if (errorText.toLowerCase().contains(linuxConnectStageFailureMarker)) {
return error;
}
return StateError('Linux connect stage failure: $error');
}
Future<BmBondStateEnum?> _getLinuxPluginBondState(
BluetoothDevice device,
) async {
try {
final response = await FlutterBluePlusPlatform.instance.getBondState(
BmBondStateRequest(remoteId: device.remoteId),
);
return response.bondState;
} catch (error) {
_appDebugLogService?.warn(
'Linux getBondState unavailable for ${device.remoteId.str}: $error',
tag: 'BLE Connect',
);
return null;
}
}
Future<void> _ensureLinuxBleBond(
BluetoothDevice device, {
Future<String?> Function()? onRequestPin,
}) async {
final remoteId = device.remoteId.str;
final bluetoothctlAvailable = await _linuxBlePairingService
.isBluetoothctlAvailable();
final beforeBondState = await _getLinuxPluginBondState(device);
if (!bluetoothctlAvailable) {
if (beforeBondState == BmBondStateEnum.bonded) {
_appDebugLogService?.warn(
'bluetoothctl unavailable; continuing with plugin bonded state',
tag: 'BLE Connect',
);
} else {
_appDebugLogService?.warn(
'bluetoothctl unavailable and device is not bonded; skipping Linux pairing fallback',
tag: 'BLE Connect',
);
}
return;
}
final trustedByBluez = await _linuxBlePairingService.isPairedAndTrusted(
remoteId,
);
if (trustedByBluez) {
_appDebugLogService?.info(
'Linux BLE device already paired/trusted, skipping pairing flow',
tag: 'BLE Connect',
);
return;
}
if (beforeBondState == BmBondStateEnum.bonded && !trustedByBluez) {
_appDebugLogService?.warn(
'Linux BLE device is bonded but not trusted in BlueZ; repairing trust',
tag: 'BLE Connect',
);
final trustRepaired = await _linuxBlePairingService.trustDevice(
remoteId,
onLog: (message) {
_appDebugLogService?.info(message, tag: 'BLE Pair');
},
);
if (trustRepaired) {
_appDebugLogService?.info(
'Linux BLE trust repair succeeded without re-pairing',
tag: 'BLE Connect',
);
return;
}
_appDebugLogService?.warn(
'Linux BLE trust repair did not stick; retrying pairing flow',
tag: 'BLE Connect',
);
}
_appDebugLogService?.info(
beforeBondState == BmBondStateEnum.bonded
? 'Linux BLE device still untrusted after repair; requesting pair'
: 'Linux BLE device not bonded, requesting pair',
tag: 'BLE Connect',
);
final paired = await _linuxBlePairingService.pairAndTrust(
remoteId: remoteId,
onLog: (message) {
_appDebugLogService?.info(message, tag: 'BLE Pair');
},
onRequestPin: onRequestPin,
);
if (!paired) {
throw StateError('Linux pairing fallback failed');
}
final afterBondState = await _getLinuxPluginBondState(device);
if (afterBondState != null && afterBondState != BmBondStateEnum.bonded) {
throw StateError('Linux BLE pairing did not complete');
} else if (afterBondState == null) {
_appDebugLogService?.warn(
'Linux plugin bond state unavailable after pairing; relying on BlueZ trust verification',
tag: 'BLE Connect',
);
}
final trustedAfter = await _linuxBlePairingService.isPairedAndTrusted(
remoteId,
);
if (!trustedAfter) {
throw StateError('Linux BLE trust repair did not complete');
}
}
Future<bool> _waitForSelfInfo({required Duration timeout}) async {
if (_selfPublicKey != null) return true;
if (!isConnected) return false;
@@ -1559,7 +1969,10 @@ class MeshCoreConnector extends ChangeNotifier {
});
}
Future<void> disconnect({bool manual = true}) async {
Future<void> disconnect({
bool manual = true,
bool skipBleDeviceDisconnect = false,
}) async {
if (_state == MeshCoreConnectionState.disconnecting) return;
final transportAtDisconnect = _activeTransport;
final transportLabel = switch (transportAtDisconnect) {
@@ -1602,11 +2015,18 @@ class MeshCoreConnector extends ChangeNotifier {
_channelSyncTimeout = null;
_channelSyncRetries = 0;
try {
// Skip queued BLE operations so disconnect doesn't get stuck behind them.
await _device?.disconnect(queue: false);
} catch (e) {
_appDebugLogService?.warn('Disconnect error: $e', tag: 'BLE Connect');
if (!skipBleDeviceDisconnect) {
try {
// Skip queued BLE operations so disconnect doesn't get stuck behind them.
await _device?.disconnect(queue: false);
} catch (e) {
_appDebugLogService?.warn('Disconnect error: $e', tag: 'BLE Connect');
}
} else {
_appDebugLogService?.info(
'Skipping plugin BLE disconnect and continuing cleanup',
tag: 'BLE Connect',
);
}
_device = null;