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

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;

View file

@ -1910,5 +1910,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Тегло, което е било премахнато от пътя след неуспешен опит за доставка.",
"appSettings_maxMessageRetries": "Максимален брой опити за изпращане на съобщение",
"appSettings_maxMessageRetriesSubtitle": "Брой опити за повторно изпращане, преди съобщението да бъде маркирано като неуспешно.",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingPinTitle": "PIN за свързване чрез Bluetooth",
"scanner_linuxPairingShowPin": "Покажи PIN",
"scanner_linuxPairingHidePin": "Скриване на PIN кода",
"scanner_linuxPairingPinPrompt": "Въведете PIN кода за {deviceName} (оставете празно, ако няма такъв)."
}

View file

@ -1938,5 +1938,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Gewicht, das nach einem fehlgeschlagenen Versand von einem Weg entfernt wurde",
"appSettings_maxMessageRetries": "Maximale Anzahl an Wiederholungsversuchen",
"appSettings_maxMessageRetriesSubtitle": "Anzahl der Versuche, eine Nachricht erneut zu senden, bevor sie als fehlgeschlagen markiert wird.",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingHidePin": "PIN verbergen",
"scanner_linuxPairingPinPrompt": "Geben Sie den PIN-Code für {deviceName} ein (lassen Sie das Feld leer, falls kein PIN-Code vorhanden ist).",
"scanner_linuxPairingShowPin": "PIN anzeigen",
"scanner_linuxPairingPinTitle": "PIN für die Bluetooth-Verbindung"
}

View file

@ -127,6 +127,17 @@
}
}
},
"scanner_linuxPairingPinTitle": "Bluetooth Pairing PIN",
"scanner_linuxPairingPinPrompt": "Enter PIN for {deviceName} (leave blank if none).",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingShowPin": "Show PIN",
"scanner_linuxPairingHidePin": "Hide PIN",
"scanner_stop": "Stop",
"scanner_scan": "Scan",
"scanner_bluetoothOff": "Bluetooth is off",

View file

@ -1938,5 +1938,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Peso retirado de un camino después de un intento de entrega fallido.",
"appSettings_maxMessageRetries": "Número máximo de reintentos de envío de mensajes",
"appSettings_maxMessageRetriesSubtitle": "Número de intentos de reintento antes de marcar un mensaje como fallido.",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingPinPrompt": "Introduzca el código PIN para {deviceName} (deje en blanco si no hay ninguno).",
"scanner_linuxPairingHidePin": "Ocultar PIN",
"scanner_linuxPairingPinTitle": "PIN para emparejar dispositivos Bluetooth",
"scanner_linuxPairingShowPin": "Mostrar código PIN"
}

View file

@ -1910,5 +1910,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Poids retiré d'un itinéraire après une tentative de livraison infructueuse.",
"appSettings_maxMessageRetries": "Nombre maximal de tentatives de récupération de messages",
"appSettings_maxMessageRetriesSubtitle": "Nombre de tentatives de relance avant de marquer un message comme ayant échoué.",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingShowPin": "Afficher le code PIN",
"scanner_linuxPairingPinTitle": "Code PIN pour la connexion Bluetooth",
"scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si nécessaire).",
"scanner_linuxPairingHidePin": "Masquer le code PIN"
}

View file

@ -1910,5 +1910,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Peso rimosso da un percorso dopo un tentativo di consegna fallito.",
"appSettings_maxMessageRetries": "Numero massimo di tentativi di invio del messaggio",
"appSettings_maxMessageRetriesSubtitle": "Numero di tentativi di riprova prima di considerare un messaggio come fallito.",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingShowPin": "Mostra PIN",
"scanner_linuxPairingHidePin": "Nascondi il PIN",
"scanner_linuxPairingPinPrompt": "Inserire il codice PIN per {deviceName} (lasciare vuoto se non presente).",
"scanner_linuxPairingPinTitle": "PIN per l'accoppiamento Bluetooth"
}

View file

@ -592,6 +592,30 @@ abstract class AppLocalizations {
/// **'Connection failed: {error}'**
String scanner_connectionFailed(String error);
/// No description provided for @scanner_linuxPairingPinTitle.
///
/// In en, this message translates to:
/// **'Bluetooth Pairing PIN'**
String get scanner_linuxPairingPinTitle;
/// No description provided for @scanner_linuxPairingPinPrompt.
///
/// In en, this message translates to:
/// **'Enter PIN for {deviceName} (leave blank if none).'**
String scanner_linuxPairingPinPrompt(String deviceName);
/// No description provided for @scanner_linuxPairingShowPin.
///
/// In en, this message translates to:
/// **'Show PIN'**
String get scanner_linuxPairingShowPin;
/// No description provided for @scanner_linuxPairingHidePin.
///
/// In en, this message translates to:
/// **'Hide PIN'**
String get scanner_linuxPairingHidePin;
/// No description provided for @scanner_stop.
///
/// In en, this message translates to:

View file

@ -268,6 +268,20 @@ class AppLocalizationsBg extends AppLocalizations {
return 'Връзката не успя: $error';
}
@override
String get scanner_linuxPairingPinTitle => 'PIN за свързване чрез Bluetooth';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Въведете PIN кода за $deviceName (оставете празно, ако няма такъв).';
}
@override
String get scanner_linuxPairingShowPin => 'Покажи PIN';
@override
String get scanner_linuxPairingHidePin => 'Скриване на PIN кода';
@override
String get scanner_stop => 'Спрете';

View file

@ -271,6 +271,20 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Verbindungsfehler: $error';
}
@override
String get scanner_linuxPairingPinTitle => 'PIN für die Bluetooth-Verbindung';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Geben Sie den PIN-Code für $deviceName ein (lassen Sie das Feld leer, falls kein PIN-Code vorhanden ist).';
}
@override
String get scanner_linuxPairingShowPin => 'PIN anzeigen';
@override
String get scanner_linuxPairingHidePin => 'PIN verbergen';
@override
String get scanner_stop => 'Stopp';

View file

@ -265,6 +265,20 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Connection failed: $error';
}
@override
String get scanner_linuxPairingPinTitle => 'Bluetooth Pairing PIN';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Enter PIN for $deviceName (leave blank if none).';
}
@override
String get scanner_linuxPairingShowPin => 'Show PIN';
@override
String get scanner_linuxPairingHidePin => 'Hide PIN';
@override
String get scanner_stop => 'Stop';

