7d8d06e2e5
The native layer now emits one JSON object per report, so the service parses each into a sealed Iperf3Event — start, interval, end, error — and publishes those instead of opaque lines. Interval samples carry bytes, bits per second, retransmits and per-stream socket/cwnd/rtt detail; the end event carries sent and received totals and CPU utilisation. None of that was available while the output was scraped text. Anything unrecognised — non-JSON, malformed JSON, an event name a future iperf3 adds — becomes an Iperf3LogLine rather than being dropped, so unexpected output still reaches the user. Failures arrive as an `error` event rather than a non-zero return from iperf_run_client, so that event is what now marks a run as failed. The result's rawOutput keeps the JSON lines verbatim, since that is what the native layer actually produced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
200 lines
6.6 KiB
Dart
200 lines
6.6 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
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';
|
|
|
|
/// Service that runs iperf3 via JNI (native library).
|
|
///
|
|
/// 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 {
|
|
Iperf3Service() {
|
|
_channel.setMethodCallHandler(_handleNativeCall);
|
|
}
|
|
|
|
static const MethodChannel _channel =
|
|
MethodChannel('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.
|
|
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;
|
|
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.
|
|
Future<void> stop() async {
|
|
if (!_isRunning) return;
|
|
_stopRequested = true;
|
|
try {
|
|
await _channel.invokeMethod('stopIperf');
|
|
} catch (e) {
|
|
debugPrint('Iperf3Service: error stopping: $e');
|
|
}
|
|
}
|
|
|
|
/// Release the stream controller. Call from the owner's dispose().
|
|
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.
|
|
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();
|
|
debugPrint('Iperf3Service: running: ${args.join(" ")}');
|
|
|
|
final completer = Completer<Iperf3Result>();
|
|
_runCompleter = completer;
|
|
|
|
try {
|
|
await _channel.invokeMethod('runIperf', {'args': args});
|
|
} catch (e, stackTrace) {
|
|
debugPrint('Iperf3Service exception: $e');
|
|
debugPrint('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: () {
|
|
debugPrint('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) {
|
|
debugPrint('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) {
|
|
debugPrint('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':
|
|
debugPrint('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:
|
|
debugPrint('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;
|
|
}
|
|
}
|