mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-24 03:14:30 +10:00
feat: Linux BLE pairing support via bluetoothctl
Add Linux BLE pairing helper that drives bluetoothctl for pair/trust/PIN entry, with Completer-based flow control, explicit retry loop, and named timeout constants. - LinuxBlePairingService: pair-and-trust with up to 2 retries - LinuxBleErrorClassifier: map bluetoothctl stderr to user-facing errors - Conditional import stub for web builds (dart.library.io gate) - Scanner screen: PIN dialog integration for Linux pairing flow - MeshCoreConnector: Linux pairing/recovery/reconnect wiring - l10n: 4 new pairing keys across all 14 locales - 12 unit tests (pairing service + error classifier)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user