diff --git a/lib/data/services/iperf3_service.dart b/lib/data/services/iperf3_service.dart index a9b3036..ff36b89 100644 --- a/lib/data/services/iperf3_service.dart +++ b/lib/data/services/iperf3_service.dart @@ -1,12 +1,13 @@ import 'dart:async'; +import 'dart:developer' as developer; -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'; +import 'package:iperf3gui/domain/services/iperf3_runner.dart'; -/// Service that runs iperf3 via JNI (native library). +/// [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. @@ -16,13 +17,17 @@ import 'package:iperf3gui/domain/models/test_configuration.dart'; /// 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 { +class Iperf3Service implements Iperf3Runner { Iperf3Service() { _channel.setMethodCallHandler(_handleNativeCall); } - static const MethodChannel _channel = - MethodChannel('com.novoyuuparosk.iperf3gui/iperf'); + /// 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; @@ -41,6 +46,7 @@ class Iperf3Service { StreamController.broadcast(); /// Live reports, one event per native report. Use this to render progress. + @override Stream get events => _eventController.stream; /// Raw JSON lines received during the current (or most recent) run. @@ -51,6 +57,7 @@ class Iperf3Service { /// Whether iperf3 is currently running. bool _isRunning = false; + @override bool get isRunning => _isRunning; /// Whether the native layer reported an error during this run. @@ -60,17 +67,19 @@ class Iperf3Service { bool _stopRequested = false; /// Cancel any running test. + @override Future stop() async { if (!_isRunning) return; _stopRequested = true; try { await _channel.invokeMethod('stopIperf'); } catch (e) { - debugPrint('Iperf3Service: error stopping: $e'); + _log('Iperf3Service: error stopping: $e'); } } /// Release the stream controller. Call from the owner's dispose(). + @override Future dispose() async { _channel.setMethodCallHandler(null); await _eventController.close(); @@ -79,6 +88,7 @@ class Iperf3Service { /// Run iperf3 with the given configuration. /// /// Returns an [Iperf3Result] with the full output and an exit code. + @override Future run(TestConfiguration config) async { if (_isRunning) { return const Iperf3Result( @@ -93,7 +103,7 @@ class Iperf3Service { _rawLines.clear(); final args = config.toCliArgs(); - debugPrint('Iperf3Service: running: ${args.join(" ")}'); + _log('Iperf3Service: running: ${args.join(" ")}'); final completer = Completer(); _runCompleter = completer; @@ -101,8 +111,8 @@ class Iperf3Service { try { await _channel.invokeMethod('runIperf', {'args': args}); } catch (e, stackTrace) { - debugPrint('Iperf3Service exception: $e'); - debugPrint('Iperf3Service stack: $stackTrace'); + _log('Iperf3Service exception: $e'); + _log('Iperf3Service stack: $stackTrace'); _finishRun(); return Iperf3Result( rawOutput: 'Error starting iperf3: $e', @@ -116,11 +126,11 @@ class Iperf3Service { return await completer.future.timeout( config.duration + const Duration(seconds: 45), onTimeout: () { - debugPrint('Iperf3Service: timeout, stopping test'); + _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) { - debugPrint('Iperf3Service: error stopping after timeout: $e'); + _log('Iperf3Service: error stopping after timeout: $e'); return null; }); _publish(const Iperf3Error('Timed out waiting for iperf3 to finish.')); @@ -131,7 +141,7 @@ class Iperf3Service { }, ); } catch (e) { - debugPrint('Iperf3Service: error waiting for completion: $e'); + _log('Iperf3Service: error waiting for completion: $e'); return Iperf3Result( rawOutput: 'Test failed: $e', exitCode: exitCodeStartFailed, @@ -163,7 +173,7 @@ class Iperf3Service { _publish(Iperf3Error(error)); } case 'onComplete': - debugPrint('Iperf3Service: test completed'); + _log('Iperf3Service: test completed'); final completer = _runCompleter; if (completer != null && !completer.isCompleted) { completer.complete( @@ -177,7 +187,7 @@ class Iperf3Service { ); } default: - debugPrint('Iperf3Service: unhandled native call ${call.method}'); + _log('Iperf3Service: unhandled native call ${call.method}'); } } @@ -197,3 +207,6 @@ class Iperf3Service { _runCompleter = null; } } + +void _log(String message) => + developer.log(message, name: 'Iperf3Service'); diff --git a/lib/domain/services/iperf3_runner.dart b/lib/domain/services/iperf3_runner.dart new file mode 100644 index 0000000..c21438d --- /dev/null +++ b/lib/domain/services/iperf3_runner.dart @@ -0,0 +1,36 @@ +import 'package:iperf3gui/domain/models/iperf3_event.dart'; +import 'package:iperf3gui/domain/models/iperf3_result.dart'; +import 'package:iperf3gui/domain/models/test_configuration.dart'; + +/// Runs iperf3 tests and reports what happens. +/// +/// This is the whole contract the UI depends on. It is deliberately pure Dart — +/// no Flutter import — so the presentation layer never names a transport. The +/// Android build supplies a MethodChannel-and-JNI implementation +/// (`Iperf3Service`); a test, a desktop build or a headless tool can supply +/// its own without touching anything above this line. +abstract interface class Iperf3Runner { + /// Reports parsed from the running test, one event per iperf3 report. + /// + /// A broadcast stream: it may have no listener, and late listeners miss + /// earlier events rather than replaying them. + Stream get events; + + /// Whether a test is in flight right now. + bool get isRunning; + + /// Run a test to completion and resolve with its outcome. + /// + /// Resolves rather than throws for an iperf3-level failure — the failure is + /// carried in [Iperf3Result.exitCode]. Implementations must always resolve, + /// including on internal error, so a caller can never be left awaiting. + Future run(TestConfiguration config); + + /// Ask a running test to stop early. A no-op when nothing is running. + /// + /// The run's future still resolves, with [Iperf3Result.stopped] set. + Future stop(); + + /// Release resources. The instance is unusable afterwards. + Future dispose(); +} diff --git a/lib/main.dart b/lib/main.dart index da2d2fb..1f98860 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:iperf3gui/data/services/iperf3_service.dart'; +import 'package:iperf3gui/domain/services/iperf3_runner.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_screen.dart'; -/// Application entry point. +/// Application entry point, and the composition root. /// -/// iperf3 is now compiled as a native shared library (libiperf3jni.so) -/// and accessed via JNI — no binary extraction or chmod needed. +/// This is the only place that names a concrete [Iperf3Runner]. Everything +/// above the data layer depends on the interface, so swapping the transport — +/// or running the UI against a fake — touches nothing but this file. Future main() async { WidgetsFlutterBinding.ensureInitialized(); - runApp(const Iperf3GuiApp()); + runApp(Iperf3GuiApp(runner: Iperf3Service())); } class Iperf3GuiApp extends StatelessWidget { - const Iperf3GuiApp({super.key}); + const Iperf3GuiApp({required this.runner, super.key}); + + final Iperf3Runner runner; @override Widget build(BuildContext context) { @@ -21,7 +26,7 @@ class Iperf3GuiApp extends StatelessWidget { colorSchemeSeed: Colors.blue, useMaterial3: true, ), - home: const TestRunScreen(), + home: TestRunScreen(runner: runner), ); } } diff --git a/lib/ui/features/test_run/test_run_screen.dart b/lib/ui/features/test_run/test_run_screen.dart index 16aa122..5eda417 100644 --- a/lib/ui/features/test_run/test_run_screen.dart +++ b/lib/ui/features/test_run/test_run_screen.dart @@ -1,24 +1,25 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; +import 'package:iperf3gui/domain/services/iperf3_runner.dart'; import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; /// Main test run screen. /// -/// iperf3 is compiled as a native shared library (libiperf3jni.so) and accessed -/// via JNI, so there is no binary extraction or chmod step. +/// Takes an [Iperf3Runner] rather than constructing one, so this layer names no +/// transport: the Android app injects the JNI-backed implementation from +/// main(), and a test injects a fake. class TestRunScreen extends StatelessWidget { - const TestRunScreen({super.key}); + const TestRunScreen({required this.runner, super.key}); + + final Iperf3Runner runner; @override Widget build(BuildContext context) { return ChangeNotifierProvider( - create: (_) => TestRunViewModel( - iperf3Service: Iperf3Service(), - ), + create: (_) => TestRunViewModel(iperf3Service: runner), child: const _TestRunContent(), ); } diff --git a/lib/ui/features/test_run/test_run_view_model.dart b/lib/ui/features/test_run/test_run_view_model.dart index 965cb14..83b2f0e 100644 --- a/lib/ui/features/test_run/test_run_view_model.dart +++ b/lib/ui/features/test_run/test_run_view_model.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:iperf3gui/data/services/iperf3_service.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'; import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; /// State of the test run. @@ -12,10 +12,10 @@ enum TestRunState { idle, running, completed, error } /// ViewModel for managing test run state. /// -/// Subscribes to [Iperf3Service.events] so the transcript and the interval +/// Subscribes to [Iperf3Runner.events] so the transcript and the interval /// series both grow while the test runs, rather than appearing at the end. class TestRunViewModel extends ChangeNotifier { - final Iperf3Service iperf3Service; + final Iperf3Runner iperf3Service; /// How often to notify listeners while output is arriving. iperf3 can report /// several times a second across many streams, and each rebuild re-renders