Compare commits

...

24 Commits

Author SHA1 Message Date
irene ff423f8bbf ci: split into fast analyze + slow release build with path gating
analyze runs on every push/PR (fast check).
release-apk only fires on push to master or workflow_dispatch (slow build).
Doc-only pushes no longer trigger any CI run.
workflow_dispatch triggers both jobs unconditionally.
2026-08-27 23:59:04 +09:00
irene 47d849a122 ci: split into fast analyze + slow release build with path gating
analyze runs on every push/PR (fast check).
release-apk only fires on push to master or workflow_dispatch (slow build).
Doc-only pushes no longer trigger any CI run.
workflow_dispatch triggers both jobs unconditionally.
2026-08-27 23:58:02 +09:00
irene ba7f335b67 ci: split into fast analyze + slow release build with path gating
analyze job runs on push/PR regardless of changed files (fast check).
release-apk job only fires when Dart source, Android, pubspec, or
tooling files change on push to master (slow build).
workflow_dispatch triggers both jobs unconditionally.
Doc-only pushes no longer trigger any CI run.
2026-08-27 23:56:31 +09:00
irene 5b6c2c0435 fix(docs): correct iperf3 version from 3.21 to 3.19 in LICENSE
CI / Analyze & Test (push) Successful in 14s
CI / Release APK (push) Successful in 22s
2026-08-27 00:06:04 +09:00
irene b01f8cdf87 docs: add MIT LICENSE file for Gitea detection
CI / Analyze & Test (push) Successful in 10s
CI / Release APK (push) Successful in 23s
2026-08-27 00:02:44 +09:00
mikkeli b960581fd1 ci: allow manual runs with workflow_dispatch
CI / Analyze & Test (push) Successful in 9s
CI / Release APK (push) Successful in 23s
The workflow only triggered on push and pull_request, so a run that failed for
reasons outside the repo — runner down, toolchain changed underneath it —
could only be retried with an empty commit. That is a likelier situation with a
self-hosted runner than with hosted ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:57:33 +09:00
mikkeli 7981ca2022 fix(ci): register from the runner's own directory with an absolute state path
CI / Analyze & Test (push) Successful in 11s
CI / Release APK (push) Successful in 22s
Registration failed with `open .runner: permission denied`. runner.file was
the relative `.runner`, which resolves against the caller's working directory
— the repo the admin ran sudo from, which the service account cannot write.
The same applied to .runner.lock, so the single-process guard was skipped too.

Two fixes rather than one, because either alone leaves a trap: runner.file is
now absolute, and register runs with cwd set to the runner's home. An earlier
draft did cd there; that was lost when the script was rewritten for a system
service, and an absolute path is what makes it not matter next time.

Also drops --labels from the register call, which gitea-runner ignores and
warns about whenever the config file defines labels, and rewrites a config that
exists while nothing is registered yet — such a config is provisional, not in
use, and leaving a broken one in place was what required manual cleanup here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:53:04 +09:00
mikkeli ae2033865c fix(ci): probe writability by writing, not with test -w
CI / Analyze & Test (push) Successful in 12s
CI / Release APK (push) Successful in 22s
The setup script refused to provision, reporting /opt/flutter/bin/cache and
/opt/android-sdk unwritable by the service account, while a touch as that same
account succeeded.

Cause: this machine's /usr/bin/test is uutils coreutils 0.8.0, whose -w does
not credit supplementary group membership. Write access to both directories
comes from the flutterdev group rather than the owner bits, so -w reports false
for any account that should be building here — including the developer's own.
-r and -x pass only because `other` carries r-x, which is what made the failure
look ACL-shaped. bash's builtin [ -w ] evaluates it correctly, but sudo execs
the external binary, so the builtin was never in play.

Replaced with a real write probe: create a file as the service account and
remove it. That tests the operation a build actually performs and is immune to
whichever test implementation is installed, which is the better check
regardless of the bug that prompted it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:50:09 +09:00
mikkeli 86b290d978 ci: run the runner as a dedicated account under a system service
CI / Analyze & Test (push) Successful in 13s
CI / Release APK (push) Successful in 24s
The earlier script installed a systemd *user* service, which conflated two
separate decisions. What matters in host mode is which account jobs run as,
because there is no container boundary and the service account is the only
sandbox. A system service with User= set gets unprivileged jobs *and* a
root-managed lifecycle that starts at boot with no lingering session.

Jobs now run as a dedicated `gitea-runner` account in the flutterdev group,
with its own HOME so its Gradle and pub caches are its own. The alternative —
a human account — would hand every workflow that user's home directory and SSH
keys, which is a sharper edge than usual here because a workflow file is code
and much of this repo is agent-authored. Running as root would be worse still,
and would also miss the warm caches in a human HOME and re-download
dependencies every run.

flutterdev is what grants write access to /opt/flutter and /opt/android-sdk.
Group membership alone does not prove it, since both carry ACLs, so the script
tests writability as the service account and fails with the getfacl command to
run. Tool lookups use systemd's default PATH rather than an interactive shell's,
because that is what the service will actually have.

Adds --check, which runs pre-flight and exits without creating anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:44:05 +09:00
mikkeli 32575530dc Merge branch 'ci/self-hosted-x86-64-runner'
CI / Analyze & Test (push) Successful in 19s
CI / Release APK (push) Successful in 1m53s
Move CI to a self-hosted x86_64 runner registered at android-stuff org scope.
Flutter cannot build an Android APK on a Linux arm64 host, so the aarch64 Gitea
box could never have run this build regardless of its container networking.

Adds tool/setup_ci_runner.sh to provision it in host mode, using the machine's
existing proven toolchain, and points both jobs at the capability label
flutter-android.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:21:28 +09:00
mikkeli 0dd0b95afb ci: run on the self-hosted x86_64 runner and drop the toolchain installers
Targets runs-on: flutter-android for both jobs. Since that runner is host mode
with the toolchain preinstalled, subosito/flutter-action and
actions/setup-java are removed — they would have downloaded a second Flutter
and JDK over the ones being used, and CI would no longer be building with the
toolchain the release was verified against. A Toolchain step fails loudly if
flutter or ANDROID_HOME is missing rather than letting a later step fail
obscurely.

