# 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. ```bash 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 `jmethodID`s 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()` and `signal(SIGPIPE, SIG_IGN)` change behaviour for the whole process, Flutter and ART included. Handlers are restored to `SIG_DFL` when a test ends. - **`g_sigend_jmp_buf` outlives 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_errno` and 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 `unlink`ed 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` 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.so` for `armeabi-v7a` and `arm64-v8a` - Config: `iperf_config.h` generated from `iperf_config.h.in` with 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 | ## CI Both jobs run on a **self-hosted runner registered at `android-stuff` org scope**, targeted by the capability label `flutter-android` rather than by hostname. `tool/setup_ci_runner.sh` provisions it. It does not run on the Gitea host. Gitea lives on an aarch64 box, and Flutter cannot build an Android APK on a Linux arm64 host at all — the SDK ships no arm64-host `gen_snapshot` ([flutter#189724](https://github.com/flutter/flutter/issues/189724), [#75864](https://github.com/flutter/flutter/issues/75864), open as of July 2026). An x86_64 runner is a requirement, not a preference. The runner is **host mode**: jobs use the machine's own Flutter, JDK and Android SDK/NDK. CI therefore builds with the same toolchain that produced the verified release, and no NDK is downloaded per run. The cost is that there is no container boundary, which is what decides the next paragraph. Jobs run as a dedicated unprivileged account, `gitea-runner`, in the `flutterdev` group. In host mode the service account *is* the sandbox — there is no other — and a workflow file is code that runs as whoever the service runs as. A human account would hand every workflow that user's home directory and SSH keys; root would be worse and would also miss the warm caches in that home, re-downloading dependencies every run. The dedicated account keeps its own Gradle and pub caches under `/var/lib/gitea-runner`, so the first Android build is slow by design and later ones are not. `flutterdev` is what grants write access to `/opt/flutter` and `/opt/android-sdk`; both carry ACLs with `group:flutterdev:rwx` and matching `default:` entries, so new files inherit it. The setup script proves that access as the service account rather than assuming group membership is sufficient. It installs a **system** service, not a user service: root-managed lifecycle, starts at boot, no lingering session. That the service is root-managed is unrelated to the jobs being unprivileged — `User=` in the unit settles the second, and conflating the two is easy to do. `sudo ./tool/setup_ci_runner.sh --check` validates this machine's toolchain without creating anything. Two jobs: | Job | What it protects | |---|---| | `analyze` | `flutter analyze`, then `flutter test` — which includes `architecture_test.dart`, failing the build if a layer boundary is crossed | | `release-apk` | builds the **minified** APK and runs `tool/check_jni_keep_rules.sh` | The second exists because of a specific escape. A release APK that aborted on every test shipped once, while every on-device check passed — because those checks all used the debug build, which does not minify. Building the release APK is necessary but not sufficient: a renamed JNI callback still produces a *successful build* and a broken app. So the guard reads R8's `mapping.txt` for the callback classes and methods, and the DEX string pool for the native entry points. It is verified to fail: delete the keep rules from `android/app/proguard-rules.pro`, rebuild, and it reports `MainThreadCallback`, `onOutput`, `onError` and `onComplete` renamed. ## Building Prerequisites: - Flutter 3.47+ - Android SDK with platform android-36 - JDK 17 - NDK r28+ - CMake 3.22.1 ```bash 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.