Files
mikkeli 314a3ea059 refactor: depend on an Iperf3Runner interface instead of the service
The presentation layer imported Iperf3Service directly, so the UI was welded
to the MethodChannel transport: the view model named it, and the screen
constructed one. Dependency direction was already inward-only, but the UI
depended on an implementation rather than a contract.

Iperf3Runner states that contract in the domain layer in five members, in pure
Dart with no Flutter import, and Iperf3Service implements it. The view model
and screen now take an Iperf3Runner; main.dart is the only file that names a
concrete one, which makes it the composition root.

Two consequences worth having. The UI can be driven against an in-memory
runner with no MethodChannel, native library or device involved. And a test
double implements five stable members rather than tracking every public member
the transport happens to expose — the previous double, forced to implement the
concrete class, broke twice while this branch's work was going on.

Also drops flutter/foundation from the data layer: debugPrint gives way to
dart:developer, leaving flutter/services (where MethodChannel lives) as that
layer's only Flutter dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:56:03 +09:00

213 lines
7.1 KiB
Dart

import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter/services.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_result.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart';
import 'package:iperf3gui/domain/services/iperf3_runner.dart';
/// [Iperf3Runner] backed by the native iperf3 library over a MethodChannel.
///
/// On Android, iperf3 is compiled into the app as a shared library (.so).
/// This avoids SELinux restrictions on executing ELF binaries from assets.
///
/// The service talks to the native layer over a MethodChannel. The test runs
/// in `--json-stream` mode, so each native report arrives as one JSON object;
/// they are parsed and published on [events] while the test runs. [run]
/// resolves once the native layer reports completion, carrying the raw JSON
/// lines as the result's output.
class Iperf3Service implements Iperf3Runner {
Iperf3Service() {
_channel.setMethodCallHandler(_handleNativeCall);
}
/// A channel's handler is keyed by channel name in the binary messenger, so
/// it is process-global: constructing a second instance would take the
/// handler away from the first. The app composes exactly one, in main().
final MethodChannel _channel = const MethodChannel(_channelName);
static const String _channelName = 'com.novoyuuparosk.iperf3gui/iperf';
/// Exit code reported when the native layer signalled an error.
static const int exitCodeNativeError = 1;
/// Exit code reported when another test is already in flight.
static const int exitCodeBusy = -2;
/// Exit code reported when the test could not be started at all.
static const int exitCodeStartFailed = -1;
/// Exit code reported when the native layer never signalled completion.
static const int exitCodeTimeout = -3;
/// Events parsed from the native layer during the current run.
final StreamController<Iperf3Event> _eventController =
StreamController<Iperf3Event>.broadcast();
/// Live reports, one event per native report. Use this to render progress.
@override
Stream<Iperf3Event> get events => _eventController.stream;
/// Raw JSON lines received during the current (or most recent) run.
final List<String> _rawLines = [];
/// Completer that resolves when the native iperf3 test finishes.
Completer<Iperf3Result>? _runCompleter;
/// Whether iperf3 is currently running.
bool _isRunning = false;
@override
bool get isRunning => _isRunning;
/// Whether the native layer reported an error during this run.
bool _sawError = false;
/// Whether [stop] was called for the current run.
bool _stopRequested = false;
/// Cancel any running test.
@override
Future<void> stop() async {
if (!_isRunning) return;
_stopRequested = true;
try {
await _channel.invokeMethod('stopIperf');
} catch (e) {
_log('Iperf3Service: error stopping: $e');
}
}
/// Release the stream controller. Call from the owner's dispose().
@override
Future<void> dispose() async {
_channel.setMethodCallHandler(null);
await _eventController.close();
}
/// Run iperf3 with the given configuration.
///
/// Returns an [Iperf3Result] with the full output and an exit code.
@override
Future<Iperf3Result> run(TestConfiguration config) async {
if (_isRunning) {
return const Iperf3Result(
rawOutput: 'Another test is already running.',
exitCode: exitCodeBusy,
);
}
_isRunning = true;
_sawError = false;
_stopRequested = false;
_rawLines.clear();
final args = config.toCliArgs();
_log('Iperf3Service: running: ${args.join(" ")}');
final completer = Completer<Iperf3Result>();
_runCompleter = completer;
try {
await _channel.invokeMethod('runIperf', {'args': args});
} catch (e, stackTrace) {
_log('Iperf3Service exception: $e');
_log('Iperf3Service stack: $stackTrace');
_finishRun();
return Iperf3Result(
rawOutput: 'Error starting iperf3: $e',
exitCode: exitCodeStartFailed,
);
}
// Wait for the native layer to signal completion. The grace period covers
// DNS resolution and the 30s native connect timeout.
try {
return await completer.future.timeout(
config.duration + const Duration(seconds: 45),
onTimeout: () {
_log('Iperf3Service: timeout, stopping test');
// Ask the native layer to wind down, then release local state so a
// stuck run can never lock the UI out of starting another test.
_channel.invokeMethod('stopIperf').catchError((Object e) {
_log('Iperf3Service: error stopping after timeout: $e');
return null;
});
_publish(const Iperf3Error('Timed out waiting for iperf3 to finish.'));
return Iperf3Result(
rawOutput: _rawOutput(),
exitCode: exitCodeTimeout,
);
},
);
} catch (e) {
_log('Iperf3Service: error waiting for completion: $e');
return Iperf3Result(
rawOutput: 'Test failed: $e',
exitCode: exitCodeStartFailed,
);
} finally {
_finishRun();
}
}
/// Handle a callback pushed up from the Kotlin/JNI layer.
Future<void> _handleNativeCall(MethodCall call) async {
switch (call.method) {
case 'onOutput':
final output = call.arguments['output'] as String?;
if (output != null) {
_rawLines.add(output);
final event = Iperf3Event.parse(output);
// iperf3 reports a failed test as an `error` event rather than a
// non-zero return, so this is where most failures surface.
if (event is Iperf3Error) _sawError = true;
_publish(event);
}
case 'onError':
final error = call.arguments['error'] as String?;
if (error != null) {
// Not terminal on its own — iperf3 reports the failure reason and
// then completes, so wait for onComplete before resolving.
_sawError = true;
_publish(Iperf3Error(error));
}
case 'onComplete':
_log('Iperf3Service: test completed');
final completer = _runCompleter;
if (completer != null && !completer.isCompleted) {
completer.complete(
Iperf3Result(
rawOutput: _rawOutput(),
// A user-requested stop is a normal outcome, not a failure.
exitCode:
(_sawError && !_stopRequested) ? exitCodeNativeError : 0,
stopped: _stopRequested,
),
);
}
default:
_log('Iperf3Service: unhandled native call ${call.method}');
}
}
void _publish(Iperf3Event event) {
if (!_eventController.isClosed) {
_eventController.add(event);
}
}
/// The raw JSON stream as received, one event per line.
String _rawOutput() => _rawLines
.map((line) => line.endsWith('\n') ? line : '$line\n')
.join();
void _finishRun() {
_isRunning = false;
_runCompleter = null;
}
}
void _log(String message) =>
developer.log(message, name: 'Iperf3Service');