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