View file

@ -269,6 +269,21 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Error de conexión: $error';
}
@override
String get scanner_linuxPairingPinTitle =>
'PIN para emparejar dispositivos Bluetooth';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Introduzca el código PIN para $deviceName (deje en blanco si no hay ninguno).';
}
@override
String get scanner_linuxPairingShowPin => 'Mostrar código PIN';
@override
String get scanner_linuxPairingHidePin => 'Ocultar PIN';
@override
String get scanner_stop => 'Detener';

View file

@ -271,6 +271,21 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Échec de la connexion : $error';
}
@override
String get scanner_linuxPairingPinTitle =>
'Code PIN pour la connexion Bluetooth';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Entrez le code PIN pour $deviceName (laissez vide si nécessaire).';
}
@override
String get scanner_linuxPairingShowPin => 'Afficher le code PIN';
@override
String get scanner_linuxPairingHidePin => 'Masquer le code PIN';
@override
String get scanner_stop => 'Arrêter';

View file

@ -271,6 +271,21 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Connessione fallita: $error';
}
@override
String get scanner_linuxPairingPinTitle =>
'PIN per l\'accoppiamento Bluetooth';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Inserire il codice PIN per $deviceName (lasciare vuoto se non presente).';
}
@override
String get scanner_linuxPairingShowPin => 'Mostra PIN';
@override
String get scanner_linuxPairingHidePin => 'Nascondi il PIN';
@override
String get scanner_stop => 'Interrompere';

View file

@ -267,6 +267,20 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Verbinding mislukt: $error';
}
@override
String get scanner_linuxPairingPinTitle => 'PIN voor Bluetooth-koppeling';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Voer het pincode-in voor $deviceName in (laat dit leeg als er geen is).';
}
@override
String get scanner_linuxPairingShowPin => 'Toon PIN';
@override
String get scanner_linuxPairingHidePin => 'Verberg PIN';
@override
String get scanner_stop => 'Stoppen';

View file

@ -272,6 +272,21 @@ class AppLocalizationsPl extends AppLocalizations {
return 'Połączenie nieudane: $error';
}
@override
String get scanner_linuxPairingPinTitle =>
'PIN do sparowania przez Bluetooth';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Wprowadź kod PIN dla $deviceName (pust, jeśli nie jest wymagany).';
}
@override
String get scanner_linuxPairingShowPin => 'Wyświetl kod PIN';
@override
String get scanner_linuxPairingHidePin => 'Ukryj kod PIN';
@override
String get scanner_stop => 'Zatrzymaj';

View file

@ -270,6 +270,20 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Falha na conexão: $error';
}
@override
String get scanner_linuxPairingPinTitle => 'PIN de pareamento Bluetooth';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Insira o código PIN para $deviceName (deixe em branco se não houver).';
}
@override
String get scanner_linuxPairingShowPin => 'Mostrar PIN';
@override
String get scanner_linuxPairingHidePin => 'Esconder o PIN';
@override
String get scanner_stop => 'Pare';

View file

@ -270,6 +270,21 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Подключение не удалось: $error';
}
@override
String get scanner_linuxPairingPinTitle =>
'PIN для сопряжения устройств по Bluetooth';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Введите PIN-код для $deviceName (оставьте поле пустым, если PIN-код отсутствует).';
}
@override
String get scanner_linuxPairingShowPin => 'Показать PIN-код';
@override
String get scanner_linuxPairingHidePin => 'Скрыть PIN-код';
@override
String get scanner_stop => 'Стоп';

View file

@ -269,6 +269,20 @@ class AppLocalizationsSk extends AppLocalizations {
return 'Pripojenie zlyhalo: $error';
}
@override
String get scanner_linuxPairingPinTitle => 'PIN pre párovanie cez Bluetooth';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Zadajte PIN pre $deviceName (nechajte prázdne, ak neexistuje).';
}
@override
String get scanner_linuxPairingShowPin => 'Zobraziť PIN';
@override
String get scanner_linuxPairingHidePin => 'Skryť PIN';
@override
String get scanner_stop => 'Zastavte';

View file

@ -267,6 +267,21 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Pošlo je z povezavo: $error';
}
@override
String get scanner_linuxPairingPinTitle =>
'PIN za združevanje preko Bluetootha';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Vnesite PIN kodo za $deviceName (ostavite prazno, če nimate kode).';
}
@override
String get scanner_linuxPairingShowPin => 'Prikaži PIN';
@override
String get scanner_linuxPairingHidePin => 'Skrijte PIN';
@override
String get scanner_stop => 'Prekliči';

View file

@ -266,6 +266,20 @@ class AppLocalizationsSv extends AppLocalizations {
return 'Anslutning misslyckades: $error';
}
@override
String get scanner_linuxPairingPinTitle => 'PIN för Bluetooth-parning';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Ange PIN-kod för $deviceName (lämna tomt om ingen finns).';
}
@override
String get scanner_linuxPairingShowPin => 'Visa PIN-kod';
@override
String get scanner_linuxPairingHidePin => 'Dölj PIN-kod';
@override
String get scanner_stop => 'Stoppa';

View file

@ -269,6 +269,21 @@ class AppLocalizationsUk extends AppLocalizations {
return 'Помилка підключення: $error';
}
@override
String get scanner_linuxPairingPinTitle =>
'PIN для з\'єднання через Bluetooth';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Введіть PIN-код для $deviceName (залиште поле порожнім, якщо немає).';
}
@override
String get scanner_linuxPairingShowPin => 'Показати PIN-код';
@override
String get scanner_linuxPairingHidePin => 'Приховати PIN-код';
@override
String get scanner_stop => 'Стоп';

View file

@ -256,6 +256,20 @@ class AppLocalizationsZh extends AppLocalizations {
return '连接失败:$error';
}
@override
String get scanner_linuxPairingPinTitle => '蓝牙配对 PIN';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return '输入 $deviceName 的 PIN 码(如果为空,则留空)。';
}
@override
String get scanner_linuxPairingShowPin => '显示PIN码';
@override
String get scanner_linuxPairingHidePin => '隐藏PIN码';
@override
String get scanner_stop => '停止';

View file

