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 |