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>
This commit is contained in:
@@ -1,12 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:iperf3gui/domain/models/iperf3_event.dart';
|
import 'package:iperf3gui/domain/models/iperf3_event.dart';
|
||||||
import 'package:iperf3gui/domain/models/iperf3_result.dart';
|
import 'package:iperf3gui/domain/models/iperf3_result.dart';
|
||||||
import 'package:iperf3gui/domain/models/test_configuration.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).
|
/// On Android, iperf3 is compiled into the app as a shared library (.so).
|
||||||
/// This avoids SELinux restrictions on executing ELF binaries from assets.
|
/// 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]
|
/// they are parsed and published on [events] while the test runs. [run]
|
||||||
/// resolves once the native layer reports completion, carrying the raw JSON
|
/// resolves once the native layer reports completion, carrying the raw JSON
|
||||||
/// lines as the result's output.
|
/// lines as the result's output.
|
||||||
class Iperf3Service {
|
class Iperf3Service implements Iperf3Runner {
|
||||||
Iperf3Service() {
|
Iperf3Service() {
|
||||||
_channel.setMethodCallHandler(_handleNativeCall);
|
_channel.setMethodCallHandler(_handleNativeCall);
|
||||||
}
|
}
|
||||||
|
|
||||||
static const MethodChannel _channel =
|
/// A channel's handler is keyed by channel name in the binary messenger, so
|
||||||
MethodChannel('com.novoyuuparosk.iperf3gui/iperf');
|
/// 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.
|
/// Exit code reported when the native layer signalled an error.
|
||||||
static const int exitCodeNativeError = 1;
|
static const int exitCodeNativeError = 1;
|
||||||
@@ -41,6 +46,7 @@ class Iperf3Service {
|
|||||||
StreamController<Iperf3Event>.broadcast();
|
StreamController<Iperf3Event>.broadcast();
|
||||||
|
|
||||||
/// Live reports, one event per native report. Use this to render progress.
|
/// Live reports, one event per native report. Use this to render progress.
|
||||||
|
@override
|
||||||
Stream<Iperf3Event> get events => _eventController.stream;
|
Stream<Iperf3Event> get events => _eventController.stream;
|
||||||
|
|
||||||
/// Raw JSON lines received during the current (or most recent) run.
|
/// Raw JSON lines received during the current (or most recent) run.
|
||||||
@@ -51,6 +57,7 @@ class Iperf3Service {
|
|||||||
|
|
||||||
/// Whether iperf3 is currently running.
|
/// Whether iperf3 is currently running.
|
||||||
bool _isRunning = false;
|
bool _isRunning = false;
|
||||||
|
@override
|
||||||
bool get isRunning => _isRunning;
|
bool get isRunning => _isRunning;
|
||||||
|
|
||||||
/// Whether the native layer reported an error during this run.
|
/// Whether the native layer reported an error during this run.
|
||||||
@@ -60,17 +67,19 @@ class Iperf3Service {
|
|||||||
bool _stopRequested = false;
|
bool _stopRequested = false;
|
||||||
|
|
||||||
/// Cancel any running test.
|
/// Cancel any running test.
|
||||||
|
@override
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
if (!_isRunning) return;
|
if (!_isRunning) return;
|
||||||
_stopRequested = true;
|
_stopRequested = true;
|
||||||
try {
|
try {
|
||||||
await _channel.invokeMethod('stopIperf');
|
await _channel.invokeMethod('stopIperf');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Iperf3Service: error stopping: $e');
|
_log('Iperf3Service: error stopping: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release the stream controller. Call from the owner's dispose().
|
/// Release the stream controller. Call from the owner's dispose().
|
||||||
|
@override
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
_channel.setMethodCallHandler(null);
|
_channel.setMethodCallHandler(null);
|
||||||
await _eventController.close();
|
await _eventController.close();
|
||||||
@@ -79,6 +88,7 @@ class Iperf3Service {
|
|||||||
/// Run iperf3 with the given configuration.
|
/// Run iperf3 with the given configuration.
|
||||||
///
|
///
|
||||||
/// Returns an [Iperf3Result] with the full output and an exit code.
|
/// Returns an [Iperf3Result] with the full output and an exit code.
|
||||||
|
@override
|
||||||
Future<Iperf3Result> run(TestConfiguration config) async {
|
Future<Iperf3Result> run(TestConfiguration config) async {
|
||||||
if (_isRunning) {
|
if (_isRunning) {
|
||||||
return const Iperf3Result(
|
return const Iperf3Result(
|
||||||
@@ -93,7 +103,7 @@ class Iperf3Service {
|
|||||||
_rawLines.clear();
|
_rawLines.clear();
|
||||||
|
|
||||||
final args = config.toCliArgs();
|
final args = config.toCliArgs();
|
||||||
debugPrint('Iperf3Service: running: ${args.join(" ")}');
|
_log('Iperf3Service: running: ${args.join(" ")}');
|
||||||
|
|
||||||
final completer = Completer<Iperf3Result>();
|
final completer = Completer<Iperf3Result>();
|
||||||
_runCompleter = completer;
|
_runCompleter = completer;
|
||||||
@@ -101,8 +111,8 @@ class Iperf3Service {
|
|||||||
try {
|
try {
|
||||||
await _channel.invokeMethod('runIperf', {'args': args});
|
await _channel.invokeMethod('runIperf', {'args': args});
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('Iperf3Service exception: $e');
|
_log('Iperf3Service exception: $e');
|
||||||
debugPrint('Iperf3Service stack: $stackTrace');
|
_log('Iperf3Service stack: $stackTrace');
|
||||||
_finishRun();
|
_finishRun();
|
||||||
return Iperf3Result(
|
return Iperf3Result(
|
||||||
rawOutput: 'Error starting iperf3: $e',
|
rawOutput: 'Error starting iperf3: $e',
|
||||||
@@ -116,11 +126,11 @@ class Iperf3Service {
|
|||||||
return await completer.future.timeout(
|
return await completer.future.timeout(
|
||||||
config.duration + const Duration(seconds: 45),
|
config.duration + const Duration(seconds: 45),
|
||||||
onTimeout: () {
|
onTimeout: () {
|
||||||
debugPrint('Iperf3Service: timeout, stopping test');
|
_log('Iperf3Service: timeout, stopping test');
|
||||||
// Ask the native layer to wind down, then release local state so a
|
// 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.
|
// stuck run can never lock the UI out of starting another test.
|
||||||
_channel.invokeMethod('stopIperf').catchError((Object e) {
|
_channel.invokeMethod('stopIperf').catchError((Object e) {
|
||||||
debugPrint('Iperf3Service: error stopping after timeout: $e');
|
_log('Iperf3Service: error stopping after timeout: $e');
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
_publish(const Iperf3Error('Timed out waiting for iperf3 to finish.'));
|
_publish(const Iperf3Error('Timed out waiting for iperf3 to finish.'));
|
||||||
@@ -131,7 +141,7 @@ class Iperf3Service {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Iperf3Service: error waiting for completion: $e');
|
_log('Iperf3Service: error waiting for completion: $e');
|
||||||
return Iperf3Result(
|
return Iperf3Result(
|
||||||
rawOutput: 'Test failed: $e',
|
rawOutput: 'Test failed: $e',
|
||||||
exitCode: exitCodeStartFailed,
|
exitCode: exitCodeStartFailed,
|
||||||
@@ -163,7 +173,7 @@ class Iperf3Service {
|
|||||||
_publish(Iperf3Error(error));
|
_publish(Iperf3Error(error));
|
||||||
}
|
}
|
||||||
case 'onComplete':
|
case 'onComplete':
|
||||||
debugPrint('Iperf3Service: test completed');
|
_log('Iperf3Service: test completed');
|
||||||
final completer = _runCompleter;
|
final completer = _runCompleter;
|
||||||
if (completer != null && !completer.isCompleted) {
|
if (completer != null && !completer.isCompleted) {
|
||||||
completer.complete(
|
completer.complete(
|
||||||
@@ -177,7 +187,7 @@ class Iperf3Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
debugPrint('Iperf3Service: unhandled native call ${call.method}');
|
_log('Iperf3Service: unhandled native call ${call.method}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,3 +207,6 @@ class Iperf3Service {
|
|||||||
_runCompleter = null;
|
_runCompleter = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _log(String message) =>
|
||||||
|
developer.log(message, name: 'Iperf3Service');
|
||||||
|
|||||||
@@ -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<Iperf3Event> 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<Iperf3Result> 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<void> stop();
|
||||||
|
|
||||||
|
/// Release resources. The instance is unusable afterwards.
|
||||||
|
Future<void> dispose();
|
||||||
|
}
|
||||||
+11
-6
@@ -1,17 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
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';
|
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)
|
/// This is the only place that names a concrete [Iperf3Runner]. Everything
|
||||||
/// and accessed via JNI — no binary extraction or chmod needed.
|
/// 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<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
runApp(const Iperf3GuiApp());
|
runApp(Iperf3GuiApp(runner: Iperf3Service()));
|
||||||
}
|
}
|
||||||
|
|
||||||
class Iperf3GuiApp extends StatelessWidget {
|
class Iperf3GuiApp extends StatelessWidget {
|
||||||
const Iperf3GuiApp({super.key});
|
const Iperf3GuiApp({required this.runner, super.key});
|
||||||
|
|
||||||
|
final Iperf3Runner runner;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -21,7 +26,7 @@ class Iperf3GuiApp extends StatelessWidget {
|
|||||||
colorSchemeSeed: Colors.blue,
|
colorSchemeSeed: Colors.blue,
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
home: const TestRunScreen(),
|
home: TestRunScreen(runner: runner),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.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/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/iperf3_event_formatter.dart';
|
||||||
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
|
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
|
||||||
|
|
||||||
/// Main test run screen.
|
/// Main test run screen.
|
||||||
///
|
///
|
||||||
/// iperf3 is compiled as a native shared library (libiperf3jni.so) and accessed
|
/// Takes an [Iperf3Runner] rather than constructing one, so this layer names no
|
||||||
/// via JNI, so there is no binary extraction or chmod step.
|
/// transport: the Android app injects the JNI-backed implementation from
|
||||||
|
/// main(), and a test injects a fake.
|
||||||
class TestRunScreen extends StatelessWidget {
|
class TestRunScreen extends StatelessWidget {
|
||||||
const TestRunScreen({super.key});
|
const TestRunScreen({required this.runner, super.key});
|
||||||
|
|
||||||
|
final Iperf3Runner runner;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ChangeNotifierProvider(
|
return ChangeNotifierProvider(
|
||||||
create: (_) => TestRunViewModel(
|
create: (_) => TestRunViewModel(iperf3Service: runner),
|
||||||
iperf3Service: Iperf3Service(),
|
|
||||||
),
|
|
||||||
child: const _TestRunContent(),
|
child: const _TestRunContent(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
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_event.dart';
|
||||||
import 'package:iperf3gui/domain/models/iperf3_result.dart';
|
import 'package:iperf3gui/domain/models/iperf3_result.dart';
|
||||||
import 'package:iperf3gui/domain/models/test_configuration.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/iperf3_event_formatter.dart';
|
||||||
|
|
||||||
/// State of the test run.
|
/// State of the test run.
|
||||||
@@ -12,10 +12,10 @@ enum TestRunState { idle, running, completed, error }
|
|||||||
|
|
||||||
/// ViewModel for managing test run state.
|
/// 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.
|
/// series both grow while the test runs, rather than appearing at the end.
|
||||||
class TestRunViewModel extends ChangeNotifier {
|
class TestRunViewModel extends ChangeNotifier {
|
||||||
final Iperf3Service iperf3Service;
|
final Iperf3Runner iperf3Service;
|
||||||
|
|
||||||
/// How often to notify listeners while output is arriving. iperf3 can report
|
/// How often to notify listeners while output is arriving. iperf3 can report
|
||||||
/// several times a second across many streams, and each rebuild re-renders
|
/// several times a second across many streams, and each rebuild re-renders
|
||||||
|
|||||||
Reference in New Issue
Block a user