@ -1910,5 +1910,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Gewicht verwijderd van een pad na een mislukte levering",
"appSettings_maxMessageRetries": "Aantal pogingen om berichten te versturen",
"appSettings_maxMessageRetriesSubtitle": "Aantal pogingen om een bericht opnieuw te versturen voordat het als mislukt wordt gemarkeerd",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingPinTitle": "PIN voor Bluetooth-koppeling",
"scanner_linuxPairingShowPin": "Toon PIN",
"scanner_linuxPairingHidePin": "Verberg PIN",
"scanner_linuxPairingPinPrompt": "Voer het pincode-in voor {deviceName} in (laat dit leeg als er geen is)."
}

View file

@ -1910,5 +1910,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Waga usunięta z trasy po nieudanej dostawie",
"appSettings_maxMessageRetries": "Maksymalna liczba prób wysłania wiadomości",
"appSettings_maxMessageRetriesSubtitle": "Liczba prób ponownego wysłania wiadomości przed oznaczaniem jej jako nieudanej",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingHidePin": "Ukryj kod PIN",
"scanner_linuxPairingPinTitle": "PIN do sparowania przez Bluetooth",
"scanner_linuxPairingPinPrompt": "Wprowadź kod PIN dla {deviceName} (pust, jeśli nie jest wymagany).",
"scanner_linuxPairingShowPin": "Wyświetl kod PIN"
}

View file

@ -1910,5 +1910,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Peso removido de um caminho após uma tentativa de entrega malsucedida.",
"appSettings_maxMessageRetries": "Número máximo de tentativas de envio de mensagens",
"appSettings_maxMessageRetriesSubtitle": "Número de tentativas de reenvio antes de classificar uma mensagem como falha.",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingPinTitle": "PIN de pareamento Bluetooth",
"scanner_linuxPairingPinPrompt": "Insira o código PIN para {deviceName} (deixe em branco se não houver).",
"scanner_linuxPairingShowPin": "Mostrar PIN",
"scanner_linuxPairingHidePin": "Esconder o PIN"
}

View file

@ -1150,5 +1150,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Вес, который был удален с пути после неудачной доставки.",
"appSettings_maxMessageRetries": "Максимальное количество повторных попыток отправки сообщения",
"appSettings_maxMessageRetriesSubtitle": "Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingShowPin": "Показать PIN-код",
"scanner_linuxPairingPinPrompt": "Введите PIN-код для {deviceName} (оставьте поле пустым, если PIN-код отсутствует).",
"scanner_linuxPairingHidePin": "Скрыть PIN-код",
"scanner_linuxPairingPinTitle": "PIN для сопряжения устройств по Bluetooth"
}

View file

@ -1910,5 +1910,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Hmotnosť odstránená z cesty po neúspešnej doručenie",
"appSettings_maxMessageRetries": "Maximalný počet pokusov o doručenie správ",
"appSettings_maxMessageRetriesSubtitle": "Počet pokusov o odošleť pred označením správy ako neúspešnej",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingPinPrompt": "Zadajte PIN pre {deviceName} (nechajte prázdne, ak neexistuje).",
"scanner_linuxPairingShowPin": "Zobraziť PIN",
"scanner_linuxPairingHidePin": "Skryť PIN",
"scanner_linuxPairingPinTitle": "PIN pre párovanie cez Bluetooth"
}

View file

@ -1910,5 +1910,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Težo, ki ni bila uspešno dostavljena, odstranili s poti.",
"appSettings_maxMessageRetries": "Najve število poskusov pošiljanja sporočil",
"appSettings_maxMessageRetriesSubtitle": "Število poskusov ponovnega poslanja, preden se sporočilo označuje kot neuspešno",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingShowPin": "Prikaži PIN",
"scanner_linuxPairingPinPrompt": "Vnesite PIN kodo za {deviceName} (ostavite prazno, če nimate kode).",
"scanner_linuxPairingHidePin": "Skrijte PIN",
"scanner_linuxPairingPinTitle": "PIN za združevanje preko Bluetootha"
}

View file

@ -1910,5 +1910,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Vikt som tagits bort från en väg efter ett misslyckat leveransförsök",
"appSettings_maxMessageRetries": "Maximalt antal försök",
"appSettings_maxMessageRetriesSubtitle": "Antal försök att skicka om ett meddelande innan det markeras som misslyckat.",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingPinPrompt": "Ange PIN-kod för {deviceName} (lämna tomt om ingen finns).",
"scanner_linuxPairingHidePin": "Dölj PIN-kod",
"scanner_linuxPairingShowPin": "Visa PIN-kod",
"scanner_linuxPairingPinTitle": "PIN för Bluetooth-parning"
}

View file

@ -1910,5 +1910,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "Вага, яка була знята з маршруту після невдалої доставки",
"appSettings_maxMessageRetries": "Максимальна кількість повторних спроб надсилання повідомлення",
"appSettings_maxMessageRetriesSubtitle": "Кількість спроб повторного відправлення повідомлення перед тим, як позначити його як невдале",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingHidePin": "Приховати PIN-код",
"scanner_linuxPairingPinTitle": "PIN для з'єднання через Bluetooth",
"scanner_linuxPairingPinPrompt": "Введіть PIN-код для {deviceName} (залиште поле порожнім, якщо немає).",
"scanner_linuxPairingShowPin": "Показати PIN-код"
}

View file

@ -1915,5 +1915,16 @@
"appSettings_routeWeightFailureDecrementSubtitle": "从一条路径上移除的货物,由于无法成功交付而移除。",
"appSettings_maxMessageRetries": "最大消息重试次数",
"appSettings_maxMessageRetriesSubtitle": "在将消息标记为失败之前,允许尝试的次数",
"path_routeWeight": "{weight}/{max}"
"path_routeWeight": "{weight}/{max}",
"@scanner_linuxPairingPinPrompt": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"scanner_linuxPairingPinTitle": "蓝牙配对 PIN",
"scanner_linuxPairingHidePin": "隐藏PIN码",
"scanner_linuxPairingPinPrompt": "输入 {deviceName} 的 PIN 码(如果为空,则留空)。",
"scanner_linuxPairingShowPin": "显示PIN码"
}

View file

