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