# iperf3-gui Implementation Plan > **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. **Goal:** Create a Flutter Android app that bundles iperf3 and provides a simple GUI for running network bandwidth tests (client mode only, MVP). **Architecture:** MVVM layered architecture following flutter-apply-architecture-best-practices — UI layer (views/ViewModels), data layer (iperf service, result parser), domain layer (test configuration, result model). Extensible for future server mode, history, etc. **Tech Stack:** - Flutter 3.47.1 (Dart 3.13.1) - Android API level 28+ (matches bundled iperf3 binary requirements) - Provider for state management (consistent with gps-speedo project) - iperf3 v3.21 (bundled prebuilt binaries for arm64-v8a, armeabi-v7a) - MIT License --- ## Task 1: Scaffold Flutter project with correct package name **Objective:** Create Flutter project with com.novoyuuparosk.iperf3gui package name. **Files:** - Create: `pubspec.yaml` - Create: `lib/main.dart` - Create: `android/app/build.gradle.kts` (updated package name) **Step 1: Scaffold Flutter project** Run: ```bash cd ~/dev/android-stuff/iperf3-gui flutter create . --org novoyuuparosk --project-name iperf3-gui --android-language kotlin --ios-language swift 2>&1 ``` Expected output: Flutter project scaffolded with all default files. **Step 2: Verify package name** Check `android/app/build.gradle.kts` contains: ```kotlin defaultConfig { applicationId = "com.novoyuuparosk.iperf3gui" ... } ``` Verify: ```bash grep -r "com.novoyuuparosk.iperf3gui" android/ ``` Expected: Found at least one match. **Step 3: Verify build** Run: ```bash flutter analyze 2>&1 ``` Expected: No errors or warnings. **Step 4: Commit** ```bash git add . git commit -m "chore: scaffold Flutter project with correct package name" ``` --- ## Task 2: Add dependencies and configure analysis options **Objective:** Add required dependencies (provider) and set up analysis_options.yaml for Dart/Flutter linting. **Files:** - Create: `pubspec.yaml` (add dependencies) - Create: `analysis_options.yaml` **Step 1: Add dependencies to pubspec.yaml** ```yaml dependencies: flutter: sdk: flutter provider: ^6.1.2 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 ``` **Step 2: Create analysis_options.yaml** ```yaml include: package:flutter_lints/flutter.yaml analyzer: language: strict-casts: true strict-inference: true strict-raw-types: true linter: rules: use_super_parameters: true prefer_const_constructors: true avoid_print: true ``` **Step 3: Get dependencies** ```bash flutter pub get 2>&1 ``` Expected: Dependencies fetched successfully. **Step 4: Run analysis** ```bash flutter analyze 2>&1 ``` Expected: No errors or warnings. **Step 5: Commit** ```bash git add pubspec.yaml analysis_options.yaml git commit -m "feat: add provider dependency and analysis options" ``` --- ## Task 3: Create domain models for test configuration and results **Objective:** Create immutable domain models for test configuration and iperf3 output parsing. **Files:** - Create: `lib/domain/models/test_configuration.dart` - Create: `lib/domain/models/iperf3_result.dart` - Test: `test/domain/models/test_configuration_test.dart` - Test: `test/domain/models/iperf3_result_test.dart` **Step 1: Create TestConfiguration model** Create `lib/domain/models/test_configuration.dart`: ```dart /// Configuration for an iperf3 client test. class TestConfiguration { final String server; final int port; final Duration duration; final int interval; final int parallelStreams; const TestConfiguration({ this.server = 'iperf.es.net', this.port = 5201, this.duration = const Duration(seconds: 10), this.interval = 1, this.parallelStreams = 1, }); /// Convert to iperf3 CLI arguments. List toCliArgs() { final args = [ '-c', server, '-p', '$port', '-t', '${duration.inSeconds}', '-i', '$interval', '-P', '$parallelStreams', ]; return args; } /// Validate the configuration. bool get isValid { if (server.isEmpty) return false; if (port < 1 || port > 65535) return false; if (duration.inSeconds < 1) return false; if (interval < 1) return false; if (parallelStreams < 1) return false; return true; } } ``` **Step 2: Create Iperf3Result model** Create `lib/domain/models/iperf3_result.dart`: ```dart /// Raw result from iperf3 execution. class Iperf3Result { final String rawOutput; final int exitCode; const Iperf3Result({ required this.rawOutput, required this.exitCode, }); } ``` **Step 3: Write tests** Create `test/domain/models/test_configuration_test.dart`: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; void main() { group('TestConfiguration', () { test('defaults produce valid configuration', () { const config = TestConfiguration(); expect(config.isValid, isTrue); }); test('empty server is invalid', () { const config = TestConfiguration(server: ''); expect(config.isValid, isFalse); }); test('port out of range is invalid', () { const config = TestConfiguration(port: 0); expect(config.isValid, isFalse); }); test('CLI args contain server and port', () { const config = TestConfiguration(server: '192.168.1.1', port: 1234); final args = config.toCliArgs(); expect(args, contains('-c')); expect(args, contains('192.168.1.1')); expect(args, contains('-p')); expect(args, contains('1234')); }); }); } ``` Create `test/domain/models/iperf3_result_test.dart`: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart'; void main() { group('Iperf3Result', () { test('can be instantiated with raw output', () { const result = Iperf3Result( rawOutput: 'Test complete', exitCode: 0, ); expect(result.rawOutput, equals('Test complete')); expect(result.exitCode, equals(0)); }); }); } ``` **Step 4: Run tests** ```bash flutter test test/domain/models/ 2>&1 ``` Expected: All tests pass. **Step 5: Commit** ```bash git add lib/domain/models/ test/domain/models/ git commit -m "feat: add domain models for test configuration and results" ``` --- ## Task 4: Implement iperf3 binary service (Process runner) **Objective:** Create a service that spawns iperf3 as a subprocess and captures output. Handles binary extraction from assets. **Files:** - Create: `lib/data/services/iperf3_service.dart` - Test: `test/data/services/iperf3_service_test.dart` **Step 1: Create iperf3_service.dart** Create `lib/data/services/iperf3_service.dart`: ```dart import 'dart:io'; import 'package:flutter/services.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart'; /// Service that runs iperf3 via Process.spawn and captures output. class Iperf3Service { final String _binaryPath; Iperf3Service({required String binaryPath}) : _binaryPath = binaryPath; /// Run iperf3 with the given configuration. /// /// Returns an [Iperf3Result] with stdout/stderr and exit code. Future run(TestConfiguration config) async { final args = config.toCliArgs(); final process = await Process.start( _binaryPath, args, runInShell: true, ); final stdoutBuffer = []; final stderrBuffer = []; final stdoutStream = process.stdout.transform(utf8.decoder).listen( (data) => stdoutBuffer.addAll(utf8.encode(data)), ); final stderrStream = process.stderr.transform(utf8.decoder).listen( (data) => stderrBuffer.addAll(utf8.encode(data)), ); await process.exitCode; await stdoutStream.cancel(); await stderrStream.cancel(); final rawOutput = utf8.decode(stdoutBuffer) + utf8.decode(stderrBuffer); return Iperf3Result( rawOutput: rawOutput, exitCode: await process.exitCode, ); } } ``` **Step 2: Create placeholder binary path** Create `lib/data/services/iperf3_binary_path.dart`: ```dart /// Placeholder for iperf3 binary path — will be overridden at runtime. /// /// On Android, the binary should be bundled as an asset and /// extracted to a temporary directory before first use. class Iperf3BinaryPathProvider { static String get path { // TODO: Implement asset extraction on first run return '/data/local/tmp/iperf3'; } } ``` **Step 3: Write tests** Create `test/data/services/iperf3_service_test.dart`: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart'; void main() { group('Iperf3Service', () { test('can be instantiated', () { final service = Iperf3Service(binaryPath: '/tmp/iperf3'); expect(service, isNotNull); }); }); } ``` **Step 4: Run tests** ```bash flutter test test/data/services/ 2>&1 ``` Expected: Tests pass. **Step 5: Commit** ```bash git add lib/data/services/ test/data/services/ git commit -m "feat: add iperf3 service to spawn subprocess" ``` --- ## Task 5: Create ViewModel for test run state **Objective:** Create a ViewModel that manages the state of a test run (idle, running, completed, error). **Files:** - Create: `lib/ui/features/test_run/test_run_view_model.dart` - Test: `test/ui/features/test_run/test_run_view_model_test.dart` **Step 1: Create TestRunViewModel** Create `lib/ui/features/test_run/test_run_view_model.dart`: ```dart import 'package:flutter/foundation.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart'; /// State of the test run. enum TestRunState { idle, running, completed, error } /// ViewModel for managing test run state. class TestRunViewModel extends ChangeNotifier { final Iperf3Service _iperf3Service; TestRunState _state = TestRunState.idle; TestRunState get state => _state; Iperf3Result? _result; Iperf3Result? get result => _result; TestConfiguration? _currentConfig; TestConfiguration? get currentConfig => _currentConfig; TestRunViewModel({required Iperf3Service iperf3Service}) : _iperf3Service = iperf3Service; /// Start a test with the given configuration. Future runTest(TestConfiguration config) async { if (!config.isValid) { _state = TestRunState.error; notifyListeners(); return; } _state = TestRunState.running; _currentConfig = config; _result = null; notifyListeners(); try { final result = await _iperf3Service.run(config); _result = result; _state = result.exitCode == 0 ? TestRunState.completed : TestRunState.error; } catch (e) { _state = TestRunState.error; } finally { notifyListeners(); } } /// Reset to idle state. void reset() { _state = TestRunState.idle; _result = null; _currentConfig = null; notifyListeners(); } } ``` **Step 2: Write tests** Create `test/ui/features/test_run/test_run_view_model_test.dart`: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; void main() { group('TestRunViewModel', () { test('starts in idle state', () { // Mock not needed for basic state test final vm = TestRunViewModel( iperf3Service: _MockIperf3Service(), ); expect(vm.state, equals(TestRunState.idle)); }); test('can be reset', () { final vm = TestRunViewModel( iperf3Service: _MockIperf3Service(), ); vm.reset(); expect(vm.state, equals(TestRunState.idle)); }); }); } class _MockIperf3Service { Future run(dynamic config) async {} } ``` **Step 3: Run tests** ```bash flutter test test/ui/features/test_run/ 2>&1 ``` Expected: Tests pass. **Step 4: Commit** ```bash git add lib/ui/features/test_run/ test/ui/features/test_run/ git commit -m "feat: add TestRunViewModel with state management" ``` --- ## Task 6: Create the main test screen UI **Objective:** Build the test screen with host/port input fields, "go" button, and raw output text box. Uses Provider for state. **Files:** - Create: `lib/ui/features/test_run/test_run_screen.dart` - Modify: `lib/main.dart` **Step 1: Create test_run_screen.dart** Create `lib/ui/features/test_run/test_run_screen.dart`: ```dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; class TestRunScreen extends StatelessWidget { const TestRunScreen({super.key}); @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) => TestRunViewModel( iperf3Service: Iperf3Service( binaryPath: Iperf3BinaryPathProvider.path, ), ), child: _TestRunContent(), ); } } class _TestRunContent extends StatelessWidget { final _serverController = TextEditingController(text: 'iperf.es.net'); final _portController = TextEditingController(text: '5201'); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('iperf3-gui'), ), body: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Server input TextField( controller: _serverController, decoration: const InputDecoration( labelText: 'Server', hintText: 'e.g. iperf.es.net or 192.168.1.1', ), ), const SizedBox(height: 16), // Port input TextField( controller: _portController, decoration: const InputDecoration( labelText: 'Port', hintText: '5201', ), keyboardType: TextInputType.number, ), const SizedBox(height: 16), // Go button Consumer( builder: (_, vm, __) { return ElevatedButton( onPressed: vm.state == TestRunState.running ? null : () { final config = TestConfiguration( server: _serverController.text, port: int.tryParse(_portController.text) ?? 5201, ); vm.runTest(config); }, child: vm.state == TestRunState.running ? const CircularProgressIndicator() : const Text('GO'), ); }, ), const SizedBox(height: 16), // Status indicator Consumer( builder: (_, vm, __) { final statusText = switch (vm.state) { TestRunState.idle => '', TestRunState.running => 'Running...', TestRunState.completed => 'Test complete', TestRunState.error => 'Error', }; return Text( statusText, style: TextStyle( color: vm.state == TestRunState.error ? Colors.red : Colors.grey, ), ); }, ), const SizedBox(height: 16), // Raw output text box Expanded( child: Consumer( builder: (_, vm, __) { final output = vm.result?.rawOutput ?? ''; return TextField( controller: TextEditingController(text: output), decoration: const InputDecoration( labelText: 'Raw Output', border: OutlineInputBorder(), ), maxLines: null, readOnly: true, ); }, ), ), ], ), ), ); } } ``` **Step 2: Update main.dart** Modify `lib/main.dart`: ```dart import 'package:flutter/material.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_screen.dart'; void main() { runApp(const Iperf3GuiApp()); } class Iperf3GuiApp extends StatelessWidget { const Iperf3GuiApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'iperf3-gui', theme: ThemeData( colorSchemeSeed: Colors.blue, useMaterial3: true, ), home: const TestRunScreen(), ); } } ``` **Step 3: Verify build** ```bash flutter analyze 2>&1 ``` Expected: No errors or warnings. **Step 4: Commit** ```bash git add lib/main.dart lib/ui/features/test_run/ git commit -m "feat: add test run screen with host/port inputs and go button" ``` --- ## Task 7: Bundle iperf3 binary assets **Objective:** Add iperf3 prebuilt binaries to the app as assets. Download from davidBar-On/android-iperf3 gh-pages. **Files:** - Create: `assets/iperf3/` directory - Modify: `pubspec.yaml` (add assets) - Modify: `android/app/src/main/AndroidManifest.xml` (add INTERNET permission) **Step 1: Create assets directory** ```bash mkdir -p assets/iperf3/arm64-v8a mkdir -p assets/iperf3/armeabi-v7a ``` **Step 2: Download iperf3 v3.21 binaries** Download from https://github.com/davidBar-On/android-iperf3/tree/gh-pages/libs/iperf-3.21/ For arm64-v8a: ```bash curl -L -o assets/iperf3/arm64-v8a/iperf3 "https://raw.githubusercontent.com/davidBar-On/android-iperf3/gh-pages/libs/iperf-3.21/arm64-v8a/iperf3" ``` For armeabi-v7a: ```bash curl -L -o assets/iperf3/armeabi-v7a/iperf3 "https://raw.githubusercontent.com/davidBar-On/android-iperf3/gh-pages/libs/iperf-3.21/armeabi-v7a/iperf3" ``` Verify: ```bash file assets/iperf3/arm64-v8a/iperf3 2>&1 | grep ELF file assets/iperf3/armeabi-v7a/iperf3 2>&1 | grep ELF ``` Expected: Both files are ELF executables. **Step 3: Update pubspec.yaml** Add under `flutter:` section: ```yaml assets: - assets/iperf3/arm64-v8a/ - assets/iperf3/armeabi-v7a/ ``` **Step 4: Add INTERNET permission to AndroidManifest.xml** Modify `android/app/src/main/AndroidManifest.xml`: ```xml ``` **Step 5: Run pub get** ```bash flutter pub get 2>&1 ``` Expected: Dependencies and assets fetched. **Step 6: Commit** ```bash git add assets/ pubspec.yaml android/app/src/main/AndroidManifest.xml git commit -m "feat: bundle iperf3 v3.21 binaries and add INTERNET permission" ``` --- ## Task 8: Implement binary extraction at runtime **Objective:** Extract bundled iperf3 binary to device storage on first run and provide the path to Iperf3Service. **Files:** - Modify: `lib/data/services/iperf3_service.dart` - Modify: `lib/main.dart` (add initialization) **Step 1: Update iperf3_service.dart with asset extraction** Replace `lib/data/services/iperf3_binary_path.dart` with: ```dart import 'dart:io'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; /// Provides the path to the iperf3 binary on the device. /// /// On first run, extracts the bundled asset to the app's cache /// directory. Subsequent runs reuse the extracted binary. class Iperf3BinaryPathProvider { static String? _cachedPath; static Future get path async { if (_cachedPath != null) return _cachedPath!; final cacheDir = await getApplicationCacheDirectory(); final binaryDir = Directory('${cacheDir.path}/iperf3'); if (!await binaryDir.exists()) { await binaryDir.create(recursive: true); } // Detect ABI and extract final abi = await _detectAbi(); final assetPath = 'assets/iperf3/$abi/iperf3'; final bytes = await rootBundle.load(assetPath); final file = File('${binaryDir.path}/iperf3'); await file.writeAsBytes(bytes.buffer.asUint8List()); await file.setPermissions('rwxr-xr-x'); _cachedPath = file.path; return file.path; } static Future _detectAbi() async { // Use Process.run to query ro.product.cpu.abi final result = await Process.run( 'getprop', ['ro.product.cpu.abi'], ); final abi = result.stdout.toString().trim(); if (abi.contains('arm64')) return 'arm64-v8a'; return 'armeabi-v7a'; } } ``` **Step 2: Add path_provider dependency** Modify `pubspec.yaml`: ```yaml dependencies: ... path_provider: ^2.1.4 ``` Then: ```bash flutter pub get 2>&1 ``` **Step 3: Update main.dart to initialize binary** Modify `lib/main.dart`: ```dart import 'package:flutter/material.dart'; import 'package:iperf3gui/data/services/iperf3_binary_path.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_screen.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Ensure iperf3 binary is available await Iperf3BinaryPathProvider.path; runApp(const Iperf3GuiApp()); } ``` **Step 4: Verify build** ```bash flutter analyze 2>&1 ``` Expected: No errors or warnings. **Step 5: Commit** ```bash git add lib/data/services/ iperf3_binary_path.dart git commit -m "feat: implement iperf3 binary extraction from assets" ``` --- ## Task 9: Add iperf3 license file **Objective:** Include the iperf3 BSD license in the app to comply with redistribution requirements. **Files:** - Create: `assets/LICENSE-iperf3` **Step 1: Create license file** Create `assets/LICENSE-iperf3`: ``` Copyright (c) 2014-2024, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. If you have questions about your rights to use or distribute this software, please contact Berkeley Lab's Intellectual Property Office at IPO@lbl.gov. NOTICE. This Software was developed under funding from the U.S. Department of Energy and the U.S. Government consequently retains certain rights. As such, the U.S. Government has been granted for itself and others acting on its behalf a paid-up, nonexclusive, irrevocable, worldwide license in the Software to reproduce, distribute copies to the public, prepare derivative works, and perform publicly and display publicly, and to permit others to do so. ``` **Step 2: Add to pubspec.yaml as an asset** ```yaml assets: - assets/iperf3/arm64-v8a/ - assets/iperf3/armeabi-v7a/ - assets/LICENSE-iperf3 ``` **Step 3: Commit** ```bash git add assets/LICENSE-iperf3 pubspec.yaml git commit -m "docs: add iperf3 BSD license for redistribution compliance" ``` --- ## Task 10: Test on device and verify **Objective:** Build and install on the Android device, verify the app works end-to-end. **Files:** - Verify: `~/dev/android-stuff/iperf3-gui/` builds successfully **Step 1: Check device connection** ```bash adb devices 2>&1 ``` Expected: At least one device listed (QV7100JQ52 for Sony XQ-AS72). **Step 2: Build APK** ```bash flutter build apk --release 2>&1 ``` Expected: APK built to `build/app/outputs/flutter-apk/app-release.apk`. **Step 3: Install on device** ```bash adb install build/app/outputs/flutter-apk/app-release.apk 2>&1 ``` Expected: "Success" message. **Step 4: Launch and test** ```bash adb shell am start -n com.novoyuuparosk.iperf3gui/.MainActivity 2>&1 ``` **Step 5: Verify app opens** Take screenshot: ```bash adb shell screencap -p /sdcard/test_screenshot.png adb pull /sdcard/test_screenshot.png 2>&1 ``` Expected: Screenshot shows the iperf3-gui app UI. **Step 6: Commit** ```bash git add . git commit -m "chore: verify MVP build and device installation" ``` --- ## Future Extensions (out of scope for MVP) These are designed into the architecture but not implemented in v1: - **Server mode**: Add server mode with similar UI - **History**: Store test results in SharedPreferences or SQLite - **Advanced options**: Duration, parallel streams, interval, bandwidth limit - **Results parsing**: Parse iperf3 stdout into structured data instead of raw text - **Protocol selection**: TCP, UDP, SCTP options - **WiFi scanning**: Helper to find iperf3 servers on local network --- ## Risks and Mitigations | Risk | Mitigation | |------|------------| | iperf3 binary download blocked | Use raw.githubusercontent.com or mirror | | Binary extraction permission issues | Use cache directory (app-writable) | | ABI detection fails | Fallback to armeabi-v7a | | iperf3 requires root | Document that it runs as non-root | | Process spawn fails on some devices | Add error handling and user feedback |