@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/linux_ble_error_classifier.dart';
import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart';
@ -288,12 +289,33 @@ class _ScannerScreenState extends State<ScannerScreen> {
MeshCoreConnector connector,
ScanResult result,
) async {
final name = result.device.platformName.isNotEmpty
? result.device.platformName
: result.advertisementData.advName;
try {
final name = result.device.platformName.isNotEmpty
? result.device.platformName
: result.advertisementData.advName;
await connector.connect(result.device, displayName: name);
await connector.connect(
result.device,
displayName: name,
linuxPairingPinProvider: PlatformInfo.isLinux
? () async {
if (!context.mounted) return null;
return _promptLinuxPairingPin(context, name);
}
: null,
);
} catch (e) {
final errorText = e.toString();
final suppressTransientLinuxConnectError =
PlatformInfo.isLinux &&
connector.isAutoReconnectScheduled &&
isLinuxBleConnectFailureText(errorText);
if (suppressTransientLinuxConnectError) {
appLogger.info(
'Suppressing transient Linux connect error while auto-reconnect is active: $e',
tag: 'ScannerScreen',
);
return;
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -305,6 +327,92 @@ class _ScannerScreenState extends State<ScannerScreen> {
}
}
Future<String?> _promptLinuxPairingPin(
BuildContext context,
String deviceName,
) async {
final l10n = context.l10n;
var pinValue = '';
var obscure = true;
appLogger.info(
'Showing Linux BLE pairing PIN prompt for $deviceName',
tag: 'ScannerScreen',
);
final pin = await showDialog<String>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (dialogContext, setDialogState) {
return AlertDialog(
title: Text(l10n.scanner_linuxPairingPinTitle),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.scanner_linuxPairingPinPrompt(deviceName)),
const SizedBox(height: 12),
TextField(
autofocus: true,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
obscureText: obscure,
enableSuggestions: false,
autocorrect: false,
onChanged: (value) {
pinValue = value.trim();
},
onSubmitted: (value) {
Navigator.of(dialogContext).pop(value.trim());
},
decoration: InputDecoration(
suffixIcon: IconButton(
onPressed: () {
setDialogState(() {
obscure = !obscure;
});
},
icon: Icon(
obscure ? Icons.visibility : Icons.visibility_off,
),
tooltip: obscure
? l10n.scanner_linuxPairingShowPin
: l10n.scanner_linuxPairingHidePin,
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(null),
child: Text(l10n.common_cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(pinValue),
child: Text(l10n.common_connect),
),
],
);
},
);
},
);
if (pin == null) {
appLogger.info(
'Linux BLE pairing PIN prompt cancelled for $deviceName',
tag: 'ScannerScreen',
);
return null;
}
appLogger.info(
'Linux BLE pairing PIN prompt completed for $deviceName',
tag: 'ScannerScreen',
);
return pin;
}
Widget _bluetoothOffWarning(BuildContext context) {
final errorColor = Theme.of(context).colorScheme.error;
return Container(

View file

@ -0,0 +1,37 @@
const String linuxConnectStageFailureMarker = 'linux connect stage failure';
bool isLinuxBleConnectFailureText(String errorText) {
final lowerErrorText = errorText.toLowerCase();
if (isLinuxBlePairingFailureText(errorText)) {
return false;
}
return lowerErrorText.contains(linuxConnectStageFailureMarker) ||
lowerErrorText.contains('| connect |') ||
lowerErrorText.contains('linux connect hard-timeout') ||
lowerErrorText.contains('org.bluez.error.failed') ||
lowerErrorText.contains('org.bluez.error.inprogress') ||
lowerErrorText.contains('le-connection-abort-by-local');
}
bool isLinuxBlePairingFailureText(String errorText) {
final lowerErrorText = errorText.toLowerCase();
final isPairingSpecificStateError =
lowerErrorText.contains('bad state: no element') &&
(lowerErrorText.contains('pair') ||
lowerErrorText.contains('bond') ||
lowerErrorText.contains('trust'));
return lowerErrorText.contains('authenticationfailed') ||
lowerErrorText.contains('authentication failed') ||
lowerErrorText.contains('notpermitted: not paired') ||
lowerErrorText.contains('pairing fallback failed') ||
lowerErrorText.contains('linux ble pairing did not complete') ||
lowerErrorText.contains('linux ble trust repair did not complete') ||
isPairingSpecificStateError ||
isLikelyLinuxBlePairingTimeoutText(errorText);
}
bool isLikelyLinuxBlePairingTimeoutText(String errorText) {
final lowerErrorText = errorText.toLowerCase();
return lowerErrorText.contains('timed out') &&
(lowerErrorText.contains('pair') || lowerErrorText.contains('bond'));
}

View file

@ -0,0 +1,423 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
typedef ProcessStartFn =
Future<Process> Function(String executable, List<String> arguments);
typedef ProcessRunFn =
Future<ProcessResult> Function(String executable, List<String> arguments);
/// Best-effort Linux BLE pairing helper using bluetoothctl.
///
/// This is used only as a fallback when BlueZ pairing via flutter_blue_plus
/// fails to surface agent prompts in-app.
class LinuxBlePairingService {
/// Maximum number of retry attempts for the pairing flow.
/// Covers one remove-and-retry plus one proactive-PIN retry.
static const int _maxRetries = 2;
static const Duration _processExitTimeout = Duration(seconds: 6);
static const Duration _pairingCleanupTimeout = Duration(seconds: 5);
static const Duration _defaultPairingTimeout = Duration(seconds: 45);
LinuxBlePairingService({
ProcessStartFn? processStart,
ProcessRunFn? processRun,
}) : _processStart = processStart ?? Process.start,
_processRun = processRun ?? Process.run;
final ProcessStartFn _processStart;
final ProcessRunFn _processRun;
Future<bool> isBluetoothctlAvailable() async {
try {
final result = await _processRun('bluetoothctl', <String>['--version']);
return result.exitCode == 0;
} on ProcessException {
return false;
}
}
Future<void> disconnectDevice(
String remoteId, {
void Function(String message)? onLog,
}) async {
onLog?.call('Requesting BlueZ disconnect for $remoteId');
Process process;
try {
process = await _processStart('bluetoothctl', <String>[]);
} on ProcessException catch (error) {
onLog?.call(
'bluetoothctl unavailable, skipping BlueZ disconnect: $error',
);
return;
}
process.stdin.writeln('disconnect $remoteId');
process.stdin.writeln('quit');
try {
await process.exitCode.timeout(_processExitTimeout);
} catch (_) {
process.kill();
}
onLog?.call('Issued bluetoothctl disconnect for $remoteId');
}
Future<bool> isPairedAndTrusted(String remoteId) async {
ProcessResult result;
try {
result = await _processRun('bluetoothctl', <String>['info', remoteId]);
} on ProcessException {
return false;
}
if (result.exitCode != 0) {
return false;
}
final output = (result.stdout as String).toLowerCase();
return output.contains('paired: yes') && output.contains('trusted: yes');
}
Future<bool> trustDevice(
String remoteId, {
void Function(String message)? onLog,
}) async {
onLog?.call('Requesting BlueZ trust for $remoteId');
ProcessResult result;
try {
result = await _processRun('bluetoothctl', <String>['trust', remoteId]);
} on ProcessException catch (error) {
onLog?.call('bluetoothctl unavailable, cannot trust $remoteId: $error');
return false;
}
if (result.exitCode != 0) {
onLog?.call('bluetoothctl trust failed for $remoteId: ${result.stderr}');
return false;
}
final trusted = await isPairedAndTrusted(remoteId);
onLog?.call(
trusted
? 'Verified BlueZ trust for $remoteId'
: 'BlueZ trust verification failed for $remoteId',
);
return trusted;
}
Future<bool> pairAndTrust({
required String remoteId,
Duration timeout = _defaultPairingTimeout,
void Function(String message)? onLog,
Future<String?> Function()? onRequestPin,
}) async {
var removeRetryUsed = false;
var proactivePinRetryUsed = false;
Future<String?> Function()? currentPinProvider = onRequestPin;
for (var attempt = 0; attempt <= _maxRetries; attempt++) {
final result = await _runPairingAttempt(
remoteId: remoteId,
timeout: timeout,
onLog: onLog,
onRequestPin: currentPinProvider,
);
if (result.success) return true;
if (result.userCancelled) {
onLog?.call('Pairing cancelled by user; skipping retry/remove flow');
return false;
}
if (result.pairFailed) {
if (!removeRetryUsed) {
removeRetryUsed = true;
onLog?.call(
'Pairing failed; removing cached bond and retrying '
'(attempt ${attempt + 1}/$_maxRetries)',
);
await _removeDevice(remoteId, onLog: onLog);
continue;
}
if (!result.pinSent &&
!proactivePinRetryUsed &&
currentPinProvider != null) {
proactivePinRetryUsed = true;
onLog?.call(
'Pairing failed before PIN challenge; requesting PIN for '
'proactive retry (attempt ${attempt + 1}/$_maxRetries)',
);
final pin = await currentPinProvider();
if (pin == null) {
onLog?.call('PIN entry cancelled for proactive retry');
return false;
}
final capturedPin = pin.trim();
currentPinProvider = () async => capturedPin;
continue;
}
return false;
}
// Timeout path pairing neither succeeded nor failed.
onLog?.call('Pairing did not complete before timeout');
if (!result.pinSent &&
!proactivePinRetryUsed &&
currentPinProvider != null) {
proactivePinRetryUsed = true;
onLog?.call(
'No PIN challenge observed before timeout; requesting PIN for '
'proactive retry (attempt ${attempt + 1}/$_maxRetries)',
);
final pin = await currentPinProvider();
if (pin == null) {
onLog?.call('PIN entry cancelled for proactive retry after timeout');
return false;
}
final capturedPin = pin.trim();
currentPinProvider = () async => capturedPin;
continue;
}
return false;
}
return false;
}
/// Runs a single bluetoothctl pairing attempt.
///
/// Uses a [Completer] to wake as soon as pairing succeeds or fails,
/// instead of polling.
Future<_PairingResult> _runPairingAttempt({
required String remoteId,
required Duration timeout,
void Function(String message)? onLog,
Future<String?> Function()? onRequestPin,
}) async {
onLog?.call('Starting bluetoothctl pairing flow for $remoteId');
Process process;
try {
process = await _processStart('bluetoothctl', <String>[]);
} on ProcessException catch (error) {
onLog?.call('bluetoothctl unavailable, cannot run pairing flow: $error');
return const _PairingResult();
}
final output = StringBuffer();
var pinSent = false;
var sessionClosed = false;
var userCancelledPinEntry = false;
var confirmationHandled = false;
var successHandled = false;
var failureHandled = false;
var detectorBuffer = '';
final pairingDone = Completer<void>();
var pairSucceeded = false;
var pairFailed = false;
void writeCmd(String cmd) {
if (sessionClosed) return;
try {
process.stdin.writeln(cmd);
} on StateError {
sessionClosed = true;
onLog?.call('bluetoothctl stdin already closed; ignoring "$cmd"');
}
}
unawaited(
process.exitCode.then((_) {
sessionClosed = true;
if (!pairingDone.isCompleted) pairingDone.complete();
}),
);
void handleChunk(String chunk) {
output.write(chunk);
detectorBuffer += chunk.toLowerCase();
if (detectorBuffer.length > 4096) {
detectorBuffer = detectorBuffer.substring(detectorBuffer.length - 4096);
}
final lower = detectorBuffer;
if (!pinSent &&
!sessionClosed &&
(lower.contains('enter pin code') ||
lower.contains('requestpin') ||
lower.contains('input pin code') ||
lower.contains('request passkey') ||
lower.contains('requestpasskey') ||
lower.contains('enter passkey'))) {
pinSent = true;
if (onRequestPin == null) {
onLog?.call(
'PIN/passkey requested but no onRequestPin callback; '
'sending empty line to accept default pairing',
);
writeCmd('');
} else {
onLog?.call('Pairing agent is ready for PIN/passkey input');
unawaited(
Future<void>(() async {
String? pin;
try {
pin = await onRequestPin();
} catch (e) {
onLog?.call('onRequestPin callback threw: $e');
pairFailed = true;
writeCmd('cancel');
if (!pairingDone.isCompleted) pairingDone.complete();
return;
}
if (pin == null) {
if (sessionClosed) {
onLog?.call(
'PIN prompt resolved after pairing session closed',
);
return;
}
onLog?.call('PIN entry cancelled by user; cancelling pairing');
userCancelledPinEntry = true;
pairFailed = true;
writeCmd('cancel');
if (!pairingDone.isCompleted) pairingDone.complete();
return;
}
if (sessionClosed) {
onLog?.call(
'PIN provided after pairing session closed; ignoring',
);
return;
}
if (pin.trim().isEmpty) {
onLog?.call(
'Blank PIN submitted; sending empty line to accept default pairing',
);
writeCmd('');
} else {
onLog?.call('Submitting PIN/passkey to pairing agent');
writeCmd(pin.trim());
}
}),
);
}
}
if (!confirmationHandled &&
(lower.contains('confirm passkey') ||
lower.contains('requestconfirmation') ||
lower.contains('[agent] confirm'))) {
confirmationHandled = true;
onLog?.call(
'Pairing agent requested passkey confirmation; answering yes',
);
writeCmd('yes');
}
if (!successHandled &&
(lower.contains('pairing successful') ||
lower.contains('already paired'))) {
successHandled = true;
onLog?.call('Pairing reported success');
pairSucceeded = true;
if (!pairingDone.isCompleted) pairingDone.complete();
}
if (!failureHandled &&
(lower.contains('failed to pair') ||
lower.contains('authenticationfailed') ||
lower.contains('authentication failed'))) {
failureHandled = true;
onLog?.call('Pairing reported authentication failure');
pairFailed = true;
if (!pairingDone.isCompleted) pairingDone.complete();
}
}
final stdoutSub = process.stdout
.transform(utf8.decoder)
.listen(handleChunk);
final stderrSub = process.stderr
.transform(utf8.decoder)
.listen(handleChunk);
writeCmd('power on');
writeCmd('agent KeyboardDisplay');
writeCmd('default-agent');
onLog?.call('Waiting for pairing challenge from bluetoothctl agent');
writeCmd('pair $remoteId');
// Wait for the Completer to fire (success/failure/process exit) or timeout.
await pairingDone.future.timeout(timeout, onTimeout: () {});
if (!pairFailed && pairSucceeded) {
onLog?.call('Pair succeeded; trusting and connecting device');
writeCmd('trust $remoteId');
writeCmd('connect $remoteId');
}
writeCmd('quit');
sessionClosed = true;
try {
await process.exitCode.timeout(_pairingCleanupTimeout);
} catch (_) {
process.kill();
}
await stdoutSub.cancel();
await stderrSub.cancel();
if (pairFailed) {
return _PairingResult(
pairFailed: true,
pinSent: pinSent,
userCancelled: userCancelledPinEntry,
);
}
final allOutput = output.toString().toLowerCase();
final reportedSuccess =
pairSucceeded ||
allOutput.contains('pairing successful') ||
allOutput.contains('already paired');
if (reportedSuccess) {
final trusted = await trustDevice(remoteId, onLog: onLog);
if (!trusted) {
onLog?.call('Pairing completed but BlueZ trust was not restored');
}
return _PairingResult(success: trusted, pinSent: pinSent);
}
return _PairingResult(pinSent: pinSent);
}
Future<void> _removeDevice(
String remoteId, {
void Function(String message)? onLog,
}) async {
Process process;
try {
process = await _processStart('bluetoothctl', <String>[]);
} on ProcessException catch (error) {
onLog?.call(
'bluetoothctl unavailable, skipping remove for $remoteId: $error',
);
return;
}
process.stdin.writeln('remove $remoteId');
process.stdin.writeln('quit');
try {
await process.exitCode.timeout(_processExitTimeout);
} catch (_) {
process.kill();
}
onLog?.call('Issued bluetoothctl remove for $remoteId');
}
}
/// Outcome of a single bluetoothctl pairing attempt.
class _PairingResult {
final bool success;
final bool pairFailed;
final bool pinSent;
final bool userCancelled;
const _PairingResult({
this.success = false,
this.pairFailed = false,
this.pinSent = false,
this.userCancelled = false,
});
}

View file

@ -0,0 +1,28 @@
/// No-op stub for web builds where dart:io is unavailable.
///
/// The real implementation lives in linux_ble_pairing_service.dart and is
/// selected via conditional import in meshcore_connector.dart.
class LinuxBlePairingService {
LinuxBlePairingService();
Future<bool> isBluetoothctlAvailable() async => false;
Future<void> disconnectDevice(
String remoteId, {
void Function(String message)? onLog,
}) async {}
Future<bool> isPairedAndTrusted(String remoteId) async => false;
Future<bool> trustDevice(
String remoteId, {
void Function(String message)? onLog,
}) async => false;
Future<bool> pairAndTrust({
required String remoteId,
Duration timeout = const Duration(seconds: 45),
void Function(String message)? onLog,
Future<String?> Function()? onRequestPin,
}) async => false;
}

View file

@ -69,6 +69,7 @@ dependencies:
material_symbols_icons: ^4.2906.0
web: ^1.1.1
flutter_svg: ^2.0.10+1
flutter_blue_plus_platform_interface: ^8.2.1
ml_algo: ^16.0.0
ml_dataframe: ^1.0.0

View file

@ -0,0 +1,150 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/services/linux_ble_error_classifier.dart';
void main() {
group('isLinuxBleConnectFailureText', () {
test('matches flutter_blue_plus connect timeout error', () {
expect(
isLinuxBleConnectFailureText(
'FlutterBluePlusException | connect | fbp-code: 1 | Timed out after 15s',
),
isTrue,
);
});
test('matches hard-timeout marker', () {
expect(
isLinuxBleConnectFailureText(
'TimeoutException: Linux connect hard-timeout after 8s',
),
isTrue,
);
});
test('matches BlueZ local abort failure', () {
expect(
isLinuxBleConnectFailureText(
'org.bluez.Error.Failed: le-connection-abort-by-local',
),
isTrue,
);
});
test('matches BlueZ in-progress failure', () {
expect(
isLinuxBleConnectFailureText(
'org.bluez.Error.InProgress: Operation already in progress',
),
isTrue,
);
});
test('matches flutter_blue_plus null-detail connect failure', () {
expect(
isLinuxBleConnectFailureText(
'FlutterBluePlusException | connect | linux-code: null | null',
),
isTrue,
);
});
test('matches tagged connect-stage failure marker', () {
expect(
isLinuxBleConnectFailureText(
'StateError: Linux connect stage failure: Bad state: No element',
),
isTrue,
);
});
test('does not match connect-shaped pairing auth failure', () {
expect(
isLinuxBleConnectFailureText(
'FlutterBluePlusException | connect | AuthenticationFailed',
),
isFalse,
);
});
test('does not match explicit pair auth failure', () {
expect(
isLinuxBleConnectFailureText(
'FlutterBluePlusException | pair | AuthenticationFailed',
),
isFalse,
);
});
});
group('isLikelyLinuxBlePairingTimeoutText', () {
test('matches pair timeout text', () {
expect(
isLikelyLinuxBlePairingTimeoutText('Timed out waiting for pair'),
isTrue,
);
});
test('matches bond timeout text', () {
expect(
isLikelyLinuxBlePairingTimeoutText('Operation timed out during bond'),
isTrue,
);
});
test('does not match generic timeout text', () {
expect(
isLikelyLinuxBlePairingTimeoutText('Timed out after 15s'),
isFalse,
);
});
});
group('isLinuxBlePairingFailureText', () {
test('matches connect-shaped authentication failure', () {
expect(
isLinuxBlePairingFailureText(
'FlutterBluePlusException | connect | AuthenticationFailed',
),
isTrue,
);
});
test('matches app pairing incomplete failure', () {
expect(
isLinuxBlePairingFailureText(
'StateError: Linux BLE pairing did not complete',
),
isTrue,
);
});
test('does not match generic bad state error', () {
expect(isLinuxBlePairingFailureText('Bad state: No element'), isFalse);
});
test('matches pair-context bad state error', () {
expect(
isLinuxBlePairingFailureText(
'Pair request failed: Bad state: No element',
),
isTrue,
);
});
test('matches app trust repair incomplete failure', () {
expect(
isLinuxBlePairingFailureText(
'StateError: Linux BLE trust repair did not complete',
),
isTrue,
);
});
test('matches pairing timeout text', () {
expect(
isLinuxBlePairingFailureText('Timed out waiting for pair'),
isTrue,
);
});
});
}

View file

@ -0,0 +1,418 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/services/linux_ble_pairing_service.dart';
class _FakeProcess implements Process {
_FakeProcess({this.stdoutText = '', this.autoFinish = true}) {
_stdin = IOSink(_stdinController.sink);
_stdinController.stream.listen((chunk) {
_stdinBuffer.write(utf8.decode(chunk));
});
// Use Timer.run (event-loop tick) instead of microtask so that broadcast
// listeners in _runPairingAttempt are attached before the event fires.
Timer.run(() {
if (_closed) {
return;
}
if (stdoutText.isNotEmpty) {
_stdoutController.add(utf8.encode(stdoutText));
}
});
if (autoFinish) {
// Scheduled after the Timer.run above (FIFO order), so stdout is
// emitted before the process exits.
Timer(Duration.zero, () async {
await _finish(exitStatus);
});
}
}
final String stdoutText;
final bool autoFinish;
final int exitStatus = 0;
final StreamController<List<int>> _stdinController =
StreamController<List<int>>();
final StreamController<List<int>> _stdoutController =
StreamController<List<int>>.broadcast();
final StreamController<List<int>> _stderrController =
StreamController<List<int>>.broadcast();
final Completer<int> _exitCodeCompleter = Completer<int>();
final StringBuffer _stdinBuffer = StringBuffer();
late final IOSink _stdin;
bool _closed = false;
String get stdinText => _stdinBuffer.toString();
void emitStdout(String text) {
if (!_closed) {
_stdoutController.add(utf8.encode(text));
}
}
void finishProcess([int code = 0]) {
unawaited(_finish(code));
}
Future<void> _finish(int code) async {
if (_closed) {
return;
}
_closed = true;
await _stdin.close();
await _stdoutController.close();
await _stderrController.close();
if (!_exitCodeCompleter.isCompleted) {
_exitCodeCompleter.complete(code);
}
}
@override
Future<int> get exitCode => _exitCodeCompleter.future;
@override
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
unawaited(_finish(exitStatus));
return true;
}
@override
int get pid => 1;
@override
IOSink get stdin => _stdin;
@override
Stream<List<int>> get stderr => _stderrController.stream;
@override
Stream<List<int>> get stdout => _stdoutController.stream;
}
void main() {
test(
'disconnectDevice skips gracefully when bluetoothctl is unavailable',
() async {
final logs = <String>[];
final service = LinuxBlePairingService(
processStart: (executable, arguments) async {
throw const ProcessException(
'bluetoothctl',
<String>[],
'not found',
2,
);
},
);
await service.disconnectDevice('AA:BB:CC:DD:EE:FF', onLog: logs.add);
expect(
logs.any((line) => line.contains('bluetoothctl unavailable')),
isTrue,
);
},
);
test(
'isPairedAndTrusted returns false when bluetoothctl is unavailable',
() async {
final service = LinuxBlePairingService(
processRun: (executable, arguments) async {
throw const ProcessException(
'bluetoothctl',
<String>[],
'not found',
2,
);
},
);
final trusted = await service.isPairedAndTrusted('AA:BB:CC:DD:EE:FF');
expect(trusted, isFalse);
},
);
test('isBluetoothctlAvailable returns false when unavailable', () async {
final service = LinuxBlePairingService(
processRun: (executable, arguments) async {
throw const ProcessException(
'bluetoothctl',
<String>[],
'not found',
2,
);
},
);
final available = await service.isBluetoothctlAvailable();
expect(available, isFalse);
});
test(
'isBluetoothctlAvailable returns true when version command succeeds',
() async {
final service = LinuxBlePairingService(
processRun: (executable, arguments) async {
return ProcessResult(1234, 0, '5.72', '');
},
);
final available = await service.isBluetoothctlAvailable();
expect(available, isTrue);
},
);
test(
'isPairedAndTrusted returns true when paired and trusted are yes',
() async {
final service = LinuxBlePairingService(
processRun: (executable, arguments) async {
return ProcessResult(1234, 0, '''
Device AA:BB:CC:DD:EE:FF
Paired: yes
Trusted: yes
''', '');
},
);
final trusted = await service.isPairedAndTrusted('AA:BB:CC:DD:EE:FF');
expect(trusted, isTrue);
},
);
test('pairAndTrust returns false when bluetoothctl is unavailable', () async {
final service = LinuxBlePairingService(
processStart: (executable, arguments) async {
throw const ProcessException(
'bluetoothctl',
<String>[],
'not found',
2,
);
},
);
final paired = await service.pairAndTrust(remoteId: 'AA:BB:CC:DD:EE:FF');
expect(paired, isFalse);
});
test('trustDevice verifies trust after trust command succeeds', () async {
final logs = <String>[];
final service = LinuxBlePairingService(
processRun: (executable, arguments) async {
switch (arguments.first) {
case 'trust':
return ProcessResult(1234, 0, 'trust succeeded', '');
case 'info':
return ProcessResult(1234, 0, '''
Device AA:BB:CC:DD:EE:FF
Paired: yes
Trusted: yes
''', '');
}
fail('Unexpected bluetoothctl arguments: $arguments');
},
);
final trusted = await service.trustDevice(
'AA:BB:CC:DD:EE:FF',
onLog: logs.add,
);
expect(trusted, isTrue);
expect(logs.any((line) => line.contains('Verified BlueZ trust')), isTrue);
});
test(
'trustDevice returns false when trust verification stays untrusted',
() async {
final logs = <String>[];
final service = LinuxBlePairingService(
processRun: (executable, arguments) async {
switch (arguments.first) {
case 'trust':
return ProcessResult(1234, 0, 'trust succeeded', '');
case 'info':
return ProcessResult(1234, 0, '''
Device AA:BB:CC:DD:EE:FF
Paired: yes
Trusted: no
''', '');
}
fail('Unexpected bluetoothctl arguments: $arguments');
},
);
final trusted = await service.trustDevice(
'AA:BB:CC:DD:EE:FF',
onLog: logs.add,
);
expect(trusted, isFalse);
expect(
logs.any((line) => line.contains('trust verification failed')),
isTrue,
);
},
);
test(
'pairAndTrust fails when pairing reports success but trust is not restored',
() async {
final logs = <String>[];
final service = LinuxBlePairingService(
processStart: (executable, arguments) async =>
_FakeProcess(stdoutText: 'Pairing successful\n'),
processRun: (executable, arguments) async {
switch (arguments.first) {
case 'trust':
return ProcessResult(1234, 0, 'trust succeeded', '');
case 'info':
return ProcessResult(1234, 0, '''
Device AA:BB:CC:DD:EE:FF
Paired: yes
Trusted: no
''', '');
}
fail('Unexpected bluetoothctl arguments: $arguments');
},
);
final paired = await service.pairAndTrust(
remoteId: 'AA:BB:CC:DD:EE:FF',
onLog: logs.add,
);
expect(paired, isFalse);
expect(
logs.any((line) => line.contains('trust was not restored')),
isTrue,
);
},
);
test(
'pairAndTrust succeeds without requesting proactive PIN after success',
() async {
final logs = <String>[];
var pinRequests = 0;
final service = LinuxBlePairingService(
processStart: (executable, arguments) async =>
_FakeProcess(stdoutText: 'Pairing successful\n'),
processRun: (executable, arguments) async {
switch (arguments.first) {
case 'trust':
return ProcessResult(1234, 0, 'trust succeeded', '');
case 'info':
return ProcessResult(1234, 0, '''
Device AA:BB:CC:DD:EE:FF
Paired: yes
Trusted: yes
''', '');
}
fail('Unexpected bluetoothctl arguments: $arguments');
},
);
final paired = await service.pairAndTrust(
remoteId: 'AA:BB:CC:DD:EE:FF',
onLog: logs.add,
onRequestPin: () async {
pinRequests++;
return '123456';
},
);
expect(paired, isTrue);
expect(pinRequests, 0);
expect(
logs.any((line) => line.contains('did not complete before timeout')),
isFalse,
);
},
);
test(
'pairAndTrust sends empty line when blank PIN is submitted (not cancel)',
() async {
final logs = <String>[];
late final _FakeProcess fakeProc;
final service = LinuxBlePairingService(
processStart: (executable, arguments) async {
fakeProc = _FakeProcess(stdoutText: '', autoFinish: false);
// Emit PIN prompt after an event-loop tick (not microtask) so
// broadcast listeners are attached first.
Timer.run(() {
fakeProc.emitStdout('Enter PIN code:\n');
Future<void>.delayed(const Duration(milliseconds: 100), () {
fakeProc.emitStdout('Pairing successful\n');
Future<void>.delayed(const Duration(milliseconds: 50), () {
fakeProc.finishProcess();
});
});
});
return fakeProc;
},
processRun: (executable, arguments) async {
switch (arguments.first) {
case 'trust':
return ProcessResult(1234, 0, 'trust succeeded', '');
case 'info':
return ProcessResult(1234, 0, '''
Device AA:BB:CC:DD:EE:FF
Paired: yes
Trusted: yes
''', '');
}
fail('Unexpected bluetoothctl arguments: $arguments');
},
);
final paired = await service.pairAndTrust(
remoteId: 'AA:BB:CC:DD:EE:FF',
timeout: const Duration(seconds: 5),
onLog: logs.add,
onRequestPin: () async => '',
);
expect(paired, isTrue);
expect(logs.any((line) => line.contains('Blank PIN submitted')), isTrue);
expect(logs.any((line) => line.contains('cancelling pairing')), isFalse);
},
);
test('pairAndTrust cancels pairing when PIN dialog returns null', () async {
final logs = <String>[];
final service = LinuxBlePairingService(
processStart: (executable, arguments) async {
final proc = _FakeProcess(stdoutText: '', autoFinish: false);
Timer.run(() {
proc.emitStdout('Enter PIN code:\n');
// Process will be killed/quit by the pairing service after cancel
Future<void>.delayed(const Duration(milliseconds: 200), () {
proc.finishProcess();
});
});
return proc;
},
processRun: (executable, arguments) async {
return ProcessResult(1234, 0, '', '');
},
);
final paired = await service.pairAndTrust(
remoteId: 'AA:BB:CC:DD:EE:FF',
timeout: const Duration(seconds: 3),
onLog: logs.add,
onRequestPin: () async => null,
);
expect(paired, isFalse);
expect(logs.any((line) => line.contains('cancelled by user')), isTrue);
});
}