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

1002 lines
25 KiB
Markdown

# 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<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`:
```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<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`:
```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<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`:
```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**
```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<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`:
```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
<uses-permission android:name="android.permission.INTERNET" />
```
**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<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`:
```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 |