Documents in docs/README.md why the runner cannot be the Gitea host, what host
mode trades away, and what each job protects — including why building the
release APK is necessary but not sufficient, which is the reason the JNI name
guard exists at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:21:17 +09:00
mikkeli 66826bbce6 ci: add a provisioning script for an x86_64 Android build runner
Gitea runs on an aarch64 host, and Flutter cannot build an Android APK on a
Linux arm64 host at all: the SDK ships no arm64-host gen_snapshot
(flutter/flutter#189724, #75864, open as of July 2026). So CI for this repo was
failing for two independent reasons, and fixing the runner's container network
would only have exposed the second. An x86_64 runner is a requirement here, not
a preference.

Registers at android-stuff org scope rather than repo scope, so any Android
repo in the org can use the same runner, and labels it by capability
(flutter-android) rather than by hostname, so the workflow does not encode
which machine happens to provide it.

Host mode rather than a container: the machine already has a proven Flutter,
JDK and Android SDK/NDK, so jobs build with the same toolchain that produced
the verified release and skip a per-run NDK download. The trade is isolation,
which the header states plainly — workflows run as that user on that machine.

Refuses to register unless the toolchain it would advertise is actually
present, so a missing SDK fails at setup with a list rather than inside every
later job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:21:17 +09:00
mikkeli 85dc74247c Merge branch 'ci/release-apk-and-jni-name-guard'
CI / Analyze & Test (push) Failing after 28s
CI / Release APK (push) Failing after 1m48s
Add a CI job that builds the release APK and verifies the names JNI resolves
survived R8, so the broken-release-APK failure mode cannot recur silently.

CI's existing failures are unrelated to workflow content: every run has failed
at actions/checkout because the runner's job containers cannot resolve the
'gitea' hostname. That needs container.network set in the runner's config.yaml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:54:33 +09:00
mikkeli 94d96297f8 ci: build the release APK and check the JNI names
CI only ran analyze and test, so it would have passed for every commit that
carried the broken release APK. Adds a release-apk job that builds the
minified artifact, runs tool/check_jni_keep_rules.sh against it, and uploads
it — turning "the shipping artifact works" into something checked rather than
assumed at tag time.

Enables flutter-action's dependency cache on both jobs, and notes that the
test step now includes the layering guard.

This does not fix the current failures: every run so far has failed at
actions/checkout, before Flutter is reached, because the runner's job
containers cannot resolve the `gitea` hostname. That is runner configuration,
not workflow content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:54:19 +09:00
mikkeli a96f302dc7 test: guard the JNI-visible names against R8 renaming
A renamed JNI callback does not fail the build — it produces a release APK
that aborts on the first callback of every test. That shipped once, because
every on-device check used the debug build, which does not minify.

This reads R8's own mapping file for the classes and callback methods, and the
DEX string pool for the native entry points. Native methods are deliberately
not checked against mapping.txt: R8 only emits entries for members with a body
or a rename, so a native method is absent either way and absence would prove
nothing.

Verified by deleting the callback keep rules and rebuilding: the check reports
MainThreadCallback renamed along with onOutput, onError and onComplete, which
is exactly the failure that shipped — with no device involved.

Also avoids `... | strings | grep -q` and dumps to a file first. Under
`set -o pipefail`, grep -q exits at the first match and SIGPIPEs strings, so
the pipeline reports failure for a name found early and success for one found
late. It reported two of four names missing while all four were present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:54:19 +09:00
mikkeli 7ca74366a3 Merge branch 'fix/release-build-jni-keep-rules'
CI / Analyze & Test (push) Failing after 30s
Release builds aborted on the first callback of every test: R8 renamed the
IperfCallback implementation and its methods, so the JNI GetMethodID lookups
resolved nothing. Adds the keep rules, and makes the native side report a
failed lookup instead of aborting the process on the next JNI call.

Debug builds do not minify, so every on-device check until now passed while the
shipping artifact was broken. Found by running the release APK during release
preparation.

Verified with the release APK on an XQ-AS72: clean run at 806 Mbits/sec,
matching the server's own figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:43:52 +09:00
mikkeli ff13d63333 fix(jni): fail cleanly when a callback method does not resolve
GetMethodID raises NoSuchMethodError when a lookup fails, and any further JNI
call made while an exception is pending aborts the process. runIperfLive
resolved three method IDs back to back without checking, so the first failure
made the second call fatal — turning a diagnosable configuration problem into
a SIGABRT with a stack that pointed at the JNI entry rather than the cause.

resolve_method() clears the pending exception and returns NULL, and the caller
now bails out with a log naming which lookups failed and pointing at the keep
rules. The R8 problem this exposed is fixed separately; this is about the
failure being reportable rather than fatal, whatever the cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:43:35 +09:00
mikkeli 2b3f20d3a0 fix(android): keep the JNI callback names through R8
Release builds aborted on the first callback of every test:

  NoSuchMethodError: no non-static method "Lp0;.onOutput(Ljava/lang/String;)V"

R8 had renamed MainThreadCallback to p0 along with its methods, so
GetMethodID(env, cls, "onOutput", ...) in iperf_jni.c resolved nothing. The
names are a contract between C and Kotlin that the minifier cannot see.

Adds keep rules for IperfRunner, the IperfCallback interface and — the part
that matters — everything implementing it, since the lookup happens on the
concrete object's class rather than on the interface. A general rule keeps any
class declaring a native method, so a future one is covered without a new rule.

Debug builds do not minify, which is why every on-device check so far passed
while the shipping artifact was broken. Found by running the release APK
during release preparation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:43:35 +09:00
mikkeli 894712b7a8 chore: set version to 0.1.0 for the first release
pubspec.yaml still carried flutter create's placeholder 1.0.0+1, which would
have had the APK reporting 1.0.0 while the release was tagged v0.1.0. Matches
the version the tag records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:38:48 +09:00
mikkeli 895b963745 fix: restore speedtest.uztelecom.uz as the default server
Consolidating the UI's seed values onto TestConfiguration's own defaults also
silently changed the default target to iperf.es.net, which was not asked for.
Sets it back in TestConfiguration, which is now the single definition the
screen and the headless entry point both read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:38:19 +09:00
mikkeli b76098c7e6 Merge branch 'refactor/core-independent-of-ui'
CI / Analyze & Test (push) Failing after 30s
Make the core work independently of the UI: Iperf3Session accumulates a run in
the domain layer, so the view model holds no run data and the screen derives no
numbers. Replacing the presentation layer now means rewriting rendering, not
re-deriving results.

Adds lib/main_headless.dart, which runs a full test through Iperf3Service and
Iperf3Session with no widget tree, as a standing check on that claim — and as
the accurate way to measure: 30.2% local CPU headless against 79.9% through the
UI on a comparable 4-stream run.

Verified on an XQ-AS72: headless run clean at 859 Mbit/s mean, matching the
server's own 859 Mbits/sec receiver figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:25:24 +09:00
mikkeli 6e35d724dc docs: record that the core runs without the UI, and the CPU cost of the UI
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>
2026-08-26 20:25:06 +09:00
mikkeli fc15897541 feat: add a headless entry point that runs a test with no UI
Separation that nothing exercises is separation that quietly stops being true,
so this 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, this stops working. An architecture test
additionally forbids it importing lib/ui.

It is also the accurate way to measure. A 4-stream run against a local server
reports 30.2% local CPU here, against 79.9% for a comparable run with the
transcript repainting alongside — the UI was costing roughly fifty points of
CPU on a phone already near CPU-bound at gigabit rates. Throughput figures
taken through the UI are therefore pessimistic, and this is the path to use
when the number matters.

Parameters come from --dart-define so a run needs no code edit:
  flutter run -t lib/main_headless.dart --dart-define=IPERF_SERVER=…

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:24:44 +09:00
mikkeli 9485049b57 refactor: move run accumulation out of the view model into the domain
The view model was doing two jobs. Alongside presentation state it held what
happened during a run — the interval series, the rule that iperf3's omitted
warm-up intervals do not count, the retained end summary — and the screen
computed mean and peak inline in a widget builder. All of that is the part
that has to be correct, and all of it would have been rewritten by anyone
replacing the UI.

Iperf3Session accumulates a run in the domain layer: samples, totals, errors,
whether it was stopped, and the statistics over them. The view model now feeds
it events and keeps only a formatted transcript and the repaint schedule; the
screen reads every displayed number off the session and derives none.

Two details the session now owns rather than leaving to a caller. Omitted
intervals are excluded, so a warm-up cannot drag the mean down. And
totalRetransmits stays null when no interval reported a count — UDP does not
measure retransmits, and "not measured" must not render as zero.

The aggregation tests move with the logic, into a pure-Dart test with no
widgets or bindings involved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:24:44 +09:00
20 changed files with 1030 additions and 104 deletions
+55 -5
View File
@@ -3,20 +3,39 @@ name: CI
on: on:
push: push:
branches: [ master ] branches: [ master ]
paths:
- 'lib/**.dart'
- 'test/**.dart'
- 'android/**'
- 'tool/**'
- 'pubspec.yaml'
- 'pubspec.lock'
pull_request: pull_request:
branches: [ master ] branches: [ master ]
workflow_dispatch:
# Both jobs target a self-hosted runner, by capability rather than by hostname.
# See tool/setup_ci_runner.sh for how that runner is provisioned and why it is
# not the Gitea host: Flutter cannot build an Android APK on a Linux arm64 host
# (flutter/flutter#189724), and the Gitea box is aarch64. The runner is
# registered at android-stuff org scope so any repo in the org can use it.
#
# The runner is host-mode, so Flutter, the JDK and the Android SDK/NDK are the
# machine's own — the same toolchain that produced the verified release, and no
# multi-hundred-MB download per run. Nothing here installs a toolchain; the
# Toolchain step fails loudly if one is missing.
jobs: jobs:
analyze: analyze:
name: Analyze & Test name: Analyze & Test
runs-on: ubuntu-latest runs-on: flutter-android
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: subosito/flutter-action@v2 - name: Toolchain
with: run: |
flutter-version: '3.47.1' flutter --version
channel: 'stable' echo "ANDROID_HOME=${ANDROID_HOME:?not set on this runner}"
- name: Get dependencies - name: Get dependencies
run: flutter pub get run: flutter pub get
@@ -24,5 +43,36 @@ jobs:
- name: Analyze - name: Analyze
run: flutter analyze run: flutter analyze
# Includes test/architecture_test.dart, which fails the build if a layer
# boundary is crossed.
- name: Run tests - name: Run tests
run: flutter test run: flutter test
release-apk:
name: Release APK
runs-on: flutter-android
needs: [analyze]
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- name: Get dependencies
run: flutter pub get
# The debug build does not minify, so it cannot catch an R8 problem. This
# job exists because a release APK that aborted on every test shipped
# once while the debug build passed every check.
- name: Build release APK
run: flutter build apk --release
# Building is not enough on its own: a renamed JNI callback still
# produces a successful build and a broken APK. This reads R8's mapping
# and the DEX string pool to confirm the names JNI resolves survived.
- name: Check JNI names survived minification
run: ./tool/check_jni_keep_rules.sh
- uses: actions/upload-artifact@v4
with:
name: app-release-apk
path: build/app/outputs/flutter-apk/app-release.apk
if-no-files-found: error
+26
View File
@@ -0,0 +1,26 @@
MIT License
Copyright (c) 2024–2026 Mikkeli Matlock
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Note: This project bundles iperf3 v3.19, which is licensed under the
BSD-3-Clause license. See the bundled iperf3 source for its full license text.
-3
View File
@@ -40,9 +40,6 @@ domain, so shipping an iperf3 ELF in assets and running it via `Process.start()`
fails with "Permission denied". iperf3 is therefore compiled as a shared library fails with "Permission denied". iperf3 is therefore compiled as a shared library
(`libiperf3jni.so`) and called in-process over JNI. (`libiperf3jni.so`) and called in-process over JNI.
`assets/iperf3/` still holds the pre-compiled binaries from that earlier
approach. They are not declared in `pubspec.yaml`, so they are not packaged into
the APK, and nothing in the app reads them.
## Building ## Building
+7
View File
@@ -37,6 +37,13 @@ android {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
// proguard-rules.pro keeps the names JNI resolves at runtime.
// Without it a release build aborts on the first callback.
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
} }
} }
+21
View File
@@ -0,0 +1,21 @@
# JNI reaches these by name. R8 renames by default, so without these rules a
# release build resolves nothing: GetMethodID("onOutput", ...) returns NULL,
# raises NoSuchMethodError, and the next JNI call aborts the process with
# NoSuchMethodError: no non-static method "Lp0;.onOutput(Ljava/lang/String;)V"
# Debug builds do not minify, so this only ever appears in release.
# The plugin object holds the native method declarations and is looked up from
# C by its fully qualified name (Java_com_novoyuuparosk_iperf3gui_IperfRunner_*).
-keep class com.novoyuuparosk.iperf3gui.IperfRunner { *; }
# The callback interface and everything implementing it: iperf_jni.c resolves
# onOutput/onError/onComplete by name on the concrete object's class, so the
# implementors' method names must survive too, not just the interface's.
-keep interface com.novoyuuparosk.iperf3gui.IperfRunner$IperfCallback { *; }
-keep class * implements com.novoyuuparosk.iperf3gui.IperfRunner$IperfCallback { *; }
# Belt and braces for any future native method: keep the name of any class
# declaring one, and the native methods themselves.
-keepclasseswithmembernames,includedescriptorclasses class * {
native <methods>;
}
+30 -3
View File
@@ -86,6 +86,21 @@ struct iperf_run_state {
* iperf_run_client() and cleared after it returns. */ * iperf_run_client() and cleared after it returns. */
static struct iperf_run_state *g_current_state = NULL; static struct iperf_run_state *g_current_state = NULL;
/* Resolve a method ID, clearing the exception a failed lookup leaves behind.
*
* GetMethodID raises NoSuchMethodError on failure, and any further JNI call
* made while an exception is pending aborts the process. Returning NULL and
* clearing lets the caller fail on its own terms. */
static jmethodID resolve_method(JNIEnv *env, jclass cls,
const char *name, const char *sig) {
jmethodID mid = (*env)->GetMethodID(env, cls, name, sig);
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionClear(env);
return NULL;
}
return mid;
}
/* Helper: invoke the output callback via CallVoidMethod */ /* Helper: invoke the output callback via CallVoidMethod */
static void signal_output(JNIEnv *env, struct iperf_run_state *state, static void signal_output(JNIEnv *env, struct iperf_run_state *state,
const char *msg) { const char *msg) {
@@ -272,13 +287,25 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
/* Resolve callback method IDs up front so failures can be reported. */ /* Resolve callback method IDs up front so failures can be reported. */
jclass callback_cls = (*env)->GetObjectClass(env, callback); jclass callback_cls = (*env)->GetObjectClass(env, callback);
jmethodID mid_output = (*env)->GetMethodID( jmethodID mid_output = resolve_method(
env, callback_cls, "onOutput", "(Ljava/lang/String;)V"); env, callback_cls, "onOutput", "(Ljava/lang/String;)V");
jmethodID mid_error = (*env)->GetMethodID( jmethodID mid_error = resolve_method(
env, callback_cls, "onError", "(Ljava/lang/String;)V"); env, callback_cls, "onError", "(Ljava/lang/String;)V");
jmethodID mid_complete = (*env)->GetMethodID( jmethodID mid_complete = resolve_method(
env, callback_cls, "onComplete", "()V"); env, callback_cls, "onComplete", "()V");
/* If the names did not resolve there is no way to report anything: the
* reporting path is the thing that is missing. Bail out loudly instead.
*
* In practice this means R8 renamed the callback in a release build and
* android/app/proguard-rules.pro has stopped covering it. */
if (!mid_output || !mid_error || !mid_complete) {
LOGE("Callback methods did not resolve (onOutput=%p onError=%p "
"onComplete=%p) — check the -keep rules for IperfCallback",
mid_output, mid_error, mid_complete);
return;
}
/* Report a startup failure directly on the callback object and finish, /* Report a startup failure directly on the callback object and finish,
* so Dart's completer always resolves instead of waiting for a timeout. */ * so Dart's completer always resolves instead of waiting for a timeout. */
#define FAIL_EARLY(msg) do { \ #define FAIL_EARLY(msg) do { \
Binary file not shown.
Binary file not shown.
+80 -1
View File
@@ -7,11 +7,13 @@ A Flutter-based Android GUI wrapper for iperf3, the popular network performance
``` ```
lib/ # Flutter/Dart application layer lib/ # Flutter/Dart application layer
├── main.dart # Composition root: picks the Iperf3Runner impl ├── 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 ├── domain/ # Pure Dart — no Flutter import
│ ├── models/ │ ├── models/
│ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams │ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams
│ │ ├── iperf3_result.dart # Raw output, exit code, stopped flag │ │ ├── iperf3_result.dart # Raw output, exit code, stopped flag
│ │ └── iperf3_event.dart # Parsed --json-stream reports │ │ ├── iperf3_event.dart # Parsed --json-stream reports
│ │ └── iperf3_session.dart # One run accumulated: samples, totals, stats
│ └── services/ │ └── services/
│ └── iperf3_runner.dart # The contract the UI depends on │ └── iperf3_runner.dart # The contract the UI depends on
├── data/ # Transport — flutter/services only ├── data/ # Transport — flutter/services only
@@ -69,6 +71,27 @@ 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 device — and what keeps a test double at five stable members instead of
mirroring the transport's whole surface. 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 `test/architecture_test.dart` enforces all of this by reading the actual
imports under `lib/`, including a check that no fourth layer directory has imports under `lib/`, including a check that no fourth layer directory has
appeared unnoticed. Change the layering there when the layering should change; appeared unnoticed. Change the layering there when the layering should change;
@@ -160,6 +183,62 @@ Dart uses `Completer<Iperf3Result>` to await native completion. The MethodChanne
| `isTestRunning()` | `isTestRunning` | Check if test is in progress | | `isTestRunning()` | `isTestRunning` | Check if test is in progress |
| `setTempDir(String)` | `setTempDir` | Point `mkstemp()` at the app cache dir | | `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 ## Building
Prerequisites: Prerequisites:
+114
View File
@@ -0,0 +1,114 @@
import 'package:iperf3gui/domain/models/iperf3_event.dart';
/// The accumulated state of one iperf3 run.
///
/// Fed the event stream one report at a time, this is the answer to "what
/// happened during this test" — the interval series, the end totals, any
/// errors, and the statistics derived from them.
///
/// It lives in the domain layer on purpose: it is what has to be *correct*,
/// independent of how anything chooses to draw it. A different UI, a headless
/// run or a future export path all consume the same object, and none of them
/// re-derive a mean or re-decide whether a warm-up interval counts.
class Iperf3Session {
Iperf3Start? _start;
/// Connection details, once the run has reported them.
Iperf3Start? get start => _start;
final List<Iperf3Interval> _intervals = [];
/// Throughput samples in arrival order, warm-up intervals excluded.
List<Iperf3Interval> get intervals => List.unmodifiable(_intervals);
Iperf3End? _summary;
/// Sent/received totals, once the run has finished.
Iperf3End? get summary => _summary;
final List<String> _errors = [];
/// Errors reported during the run, in order.
List<String> get errors => List.unmodifiable(_errors);
bool _stopped = false;
/// Whether the run ended because it was asked to stop.
bool get stopped => _stopped;
/// Whether iperf3 reported any error.
bool get hasError => _errors.isNotEmpty;
/// Whether anything at all has been recorded.
bool get isEmpty =>
_start == null && _intervals.isEmpty && _summary == null && !hasError;
/// How many throughput samples have been recorded.
int get sampleCount => _intervals.length;
/// Throughput of the most recent sample, or null before the first one.
double? get latestBitsPerSecond =>
_intervals.isEmpty ? null : _intervals.last.bitsPerSecond;
/// Mean throughput across the samples, or null before the first one.
///
/// This is the mean of the per-interval rates, which is not identical to the
/// run's overall rate when intervals differ in length — prefer
/// [summary]'s totals for the headline figure once it is available.
double? get meanBitsPerSecond {
if (_intervals.isEmpty) return null;
final total = _intervals.fold<double>(0, (sum, i) => sum + i.bitsPerSecond);
return total / _intervals.length;
}
/// Highest sampled throughput, or null before the first sample.
double? get peakBitsPerSecond {
if (_intervals.isEmpty) return null;
return _intervals
.map((i) => i.bitsPerSecond)
.reduce((a, b) => a > b ? a : b);
}
/// Total bytes across the recorded samples.
int get sampledBytes => _intervals.fold<int>(0, (sum, i) => sum + i.bytes);
/// Total retransmits across the samples, or null if none were reported.
///
/// UDP tests report no retransmit counts, so null means "not measured"
/// rather than zero.
int? get totalRetransmits {
final reported =
_intervals.map((i) => i.retransmits).whereType<int>().toList();
if (reported.isEmpty) return null;
return reported.fold<int>(0, (sum, r) => sum + r);
}
/// Record one report.
void add(Iperf3Event event) {
switch (event) {
case Iperf3Start e:
_start = e;
case Iperf3Interval e:
// Warm-up intervals (-O) are marked omitted by iperf3 and must not
// drag the statistics down.
if (!e.omitted) _intervals.add(e);
case Iperf3End e:
_summary = e;
case Iperf3Error e:
_errors.add(e.message);
case Iperf3Stopped _:
_stopped = true;
case Iperf3LogLine _:
break;
}
}
/// Forget everything, ready for another run.
void clear() {
_start = null;
_intervals.clear();
_summary = null;
_errors.clear();
_stopped = false;
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ class TestConfiguration {
final int parallelStreams; final int parallelStreams;
const TestConfiguration({ const TestConfiguration({
this.server = 'iperf.es.net', this.server = 'speedtest.uztelecom.uz',
this.port = 5201, this.port = 5201,
this.duration = const Duration(seconds: 10), this.duration = const Duration(seconds: 10),
this.interval = 1, this.interval = 1,
+105
View File
@@ -0,0 +1,105 @@
// Headless entry point: runs one iperf3 test with no widgets.
//
// This exists to keep the core honest. If anything in the run path ever starts
// depending on the UI, this stops compiling or stops working — a claim about
// separation that is checked rather than asserted.
//
// It is also the low-noise way to measure. The UI competes with the test for
// CPU (a gigabit run can put the phone near CPU-bound), so figures taken here
// are cleaner than figures taken with a transcript repainting alongside.
//
// flutter run -t lib/main_headless.dart \
// --dart-define=IPERF_SERVER=192.168.2.182 \
// --dart-define=IPERF_STREAMS=4
//
// Nothing below imports lib/ui, and test/architecture_test.dart enforces that.
import 'package:flutter/widgets.dart' show WidgetsFlutterBinding;
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:iperf3gui/data/services/iperf3_service.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_session.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart';
import 'package:iperf3gui/domain/services/iperf3_runner.dart';
/// Test parameters, supplied at build time with --dart-define.
const String _server = String.fromEnvironment('IPERF_SERVER',
defaultValue: 'speedtest.uztelecom.uz');
const int _port = int.fromEnvironment('IPERF_PORT', defaultValue: 5201);
const int _seconds = int.fromEnvironment('IPERF_SECONDS', defaultValue: 10);
const int _streams = int.fromEnvironment('IPERF_STREAMS', defaultValue: 1);
Future<void> main() async {
// Required for the MethodChannel. Notably NOT runApp() — there is no widget
// tree, no render surface and no Flutter UI thread work in this process.
WidgetsFlutterBinding.ensureInitialized();
const config = TestConfiguration(
server: _server,
port: _port,
duration: Duration(seconds: _seconds),
parallelStreams: _streams,
);
final Iperf3Runner runner = Iperf3Service();
final session = Iperf3Session();
final subscription = runner.events.listen((event) {
session.add(event);
if (event is Iperf3Interval && !event.omitted) {
_out('interval ${event.start.toStringAsFixed(2)}-'
'${event.end.toStringAsFixed(2)}s '
'${_mbits(event.bitsPerSecond)} Mbit/s'
'${event.retransmits != null ? ' retr=${event.retransmits}' : ''}');
} else if (event is Iperf3Error) {
_out('error: ${event.message}');
}
});
_out('running iperf3 -c ${config.server} -p ${config.port} '
'-t ${config.duration.inSeconds} -P ${config.parallelStreams}');
final result = await runner.run(config);
await subscription.cancel();
await runner.dispose();
_report(result.exitCode, session);
}
void _report(int exitCode, Iperf3Session session) {
_out('--- result ---');
_out('exit code: $exitCode');
_out('samples: ${session.sampleCount}');
final mean = session.meanBitsPerSecond;
final peak = session.peakBitsPerSecond;
if (mean != null) _out('mean: ${_mbits(mean)} Mbit/s');
if (peak != null) _out('peak: ${_mbits(peak)} Mbit/s');
final retr = session.totalRetransmits;
_out('retransmits: ${retr ?? 'not measured'}');
final sent = session.summary?.sent;
final received = session.summary?.received;
if (sent != null) {
_out('sent: ${_mbits(sent.bitsPerSecond)} Mbit/s '
'over ${sent.seconds.toStringAsFixed(2)}s');
}
if (received != null) {
_out('received: ${_mbits(received.bitsPerSecond)} Mbit/s '
'over ${received.seconds.toStringAsFixed(2)}s');
}
final cpu = session.summary?.hostCpuPercent;
if (cpu != null) _out('local cpu: ${cpu.toStringAsFixed(1)}%');
if (session.stopped) _out('ended early: stopped');
for (final error in session.errors) {
_out('error: $error');
}
}
String _mbits(double bitsPerSecond) =>
(bitsPerSecond / 1e6).toStringAsFixed(1);
void _out(String line) => debugPrint('[iperf3-headless] $line');
+15 -13
View File
@@ -110,7 +110,7 @@ class _TestRunContentState extends State<_TestRunContent> {
autocorrect: false, autocorrect: false,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Server', labelText: 'Server',
hintText: 'e.g. iperf.es.net or 192.168.1.1', hintText: 'e.g. speedtest.uztelecom.uz or 192.168.1.1',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -195,29 +195,31 @@ class _TestRunContentState extends State<_TestRunContent> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Live throughput figures, derived from the parsed interval series // Live throughput figures. Every number here is read off the
// session; none are computed in the widget tree.
Consumer<TestRunViewModel>( Consumer<TestRunViewModel>(
builder: (context, vm, _) { builder: (context, vm, _) {
final intervals = vm.intervals; final session = vm.session;
if (intervals.isEmpty) return const SizedBox.shrink(); final latest = session.latestBitsPerSecond;
final rates = final mean = session.meanBitsPerSecond;
intervals.map((i) => i.bitsPerSecond).toList(growable: false); final peak = session.peakBitsPerSecond;
final latest = rates.last; if (latest == null || mean == null || peak == null) {
final peak = rates.reduce((a, b) => a > b ? a : b); return const SizedBox.shrink();
final mean = rates.reduce((a, b) => a + b) / rates.length; }
final summary = vm.summary?.sent; final total = session.summary?.sent;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_Stat( _Stat(
label: summary != null ? 'total' : 'now', label: total != null ? 'total' : 'now',
value: formatBitrate(summary?.bitsPerSecond ?? latest), value: formatBitrate(total?.bitsPerSecond ?? latest),
), ),
_Stat(label: 'avg', value: formatBitrate(mean)), _Stat(label: 'avg', value: formatBitrate(mean)),
_Stat(label: 'peak', value: formatBitrate(peak)), _Stat(label: 'peak', value: formatBitrate(peak)),
_Stat(label: 'samples', value: '${rates.length}'), _Stat(
label: 'samples', value: '${session.sampleCount}'),
], ],
), ),
); );
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart'; import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_result.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart';
import 'package:iperf3gui/domain/models/iperf3_session.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart';
import 'package:iperf3gui/domain/services/iperf3_runner.dart'; import 'package:iperf3gui/domain/services/iperf3_runner.dart';
import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart'; import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart';
@@ -12,8 +13,10 @@ enum TestRunState { idle, running, completed, error }
/// ViewModel for managing test run state. /// ViewModel for managing test run state.
/// ///
/// Subscribes to [Iperf3Runner.events] so the transcript and the interval /// Presentation only. What happened during a run is accumulated by
/// series both grow while the test runs, rather than appearing at the end. /// [Iperf3Session] in the domain layer; this class subscribes to
/// [Iperf3Runner.events], feeds that session, keeps a formatted transcript for
/// display, and decides when to repaint.
class TestRunViewModel extends ChangeNotifier { class TestRunViewModel extends ChangeNotifier {
final Iperf3Runner iperf3Service; final Iperf3Runner iperf3Service;
@@ -41,24 +44,14 @@ class TestRunViewModel extends ChangeNotifier {
final StringBuffer _transcript = StringBuffer(); final StringBuffer _transcript = StringBuffer();
/// Human-readable output accumulated so far. /// Human-readable output accumulated so far. Display only — the structured
/// record of the run is [session].
String get output => _transcript.toString(); String get output => _transcript.toString();
final List<Iperf3Interval> _intervals = []; final Iperf3Session _session = Iperf3Session();
/// Per-interval throughput samples, in arrival order. Structured, so a chart /// The accumulated run: interval series, totals, errors and their statistics.
/// can be driven from this without re-parsing text. Iperf3Session get session => _session;
List<Iperf3Interval> get intervals => List.unmodifiable(_intervals);
Iperf3Start? _start;
/// Connection details reported at the start of the run.
Iperf3Start? get start => _start;
Iperf3End? _summary;
/// Totals reported at the end of the run.
Iperf3End? get summary => _summary;
/// Whether a test is currently in flight. /// Whether a test is currently in flight.
bool get isRunning => _state == TestRunState.running; bool get isRunning => _state == TestRunState.running;
@@ -118,26 +111,12 @@ class TestRunViewModel extends ChangeNotifier {
void _resetRunData() { void _resetRunData() {
_result = null; _result = null;
_transcript.clear(); _transcript.clear();
_intervals.clear(); _session.clear();
_start = null;
_summary = null;
_dirty = false; _dirty = false;
} }
void _onEvent(Iperf3Event event) { void _onEvent(Iperf3Event event) {
switch (event) { _session.add(event);
case Iperf3Start e:
_start = e;
case Iperf3Interval e:
// Omitted intervals are warm-up (-O) and are not part of the series.
if (!e.omitted) _intervals.add(e);
case Iperf3End e:
_summary = e;
case Iperf3Error _:
case Iperf3Stopped _:
case Iperf3LogLine _:
break;
}
final line = formatIperf3Event(event); final line = formatIperf3Event(event);
if (line != null && line.isNotEmpty) { if (line != null && line.isNotEmpty) {
+1 -1
View File
@@ -2,7 +2,7 @@ name: iperf3gui
description: "A Flutter-based Android GUI wrapper for iperf3." description: "A Flutter-based Android GUI wrapper for iperf3."
publish_to: 'none' publish_to: 'none'
version: 1.0.0+1 version: 0.1.0+1
environment: environment:
sdk: ^3.13.1 sdk: ^3.13.1
+20 -2
View File
@@ -82,6 +82,22 @@ void main() {
); );
}); });
test('the headless entry point does not depend on ui', () {
// main_headless.dart is the standing proof that the core runs without a
// widget tree; an import from lib/ui would quietly end that.
final offenders = _importsMatching(
'lib',
(import) => import.contains('/ui/'),
onlyFiles: {'lib/main_headless.dart'},
);
expect(
offenders,
isEmpty,
reason: 'The headless entry point must run the core with no UI.\n'
'${_render(offenders)}',
);
});
test('every layer directory is actually covered by these rules', () { test('every layer directory is actually covered by these rules', () {
// Guards against a new top-level layer appearing and silently escaping // Guards against a new top-level layer appearing and silently escaping
// every rule above. // every rule above.
@@ -102,8 +118,9 @@ void main() {
/// Every `(file, import)` pair under [directory] whose import satisfies [test]. /// Every `(file, import)` pair under [directory] whose import satisfies [test].
List<({String file, String import})> _importsMatching( List<({String file, String import})> _importsMatching(
String directory, String directory,
bool Function(String import) test, bool Function(String import) test, {
) { Set<String>? onlyFiles,
}) {
final results = <({String file, String import})>[]; final results = <({String file, String import})>[];
final dir = Directory(directory); final dir = Directory(directory);
if (!dir.existsSync()) return results; if (!dir.existsSync()) return results;
@@ -111,6 +128,7 @@ List<({String file, String import})> _importsMatching(
final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true); final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true);
for (final entity in dir.listSync(recursive: true)) { for (final entity in dir.listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue; if (entity is! File || !entity.path.endsWith('.dart')) continue;
if (onlyFiles != null && !onlyFiles.contains(entity.path)) continue;
final source = entity.readAsStringSync(); final source = entity.readAsStringSync();
for (final match in pattern.allMatches(source)) { for (final match in pattern.allMatches(source)) {
final import = match.group(1)!; final import = match.group(1)!;
+135
View File
@@ -0,0 +1,135 @@
// Pure-Dart tests for the run aggregate. No widgets, no bindings, no channel —
// this is the part that has to be correct regardless of what draws it.
import 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_session.dart';
void main() {
Iperf3Interval sample(double start, double bits,
{int bytes = 1000, int? retransmits, bool omitted = false}) =>
Iperf3Interval(
start: start,
end: start + 1,
bytes: bytes,
bitsPerSecond: bits,
retransmits: retransmits,
omitted: omitted,
);
group('Iperf3Session', () {
test('starts empty', () {
final session = Iperf3Session();
expect(session.isEmpty, isTrue);
expect(session.sampleCount, isZero);
expect(session.intervals, isEmpty);
expect(session.start, isNull);
expect(session.summary, isNull);
expect(session.errors, isEmpty);
expect(session.stopped, isFalse);
expect(session.hasError, isFalse);
});
test('statistics are null before any sample', () {
final session = Iperf3Session();
expect(session.latestBitsPerSecond, isNull);
expect(session.meanBitsPerSecond, isNull);
expect(session.peakBitsPerSecond, isNull);
expect(session.totalRetransmits, isNull);
});
test('records start, intervals and summary', () {
final session = Iperf3Session()
..add(const Iperf3Start(targetHost: 'host', targetPort: 5201))
..add(sample(0, 100))
..add(const Iperf3End(
sent: Iperf3Total(seconds: 1, bytes: 10, bitsPerSecond: 80),
));
expect(session.isEmpty, isFalse);
expect(session.start!.targetHost, equals('host'));
expect(session.sampleCount, equals(1));
expect(session.summary!.sent!.bitsPerSecond, equals(80));
});
test('excludes omitted warm-up intervals from the series', () {
final session = Iperf3Session()
..add(sample(0, 10, omitted: true))
..add(sample(1, 100))
..add(sample(2, 200));
expect(session.sampleCount, equals(2));
expect(session.meanBitsPerSecond, equals(150),
reason: 'the omitted 10 would have pulled the mean to ~103');
});
test('computes mean, peak and latest over the samples', () {
final session = Iperf3Session()
..add(sample(0, 100))
..add(sample(1, 400))
..add(sample(2, 250));
expect(session.latestBitsPerSecond, equals(250));
expect(session.peakBitsPerSecond, equals(400));
expect(session.meanBitsPerSecond, equals(250));
});
test('sums sampled bytes', () {
final session = Iperf3Session()
..add(sample(0, 1, bytes: 1500))
..add(sample(1, 1, bytes: 2500));
expect(session.sampledBytes, equals(4000));
});
test('sums retransmits when reported', () {
final session = Iperf3Session()
..add(sample(0, 1, retransmits: 2))
..add(sample(1, 1, retransmits: 5));
expect(session.totalRetransmits, equals(7));
});
test('retransmits stay null when never reported, not zero', () {
// UDP reports no retransmit counts; "not measured" must not read as 0.
final session = Iperf3Session()
..add(sample(0, 1))
..add(sample(1, 1));
expect(session.totalRetransmits, isNull);
});
test('collects errors in order and flags them', () {
final session = Iperf3Session()
..add(const Iperf3Error('first'))
..add(const Iperf3Error('second'));
expect(session.hasError, isTrue);
expect(session.errors, equals(['first', 'second']));
expect(session.isEmpty, isFalse);
});
test('records a stop', () {
final session = Iperf3Session()..add(const Iperf3Stopped());
expect(session.stopped, isTrue);
});
test('ignores log lines', () {
final session = Iperf3Session()..add(const Iperf3LogLine('noise'));
expect(session.isEmpty, isTrue);
});
test('clear returns it to the empty state', () {
final session = Iperf3Session()
..add(const Iperf3Start(targetHost: 'host'))
..add(sample(0, 100))
..add(const Iperf3Error('boom'))
..add(const Iperf3Stopped())
..add(const Iperf3End())
..clear();
expect(session.isEmpty, isTrue);
expect(session.stopped, isFalse);
expect(session.hasError, isFalse);
expect(session.peakBitsPerSecond, isNull);
});
test('exposed collections are unmodifiable', () {
final session = Iperf3Session()..add(sample(0, 1));
expect(() => session.intervals.add(sample(1, 1)), throwsUnsupportedError);
expect(() => session.errors.add('x'), throwsUnsupportedError);
});
});
}
@@ -82,48 +82,13 @@ void main() {
expect(vm.output, isNot(contains('ignored'))); expect(vm.output, isNot(contains('ignored')));
}); });
test('intervals are collected as structured samples', () async { test('events are fed into the session', () async {
final mock = FakeIperf3Runner(); final mock = FakeIperf3Runner();
mock.onRun = (_) async { mock.onRun = (_) async {
mock.emit(const Iperf3Interval( mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000)); start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
mock.emit(const Iperf3Interval(
start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000));
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0);
};
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(2));
expect(vm.intervals[1].bitsPerSecond, equals(16000));
});
test('omitted warm-up intervals are excluded from the series', () async {
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1, bitsPerSecond: 1, omitted: true));
mock.emit(const Iperf3Interval(
start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000));
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0);
};
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(1));
expect(vm.intervals.single.bitsPerSecond, equals(16000));
});
test('end event is retained as the summary', () async {
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3End( mock.emit(const Iperf3End(
sent: Iperf3Total( sent: Iperf3Total(seconds: 1, bytes: 1000, bitsPerSecond: 8000),
seconds: 10, bytes: 987654321, bitsPerSecond: 790000000),
)); ));
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0); return const Iperf3Result(rawOutput: '', exitCode: 0);
@@ -132,11 +97,11 @@ void main() {
final vm = TestRunViewModel(iperf3Service: mock); final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1')); await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.summary?.sent?.bitsPerSecond, equals(790000000)); expect(vm.session.sampleCount, equals(1));
expect(vm.output, contains('790 Mbits/sec')); expect(vm.session.summary, isNotNull);
}); });
test('a new run clears the previous interval series', () async { test('a new run clears the previous session', () async {
final mock = FakeIperf3Runner(); final mock = FakeIperf3Runner();
mock.onRun = (_) async { mock.onRun = (_) async {
mock.emit(const Iperf3Interval( mock.emit(const Iperf3Interval(
@@ -147,10 +112,11 @@ void main() {
final vm = TestRunViewModel(iperf3Service: mock); final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1')); await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(1)); expect(vm.session.sampleCount, equals(1));
await vm.runTest(const TestConfiguration(server: '192.168.1.1')); await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(1), reason: 'not appended to the old run'); expect(vm.session.sampleCount, equals(1),
reason: 'not appended to the old run');
}); });
test('falls back to result output when nothing streamed', () async { test('falls back to result output when nothing streamed', () async {
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Assert that R8 left the names JNI resolves at runtime alone.
#
# iperf_jni.c looks up the callback methods by name with GetMethodID. R8 renames
# by default, and a renamed callback does not fail the build — it produces an
# APK that aborts on the first callback of every test:
#
# NoSuchMethodError: no non-static method "Lp0;.onOutput(Ljava/lang/String;)V"
#
# That shipped once. Building the release APK is not enough to catch it, because
# the build succeeds; this reads R8's own mapping file and checks the names
# survived. Run after `flutter build apk --release`.
set -euo pipefail
MAPPING="${1:-build/app/outputs/mapping/release/mapping.txt}"
APK="${2:-build/app/outputs/flutter-apk/app-release.apk}"
if [[ ! -f "$MAPPING" ]]; then
echo "FAIL: no R8 mapping at $MAPPING"
echo " Run 'flutter build apk --release' first. If the release build"
echo " no longer minifies, this check is meaningless — say so"
echo " explicitly rather than deleting it."
exit 1
fi
failed=0
# A class kept by name maps to itself: "a.b.C -> a.b.C:".
require_class_kept() {
local fqcn="$1"
if grep -qF -- "$fqcn -> $fqcn:" "$MAPPING"; then
echo " ok class $fqcn"
else
echo " FAIL class $fqcn was renamed or removed"
grep -F -- "$fqcn ->" "$MAPPING" | head -3 | sed 's/^/ got: /' || true
failed=1
fi
}
# A method kept by name ends with "-> <name>".
require_method_kept() {
local signature="$1" name="$2"
if grep -qE -- "$signature.*-> ${name}\$" "$MAPPING"; then
echo " ok method $name"
else
echo " FAIL method $name was renamed"
grep -E -- "$signature" "$MAPPING" | head -3 | sed 's/^/ got: /' || true
failed=1
fi
}
echo "Checking JNI-visible names in $MAPPING"
require_class_kept 'com.novoyuuparosk.iperf3gui.IperfRunner'
require_class_kept 'com.novoyuuparosk.iperf3gui.IperfRunner$IperfCallback'
require_class_kept 'com.novoyuuparosk.iperf3gui.IperfRunner$MainThreadCallback'
# Resolved by iperf_jni.c on the concrete callback object's class.
require_method_kept 'void onOutput\(java\.lang\.String\)' 'onOutput'
require_method_kept 'void onError\(java\.lang\.String\)' 'onError'
require_method_kept 'void onComplete\(\)' 'onComplete'
# Native methods are NOT checked against mapping.txt: R8 only emits entries for
# members it has line-number info or a rename for, and a native method has no
# body, so it is absent whether or not it was renamed. Absence would prove
# nothing. Check the DEX string pool instead — a surviving name is interned
# there, a renamed one is not.
check_dex_names() {
local apk="$1"
shift
if [[ ! -f "$apk" ]]; then
echo " skip DEX name check: no APK at $apk"
return
fi
local tmp
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
if ! unzip -o -q "$apk" 'classes*.dex' -d "$tmp" 2>/dev/null; then
echo " FAIL could not extract classes*.dex from $apk"
failed=1
return
fi
# Dump the string pool once to a file, then grep the file. Never
# `... | strings | grep -q`: grep -q exits at the first match, SIGPIPEs
# strings, and `set -o pipefail` reports the whole pipeline as failed — so
# a name found early "fails" and one found late passes. That flakiness cost
# an afternoon; leave the intermediate file in place.
cat "$tmp"/classes*.dex | strings > "$tmp/dex-strings.txt"
local name
for name in "$@"; do
if grep -qxF -- "$name" "$tmp/dex-strings.txt"; then
echo " ok native $name"
else
echo " FAIL native $name is absent from the DEX string pool"
failed=1
fi
done
}
check_dex_names "${2:-build/app/outputs/flutter-apk/app-release.apk}" \
runIperfLive forceStopIperfTest setTempDir isTestRunning
if [[ $failed -ne 0 ]]; then
echo
echo "R8 renamed something JNI resolves by name. The release APK will abort"
echo "on the first callback. Fix android/app/proguard-rules.pro."
exit 1
fi
echo "All JNI-visible names survived minification."
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env bash
# Provision this machine as a Gitea Actions runner for the android-stuff org.
#
# sudo ./tool/setup_ci_runner.sh --check # pre-flight only, changes nothing
# sudo RUNNER_TOKEN=xxxx ./tool/setup_ci_runner.sh
#
# Get an ORG-scoped token from
# <gitea>/org/android-stuff/settings/actions/runners → "Create new runner"
# Org scope, not repo scope, so every android-stuff repo shares one runner.
#
# ── WHY THIS MACHINE, NOT THE GITEA HOST ────────────────────────────────────
# Gitea runs on an aarch64 box. Flutter cannot build an Android APK on a Linux
# arm64 host at all: the SDK ships no arm64-host gen_snapshot
# (flutter/flutter#189724, #75864, open as of July 2026). An x86_64 runner is a
# requirement here, not a preference.
#
# ── WHY HOST MODE, NOT A CONTAINER ──────────────────────────────────────────
# This machine has a working Flutter + Android SDK + NDK. In host mode jobs use
# it directly, so CI builds with the same toolchain that produced the verified
# release and skips a per-run NDK download. The cost is that there is no
# container boundary — which is exactly why the service account below matters.
#
# ── WHY A DEDICATED USER, AND A SYSTEM SERVICE ──────────────────────────────
# Host mode means the service account IS the sandbox; there is no other. A
# workflow file is code, and it runs as whoever this service runs as. Running
# as a human user would give every workflow read access to that user's home,
# SSH keys included. Running as root would be worse, and would also miss the
# warm Gradle/pub caches in that home (HOME=/root), re-downloading dependencies
# every run for no benefit.
#
# So: a dedicated unprivileged account, in the flutterdev group for SDK access,
# with its own HOME so its caches are its own. A *system* service rather than a
# user service, so it starts at boot under plain systemctl with no lingering
# session — the service being root-managed is unrelated to the jobs being
# unprivileged, and conflating those two was the earlier mistake here.
set -euo pipefail
VERSION="${RUNNER_VERSION:-3.3.0}"
INSTANCE="${GITEA_INSTANCE:-http://pi5-16.local:3005}"
RUNNER_USER="${RUNNER_USER:-gitea-runner}"
SDK_GROUP="${SDK_GROUP:-flutterdev}"
RUNNER_HOME="${RUNNER_HOME:-/var/lib/gitea-runner}"
BIN="${RUNNER_BIN:-/usr/local/bin/gitea-runner}"
UNIT="/etc/systemd/system/gitea-runner.service"
RUNNER_NAME="${RUNNER_NAME:-$(hostname)-android}"
# Capability label, deliberately not a hostname: it says what the runner can
# do, so a workflow does not encode which machine happens to provide it.
LABEL="${RUNNER_LABEL:-flutter-android}"
CHECK_ONLY=0
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1
[[ $EUID -eq 0 ]] || { echo "Run with sudo: sudo RUNNER_TOKEN=... $0" >&2; exit 1; }
# --check is for validating this machine before committing to anything, so it
# deliberately does not require a token.
if [[ $CHECK_ONLY -eq 0 ]]; then
: "${RUNNER_TOKEN:?set RUNNER_TOKEN to an org registration token (see header)}"
fi
say() { printf '\n==> %s\n' "$1"; }
# ── Pre-flight, before anything is created ─────────────────────────────────
say "Pre-flight"
missing=0
for cmd in flutter dart java cmake ninja node git unzip curl; do
# Resolved from systemd's own PATH, not an interactive shell's, since that
# is what the service will actually have.
if PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
command -v "$cmd" >/dev/null; then
printf ' ok %s\n' "$cmd"
else
printf ' MISSING %s (not on systemd default PATH)\n' "$cmd"
missing=1
fi
done
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/opt/android-sdk}}"
for d in "$SDK/ndk" "$SDK/build-tools" "$SDK/platforms"; do
[[ -d "$d" ]] && printf ' ok %s\n' "$d" \
|| { printf ' MISSING %s\n' "$d"; missing=1; }
done
getent group "$SDK_GROUP" >/dev/null \
&& printf ' ok group %s\n' "$SDK_GROUP" \
|| { printf ' MISSING group %s\n' "$SDK_GROUP"; missing=1; }
if [[ $missing -ne 0 ]]; then
echo
echo "Refusing to register: a runner advertising '$LABEL' must actually" >&2
echo "provide these, or every job fails confusingly instead of here." >&2
exit 1
fi
if [[ $CHECK_ONLY -eq 1 ]]; then
say "Pre-flight passed"
echo " Nothing was created. Re-run with RUNNER_TOKEN set to provision."
echo " Note: SDK write access is verified as $RUNNER_USER, which can only"
echo " be checked once that account exists — so it is not covered here."
exit 0
fi
# ── Service account ────────────────────────────────────────────────────────
say "Service account: $RUNNER_USER"
if id "$RUNNER_USER" &>/dev/null; then
echo " exists; ensuring membership of $SDK_GROUP"
usermod -aG "$SDK_GROUP" "$RUNNER_USER"
else
# --shell /bin/bash, not nologin: host-mode steps are executed as shell
# scripts, and a login shell costs nothing here — the account has no
# password, so it is only reachable via root anyway.
useradd --system \
--create-home --home-dir "$RUNNER_HOME" \
--shell /bin/bash \
--groups "$SDK_GROUP" \
--comment "Gitea Actions runner ($LABEL)" \
"$RUNNER_USER"
echo " created"
fi
install -d -o "$RUNNER_USER" -g "$SDK_GROUP" -m 0750 \
"$RUNNER_HOME" "$RUNNER_HOME/workspace"
id "$RUNNER_USER"
# Group membership is worthless if the ACLs disagree, so prove it rather than
# assume it — as the service user, against the paths a build actually writes.
#
# By actually writing a file, not by asking `test -w`. This machine's
# /usr/bin/test is uutils coreutils 0.8.0, whose -w does not credit
# supplementary group membership: it reports /opt/android-sdk unwritable for
# any account whose write access comes from the flutterdev group rather than
# from the owner bits — which is every account that should be running builds
# here. (-r and -x pass only because `other` has r-x.) bash's builtin `[ -w ]`
# gets it right, but sudo execs the external binary, so the builtin is not
# available here. A write probe is immune to the whole question.
say "Verifying $RUNNER_USER can write the SDKs"
write_ok=1
for d in "/opt/flutter/bin/cache" "$SDK"; do
if sudo -u "$RUNNER_USER" env probe="$d/.gitea-runner-probe.$$" \
sh -c 'touch "$probe" && rm -f "$probe"' 2>/dev/null; then
printf ' ok writable: %s\n' "$d"
else
printf ' FAIL not writable: %s\n' "$d"
sudo -u "$RUNNER_USER" rm -f "$d/.gitea-runner-probe.$$" 2>/dev/null || true
write_ok=0
fi
done
if [[ $write_ok -ne 1 ]]; then
echo
echo "$RUNNER_USER is in $SDK_GROUP but still cannot write. Check:" >&2
echo " getfacl /opt/flutter /opt/android-sdk" >&2
echo " id $RUNNER_USER" >&2
exit 1
fi
# ── Binary ─────────────────────────────────────────────────────────────────
say "Installing gitea-runner $VERSION"
if [[ -x "$BIN" ]] && "$BIN" --version 2>/dev/null | grep -qF "$VERSION"; then
echo " already at $VERSION"
else
url="https://dl.gitea.com/gitea-runner/${VERSION}/gitea-runner-${VERSION}-linux-amd64"
echo " $url"
curl -fSL --retry 3 -o "$BIN.tmp" "$url"
chmod 0755 "$BIN.tmp"
mv "$BIN.tmp" "$BIN"
fi
"$BIN" --version
# ── Config ─────────────────────────────────────────────────────────────────
say "Config"
CONFIG="$RUNNER_HOME/config.yaml"
if [[ -f "$CONFIG" && -f "$RUNNER_HOME/.runner" ]]; then
# Registered: this config is in use, do not touch it.
echo " $CONFIG exists and the runner is registered — leaving it alone"
else
if [[ -f "$CONFIG" ]]; then
echo " $CONFIG exists but nothing is registered yet — rewriting it"
fi
cat > "$CONFIG" <<YAML
log:
level: info
runner:
# Absolute on purpose: a relative path resolves against whatever directory
# the command was invoked from, which for `register` is wherever the admin
# happened to be standing — typically a repo this account cannot write.
file: ${RUNNER_HOME}/.runner
# One at a time: a 2-ABI NDK compile plus Gradle will saturate this box, and
# concurrent jobs would only contend for it.
capacity: 1
timeout: 45m
# ':host' runs steps directly on this machine as ${RUNNER_USER}, using the
# toolchain verified in pre-flight, rather than in a container that would
# have to fetch its own.
labels:
- "${LABEL}:host"
container:
# Unused in host mode. Kept so switching to container mode later is an edit
# rather than a rediscovery — it would need the Docker network that the
# Gitea host's own runner also needs.
network: ""
privileged: false
host:
workdir_parent: ${RUNNER_HOME}/workspace
YAML
chown "$RUNNER_USER:$SDK_GROUP" "$CONFIG"
chmod 0640 "$CONFIG"
echo " wrote $CONFIG"
fi
# ── Registration ───────────────────────────────────────────────────────────
say "Registering with $INSTANCE"
if [[ -f "$RUNNER_HOME/.runner" ]]; then
echo " already registered ($RUNNER_HOME/.runner) — skipping."
echo " To re-register: delete that file AND remove the runner in Gitea."
else
# Run from the runner's own directory: register writes .runner and
# .runner.lock relative to cwd unless told otherwise, and this account
# cannot write wherever the admin invoked the script from.
# --labels is deliberately absent: gitea-runner ignores it when the config
# file defines labels, and warns that it is doing so.
sudo -u "$RUNNER_USER" env HOME="$RUNNER_HOME" sh -c '
cd "$1" || exit 1
exec "$2" register --no-interactive \
--instance "$3" --token "$4" --name "$5" --config "$6"
' sh "$RUNNER_HOME" "$BIN" "$INSTANCE" "$RUNNER_TOKEN" "$RUNNER_NAME" "$CONFIG"
# register writes .runner into the cwd it was given; make sure it landed.
[[ -f "$RUNNER_HOME/.runner" ]] || {
echo " registration produced no .runner in $RUNNER_HOME" >&2
exit 1
}
fi
# ── Service ────────────────────────────────────────────────────────────────
say "systemd unit"
cat > "$UNIT" <<UNITFILE
[Unit]
Description=Gitea Actions runner (${LABEL}, host mode)
Documentation=https://gitea.com/gitea/act_runner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${RUNNER_USER}
Group=${SDK_GROUP}
WorkingDirectory=${RUNNER_HOME}
ExecStart=${BIN} daemon --config ${CONFIG}
Restart=always
RestartSec=5
# HOME must be explicit: it decides where Gradle and pub put their caches, and
# a service does not inherit a login environment.
Environment=HOME=${RUNNER_HOME}
Environment=GRADLE_USER_HOME=${RUNNER_HOME}/.gradle
Environment=PUB_CACHE=${RUNNER_HOME}/.pub-cache
Environment=ANDROID_HOME=${SDK}
Environment=ANDROID_SDK_ROOT=${SDK}
[Install]
WantedBy=multi-user.target
UNITFILE
echo " wrote $UNIT"
systemctl daemon-reload
systemctl enable --now gitea-runner
sleep 2
systemctl --no-pager --lines=15 status gitea-runner || true
cat <<EOF
==> Done.
Logs: journalctl -u gitea-runner -f
Stop: systemctl stop gitea-runner
Remove: systemctl disable --now gitea-runner
rm $UNIT $BIN && rm -rf $RUNNER_HOME
userdel $RUNNER_USER
...and delete the runner in the Gitea org settings.
The runner should now appear under
${INSTANCE}/org/android-stuff/settings/actions/runners
with label '$LABEL'. The queued run for this repo should start on its own.
First Android build will be slow: this account's Gradle and pub caches
start empty by design. Subsequent runs reuse them.
EOF