States where run data lives now that Iperf3Session owns it, so the next person does not put accumulation back in the view model, and documents the headless entry point as the way to check that claim rather than trust it. Includes the measurement that motivates using it: 30.2% local CPU headless versus 79.9% through the UI on a comparable 4-stream run. That gap is worth recording, because it means throughput read off the app's own screen is pessimistic at high rates and should not be compared against a headless or desktop figure without noting which path produced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
iperf3-gui
A Flutter-based Android GUI wrapper for iperf3, the popular network performance measurement tool.
Architecture
lib/ # Flutter/Dart application layer
├── main.dart # Composition root: picks the Iperf3Runner impl
├── main_headless.dart # Runs a test with no widgets (see Layering)
├── domain/ # Pure Dart — no Flutter import
│ ├── models/
│ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams
│ │ ├── iperf3_result.dart # Raw output, exit code, stopped flag
│ │ ├── iperf3_event.dart # Parsed --json-stream reports
│ │ └── iperf3_session.dart # One run accumulated: samples, totals, stats
│ └── services/
│ └── iperf3_runner.dart # The contract the UI depends on
├── data/ # Transport — flutter/services only
│ └── services/
│ └── iperf3_service.dart # Iperf3Runner over MethodChannel → JNI
└── ui/ # Presentation
└── features/
└── test_run/
├── test_run_screen.dart # Inputs, GO/STOP, live transcript
├── test_run_view_model.dart # idle → running → completed/error
└── iperf3_event_formatter.dart # Events → iperf3-style text
android/app/src/main/
├── kotlin/ # Kotlin plugin layer
│ └── com/novoyuuparosk/iperf3gui/
│ ├── MainActivity.kt # Registers IperfRunner as FlutterPlugin
│ └── IperfRunner.kt # MethodChannel handler → native JNI calls
└── cpp/ # Native iperf3 library
├── CMakeLists.txt # Compiles iperf3 + cJSON + JNI into libiperf3jni.so
├── iperf_jni.c # JNI bridge: Java String[] → iperf3 API
├── iperf/ # iperf3 3.19 C source tree (see below)
├── iperf_config.h # Generated Android-compatible config header
└── iperf_config_android.h # Android-specific compile options
Data Flow
User taps GO
→ Dart TestRunViewModel.runTest(config)
→ Iperf3Service.run(config) via MethodChannel
→ Kotlin IperfRunner: submits to the iperf3-run worker thread, returns at once
→ JNI C: build_argv() (prepends argv[0]) → iperf_parse_arguments()
→ iperf_run_client(test) [blocks on the worker thread]
→ iperf3 json_callback → one JSON event per report → CallVoidMethod
→ main-thread hop → Dart onOutput → Iperf3Event.parse()
→ Iperf3Service.events → view model: transcript + interval series + summary
→ On completion: Dart Completer resolves with Iperf3Result
Layering
Dependencies point inward: ui → domain ← data, never the reverse.
| Layer | May import | Holds |
|---|---|---|
domain |
nothing else in this package, no Flutter | models and the Iperf3Runner contract |
data |
domain, package:flutter/services.dart |
the MethodChannel/JNI implementation |
ui |
domain |
widgets, view model, formatting |
main.dart |
everything | the single place that picks an implementation |
The UI depends on Iperf3Runner, not on Iperf3Service, so presentation names
no transport. That is what lets test/widget_test.dart drive the real widget
tree against an in-memory runner with no MethodChannel, native library or
device — and what keeps a test double at five stable members instead of
mirroring the transport's whole surface.
The core does not need the UI. What happened during a run is accumulated by
Iperf3Session in the domain layer — samples, totals, errors, and the mean,
peak and retransmit figures over them. The view model holds no run data of its
own and the screen derives no numbers; both read the session. Replacing the
presentation layer therefore means rewriting rendering, not re-deriving results.
lib/main_headless.dart is the standing proof: it runs a full test through
Iperf3Service and Iperf3Session with WidgetsFlutterBinding but no
runApp — no widget tree, no render surface. If the run path ever starts
needing the UI, it stops working.
flutter run -t lib/main_headless.dart \
--dart-define=IPERF_SERVER=192.168.2.182 --dart-define=IPERF_STREAMS=4
Use it when a figure has to be trustworthy. A 4-stream local run reports 30.2% local CPU headless against 79.9% through the UI — the phone is near CPU-bound at gigabit rates, so a repainting transcript measurably depresses throughput. Numbers taken through the UI are pessimistic.
test/architecture_test.dart enforces all of this by reading the actual
imports under lib/, including a check that no fourth layer directory has
appeared unnoticed. Change the layering there when the layering should change;
don't work around it.
Key Design Decisions
JNI Over Process Execution
Android SELinux Enforcing mode blocks execve() from the untrusted_app domain, making Process.start() of external binaries fail with Permission denied. Compiling iperf3 as a shared library linked into the app process eliminates this restriction entirely — iperf3 code runs in-process with no domain transitions needed.
Threading
runIperfLive() blocks for the whole test, so Kotlin submits it to a dedicated
single-thread executor and hands control straight back to Dart. Running it on the
Flutter platform thread froze the UI and — because output is delivered by posting
to the main looper — stalled every callback until the test was already over.
Callback Mechanism
The JNI bridge uses CallVoidMethod on stored jmethodIDs to invoke the Kotlin IperfCallback interface, NOT function pointer casts. Direct function pointer casts caused SIGBUS crashes on ARM due to JNINativeInterface method ID layout differences.
Output via json_callback, not a pipe
The test runs with json_output and json_stream set and a json_callback
installed, so iperf3 hands each report to the bridge as a JSON string and never
writes to test->outfile. iperf.h describes the hook as "allow user apps to
receive the JSON strings, instead of writing them to the output file" — this is
its intended use.
An earlier design piped test->outfile to a reader thread. That imposed two
constraints, both of which were gotten wrong and deadlocked every test: the
pipe's write end had to be closed before joining a reader blocked in
fgets(), and exactly one side could own the shared state struct. Reading from
the callback removes the pipe, the reader thread and both constraints. Don't
reintroduce it.
json_callback carries no user-data pointer, so the run state is reached
through a global published before iperf_run_client() and cleared afterwards;
only one test may run at a time, which runIperfLive enforces. The callback
fires on iperf3's reporting thread, so it resolves a JNIEnv for the calling
thread rather than reusing a stored one.
Dart parses each event into a sealed Iperf3Event; unrecognised output becomes
an Iperf3LogLine rather than being discarded. The UI rebuilds iperf3's text
layout from the events and keeps the interval series as data.
Embedding a CLI in a long-lived process
iperf3's main() runs one test and exits, so anything it leaves behind is
reclaimed by the OS. Linked into an app process that runs tests repeatedly for
hours, every such leftover becomes this app's problem. What that costs in
practice:
- Signal handlers are process-global.
iperf_catch_sigend()andsignal(SIGPIPE, SIG_IGN)change behaviour for the whole process, Flutter and ART included. Handlers are restored toSIG_DFLwhen a test ends. g_sigend_jmp_bufoutlives its frame. Harmless in a process about to exit; a jump into a dead stack frame otherwise. Same fix as above.- Globals mean one test at a time.
i_errnoand the bridge's own statics hold a single test's worth of state, so concurrent runs are refused.
What iperf3 itself handles correctly, verified rather than assumed: per-stream
threads are joined (iperf_client_api.c), stream buffer fds are closed, and the
mkstemp temp file is unlinked immediately after creation
(iperf_api.c:4652), so nothing accumulates in the cache dir. Measured across
four consecutive 4-stream runs on device, thread count returned to baseline
(33 → 40 mid-run → 34) and fd count returned exactly to baseline (175 → 183 →
175).
Stop Handling
A stop shuts down the control socket, so iperf_run_client() returns -1 with
"Broken pipe". That is the expected outcome of a stop, not a failure, so the
bridge checks its own g_stop flag and reports a stop rather than an error; the
flag travels up to the UI as Iperf3Result.stopped.
Async Pattern
Dart uses Completer<Iperf3Result> to await native completion. The MethodChannel callback handler resolves the completer on onComplete, with a timeout fallback that calls stopIperf on the native layer.
iperf3 Source
- Version: 3.19
- Location:
android/app/src/main/cpp/iperf/ - Files: Full C source tree including cJSON, headers, man pages, PEM keys
- Build: CMake compiles into
libiperf3jni.soforarmeabi-v7aandarm64-v8a - Config:
iperf_config.hgenerated fromiperf_config.h.inwith Android-compatible POSIX/threading macros
Native JNI API
| Kotlin Method | JNI Signature | Purpose |
|---|---|---|
runIperfLive(String[], IperfCallback) |
runIperfLive |
Run a test to completion — blocks, call off the platform thread |
forceStopIperfTest(IperfCallback) |
forceStopIperfTest |
Graceful stop via shutdown() |
isTestRunning() |
isTestRunning |
Check if test is in progress |
setTempDir(String) |
setTempDir |
Point mkstemp() at the app cache dir |
Building
Prerequisites:
- Flutter 3.47+
- Android SDK with platform android-36
- JDK 17
- NDK r28+
- CMake 3.22.1
flutter pub get
flutter build apk --debug
adb install build/app/outputs/flutter-apk/app-debug.apk
Dependencies
- Dart:
provider - Android: CMake external native build
- Native: iperf3 3.19, cJSON (single file)
License
MIT — bundled iperf3 3.19 is BSD-3-Clause licensed.