Merge pull request #270 from just-stuff-tm/fix/linux-ble-pairing-flow
Some checks failed
Build / android (push) Has been cancelled
Build / ios (push) Has been cancelled
Build / linux (push) Has been cancelled
Build / macos (push) Has been cancelled
Build / web (push) Has been cancelled
Flutter and Dart / analyze (push) Has been cancelled

Fix/linux ble pairing flow
This commit is contained in:
zjs81 2026-03-24 17:48:07 -07:00 committed by GitHub
commit 411cd3f8d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2129 additions and 52 deletions

View file

@ -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<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 = [];
@ -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<void> _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 <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
@ -1345,7 +1412,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;
@ -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<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');
@ -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<BluetoothService> 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<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 != 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<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 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<bool> _waitForSelfInfo({required Duration timeout}) async {
if (_selfPublicKey != null) return true;
if (!isConnected) return false;
@ -1656,7 +2070,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) {
@ -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;

View file

@ -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<String> deviceNamePrefixes = [
"MeshCore-",
"Whisper-",
"WisCore-",
"HT-",
];
}

View file

@ -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} (оставете празно, ако няма)."
}

View file

@ -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)."
}

View file

@ -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"
}
}
}
}

View file

@ -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)."
}

View file

@ -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 dappairage Bluetooth",
"scanner_linuxPairingPinPrompt": "Entrez le code PIN pour {deviceName} (laissez vide si aucun)."
}

View file

@ -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)."
}

View file

@ -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'è)."
}

View file

@ -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を入力してくださいなしの場合は空欄のまま。"
}

View file

@ -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을 입력하세요 (없으면 비워두세요)."
}

View file

@ -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

View file

@ -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 (оставете празно, ако няма).';
}
}

View file

@ -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).';
}
}

View file

@ -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).';
}
}

View file

@ -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).';
}
}

View file

@ -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 dappairage Bluetooth';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Entrez le code PIN pour $deviceName (laissez vide si aucun).';
}
}

View file

@ -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).';
}
}

View file

@ -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\'è).';
}
}

View file

@ -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を入力してください(なしの場合は空欄のまま)。';
}
}

View file

@ -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을 입력하세요 (없으면 비워두세요).';
}
}

View file

@ -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 => 'BluetoothkoppelingsPIN';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Voer PIN in voor $deviceName (laat leeg als er geen is).';
}
}

View file

@ -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).';
}
}

View file

@ -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).';
}
}

View file

@ -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 (оставьте пустым, если нет).';
}
}

View file

@ -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).';
}
}

View file

@ -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).';
}
}

View file

@ -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 => 'BluetoothparningsPIN';
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Ange PIN för $deviceName (lämna tomt om ingen).';
}
}

View file

@ -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 (залиште порожнім, якщо його немає).';
}
}

View file

@ -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如果没有请留空';
}
}

View file

@ -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": "BluetoothkoppelingsPIN"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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": "BluetoothparningsPIN",
"scanner_linuxPairingPinPrompt": "Ange PIN för {deviceName} (lämna tomt om ingen).",
"scanner_linuxPairingHidePin": "Dölj PIN"
}

View file

@ -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"
}

View file

@ -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"
}

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 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<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 < _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<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

@ -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

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);
});
}