diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index d489238..b99ecf7 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -6,6 +6,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'; @@ -17,6 +18,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'; @@ -37,14 +41,9 @@ import '../storage/unread_store.dart'; import '../utils/app_logger.dart'; import '../utils/battery_utils.dart'; import '../utils/platform_info.dart'; +import 'meshcore_uuids.dart'; import 'meshcore_protocol.dart'; -class MeshCoreUuids { - static const String service = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; - static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; - static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; -} - class DirectRepeater { static const int maxAgeMinutes = 30; // Max age for direct repeater info final int pubkeyFirstByte; @@ -118,11 +117,14 @@ class MeshCoreConnector extends ChangeNotifier { String? _lastDeviceDisplayName; bool _manualDisconnect = false; final MeshCoreUsbManager _usbManager = MeshCoreUsbManager(); + final LinuxBlePairingService _linuxBlePairingService = + LinuxBlePairingService(); StreamSubscription? _usbFrameSubscription; final MeshCoreTcpConnector _tcpConnector = MeshCoreTcpConnector(); MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth; final List _scanResults = []; + final List _linuxSystemScanResults = []; final List _contacts = []; final List _discoveredContacts = []; final List _channels = []; @@ -287,6 +289,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 && @@ -1026,6 +1030,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 @@ -1064,15 +1069,21 @@ 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(); }); try { await FlutterBluePlus.startScan( - withKeywords: ["MeshCore-", "Whisper-"], + withKeywords: MeshCoreUuids.deviceNamePrefixes, webOptionalServices: [Guid(MeshCoreUuids.service)], timeout: timeout, androidScanMode: AndroidScanMode.lowLatency, @@ -1087,6 +1098,62 @@ class MeshCoreConnector extends ChangeNotifier { await stopScan(); } + Future _loadLinuxSystemDevicesForScan() async { + try { + final systemDevices = await FlutterBluePlus.systemDevices([ + Guid(MeshCoreUuids.service), + ]); + _linuxSystemScanResults + ..clear() + ..addAll( + systemDevices + .where( + (device) => MeshCoreUuids.deviceNamePrefixes.any( + device.platformName.startsWith, + ), + ) + .map( + (device) => ScanResult( + device: device, + advertisementData: AdvertisementData( + advName: device.platformName, + txPowerLevel: null, + appearance: null, + connectable: true, + manufacturerData: const >{}, + serviceData: const >{}, + serviceUuids: [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 stopScan() async { // Only call FlutterBluePlus.stopScan() when a scan is actually running. // Calling it when idle triggers a native BLE completion callback even @@ -1345,7 +1412,11 @@ class MeshCoreConnector extends ChangeNotifier { activeTransport == MeshCoreTransportType.tcp; } - Future connect(BluetoothDevice device, {String? displayName}) async { + Future connect( + BluetoothDevice device, { + String? displayName, + Future Function()? linuxPairingPinProvider, + }) async { if (_state == MeshCoreConnectionState.connecting || _state == MeshCoreConnectionState.connected) { return; @@ -1390,22 +1461,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 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.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.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'); @@ -1415,6 +1613,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 services; @@ -1528,11 +1731,222 @@ 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 _recoverLinuxConnectFailure( + BluetoothDevice device, { + required Future Function() attemptConnect, + Future 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 != null && + 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.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 _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 _ensureLinuxBleBond( + BluetoothDevice device, { + Future 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 if (beforeBondState == null) { + _appDebugLogService?.warn( + 'bluetoothctl unavailable and plugin bond state is unknown; skipping Linux pairing fallback', + 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' + : beforeBondState == null + ? 'Linux BLE device bond state unknown; 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 _waitForSelfInfo({required Duration timeout}) async { if (_selfPublicKey != null) return true; if (!isConnected) return false; @@ -1656,7 +2070,10 @@ class MeshCoreConnector extends ChangeNotifier { }); } - Future disconnect({bool manual = true}) async { + Future disconnect({ + bool manual = true, + bool skipBleDeviceDisconnect = false, + }) async { if (_state == MeshCoreConnectionState.disconnecting) return; final transportAtDisconnect = _activeTransport; final transportLabel = switch (transportAtDisconnect) { @@ -1700,11 +2117,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; diff --git a/lib/connector/meshcore_uuids.dart b/lib/connector/meshcore_uuids.dart new file mode 100644 index 0000000..da7f6b5 --- /dev/null +++ b/lib/connector/meshcore_uuids.dart @@ -0,0 +1,12 @@ +class MeshCoreUuids { + static const String service = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; + static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; + static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; + + static const List deviceNamePrefixes = [ + "MeshCore-", + "Whisper-", + "WisCore-", + "HT-", + ]; +} diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index d4ebb4b..0f5145d 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -2006,5 +2006,16 @@ "radioStats_stripNoise": "Ниво на шума: {noiseDbm} dBm", "radioStats_stripWaiting": "Извличане на данни за радиото…", "radioStats_settingsTile": "Статистически данни за радиостанции", - "radioStats_settingsSubtitle": "Ниво на шума, RSSI, SNR и време на пренос" + "radioStats_settingsSubtitle": "Ниво на шума, RSSI, SNR и време на пренос", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingHidePin": "Скрий ПИН", + "scanner_linuxPairingShowPin": "Покажи PIN", + "scanner_linuxPairingPinTitle": "PIN код за сдвояване на Bluetooth", + "scanner_linuxPairingPinPrompt": "Въведете ПИН за {deviceName} (оставете празно, ако няма)." } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c128a3a..c156a44 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2034,5 +2034,16 @@ "radioStats_stripNoise": "Rauschpegel: {noiseDbm} dBm", "radioStats_stripWaiting": "Abrufen von Radiostatus…", "radioStats_settingsTile": "Senderinformationen", - "radioStats_settingsSubtitle": "Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit" + "radioStats_settingsSubtitle": "Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "PIN anzeigen", + "scanner_linuxPairingHidePin": "PIN ausblenden", + "scanner_linuxPairingPinTitle": "Bluetooth-Paarungs-PIN", + "scanner_linuxPairingPinPrompt": "Geben Sie die PIN für {deviceName} ein (leer lassen, falls keine)." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 113fa2b..d8d73ab 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -127,6 +127,7 @@ } } }, + "scanner_stop": "Stop", "scanner_scan": "Scan", "scanner_bluetoothOff": "Bluetooth is off", @@ -2040,5 +2041,16 @@ }, "radioStats_stripWaiting": "Fetching radio stats…", "radioStats_settingsTile": "Radio stats", - "radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime" -} + "radioStats_settingsSubtitle": "Noise floor, RSSI, SNR, and airtime", + "scanner_linuxPairingShowPin": "Show PIN", + "scanner_linuxPairingHidePin": "Hide PIN", + "scanner_linuxPairingPinTitle": "Bluetooth Pairing PIN", + "scanner_linuxPairingPinPrompt": "Enter PIN for {deviceName} (leave blank if none).", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + } +} \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 154fec6..245f732 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2034,5 +2034,16 @@ "radioStats_stripNoise": "Nivel de ruido: {noiseDbm} dBm", "radioStats_stripWaiting": "Obteniendo estadísticas de la radio…", "radioStats_settingsTile": "Estadísticas de radio", - "radioStats_settingsSubtitle": "Nivel de ruido, RSSI, SNR y tiempo de transmisión" + "radioStats_settingsSubtitle": "Nivel de ruido, RSSI, SNR y tiempo de transmisión", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "Mostrar PIN", + "scanner_linuxPairingPinTitle": "PIN de emparejamiento Bluetooth", + "scanner_linuxPairingHidePin": "Ocultar PIN", + "scanner_linuxPairingPinPrompt": "Introduzca el PIN para {deviceName} (déjelo en blanco si no hay ninguno)." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 90948d0..21b231a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2006,5 +2006,16 @@ "radioStats_stripNoise": "Niveau de bruit : {noiseDbm} dBm", "radioStats_stripWaiting": "Récupération des statistiques de la radio…", "radioStats_settingsTile": "Statistiques de radio", - "radioStats_settingsSubtitle": "Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d'antenne" + "radioStats_settingsSubtitle": "Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d'antenne", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "Afficher le code PIN", + "scanner_linuxPairingHidePin": "Masquer le code PIN", + "scanner_linuxPairingPinTitle": "Code PIN d’appairage Bluetooth", + "scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si aucun)." } diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index 558e1f0..dc96020 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -2044,5 +2044,16 @@ "contact_teleEnv": "Adatkapcsolati környezet", "contact_teleEnvSubtitle": "Engedje meg az érzékelő adatok megosztását", "map_showOverlaps": "Az ismétlő kulcsok ütköznek", - "map_runTraceWithReturnPath": "Visszaforduljon az eredeti úton." + "map_runTraceWithReturnPath": "Visszaforduljon az eredeti úton.", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingHidePin": "PIN elrejtése", + "scanner_linuxPairingShowPin": "PIN megjelenítése", + "scanner_linuxPairingPinTitle": "Bluetooth párosítási PIN", + "scanner_linuxPairingPinPrompt": "Adja meg a(z) {deviceName} PIN-kódját (hagyja üresen, ha nincs)." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 99befbb..13a9602 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2006,5 +2006,16 @@ "radioStats_stripNoise": "Livello di rumore: {noiseDbm} dBm", "radioStats_stripWaiting": "Recupero delle statistiche radio…", "radioStats_settingsTile": "Statistiche radio", - "radioStats_settingsSubtitle": "Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione" + "radioStats_settingsSubtitle": "Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "Mostra PIN", + "scanner_linuxPairingHidePin": "Nascondi PIN", + "scanner_linuxPairingPinTitle": "PIN di associazione Bluetooth", + "scanner_linuxPairingPinPrompt": "Inserisci il PIN per {deviceName} (lascia vuoto se non ce n'è)." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d63834c..adb4eea 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2044,5 +2044,16 @@ "contact_teleEnv": "テレメトリ環境", "contact_teleEnvSubtitle": "環境センサーのデータを共有することを許可する", "map_showOverlaps": "リピーターキーの重複", - "map_runTraceWithReturnPath": "元の経路に戻る。" + "map_runTraceWithReturnPath": "元の経路に戻る。", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "PINを表示", + "scanner_linuxPairingHidePin": "PINを非表示", + "scanner_linuxPairingPinTitle": "Bluetooth ペアリング PIN", + "scanner_linuxPairingPinPrompt": "{deviceName}のPINを入力してください(なしの場合は空欄のまま)。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index c2215b9..6bccc19 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2044,5 +2044,16 @@ "contact_teleEnv": "텔레메트리 환경", "contact_teleEnvSubtitle": "환경 센서 데이터를 공유하도록 허용", "map_showOverlaps": "반복 키 중복", - "map_runTraceWithReturnPath": "원래 경로로 돌아가세요." + "map_runTraceWithReturnPath": "원래 경로로 돌아가세요.", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "PIN 표시", + "scanner_linuxPairingPinTitle": "블루투스 페어링 PIN", + "scanner_linuxPairingHidePin": "PIN 숨기기", + "scanner_linuxPairingPinPrompt": "{deviceName}에 대한 PIN을 입력하세요 (없으면 비워두세요)." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d2d4040..db787b3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -6148,6 +6148,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Noise floor, RSSI, SNR, and airtime'** String get radioStats_settingsSubtitle; + + /// 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_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); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index e010040..2909278 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -3567,4 +3567,19 @@ class AppLocalizationsBg extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Ниво на шума, RSSI, SNR и време на пренос'; + + @override + String get scanner_linuxPairingShowPin => 'Покажи PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Скрий ПИН'; + + @override + String get scanner_linuxPairingPinTitle => + 'PIN код за сдвояване на Bluetooth'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Въведете ПИН за $deviceName (оставете празно, ако няма).'; + } } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 3967829..4afefde 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3576,4 +3576,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Rauschpegel, RSSI, Signal-Rausch-Verhältnis (SNR) und Nutzzeit'; + + @override + String get scanner_linuxPairingShowPin => 'PIN anzeigen'; + + @override + String get scanner_linuxPairingHidePin => 'PIN ausblenden'; + + @override + String get scanner_linuxPairingPinTitle => 'Bluetooth-Paarungs-PIN'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Geben Sie die PIN für $deviceName ein (leer lassen, falls keine).'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4e90c25..a420a55 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3501,4 +3501,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Noise floor, RSSI, SNR, and airtime'; + + @override + String get scanner_linuxPairingShowPin => 'Show PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Hide PIN'; + + @override + String get scanner_linuxPairingPinTitle => 'Bluetooth Pairing PIN'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Enter PIN for $deviceName (leave blank if none).'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 6e9c0de..93a8bc9 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3569,4 +3569,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Nivel de ruido, RSSI, SNR y tiempo de transmisión'; + + @override + String get scanner_linuxPairingShowPin => 'Mostrar PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Ocultar PIN'; + + @override + String get scanner_linuxPairingPinTitle => 'PIN de emparejamiento Bluetooth'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Introduzca el PIN para $deviceName (déjelo en blanco si no hay ninguno).'; + } } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 22ff6a8..9912542 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3595,4 +3595,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Niveau de bruit, RSSI, rapport signal/bruit (SNR) et temps d\'antenne'; + + @override + String get scanner_linuxPairingShowPin => 'Afficher le code PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Masquer le code PIN'; + + @override + String get scanner_linuxPairingPinTitle => 'Code PIN d’appairage Bluetooth'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Entrez le code PIN pour $deviceName (laissez vide si aucun).'; + } } diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 7a0bf11..dc6374a 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -3586,4 +3586,18 @@ class AppLocalizationsHu extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Háttérzaj, RSSI, zaj-sűrűség, és a használat időtartama'; + + @override + String get scanner_linuxPairingShowPin => 'PIN megjelenítése'; + + @override + String get scanner_linuxPairingHidePin => 'PIN elrejtése'; + + @override + String get scanner_linuxPairingPinTitle => 'Bluetooth párosítási PIN'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Adja meg a(z) $deviceName PIN-kódját (hagyja üresen, ha nincs).'; + } } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 5e1aa0b..3fc5e56 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -3573,4 +3573,18 @@ class AppLocalizationsIt extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Livello di rumore, RSSI, rapporto segnale/rumore (SNR) e tempo di trasmissione'; + + @override + String get scanner_linuxPairingShowPin => 'Mostra PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Nascondi PIN'; + + @override + String get scanner_linuxPairingPinTitle => 'PIN di associazione Bluetooth'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Inserisci il PIN per $deviceName (lascia vuoto se non ce n\'è).'; + } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 12f7bb7..03d70d4 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3401,4 +3401,18 @@ class AppLocalizationsJa extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'ノイズレベル、RSSI、SNR、および通信時間'; + + @override + String get scanner_linuxPairingShowPin => 'PINを表示'; + + @override + String get scanner_linuxPairingHidePin => 'PINを非表示'; + + @override + String get scanner_linuxPairingPinTitle => 'Bluetooth ペアリング PIN'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return '$deviceNameのPINを入力してください(なしの場合は空欄のまま)。'; + } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 57ee40a..5e5925f 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3400,4 +3400,18 @@ class AppLocalizationsKo extends AppLocalizations { @override String get radioStats_settingsSubtitle => '잡음 수준, RSSI, 신호 대 잡음비, 통신 시간'; + + @override + String get scanner_linuxPairingShowPin => 'PIN 표시'; + + @override + String get scanner_linuxPairingHidePin => 'PIN 숨기기'; + + @override + String get scanner_linuxPairingPinTitle => '블루투스 페어링 PIN'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return '$deviceName에 대한 PIN을 입력하세요 (없으면 비워두세요).'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 50774c9..bcb2d5d 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3551,4 +3551,18 @@ class AppLocalizationsNl extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Ruimtelijke ruis, RSSI, SNR en beschikbare tijd'; + + @override + String get scanner_linuxPairingShowPin => 'Toon PIN'; + + @override + String get scanner_linuxPairingHidePin => 'PIN verbergen'; + + @override + String get scanner_linuxPairingPinTitle => 'Bluetooth‑koppelings‑PIN'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Voer PIN in voor $deviceName (laat leeg als er geen is).'; + } } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 946c26f..5c66761 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -3585,4 +3585,18 @@ class AppLocalizationsPl extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Szum tła, RSSI, SNR oraz czas dostępny'; + + @override + String get scanner_linuxPairingShowPin => 'Pokaż PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Ukryj PIN'; + + @override + String get scanner_linuxPairingPinTitle => 'Kod PIN parowania Bluetooth'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Wprowadź kod PIN dla $deviceName (pozostaw puste, jeśli brak).'; + } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 0d6a099..98c72f5 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3566,4 +3566,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Nível de ruído, RSSI, SNR e tempo de transmissão'; + + @override + String get scanner_linuxPairingShowPin => 'Mostrar PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Ocultar PIN'; + + @override + String get scanner_linuxPairingPinTitle => 'PIN de emparelhamento Bluetooth'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Insira o PIN para $deviceName (deixe em branco se não houver).'; + } } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 0c08b6c..4184641 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3580,4 +3580,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Уровень шума, RSSI, SNR и время передачи'; + + @override + String get scanner_linuxPairingShowPin => 'Показать PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Скрыть PIN'; + + @override + String get scanner_linuxPairingPinTitle => 'PIN‑код сопряжения Bluetooth'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Введите PIN‑код для $deviceName (оставьте пустым, если нет).'; + } } diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index c3884cf..59f46bd 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -3544,4 +3544,18 @@ class AppLocalizationsSk extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Úroveň hluku, RSSI, SNR a časové rozloženie'; + + @override + String get scanner_linuxPairingShowPin => 'Zobraziť PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Skryť PIN'; + + @override + String get scanner_linuxPairingPinTitle => 'Bluetooth párovací PIN'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Zadajte PIN pre $deviceName (ak nie je, nechajte prázdne).'; + } } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 72cbc7d..171353c 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -3549,4 +3549,18 @@ class AppLocalizationsSl extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema'; + + @override + String get scanner_linuxPairingShowPin => 'Prikaži PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Skrij PIN'; + + @override + String get scanner_linuxPairingPinTitle => 'Bluetooth PIN za seznanjanje'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Vnesite PIN za $deviceName (pustite prazno, če ga ni).'; + } } diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 3bf5887..6a776d7 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -3526,4 +3526,18 @@ class AppLocalizationsSv extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Bakgrundsnivå, RSSI, SNR och tillgänglig tid'; + + @override + String get scanner_linuxPairingShowPin => 'Visa PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Dölj PIN'; + + @override + String get scanner_linuxPairingPinTitle => 'Bluetooth‑parnings‑PIN'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Ange PIN för $deviceName (lämna tomt om ingen).'; + } } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8b9d505..9ebead2 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3583,4 +3583,18 @@ class AppLocalizationsUk extends AppLocalizations { @override String get radioStats_settingsSubtitle => 'Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал.'; + + @override + String get scanner_linuxPairingShowPin => 'Показати PIN'; + + @override + String get scanner_linuxPairingHidePin => 'Приховати PIN'; + + @override + String get scanner_linuxPairingPinTitle => 'PIN‑код спарювання Bluetooth'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return 'Введіть PIN для $deviceName (залиште порожнім, якщо його немає).'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 66981e1..6d3a856 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3298,4 +3298,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get radioStats_settingsSubtitle => '噪声水平、RSSI、信噪比和空中时间'; + + @override + String get scanner_linuxPairingShowPin => '显示 PIN码'; + + @override + String get scanner_linuxPairingHidePin => '隐藏 PIN'; + + @override + String get scanner_linuxPairingPinTitle => '蓝牙配对 PIN'; + + @override + String scanner_linuxPairingPinPrompt(String deviceName) { + return '输入 $deviceName 的 PIN(如果没有,请留空)。'; + } } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 0288e70..9f164fd 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -2006,5 +2006,16 @@ "radioStats_stripNoise": "Ruisfrequentie: {noiseDbm} dBm", "radioStats_stripWaiting": "Radio-statistieken ophalen…", "radioStats_settingsTile": "Statistieken over radio", - "radioStats_settingsSubtitle": "Ruimtelijke ruis, RSSI, SNR en beschikbare tijd" + "radioStats_settingsSubtitle": "Ruimtelijke ruis, RSSI, SNR en beschikbare tijd", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "Toon PIN", + "scanner_linuxPairingHidePin": "PIN verbergen", + "scanner_linuxPairingPinPrompt": "Voer PIN in voor {deviceName} (laat leeg als er geen is).", + "scanner_linuxPairingPinTitle": "Bluetooth‑koppelings‑PIN" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 234fa3e..87b4754 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -2044,5 +2044,16 @@ "radioStats_stripNoise": "Poziom szumów: {noiseDbm} dBm", "radioStats_stripWaiting": "Pobieranie danych dotyczących radia…", "radioStats_settingsTile": "Statystyki radiowe", - "radioStats_settingsSubtitle": "Szum tła, RSSI, SNR oraz czas dostępny" + "radioStats_settingsSubtitle": "Szum tła, RSSI, SNR oraz czas dostępny", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "Pokaż PIN", + "scanner_linuxPairingHidePin": "Ukryj PIN", + "scanner_linuxPairingPinPrompt": "Wprowadź kod PIN dla {deviceName} (pozostaw puste, jeśli brak).", + "scanner_linuxPairingPinTitle": "Kod PIN parowania Bluetooth" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 1ed3dd3..eb87a15 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -2006,5 +2006,16 @@ "radioStats_stripNoise": "Nível de ruído: {noiseDbm} dBm", "radioStats_stripWaiting": "Obtendo estatísticas de rádio…", "radioStats_settingsTile": "Estatísticas de rádio", - "radioStats_settingsSubtitle": "Nível de ruído, RSSI, SNR e tempo de transmissão" + "radioStats_settingsSubtitle": "Nível de ruído, RSSI, SNR e tempo de transmissão", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "Mostrar PIN", + "scanner_linuxPairingHidePin": "Ocultar PIN", + "scanner_linuxPairingPinPrompt": "Insira o PIN para {deviceName} (deixe em branco se não houver).", + "scanner_linuxPairingPinTitle": "PIN de emparelhamento Bluetooth" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7b40819..c9493a0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1246,5 +1246,16 @@ "radioStats_stripNoise": "Уровень шума: {noiseDbm} дБм", "radioStats_stripWaiting": "Получение данных о радио…", "radioStats_settingsTile": "Статистика радиовещания", - "radioStats_settingsSubtitle": "Уровень шума, RSSI, SNR и время передачи" + "radioStats_settingsSubtitle": "Уровень шума, RSSI, SNR и время передачи", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "Показать PIN", + "scanner_linuxPairingPinPrompt": "Введите PIN‑код для {deviceName} (оставьте пустым, если нет).", + "scanner_linuxPairingHidePin": "Скрыть PIN", + "scanner_linuxPairingPinTitle": "PIN‑код сопряжения Bluetooth" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 59303ba..5a7aa6d 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -2006,5 +2006,16 @@ "radioStats_stripNoise": "Úroveň hluku: {noiseDbm} dBm", "radioStats_stripWaiting": "Získavanie údajov o rádiu…", "radioStats_settingsTile": "Štatistiky rádiových vysielaní", - "radioStats_settingsSubtitle": "Úroveň hluku, RSSI, SNR a časové rozloženie" + "radioStats_settingsSubtitle": "Úroveň hluku, RSSI, SNR a časové rozloženie", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingPinPrompt": "Zadajte PIN pre {deviceName} (ak nie je, nechajte prázdne).", + "scanner_linuxPairingShowPin": "Zobraziť PIN", + "scanner_linuxPairingHidePin": "Skryť PIN", + "scanner_linuxPairingPinTitle": "Bluetooth párovací PIN" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 005054c..9adb387 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -2006,5 +2006,16 @@ "radioStats_stripNoise": "Število šuma: {noiseDbm} dBm", "radioStats_stripWaiting": "Prejemanje statistike o radiju…", "radioStats_settingsTile": "Radijske statistike", - "radioStats_settingsSubtitle": "Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema" + "radioStats_settingsSubtitle": "Število šumov, RSSI, SNR in čas, ki ga je napolnila oprema", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "Prikaži PIN", + "scanner_linuxPairingHidePin": "Skrij PIN", + "scanner_linuxPairingPinPrompt": "Vnesite PIN za {deviceName} (pustite prazno, če ga ni).", + "scanner_linuxPairingPinTitle": "Bluetooth PIN za seznanjanje" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 0784229..e4ace3e 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -2006,5 +2006,16 @@ "radioStats_stripNoise": "Bakgrundsnivå: {noiseDbm} dBm", "radioStats_stripWaiting": "Hämtar radiostatistik…", "radioStats_settingsTile": "Radiostation", - "radioStats_settingsSubtitle": "Bakgrundsnivå, RSSI, SNR och tillgänglig tid" + "radioStats_settingsSubtitle": "Bakgrundsnivå, RSSI, SNR och tillgänglig tid", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "Visa PIN", + "scanner_linuxPairingPinTitle": "Bluetooth‑parnings‑PIN", + "scanner_linuxPairingPinPrompt": "Ange PIN för {deviceName} (lämna tomt om ingen).", + "scanner_linuxPairingHidePin": "Dölj PIN" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index cfed24b..8e27da1 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -2006,5 +2006,16 @@ "radioStats_stripNoise": "Рівень шуму: {noiseDbm} дБм", "radioStats_stripWaiting": "Отримано статистику радіо…", "radioStats_settingsTile": "Дані про радіостанції", - "radioStats_settingsSubtitle": "Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал." + "radioStats_settingsSubtitle": "Рівень шуму, RSSI, SNR та час, протягом якого пристрій використовує радіоканал.", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingPinTitle": "PIN‑код спарювання Bluetooth", + "scanner_linuxPairingShowPin": "Показати PIN", + "scanner_linuxPairingPinPrompt": "Введіть PIN для {deviceName} (залиште порожнім, якщо його немає).", + "scanner_linuxPairingHidePin": "Приховати PIN" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fb758b5..cd7b44d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2011,5 +2011,16 @@ "radioStats_stripNoise": "噪声水平:{noiseDbm} dBm", "radioStats_stripWaiting": "正在获取收音机数据…", "radioStats_settingsTile": "广播统计数据", - "radioStats_settingsSubtitle": "噪声水平、RSSI、信噪比和空中时间" + "radioStats_settingsSubtitle": "噪声水平、RSSI、信噪比和空中时间", + "@scanner_linuxPairingPinPrompt": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "scanner_linuxPairingShowPin": "显示 PIN码", + "scanner_linuxPairingPinPrompt": "输入 {deviceName} 的 PIN(如果没有,请留空)。", + "scanner_linuxPairingPinTitle": "蓝牙配对 PIN", + "scanner_linuxPairingHidePin": "隐藏 PIN" } diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index 986a598..17f26ea 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -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 { 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 { } } + Future _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( + 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( diff --git a/lib/services/linux_ble_error_classifier.dart b/lib/services/linux_ble_error_classifier.dart new file mode 100644 index 0000000..acef4c2 --- /dev/null +++ b/lib/services/linux_ble_error_classifier.dart @@ -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')); +} diff --git a/lib/services/linux_ble_pairing_service.dart b/lib/services/linux_ble_pairing_service.dart new file mode 100644 index 0000000..8ed52f6 --- /dev/null +++ b/lib/services/linux_ble_pairing_service.dart @@ -0,0 +1,423 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +typedef ProcessStartFn = + Future Function(String executable, List arguments); +typedef ProcessRunFn = + Future Function(String executable, List 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 pairing attempts (initial + retries). + /// Covers one remove-and-retry plus one proactive-PIN retry. + static const int _maxAttempts = 3; + + 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 isBluetoothctlAvailable() async { + try { + final result = await _processRun('bluetoothctl', ['--version']); + return result.exitCode == 0; + } on ProcessException { + return false; + } + } + + Future disconnectDevice( + String remoteId, { + void Function(String message)? onLog, + }) async { + onLog?.call('Requesting BlueZ disconnect for $remoteId'); + Process process; + try { + process = await _processStart('bluetoothctl', []); + } 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 isPairedAndTrusted(String remoteId) async { + ProcessResult result; + try { + result = await _processRun('bluetoothctl', ['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 trustDevice( + String remoteId, { + void Function(String message)? onLog, + }) async { + onLog?.call('Requesting BlueZ trust for $remoteId'); + ProcessResult result; + try { + result = await _processRun('bluetoothctl', ['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 pairAndTrust({ + required String remoteId, + Duration timeout = _defaultPairingTimeout, + void Function(String message)? onLog, + Future Function()? onRequestPin, + }) async { + var removeRetryUsed = false; + var proactivePinRetryUsed = false; + Future Function()? currentPinProvider = onRequestPin; + + for (var attempt = 0; attempt < _maxAttempts; 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}/$_maxAttempts)', + ); + 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}/$_maxAttempts)', + ); + 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}/$_maxAttempts)', + ); + 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 Function()? onRequestPin, + }) async { + onLog?.call('Starting bluetoothctl pairing flow for $remoteId'); + Process process; + try { + process = await _processStart('bluetoothctl', []); + } 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(); + 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(() 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 _removeDevice( + String remoteId, { + void Function(String message)? onLog, + }) async { + Process process; + try { + process = await _processStart('bluetoothctl', []); + } 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, + }); +} diff --git a/lib/services/linux_ble_pairing_service_stub.dart b/lib/services/linux_ble_pairing_service_stub.dart new file mode 100644 index 0000000..c00ced8 --- /dev/null +++ b/lib/services/linux_ble_pairing_service_stub.dart @@ -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 isBluetoothctlAvailable() async => false; + + Future disconnectDevice( + String remoteId, { + void Function(String message)? onLog, + }) async {} + + Future isPairedAndTrusted(String remoteId) async => false; + + Future trustDevice( + String remoteId, { + void Function(String message)? onLog, + }) async => false; + + Future pairAndTrust({ + required String remoteId, + Duration timeout = const Duration(seconds: 45), + void Function(String message)? onLog, + Future Function()? onRequestPin, + }) async => false; +} diff --git a/pubspec.yaml b/pubspec.yaml index 59825c4..39e9f96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,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 diff --git a/test/services/linux_ble_error_classifier_test.dart b/test/services/linux_ble_error_classifier_test.dart new file mode 100644 index 0000000..b41b17c --- /dev/null +++ b/test/services/linux_ble_error_classifier_test.dart @@ -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, + ); + }); + }); +} diff --git a/test/services/linux_ble_pairing_service_test.dart b/test/services/linux_ble_pairing_service_test.dart new file mode 100644 index 0000000..9d34f52 --- /dev/null +++ b/test/services/linux_ble_pairing_service_test.dart @@ -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> _stdinController = + StreamController>(); + final StreamController> _stdoutController = + StreamController>.broadcast(); + final StreamController> _stderrController = + StreamController>.broadcast(); + final Completer _exitCodeCompleter = Completer(); + 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 _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 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> get stderr => _stderrController.stream; + + @override + Stream> get stdout => _stdoutController.stream; +} + +void main() { + test( + 'disconnectDevice skips gracefully when bluetoothctl is unavailable', + () async { + final logs = []; + final service = LinuxBlePairingService( + processStart: (executable, arguments) async { + throw const ProcessException( + 'bluetoothctl', + [], + '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', + [], + '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', + [], + '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', + [], + '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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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.delayed(const Duration(milliseconds: 100), () { + fakeProc.emitStdout('Pairing successful\n'); + Future.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 = []; + 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.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); + }); +}