Files
iperf3-gui/.hermes/plans/2025-08-24_1200-iperf3-gui-mvp.md
T

25 KiB

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:

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:

defaultConfig {
    applicationId = "com.novoyuuparosk.iperf3gui"
    ...
}

Verify:

grep -r "com.novoyuuparosk.iperf3gui" android/

Expected: Found at least one match.

Step 3: Verify build

Run:

flutter analyze 2>&1

Expected: No errors or warnings.

Step 4: Commit

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

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

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

flutter pub get 2>&1

Expected: Dependencies fetched successfully.

Step 4: Run analysis

flutter analyze 2>&1

Expected: No errors or warnings.

Step 5: Commit

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:

/// 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<String> toCliArgs() {
    final args = <String>[
      '-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:

/// 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:

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:

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

flutter test test/domain/models/ 2>&1

Expected: All tests pass.

Step 5: Commit

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:

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<Iperf3Result> run(TestConfiguration config) async {
    final args = config.toCliArgs();

    final process = await Process.start(
      _binaryPath,
      args,
      runInShell: true,
    );

    final stdoutBuffer = <int>[];
    final stderrBuffer = <int>[];

    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:

/// 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:

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

flutter test test/data/services/ 2>&1

Expected: Tests pass.

Step 5: Commit

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:

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<void> 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:

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<void> run(dynamic config) async {}
}

Step 3: Run tests

flutter test test/ui/features/test_run/ 2>&1

Expected: Tests pass.

Step 4: Commit

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:

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<TestRunViewModel>(
              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<TestRunViewModel>(
              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<TestRunViewModel>(
                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:

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

flutter analyze 2>&1

Expected: No errors or warnings.

Step 4: Commit

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

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:

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:

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:

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:

  assets:
    - assets/iperf3/arm64-v8a/
    - assets/iperf3/armeabi-v7a/

Step 4: Add INTERNET permission to AndroidManifest.xml

Modify android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />

Step 5: Run pub get

flutter pub get 2>&1

Expected: Dependencies and assets fetched.

Step 6: Commit

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:

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<String> 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<String> _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:

dependencies:
  ...
  path_provider: ^2.1.4

Then:

flutter pub get 2>&1

Step 3: Update main.dart to initialize binary

Modify lib/main.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

flutter analyze 2>&1

Expected: No errors or warnings.

Step 5: Commit

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

  assets:
    - assets/iperf3/arm64-v8a/
    - assets/iperf3/armeabi-v7a/
    - assets/LICENSE-iperf3

Step 3: Commit

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

adb devices 2>&1

Expected: At least one device listed (QV7100JQ52 for Sony XQ-AS72).

Step 2: Build APK

flutter build apk --release 2>&1

Expected: APK built to build/app/outputs/flutter-apk/app-release.apk.

Step 3: Install on device

adb install build/app/outputs/flutter-apk/app-release.apk 2>&1

Expected: "Success" message.

Step 4: Launch and test

adb shell am start -n com.novoyuuparosk.iperf3gui/.MainActivity 2>&1

Step 5: Verify app opens

Take screenshot:

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

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