Compare commits

..

42 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
mikkeli 838c883cc0 Merge branch 'refactor/separate-ui-from-data'
CI / Analyze & Test (push) Failing after 38s
Separate presentation from the data layer: the UI depends on an Iperf3Runner
interface declared in the domain layer, and main.dart is the only file that
names a concrete implementation.

Adds widget tests that drive the real UI against an in-memory runner with no
MethodChannel or native library, and an architecture test that reads the
imports under lib/ and fails the build when a layer boundary is crossed.

Verified on an XQ-AS72 after the change: clean run, iperf_run_client returned
0, server logged 608 Mbits/sec, no ANR or native abort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:56:41 +09:00
mikkeli fe042efc29 docs: record the layering rule and where it is enforced
The architecture section listed files but said nothing about which layer may
import what, so the direction of dependency was only visible by reading the
imports. States the rule as a table, notes that the UI depends on the
Iperf3Runner contract rather than on a transport and what that buys, and points
at the test that enforces it so the rule is not just prose.

Also brings the tree up to date: it was missing the domain services directory,
the event model and the formatter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:56:25 +09:00
mikkeli 0cf019379a test: fail the build when a layer boundary is crossed
The layering only holds if something checks it, so these tests read the real
import statements under lib/ and assert the direction: domain imports no
Flutter and nothing else in the package, data never reaches into ui and may
import flutter/services alone, and ui never names a concrete transport.

A final test asserts the set of directories under lib/ is exactly the three
layers, so a new one cannot appear and silently escape every rule above.

Each violation reports the offending file and import with the reason, since a
guard that only says "expected empty" costs more than it saves. Verified by
introducing a UI-to-data import and a Flutter import in domain and confirming
both fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:56:03 +09:00
mikkeli 95acf5ea38 test: drive the real UI against an in-memory runner
FakeIperf3Runner implements Iperf3Runner, so the widget tests exercise the
actual widget tree — GO, STOP, the status line, the stat row, validation —
with no MethodChannel and no native library. That was impossible while the
screen constructed its own Iperf3Service.

The view model tests share the same fake instead of each file carrying a
double that had to mirror the concrete service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:56:03 +09:00
mikkeli 314a3ea059 refactor: depend on an Iperf3Runner interface instead of the service
The presentation layer imported Iperf3Service directly, so the UI was welded
to the MethodChannel transport: the view model named it, and the screen
constructed one. Dependency direction was already inward-only, but the UI
depended on an implementation rather than a contract.

Iperf3Runner states that contract in the domain layer in five members, in pure
Dart with no Flutter import, and Iperf3Service implements it. The view model
and screen now take an Iperf3Runner; main.dart is the only file that names a
concrete one, which makes it the composition root.

Two consequences worth having. The UI can be driven against an in-memory
runner with no MethodChannel, native library or device involved. And a test
double implements five stable members rather than tracking every public member
the transport happens to expose — the previous double, forced to implement the
concrete class, broke twice while this branch's work was going on.

Also drops flutter/foundation from the data layer: debugPrint gives way to
dart:developer, leaving flutter/services (where MethodChannel lives) as that
layer's only Flutter dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:56:03 +09:00
mikkeli defd64a073 Merge branch 'refactor/json-stream-events'
CI / Analyze & Test (push) Failing after 34s
Replace the stdout pipe with iperf3's json_callback hook and consume the
resulting --json-stream events as structured data: interval samples with
retransmits and per-stream detail, and an end summary with sent/received
totals and CPU utilisation.

Deletes the pipe, the reader thread and the join-ordering and ownership
constraints that came with them.

Verified on an XQ-AS72 against a local iperf3 server: 4 streams at 889
Mbits/sec, matching the server's own 887 Mbits/sec receiver figure. Across
four consecutive runs thread count returned to baseline and fd count returned
exactly to baseline, with no ANR or native abort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:35:55 +09:00
mikkeli 857b79fbaf docs: replace the pipe design with json_callback and note the embedding costs
The output-streaming section described the pipe and reader thread that no
longer exist, including the join-ordering rule that was the reason they were
dangerous. Replaced with how the json_callback path works and why, and an
explicit note not to reintroduce the pipe.

Adds a section on what embedding a CLI in a long-lived app process actually
costs, since that is the recurring trap here: iperf3's main() can leave
process-global state behind because it is about to exit, and this app cannot.
Records what iperf3 does clean up — joined stream threads, closed fds, an
immediately unlinked temp file — with the measurements taken on device, so the
next person does not have to re-derive whether repeated runs leak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:35:41 +09:00
mikkeli cbb2aba8b0 feat(ui): render the event stream and show live throughput figures
Consuming JSON means the familiar iperf3 text layout has to be rebuilt, so a
pure formatter turns each event back into a line — binary units for transfer
totals, decimal units for bitrates, matching how iperf3 quotes each. The view
model keeps the interval series and the end summary as data alongside the
transcript, and a stat row shows current/total, mean, peak and sample count
off that series. A chart can be driven from the same list without re-parsing
text.

Repaints are coalesced on a 250ms timer rather than one per event. Reports can
now arrive several times a second across many streams, and each rebuild
re-renders the whole transcript, so notifying per event made the cost of
drawing scale with the report rate — which competes with the test itself for
CPU and skews the measurement.

Warm-up intervals (-O) are marked omitted by iperf3 and are kept out of the
series so they cannot drag the mean down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:35:10 +09:00
mikkeli 7d8d06e2e5 feat(app): parse json-stream reports into structured events
The native layer now emits one JSON object per report, so the service parses
each into a sealed Iperf3Event — start, interval, end, error — and publishes
those instead of opaque lines. Interval samples carry bytes, bits per second,
retransmits and per-stream socket/cwnd/rtt detail; the end event carries sent
and received totals and CPU utilisation. None of that was available while the
output was scraped text.

Anything unrecognised — non-JSON, malformed JSON, an event name a future
iperf3 adds — becomes an Iperf3LogLine rather than being dropped, so
unexpected output still reaches the user.

Failures arrive as an `error` event rather than a non-zero return from
iperf_run_client, so that event is what now marks a run as failed. The
result's rawOutput keeps the JSON lines verbatim, since that is what the
native layer actually produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:35:10 +09:00
mikkeli 598a2d99c9 refactor(jni): take results from json_callback instead of piping stdout
iperf3 exposes a hook for exactly this use — iperf.h describes json_callback
as "allow user apps to receive the JSON strings, instead of writing them to
the output file". Setting it alongside --json-stream's two flags makes iperf3
hand each report ("start", "interval", "end", "error") straight to the bridge
as a JSON string, and test->outfile is never touched.

That removes the pipe, the reader thread, the fdopen/fgets loop, and
AttachCurrentThread from the output path — roughly 90 lines. It also removes
the two constraints that came with them and were both gotten wrong: that the
write end must be closed before joining a reader blocked in fgets(), and that
exactly one side may own the shared state struct. Neither can be violated now
because neither exists.

json_callback carries no user-data pointer, so the run state is reached
through a global published before iperf_run_client() and cleared after it.
Only one test runs at a time, which runIperfLive already enforced. The
callback fires on iperf3's reporting thread, so it resolves a JNIEnv for the
calling thread rather than trusting a stored one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:35:10 +09:00
mikkeli d448d5f885 Merge branch 'fix/iperf3-test-execution'
CI / Analyze & Test (push) Failing after 34s
Make iperf3 tests actually run on device: fix the end-of-test deadlock and
memory ownership in the JNI bridge, move the blocking test run off the
Flutter platform thread, stream output to the UI as it arrives, and report a
user-requested stop as a stop rather than a failure.

Verified end to end on an XQ-AS72 against a local iperf3 server: 790 Mbits/sec
over a full 10s run, mid-run stop, and back-to-back runs, with no ANR or
native abort in logcat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:08:38 +09:00
mikkeli 0dc747edef docs: describe the JNI architecture the app actually uses
The root README still documented the abandoned approach: iperf3 3.21 binaries
bundled in assets and run through a Process runner, and a lib/ layout that no
longer matched. It now covers the JNI design, the current feature set and how
to test against a local iperf3 server, and says plainly that the binaries
still sitting in assets/iperf3/ are undeclared in pubspec.yaml and therefore
never reach the APK.

docs/README.md gains the threading, pipe-shutdown and stop-handling
constraints that the fixes on this branch depend on, so the next change does
not have to rediscover why the join order and the worker thread matter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:59 +09:00
mikkeli 6f809913bd chore: drop leftovers from the pre-JNI binary-execution approach
iperf3_binary_path_provider.dart held no code, only a comment saying the
extract-and-exec approach it belonged to had been replaced. path_provider was
a dependency of that same approach and no longer has a single usage; removing
it regenerates the desktop plugin registrants.

Also drops an unused import in main.dart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:59 +09:00
mikkeli 02a8ba9c18 fix: report a user-requested stop as stopped rather than failed
Stopping a test shuts down the control socket, so iperf_run_client() returns
-1 with "unable to send control message ... Broken pipe". That is the
expected consequence of the stop, not a fault, but the bridge read the
negative return as a failure — so pressing STOP left the UI showing
"Error (exit 1)" above a broken-pipe message.

The bridge now consults its own g_stop flag before classifying the outcome,
and the distinction travels up as Iperf3Result.stopped so the status line can
say "Stopped". A stop is reported with exit code 0: the run ended the way the
user asked it to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:59 +09:00
mikkeli fb6ec3af37 feat(ui): show live output, add a stop button and duration/stream inputs
The screen could only start a 10s single-stream test and read the result
afterwards. It now renders the transcript as it arrives in a scrolling
monospace view that follows the newest line, exposes the duration and
parallel-stream settings TestConfiguration already carried, can stop a run in
progress, and copies the transcript to the clipboard.

The output field previously built a TextEditingController inside build(), so
every rebuild — one per output line now that output streams — discarded and
replaced it. Replaced with SelectableText, which needs no controller.

Seeds the inputs from TestConfiguration's own defaults rather than a second
hardcoded server address, so there is one default to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:22 +09:00
mikkeli 3b01b0c4a6 fix(app): stream native output and release run state on every exit path
_isRunning was never cleared on the timeout path, so a single timed-out run
locked the app out of starting another until it was restarted. It is now
cleared in a finally block covering all three exits.

Output was buffered inside the service and only handed over once the run
finished, so the UI showed nothing until the end despite the native layer
streaming line by line. The service republishes each line on outputLines and
the view model appends to a transcript as they arrive.

The channel handler was re-registered on every run and dereferenced
_runCompleter with `!`, which threw once a late callback arrived after the
field had been nulled. It is registered once in the constructor and reads the
completer through a local.

Also drops the duplicated argument list in favour of
TestConfiguration.toCliArgs(), so the flags the UI sends have one definition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:22 +09:00
mikkeli f410a7e0d3 fix(android): run iperf3 tests off the Flutter platform thread
runIperfLive() blocks for the full duration of a test, but the MethodChannel
handler called it directly on the platform thread. That froze the UI for the
whole run, and because output reaches Dart by posting to the main looper, it
also meant no line could be delivered until the test was already over — the
live output the bridge streams had nowhere to go. Tests now run on a
dedicated single-thread executor and the channel call returns immediately.

MainThreadCallback's constructor properties were named onOutput/onError/
onComplete, the same names as the interface methods overriding them, so
`mainHandler.post { onOutput(line) }` resolved to the member function rather
than the lambda and re-posted itself instead of reaching the channel. Renamed
to emit* so the call cannot bind to the wrong declaration.

A failed System.loadLibrary now surfaces as a NATIVE_LIB_MISSING channel
error rather than leaving every later call to hang, and detaching from the
engine stops an in-flight test whose callbacks would fire into a dead
channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:22 +09:00
mikkeli 2bba647a7b fix(jni): close the output pipe before joining the reader thread
Every test hung on the thread that started it. iperf_cleanup_and_signal()
called pthread_join() on the reader thread before closing the pipe's write
end, but the reader blocks in fgets() until EOF and EOF only arrives once
that write end is closed — two lines further down, after the join. Neither
side could ever make progress.

The reader also freed the shared state struct on exit while the caller went
on to read state->outfile and state->callback_ref and free it a second time,
so any path that got past the join corrupted the heap. Ownership now sits
solely with the JNI caller, which frees the struct after the join.

On the longjmp path outfile was fclose'd twice, once inline and once inside
the cleanup helper, so close_outfile() is idempotent and the hand-rolled
unwinding is gone. Signal handlers are restored to SIG_DFL once a test ends,
because leaving them armed pointed a later SIGTERM at a jmp_buf whose stack
frame no longer existed.

g_test is now guarded by a mutex and a second concurrent run is refused: a
stop request arrives on the platform thread while the test runs elsewhere,
which would otherwise race the worker retiring the pointer. Startup failures
report through onError/onComplete instead of returning silently, so Dart
resolves its completer rather than waiting out a timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:22 +09:00
36 changed files with 2947 additions and 703 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.
+41 -12
View File
@@ -1,13 +1,16 @@
# iperf3-gui # iperf3-gui
A Flutter-based Android GUI wrapper for iperf3, the popular network performance measurement tool. A Flutter-based Android GUI wrapper for iperf3, the popular network performance
measurement tool.
## Features ## Features
- Client-mode network throughput testing - Client-mode network throughput testing
- Simple UI: enter server address, port, and press GO - Configurable server, port, duration and parallel streams
- Raw iperf3 output display - Live iperf3 output, streamed line by line while the test runs
- Bundled iperf3 v3.21 binaries for arm64-v8a and armeabi-v7a - Stop a running test early
- Copy the transcript to the clipboard
- iperf3 3.19 compiled into the app as a native library — no external binary
## Architecture ## Architecture
@@ -15,33 +18,59 @@ A Flutter-based Android GUI wrapper for iperf3, the popular network performance
lib/ lib/
├── domain/ ├── domain/
│ ├── models/ # TestConfiguration, Iperf3Result │ ├── models/ # TestConfiguration, Iperf3Result
│ └── services/ # Iperf3Service (Process runner) ├── data/
│ └── services/ # Iperf3Service (MethodChannel → JNI)
├── ui/ ├── ui/
│ └── features/ │ └── features/
│ └── test_run/ # TestRunScreen, TestRunViewModel │ └── test_run/ # TestRunScreen, TestRunViewModel
├── main.dart # App entry point └── main.dart # App entry point
└── pubspec.yaml # Dependencies: provider, path_provider
android/app/src/main/
├── kotlin/…/IperfRunner.kt # MethodChannel handler, runs tests off the platform thread
└── cpp/iperf_jni.c # JNI bridge into the iperf3 API
``` ```
See [docs/README.md](docs/README.md) for the data flow, the native API surface
and the design decisions behind them.
## Why JNI instead of executing a binary
Android SELinux in Enforcing mode blocks `execve()` from the `untrusted_app`
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
(`libiperf3jni.so`) and called in-process over JNI.
## Building ## Building
Prerequisites: Prerequisites:
- Flutter 3.47+ - Flutter 3.47+
- Android SDK with platform android-36 - Android SDK with platform android-36
- JDK 17 - JDK 17
- NDK r28+ and CMake 3.22.1 (for the native build)
```bash ```bash
flutter create . --org novoyuuparosk --project-name iperf3gui
flutter pub get flutter pub get
flutter analyze flutter analyze
flutter test
flutter build apk --debug flutter build apk --debug
adb install -r build/app/outputs/flutter-apk/app-debug.apk
``` ```
### iperf3 Binaries ## Testing against a local server
Pre-compiled binaries are sourced from [davidBar-On/android-iperf3](https://github.com/davidBar-On/android-iperf3) ```bash
and bundled in `assets/iperf3/` (arm64-v8a and armeabi-v7a for Android 28+). iperf3 -s -p 5201 # on a machine on the same network
ip -4 addr show # note its address
```
Enter that address in the app, then press GO.
## Dependencies
- Dart: `provider`
- Native: iperf3 3.19 + cJSON, built by CMake for `armeabi-v7a` and `arm64-v8a`
## License ## License
MIT — bundled iperf3 v3.21 is BSD-3-Clause licensed. MIT — bundled iperf3 3.19 is BSD-3-Clause licensed.
+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>;
}
+242 -214
View File
@@ -5,12 +5,34 @@
* the Flutter app to run iperf3 tests natively inside the app process * the Flutter app to run iperf3 tests natively inside the app process
* (no Process.start needed, no SELinux restrictions). * (no Process.start needed, no SELinux restrictions).
* *
* OUTPUT NOTE:
* The test runs in --json-stream mode with a json_callback installed, so
* iperf3 hands each event ("start", "interval", "end", "error") straight to
* us as a JSON string and never touches test->outfile. That is what the hook
* exists for -- iperf.h calls it "allow user apps to receive the JSON
* strings, instead of writing them to the output file".
*
* An earlier version piped test->outfile to a reader thread instead. That
* required closing the pipe's write end before joining the reader (which
* blocks in fgets() until EOF) and single ownership of the shared state
* struct. Both were gotten wrong and deadlocked every test. Reading events
* from the callback removes the pipe, the reader thread, and that whole class
* of ordering bug.
*
* THREADING NOTE:
* runIperfLive() BLOCKS for the whole duration of the test. Kotlin must
* call it from a worker thread, never from the Flutter platform thread.
* json_callback fires on whichever thread iperf3 reports from, so it resolves
* a JNIEnv for the calling thread rather than reusing a stored one.
*
* SIGNAL HANDLING NOTE: * SIGNAL HANDLING NOTE:
* iperf3's run() function (called from iperf_run_client()) uses * iperf3's run() function (called from iperf_run_client()) uses
* setjmp/longjmp for graceful signal handling. The JNI bridge must * setjmp/longjmp for graceful signal handling. The JNI bridge must
* replicate this setup — otherwise iperf_catch_sigend() installs * replicate this setup — otherwise iperf_catch_sigend() installs
* handlers that longjmp to an unset buffer, causing undefined * handlers that longjmp to an unset buffer, causing undefined
* behaviour (typically a SIGSEGV or hang). * behaviour (typically a SIGSEGV or hang). Handlers are restored to
* SIG_DFL once the test finishes so a later signal cannot longjmp into
* a stack frame that no longer exists.
*/ */
#include <stdio.h> #include <stdio.h>
@@ -23,6 +45,7 @@
#include <android/log.h> #include <android/log.h>
#include <jni.h> #include <jni.h>
#include <pthread.h> #include <pthread.h>
#include <sys/socket.h>
#include "iperf.h" #include "iperf.h"
#include "iperf_api.h" #include "iperf_api.h"
@@ -33,31 +56,55 @@
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
/* Global state for controlling the iperf test */ /* ------------------------------------------------------------------ */
/* Global state for controlling the iperf test. */
/* */
/* g_test is written by the worker thread running the test and read by */
/* the platform thread in forceStopIperfTest()/isTestRunning(), so all */
/* access is serialised through g_test_mutex. The mutex is never held */
/* across iperf_run_client() — only around pointer publish/retire and */
/* the (short) stop request. */
/* ------------------------------------------------------------------ */
static struct iperf_test *g_test = NULL; static struct iperf_test *g_test = NULL;
static pthread_mutex_t g_test_mutex = PTHREAD_MUTEX_INITIALIZER;
static volatile int g_stop = 0; static volatile int g_stop = 0;
static char g_tmp_template[1024] = "/data/local/tmp/iperf3.XXXXXX"; static char g_tmp_template[1024] = "/data/local/tmp/iperf3.XXXXXX";
/* Per-call state to pass between JNI and the reader thread. /* Per-call state, reachable from the json_callback.
* We store the callback object (global ref) and method IDs.
* Callbacks are invoked via CallVoidMethod, NOT as function pointers. */ * Callbacks are invoked via CallVoidMethod, NOT as function pointers. */
struct iperf_run_state { struct iperf_run_state {
JNIEnv *env;
jobject callback_ref; /* Global ref to the callback object */ jobject callback_ref; /* Global ref to the callback object */
jmethodID mid_output; /* Method ID for onOutput(String) */ jmethodID mid_output; /* Method ID for onOutput(String) */
jmethodID mid_error; /* Method ID for onError(String) */ jmethodID mid_error; /* Method ID for onError(String) */
jmethodID mid_complete; /* Method ID for onComplete() */ jmethodID mid_complete; /* Method ID for onComplete() */
FILE *outfile;
int pipe_fd[2];
JavaVM *jvm; JavaVM *jvm;
pthread_t reader_thread;
}; };
/* iperf3's json_callback signature carries no user-data pointer, so the
* current run's state has to be reachable from a global. Only one test runs
* at a time (enforced in runIperfLive), and this is published before
* iperf_run_client() and cleared after it returns. */
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(struct iperf_run_state *state, const char *msg) { static void signal_output(JNIEnv *env, struct iperf_run_state *state,
if (!state || !msg || !msg[0]) return; const char *msg) {
JNIEnv *env = state->env; if (!env || !state || !msg || !msg[0] || !state->mid_output) return;
if (!env || !state->mid_output) return;
jstring jline = (*env)->NewStringUTF(env, msg); jstring jline = (*env)->NewStringUTF(env, msg);
if (jline) { if (jline) {
@@ -67,10 +114,9 @@ static void signal_output(struct iperf_run_state *state, const char *msg) {
} }
/* Helper: invoke the error callback via CallVoidMethod */ /* Helper: invoke the error callback via CallVoidMethod */
static void signal_error(struct iperf_run_state *state, const char *msg) { static void signal_error(JNIEnv *env, struct iperf_run_state *state,
if (!state || !msg) return; const char *msg) {
JNIEnv *env = state->env; if (!env || !state || !msg || !state->mid_error) return;
if (!env || !state->mid_error) return;
jstring jmsg = (*env)->NewStringUTF(env, msg); jstring jmsg = (*env)->NewStringUTF(env, msg);
if (jmsg) { if (jmsg) {
@@ -80,87 +126,81 @@ static void signal_error(struct iperf_run_state *state, const char *msg) {
} }
/* Helper: invoke the complete callback via CallVoidMethod */ /* Helper: invoke the complete callback via CallVoidMethod */
static void signal_complete(struct iperf_run_state *state) { static void signal_complete(JNIEnv *env, struct iperf_run_state *state) {
if (!state || !state->mid_complete) return; if (!env || !state || !state->mid_complete) return;
JNIEnv *env = state->env;
if (!env) return;
(*env)->CallVoidMethod(env, state->callback_ref, state->mid_complete); (*env)->CallVoidMethod(env, state->callback_ref, state->mid_complete);
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Output reader thread — reads from iperf's output pipe and forwards */ /* json_callback — receives one JSON event string per report from */
/* each line to the Java callback via JNI. */ /* iperf3 and forwards it to Java verbatim. Parsing happens in Dart. */
/* */
/* Runs on iperf3's reporting thread. In the client that is the thread */
/* that called iperf_run_client() (already attached, since Kotlin's */
/* executor thread is a Java thread), but GetEnv is checked rather */
/* than assumed, with an attach as fallback. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
static void *reader_thread_func(void *arg) { static void json_event_callback(struct iperf_test *test, char *json) {
struct iperf_run_state *state = (struct iperf_run_state *) arg; struct iperf_run_state *state = g_current_state;
JNIEnv *env; if (!state || !json) return;
int ret;
/* Attach this native thread to the Java VM */ JNIEnv *env = NULL;
ret = (*state->jvm)->AttachCurrentThread(state->jvm, &env, NULL); int attached = 0;
if (ret != JNI_OK) {
LOGE("AttachCurrentThread failed: %d", ret);
return NULL;
}
char buf[1024]; if ((*state->jvm)->GetEnv(state->jvm, (void **) &env, JNI_VERSION_1_6)
FILE *fp = fdopen(state->pipe_fd[0], "r"); != JNI_OK) {
if (fp) { if ((*state->jvm)->AttachCurrentThread(state->jvm, &env, NULL)
while (fgets(buf, sizeof(buf), fp) != NULL) { != JNI_OK) {
/* Skip empty lines */ LOGE("json_event_callback: could not reach a JNIEnv");
if (buf[0] == '\n' || buf[0] == '\0') return;
continue;
jstring line = (*env)->NewStringUTF(env, buf);
if (line != NULL) {
(*env)->CallVoidMethod(env, state->callback_ref,
state->mid_output, line);
(*env)->DeleteLocalRef(env, line);
}
} }
fclose(fp); attached = 1;
} }
/* Detach this native thread */ signal_output(env, state, json);
(*state->jvm)->DetachCurrentThread(state->jvm);
/* Free the state struct — caller owns it */ if (attached) {
free(state); (*state->jvm)->DetachCurrentThread(state->jvm);
return NULL; }
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Convert a Java String[] into a native char** argv array. */ /* Build a native argv from a Java String[], prepending "iperf3". */
/* */
/* iperf_parse_arguments() expects argv[0] to be the program name; */
/* passing the Dart args directly would make argv[0] "-c", which */
/* iperf3 misreads as the program name rather than the client flag. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
static char **java_string_array_to_argv(JNIEnv *env, jobjectArray str_array, static char **build_argv(JNIEnv *env, jobjectArray str_array, int *out_argc) {
int *out_argc) { int user_argc = (*env)->GetArrayLength(env, str_array);
int argc = (*env)->GetArrayLength(env, str_array); if (user_argc < 0) user_argc = 0;
if (argc <= 0) {
int argc = user_argc + 1; /* +1 for argv[0] */
char **argv = malloc((argc + 1) * sizeof(char *));
if (!argv) {
*out_argc = 0; *out_argc = 0;
return NULL; return NULL;
} }
char **argv = malloc((argc + 1) * sizeof(char *)); argv[0] = strdup("iperf3");
if (!argv) return NULL; for (int i = 0; i < user_argc; i++) {
jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, str_array, i);
for (int i = 0; i < argc; i++) { const char *cstr = jstr ? (*env)->GetStringUTFChars(env, jstr, NULL) : NULL;
jstring jstr = (jstring) (*env)->GetObjectArrayElement(env,
str_array, i);
const char *cstr = (*env)->GetStringUTFChars(env, jstr, NULL);
if (cstr) { if (cstr) {
argv[i] = strdup(cstr); argv[i + 1] = strdup(cstr);
(*env)->ReleaseStringUTFChars(env, jstr, cstr); (*env)->ReleaseStringUTFChars(env, jstr, cstr);
} else { } else {
argv[i] = strdup(""); argv[i + 1] = strdup("");
} }
(*env)->DeleteLocalRef(env, jstr); if (jstr) (*env)->DeleteLocalRef(env, jstr);
} }
argv[argc] = NULL; /* NULL-terminated for iperf */ argv[argc] = NULL; /* NULL-terminated for iperf */
*out_argc = argc; *out_argc = argc;
return argv; return argv;
} }
/* Free an argv array returned by java_string_array_to_argv() */ /* Free an argv array returned by build_argv() */
static void free_argv(char **argv, int argc) { static void free_argv(char **argv, int argc) {
if (!argv) return; if (!argv) return;
for (int i = 0; i < argc; i++) { for (int i = 0; i < argc; i++) {
@@ -169,43 +209,52 @@ static void free_argv(char **argv, int argc) {
free(argv); free(argv);
} }
/* Retire the global test pointer and free it, under the mutex. */
static void retire_test(void) {
pthread_mutex_lock(&g_test_mutex);
struct iperf_test *test = g_test;
g_test = NULL;
if (test) {
iperf_free_test(test);
}
pthread_mutex_unlock(&g_test_mutex);
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Internal: clean up after iperf_run_client returns (normal path). */ /* Internal: clean up after iperf_run_client returns and report the */
/* Must be called only on the normal return path (NOT after longjmp). */ /* outcome to Java. Called from both the normal and the longjmp path. */
/* Frees `state`; do not touch it afterwards. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
static void iperf_cleanup_and_signal(JNIEnv *env, static void iperf_cleanup_and_signal(JNIEnv *env,
struct iperf_run_state *state, struct iperf_run_state *state,
char **argv, int argc, char **argv, int argc,
int ret, int stopped) { int ret, int stopped) {
/* Wait for reader thread to finish */ /* No more events can be delivered once the run is over. */
pthread_join(state->reader_thread, NULL); g_current_state = NULL;
/* Flush and close output */ /* Restore default signal handlers before g_sigend_jmp_buf goes stale. */
fflush(state->outfile); iperf_catch_sigend(SIG_DFL);
fclose(state->outfile);
/* Clean up iperf */ retire_test();
iperf_free_test(g_test);
g_test = NULL;
/* Free argv */
free_argv(argv, argc); free_argv(argv, argc);
/* A user-requested stop shuts down the control socket, so
* iperf_run_client() returns -1 with "Broken pipe" — expected, not a
* failure. Check g_stop so that path is reported as a stop, not an error. */
int user_stopped = stopped || g_stop;
g_stop = 0;
/* Signal result to Java */ /* Signal result to Java */
if (stopped) { if (user_stopped) {
signal_output(state, "[iPerf JNI] Test was stopped by user.\n"); signal_output(env, state, "{\"event\":\"jni_stopped\",\"data\":{}}");
} else if (ret < 0) { } else if (ret < 0) {
char errbuf[256]; char errbuf[256];
snprintf(errbuf, sizeof(errbuf), snprintf(errbuf, sizeof(errbuf), "%s", iperf_strerror(i_errno));
"[iPerf JNI] Test failed: %s\n", signal_error(env, state, errbuf);
iperf_strerror(i_errno));
signal_error(state, errbuf);
} else {
signal_output(state, "[iPerf JNI] Test completed successfully.\n");
} }
/* Signal completion to Java */ /* Signal completion to Java */
signal_complete(state); signal_complete(env, state);
/* Free global ref and state struct */ /* Free global ref and state struct */
(*env)->DeleteGlobalRef(env, state->callback_ref); (*env)->DeleteGlobalRef(env, state->callback_ref);
@@ -228,55 +277,83 @@ static void sigend_handler(int sig) {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* JNI: Start an iperf3 test with the given arguments. */ /* JNI: Start an iperf3 test with the given arguments. */
/* Signature: void runIperfLive(String[] args, IperfCallback cb) */ /* Signature: void runIperfLive(String[] args, IperfCallback cb) */
/* */
/* BLOCKS until the test finishes — call from a worker thread. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive( Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
JNIEnv *env, jobject thiz, JNIEnv *env, jobject thiz,
jobjectArray arguments, jobject callback) { jobjectArray arguments, jobject callback) {
/* Resolve callback method IDs up front so failures can be reported. */
jclass callback_cls = (*env)->GetObjectClass(env, callback);
jmethodID mid_output = resolve_method(
env, callback_cls, "onOutput", "(Ljava/lang/String;)V");
jmethodID mid_error = resolve_method(
env, callback_cls, "onError", "(Ljava/lang/String;)V");
jmethodID mid_complete = resolve_method(
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,
* so Dart's completer always resolves instead of waiting for a timeout. */
#define FAIL_EARLY(msg) do { \
LOGE("%s", msg); \
if (mid_error) { \
jstring jm = (*env)->NewStringUTF(env, msg); \
if (jm) { \
(*env)->CallVoidMethod(env, callback, mid_error, jm); \
(*env)->DeleteLocalRef(env, jm); \
} \
} \
if (mid_complete) (*env)->CallVoidMethod(env, callback, mid_complete); \
} while (0)
/* Refuse to start a second concurrent test — the globals above hold
* exactly one test's worth of state. */
pthread_mutex_lock(&g_test_mutex);
int busy = (g_test != NULL);
pthread_mutex_unlock(&g_test_mutex);
if (busy) {
FAIL_EARLY("A test is already running.");
return;
}
/* Create the iperf test object */ /* Create the iperf test object */
struct iperf_test *test = iperf_new_test(); struct iperf_test *test = iperf_new_test();
if (!test) { if (!test) {
const char *err = "Failed to create iperf test"; FAIL_EARLY("Failed to create iperf test");
LOGE("%s", err);
return; return;
} }
iperf_defaults(test); iperf_defaults(test);
g_test = test;
/* pthread_mutex_lock(&g_test_mutex);
* Convert Java String[] -> char** argv, prepending "iperf3" as argv[0]. g_test = test;
* iperf_parse_arguments() expects argv[0] to be the program name; pthread_mutex_unlock(&g_test_mutex);
* passing args directly from Dart means argv[0] would be "-c",
* which iperf3 misinterprets as the program name, not the client flag. int total_argc = 0;
*/ char **argv = build_argv(env, arguments, &total_argc);
int dart_argc = (*env)->GetArrayLength(env, arguments);
int total_argc = dart_argc + 1; /* +1 for "iperf3" */
char **argv = malloc((total_argc + 1) * sizeof(char *));
if (!argv) { if (!argv) {
const char *err = "Failed to allocate argv"; retire_test();
LOGE("%s", err); FAIL_EARLY("Failed to allocate argv");
iperf_free_test(test);
g_test = NULL;
return; return;
} }
argv[0] = strdup("iperf3");
for (int i = 0; i < dart_argc; i++) {
jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, arguments, i);
const char *cstr = (*env)->GetStringUTFChars(env, jstr, NULL);
if (cstr) {
argv[i + 1] = strdup(cstr);
(*env)->ReleaseStringUTFChars(env, jstr, cstr);
} else {
argv[i + 1] = strdup("");
}
(*env)->DeleteLocalRef(env, jstr);
}
argv[total_argc] = NULL; /* NULL-terminated for iperf */
/* Set a reasonable connect timeout (30s) to avoid hanging on DNS resolution. /* Set a reasonable connect timeout (30s) to avoid hanging on DNS
* Default iperf3 connect timeout is 120s which is far too long. */ * resolution. Default iperf3 connect timeout is 120s, far too long for a
* phone UI. Set before parsing so an explicit --connect-timeout wins. */
test->settings->connect_timeout = 30000; test->settings->connect_timeout = 30000;
/* Parse arguments */ /* Parse arguments */
@@ -284,24 +361,9 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
char errbuf[256]; char errbuf[256];
snprintf(errbuf, sizeof(errbuf), snprintf(errbuf, sizeof(errbuf),
"Argument parse error: %s", iperf_strerror(i_errno)); "Argument parse error: %s", iperf_strerror(i_errno));
LOGE("Parse error: %s", errbuf);
free_argv(argv, total_argc); free_argv(argv, total_argc);
iperf_free_test(test); retire_test();
g_test = NULL; FAIL_EARLY(errbuf);
return;
}
/* Create a pipe to capture iperf's stdout/stderr */
int pipefd[2];
if (pipe(pipefd) != 0) {
free_argv(argv, total_argc);
const char *err = "Failed to create output pipe";
LOGE("pipe() failed: %s", strerror(errno));
signal_error((struct iperf_run_state *) 0, err);
iperf_free_test(test);
g_test = NULL;
return; return;
} }
@@ -310,35 +372,28 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
calloc(1, sizeof(struct iperf_run_state)); calloc(1, sizeof(struct iperf_run_state));
if (!state) { if (!state) {
free_argv(argv, total_argc); free_argv(argv, total_argc);
close(pipefd[0]); retire_test();
close(pipefd[1]); FAIL_EARLY("Failed to allocate run state");
iperf_free_test(test);
g_test = NULL;
return; return;
} }
/* Resolve callback method IDs (do this in the calling thread) */
jclass callback_cls = (*env)->GetObjectClass(env, callback);
state->mid_output = (*env)->GetMethodID(
env, callback_cls, "onOutput", "(Ljava/lang/String;)V");
state->mid_error = (*env)->GetMethodID(
env, callback_cls, "onError", "(Ljava/lang/String;)V");
state->mid_complete = (*env)->GetMethodID(
env, callback_cls, "onComplete", "()V");
/* Get JavaVM for thread attachment */
JavaVM *jvm; JavaVM *jvm;
(*env)->GetJavaVM(env, &jvm); (*env)->GetJavaVM(env, &jvm);
state->env = env; state->mid_output = mid_output;
state->mid_error = mid_error;
state->mid_complete = mid_complete;
state->callback_ref = (*env)->NewGlobalRef(env, callback); state->callback_ref = (*env)->NewGlobalRef(env, callback);
state->outfile = fdopen(pipefd[1], "w");
state->pipe_fd[0] = pipefd[0];
state->jvm = jvm; state->jvm = jvm;
/* Redirect iperf's output to our pipe */ /* Deliver results as a stream of JSON events straight to
test->outfile = state->outfile; * json_event_callback, rather than as text through test->outfile.
setvbuf(test->outfile, NULL, _IOLBF, 0); /* Line-buffered */ * --json-stream sets both of these flags, so setting json_stream alone
* would leave iperf3 printing human-readable text as well. */
iperf_set_test_json_output(test, 1);
iperf_set_test_json_stream(test, 1);
iperf_set_test_json_callback(test, json_event_callback);
g_current_state = state;
/* Set temp directory for stream buffers (mkstemp in iperf_new_stream). /* Set temp directory for stream buffers (mkstemp in iperf_new_stream).
* On Android, /data/local/tmp is NOT writable from the app sandbox — * On Android, /data/local/tmp is NOT writable from the app sandbox —
@@ -346,42 +401,26 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
* the strdup internally. */ * the strdup internally. */
iperf_set_test_template(test, g_tmp_template); iperf_set_test_template(test, g_tmp_template);
#undef FAIL_EARLY
/* /*
* Set up signal handling BEFORE calling iperf_run_client(). * Set up signal handling BEFORE calling iperf_run_client().
* iperf3's run() function uses setjmp/longjmp for signal handling. * iperf3's run() function uses setjmp/longjmp for signal handling.
* - setjmp returns 0 on first call (normal path) * - setjmp returns 0 on first call (normal path)
* - setjmp returns non-zero when called via longjmp (signal path) * - setjmp returns non-zero when called via longjmp (signal path)
* *
* Both paths share the same `state`, `argv`, `argc` variables, * `state` and `argv` are assigned before setjmp and never reassigned
* so the stack frame is preserved across the longjmp. * afterwards, so both paths observe the same values.
*/ */
if (setjmp(g_sigend_jmp_buf) == 0) { if (setjmp(g_sigend_jmp_buf) == 0) {
/* Normal path — run the test */ /* Normal path — run the test */
signal(SIGPIPE, SIG_IGN); /* Ignore SIGPIPE for socket errors */ signal(SIGPIPE, SIG_IGN); /* Ignore SIGPIPE for socket errors */
iperf_catch_sigend(sigend_handler); iperf_catch_sigend(sigend_handler);
/* Notify Java that the test is starting */
signal_output(state, "[iPerf JNI] Starting iPerf3 client...\n");
/* Spawn reader thread to forward iperf output to Java */
if (pthread_create(&state->reader_thread, NULL,
reader_thread_func, state) != 0) {
free_argv(argv, total_argc);
close(pipefd[0]);
close(pipefd[1]);
const char *err = "Failed to start output reader thread";
LOGE("%s", err);
signal_error(state, err);
iperf_free_test(test);
g_test = NULL;
free(state);
return;
}
/* Run the test */
g_stop = 0; g_stop = 0;
int ret = iperf_run_client(test); int ret = iperf_run_client(test);
LOGI("iperf_run_client returned: %d i_errno=%d strerror=%s ctrl_sck=%d", ret, i_errno, iperf_strerror(i_errno), test->ctrl_sck); LOGI("iperf_run_client returned: %d i_errno=%d strerror=%s",
ret, i_errno, iperf_strerror(i_errno));
/* /*
* We get here after iperf_run_client returns normally. * We get here after iperf_run_client returns normally.
@@ -392,58 +431,44 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
} else { } else {
/* /*
* We got here via longjmp() from sigend_handler. * We got here via longjmp() from sigend_handler.
* Clean up resources allocated before the setjmp. * iperf_cleanup_and_signal() frees everything, so there is nothing
* NOTE: state, argv, argc are still valid on the stack. * to unwind by hand here.
*/ */
LOGI("Test interrupted by signal %d", g_signed_sig); LOGI("Test interrupted by signal %d", g_signed_sig);
/* Stop flag for cleanup */
g_stop = 1; g_stop = 1;
iperf_cleanup_and_signal(env, state, argv, total_argc, 0, 1);
/* Close the output file (the pipe write end) */
if (state->outfile) {
fclose(state->outfile);
}
/* Free argv (argv is still valid across longjmp since it's on stack) */
free_argv(argv, total_argc);
/* Run the cleanup with stopped=true.
* Note: argv is NULL, argc=0 so free_argv is a no-op.
* The state struct will be freed inside this function. */
iperf_cleanup_and_signal(env, state, NULL, 0, 0, 1);
} }
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* JNI: Gracefully stop a running iperf3 test. */ /* JNI: Gracefully stop a running iperf3 test. */
/* Signature: void forceStopIperfTest(IperfCallback cb) */ /* Signature: void forceStopIperfTest(IperfCallback cb) */
/* */
/* Runs on the platform thread while the test runs on a worker thread, */
/* so the whole body holds g_test_mutex: that keeps the worker from */
/* freeing g_test underneath us. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest( Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest(
JNIEnv *env, jobject thiz, jobject callback) { JNIEnv *env, jobject thiz, jobject callback) {
if (!g_test || g_test->done) return;
/* Set the stop flag */ pthread_mutex_lock(&g_test_mutex);
g_stop = 1; int requested = 0;
g_test->done = 1; if (g_test && !g_test->done) {
iperf_set_send_state(g_test, IPERF_DONE); g_stop = 1;
g_test->done = 1;
iperf_set_send_state(g_test, IPERF_DONE);
/* Try to unblock select() on the control socket */ /* Try to unblock select() on the control socket */
if (g_test->ctrl_sck >= 0) { if (g_test->ctrl_sck >= 0) {
shutdown(g_test->ctrl_sck, SHUT_RDWR); shutdown(g_test->ctrl_sck, SHUT_RDWR);
}
requested = 1;
} }
pthread_mutex_unlock(&g_test_mutex);
/* Notify Java */ if (!requested) return;
jclass callback_cls = (*env)->GetObjectClass(env, callback); LOGI("Graceful stop requested");
jmethodID mid_output = (*env)->GetMethodID(
env, callback_cls, "onOutput", "(Ljava/lang/String;)V");
if (mid_output) {
jstring msg = (*env)->NewStringUTF(env,
"[iPerf JNI] Requested graceful stop.");
(*env)->CallVoidMethod(env, callback, mid_output, msg);
(*env)->DeleteLocalRef(env, msg);
}
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -453,7 +478,10 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest(
JNIEXPORT jboolean JNICALL JNIEXPORT jboolean JNICALL
Java_com_novoyuuparosk_iperf3gui_IperfRunner_isTestRunning(JNIEnv *env, Java_com_novoyuuparosk_iperf3gui_IperfRunner_isTestRunning(JNIEnv *env,
jobject thiz) { jobject thiz) {
return (g_test != NULL && !g_test->done); pthread_mutex_lock(&g_test_mutex);
jboolean running = (g_test != NULL && !g_test->done);
pthread_mutex_unlock(&g_test_mutex);
return running;
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -4,24 +4,38 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
/** /**
* Kotlin binding layer for native iperf3 via JNI. * Kotlin binding layer for native iperf3 via JNI.
* *
* iperf3 runs natively inside the app process — no Process.start() needed, * iperf3 runs natively inside the app process — no Process.start() needed,
* no SELinux restrictions. Output is streamed live to the Flutter UI via * no SELinux restrictions.
* the IperfCallback interface, with all callbacks posted to the main thread. *
* Threading: runIperfLive() blocks for the entire duration of the test, so it
* runs on a dedicated worker thread. Running it on the platform thread would
* freeze the UI and — because output is delivered by posting to the main
* looper — stall every callback until the test was already over.
*/ */
object IperfRunner : FlutterPlugin { object IperfRunner : FlutterPlugin {
private const val CHANNEL = "com.novoyuuparosk.iperf3gui/iperf"
private var channel: MethodChannel? = null private var channel: MethodChannel? = null
private var appContext: android.content.Context? = null private var appContext: android.content.Context? = null
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
/** Serialises test runs: one test at a time, off the platform thread. */
private val runExecutor =
Executors.newSingleThreadExecutor { r -> Thread(r, "iperf3-run") }
private var nativeLibraryLoaded = false
init { init {
try { try {
android.util.Log.i("IperfRunner", "Loading native library...") android.util.Log.i("IperfRunner", "Loading native library...")
System.loadLibrary("iperf3jni") System.loadLibrary("iperf3jni")
nativeLibraryLoaded = true
android.util.Log.i("IperfRunner", "Native library loaded successfully") android.util.Log.i("IperfRunner", "Native library loaded successfully")
} catch (e: UnsatisfiedLinkError) { } catch (e: UnsatisfiedLinkError) {
android.util.Log.e("IperfRunner", "Failed to load native library: ${e.message}") android.util.Log.e("IperfRunner", "Failed to load native library: ${e.message}")
@@ -31,62 +45,87 @@ object IperfRunner : FlutterPlugin {
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
appContext = binding.applicationContext appContext = binding.applicationContext
channel = MethodChannel(binding.binaryMessenger, "com.novoyuuparosk.iperf3gui/iperf") channel = MethodChannel(binding.binaryMessenger, CHANNEL)
channel?.setMethodCallHandler { call, result -> channel?.setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"runIperf" -> { "runIperf" -> handleRunIperf(call.argument<List<String>>("args"), result)
val args = call.argument<List<String>>("args")?.toTypedArray() "stopIperf" -> handleStopIperf(result)
if (args != null) { "isTestRunning" -> result.success(nativeLibraryLoaded && isTestRunning())
// Set temp dir to app's cache dir — /data/local/tmp is NOT writable
appContext?.cacheDir?.absolutePath?.let { cacheDir ->
setTempDir(cacheDir)
}
// Wrap callbacks so they always run on the main thread.
// The native reader thread cannot call invokeMethod directly.
val safeCallback = MainThreadCallback(
onOutput = { line ->
channel?.invokeMethod("onOutput", mapOf("output" to line))
},
onError = { error ->
channel?.invokeMethod("onError", mapOf("error" to error))
},
onComplete = {
channel?.invokeMethod("onComplete", emptyMap<String, Any>())
}
)
runIperfLive(args, safeCallback)
result.success(null)
} else {
result.error("INVALID_ARGS", "args must be a String array", null)
}
}
"stopIperf" -> {
val safeCallback = MainThreadCallback(
onOutput = { line ->
channel?.invokeMethod("onOutput", mapOf("output" to line))
},
onError = { error ->
channel?.invokeMethod("onError", mapOf("error" to error))
},
onComplete = {
channel?.invokeMethod("onComplete", emptyMap<String, Any>())
}
)
forceStopIperfTest(safeCallback)
result.success(null)
}
"isTestRunning" -> {
result.success(isTestRunning())
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// Stop any in-flight test: its callbacks would otherwise fire into a
// dead channel, and the worker thread would keep the socket open.
if (nativeLibraryLoaded && isTestRunning()) {
forceStopIperfTest(NoopCallback)
}
channel?.setMethodCallHandler(null)
channel = null channel = null
appContext = null
} }
private fun handleRunIperf(args: List<String>?, result: MethodChannel.Result) {
if (!nativeLibraryLoaded) {
result.error(
"NATIVE_LIB_MISSING",
"libiperf3jni.so failed to load — cannot run iperf3",
null,
)
return
}
if (args == null) {
result.error("INVALID_ARGS", "args must be a String array", null)
return
}
if (isTestRunning()) {
result.error("ALREADY_RUNNING", "A test is already running", null)
return
}
// Set temp dir to the app's cache dir — mkstemp() in iperf_new_stream()
// cannot write to /data/local/tmp from inside the app sandbox.
appContext?.cacheDir?.absolutePath?.let { setTempDir(it) }
val argv = args.toTypedArray()
val callback = mainThreadCallback()
// Hand back control immediately; progress arrives via the callback.
result.success(null)
runExecutor.execute {
try {
runIperfLive(argv, callback)
} catch (t: Throwable) {
android.util.Log.e("IperfRunner", "runIperfLive threw", t)
callback.onError("Native error: ${t.message}")
callback.onComplete()
}
}
}
private fun handleStopIperf(result: MethodChannel.Result) {
if (!nativeLibraryLoaded) {
result.success(null)
return
}
forceStopIperfTest(mainThreadCallback())
result.success(null)
}
/**
* Build a callback that forwards native output to Dart, hopping to the
* main thread first: MethodChannel.invokeMethod must not be called from
* the native reader thread.
*/
private fun mainThreadCallback(): IperfCallback = MainThreadCallback(
emitOutput = { line -> channel?.invokeMethod("onOutput", mapOf("output" to line)) },
emitError = { error -> channel?.invokeMethod("onError", mapOf("error" to error)) },
emitComplete = { channel?.invokeMethod("onComplete", emptyMap<String, Any>()) },
)
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
// Native method bindings // Native method bindings
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
@@ -107,23 +146,35 @@ object IperfRunner : FlutterPlugin {
} }
/** /**
* Thread-safe wrapper: posts all callbacks to the main thread. * Posts every callback to the main thread.
* This is required because the native reader thread cannot safely *
* call MethodChannel.invokeMethod from a background thread. * The lambdas are named `emit*` rather than `on*` on purpose: a constructor
* property with the same name as the overriding method would make
* `onOutput(line)` resolve back to this class's own method — an endless
* post-and-re-post loop that never reaches the channel.
*/ */
private class MainThreadCallback( private class MainThreadCallback(
private val onOutput: (String) -> Unit, private val emitOutput: (String) -> Unit,
private val onError: (String) -> Unit, private val emitError: (String) -> Unit,
private val onComplete: () -> Unit private val emitComplete: () -> Unit,
) : IperfCallback { ) : IperfCallback {
override fun onOutput(line: String) { override fun onOutput(line: String) {
mainHandler.post { onOutput(line) } mainHandler.post { emitOutput(line) }
} }
override fun onError(error: String) { override fun onError(error: String) {
mainHandler.post { onError(error) } mainHandler.post { emitError(error) }
} }
override fun onComplete() { override fun onComplete() {
mainHandler.post { onComplete() } mainHandler.post { emitComplete() }
} }
} }
/** Used when a stop is requested with no channel left to report to. */
private object NoopCallback : IperfCallback {
override fun onOutput(line: String) = Unit
override fun onError(error: String) = Unit
override fun onComplete() = Unit
}
} }
Binary file not shown.
Binary file not shown.
+179 -16
View File
@@ -6,19 +6,25 @@ A Flutter-based Android GUI wrapper for iperf3, the popular network performance
``` ```
lib/ # Flutter/Dart application layer lib/ # Flutter/Dart application layer
├── main.dart # App entry point (MaterialApp) ├── main.dart # Composition root: picks the Iperf3Runner impl
├── domain/ # Business logic (platform-independent) ├── main_headless.dart # Runs a test with no widgets (see Layering)
├── 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 │ │ ├── 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/ │ └── services/
│ ├── iperf3_service.dart # Dart → MethodChannel bridge (async with Completer) │ └── iperf3_runner.dart # The contract the UI depends on
│ └── iperf3_binary_path_provider.dart # Deprecated (kept for reference) ├── data/ # Transport — flutter/services only
└── ui/ # Presentation layer │ └── services/
│ └── iperf3_service.dart # Iperf3Runner over MethodChannel → JNI
└── ui/ # Presentation
└── features/ └── features/
└── test_run/ └── test_run/
├── test_run_screen.dart # Server/port inputs + GO button ├── test_run_screen.dart # Inputs, GO/STOP, live transcript
└── test_run_view_model.dart # State machine: idle → running → completed/error ├── test_run_view_model.dart # idle → running → completed/error
└── iperf3_event_formatter.dart # Events → iperf3-style text
android/app/src/main/ android/app/src/main/
├── kotlin/ # Kotlin plugin layer ├── kotlin/ # Kotlin plugin layer
@@ -39,23 +45,123 @@ android/app/src/main/
User taps GO User taps GO
→ Dart TestRunViewModel.runTest(config) → Dart TestRunViewModel.runTest(config)
→ Iperf3Service.run(config) via MethodChannel → Iperf3Service.run(config) via MethodChannel
→ Kotlin IperfRunner.runIperfLive(args, callback) → Kotlin IperfRunner: submits to the iperf3-run worker thread, returns at once
→ JNI C: java_string_array_to_argv() → iperf_parse_arguments() → JNI C: build_argv() (prepends argv[0]) → iperf_parse_arguments()
→ iperf_run_client(test) → iperf_run_client(test) [blocks on the worker thread]
→ Reader thread pipes stdout → CallVoidMethod → Dart onOutput callbacks → 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 → 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 ## Key Design Decisions
### JNI Over Process Execution ### 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. 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 ### 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. 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 Streaming ### Output via json_callback, not a pipe
iperf3 output is redirected via pipe to a background reader thread that attaches to the Java VM via `AttachCurrentThread` and invokes callbacks via `CallVoidMethod`. The reader thread frees the state struct on exit; the JNI caller owns the callback object's lifetime. 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 ### 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. 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.
@@ -72,9 +178,66 @@ Dart uses `Completer<Iperf3Result>` to await native completion. The MethodChanne
| Kotlin Method | JNI Signature | Purpose | | Kotlin Method | JNI Signature | Purpose |
|---|---|---| |---|---|---|
| `runIperfLive(String[], IperfCallback)` | `runIperfLive` | Start test with args and callback | | `runIperfLive(String[], IperfCallback)` | `runIperfLive` | Run a test to completion — blocks, call off the platform thread |
| `forceStopIperfTest(IperfCallback)` | `forceStopIperfTest` | Graceful stop via shutdown() | | `forceStopIperfTest(IperfCallback)` | `forceStopIperfTest` | Graceful stop via shutdown() |
| `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 |
## 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
@@ -93,7 +256,7 @@ adb install build/app/outputs/flutter-apk/app-debug.apk
## Dependencies ## Dependencies
- Dart: `provider`, `path_provider` - Dart: `provider`
- Android: CMake external native build - Android: CMake external native build
- Native: iperf3 3.19, cJSON (single file) - Native: iperf3 3.19, cJSON (single file)
@@ -1,9 +0,0 @@
// This file is kept as a stub — iperf3 is now compiled as a native
// shared library (libiperf3jni.so) via JNI.
// The old binary-path-provider pattern (extract ELF from assets → chmod → execve)
// was replaced by the JNI approach after SELinux blocked binary execution.
// See .hermes/plans/ for details on the migration.
// The Iperf3Service now uses MethodChannel to communicate with Kotlin/JNI:
// Dart → MethodChannel → Kotlin → JNI → iperf3 (same process)
// No file extraction, no chmod, no Process.start needed.
+150 -80
View File
@@ -1,142 +1,212 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:developer' as developer;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.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/test_configuration.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart';
import 'package:iperf3gui/domain/services/iperf3_runner.dart';
/// Service that runs iperf3 via JNI (native library). /// [Iperf3Runner] backed by the native iperf3 library over a MethodChannel.
/// ///
/// On Android, iperf3 is compiled into the app as a shared library (.so). /// On Android, iperf3 is compiled into the app as a shared library (.so).
/// This avoids SELinux restrictions on executing ELF binaries from assets. /// This avoids SELinux restrictions on executing ELF binaries from assets.
/// ///
/// The service communicates with the native layer via a MethodChannel. /// The service talks to the native layer over a MethodChannel. The test runs
/// Output is streamed live to the Flutter UI through callbacks. /// in `--json-stream` mode, so each native report arrives as one JSON object;
class Iperf3Service { /// they are parsed and published on [events] while the test runs. [run]
static const MethodChannel _channel = /// resolves once the native layer reports completion, carrying the raw JSON
MethodChannel('com.novoyuuparosk.iperf3gui/iperf'); /// lines as the result's output.
class Iperf3Service implements Iperf3Runner {
Iperf3Service() {
_channel.setMethodCallHandler(_handleNativeCall);
}
/// Collect output lines during a test run. /// A channel's handler is keyed by channel name in the binary messenger, so
final List<String> _outputBuffer = []; /// it is process-global: constructing a second instance would take the
/// handler away from the first. The app composes exactly one, in main().
final MethodChannel _channel = const MethodChannel(_channelName);
static const String _channelName = 'com.novoyuuparosk.iperf3gui/iperf';
/// Exit code reported when the native layer signalled an error.
static const int exitCodeNativeError = 1;
/// Exit code reported when another test is already in flight.
static const int exitCodeBusy = -2;
/// Exit code reported when the test could not be started at all.
static const int exitCodeStartFailed = -1;
/// Exit code reported when the native layer never signalled completion.
static const int exitCodeTimeout = -3;
/// Events parsed from the native layer during the current run.
final StreamController<Iperf3Event> _eventController =
StreamController<Iperf3Event>.broadcast();
/// Live reports, one event per native report. Use this to render progress.
@override
Stream<Iperf3Event> get events => _eventController.stream;
/// Raw JSON lines received during the current (or most recent) run.
final List<String> _rawLines = [];
/// Completer that resolves when the native iperf3 test finishes. /// Completer that resolves when the native iperf3 test finishes.
Completer<Iperf3Result>? _runCompleter; Completer<Iperf3Result>? _runCompleter;
/// Whether iperf3 is currently running. /// Whether iperf3 is currently running.
bool _isRunning = false; bool _isRunning = false;
@override
/// Whether iperf3 is currently running.
bool get isRunning => _isRunning; bool get isRunning => _isRunning;
/// Whether the native layer reported an error during this run.
bool _sawError = false;
/// Whether [stop] was called for the current run.
bool _stopRequested = false;
/// Cancel any running test. /// Cancel any running test.
@override
Future<void> stop() async { Future<void> stop() async {
if (!_isRunning) return; if (!_isRunning) return;
_stopRequested = true;
try { try {
await _channel.invokeMethod('stopIperf'); await _channel.invokeMethod('stopIperf');
} catch (e) { } catch (e) {
debugPrint('Iperf3Service: error stopping: $e'); _log('Iperf3Service: error stopping: $e');
} }
} }
/// Release the stream controller. Call from the owner's dispose().
@override
Future<void> dispose() async {
_channel.setMethodCallHandler(null);
await _eventController.close();
}
/// Run iperf3 with the given configuration. /// Run iperf3 with the given configuration.
/// ///
/// Returns an [Iperf3Result] with stdout/stderr and exit code. /// Returns an [Iperf3Result] with the full output and an exit code.
@override
Future<Iperf3Result> run(TestConfiguration config) async { Future<Iperf3Result> run(TestConfiguration config) async {
if (_isRunning) { if (_isRunning) {
return Iperf3Result( return const Iperf3Result(
rawOutput: 'Another test is already running.', rawOutput: 'Another test is already running.',
exitCode: -2, exitCode: exitCodeBusy,
); );
} }
_isRunning = true; _isRunning = true;
_outputBuffer.clear(); _sawError = false;
_stopRequested = false;
_rawLines.clear();
final args = <String>[ final args = config.toCliArgs();
'-c', config.server, _log('Iperf3Service: running: ${args.join(" ")}');
'-p', config.port.toString(),
'-t', config.duration.inSeconds.toString(),
'-i', config.interval.toString(),
'-P', config.parallelStreams.toString(),
];
debugPrint('Iperf3Service: running: ${args.join(" ")}'); final completer = Completer<Iperf3Result>();
_runCompleter = completer;
// Set up the completer to await completion
_runCompleter = Completer<Iperf3Result>();
// Listen for events from native layer
_channel.setMethodCallHandler((call) async {
if (!_runCompleter!.isCompleted) {
switch (call.method) {
case 'onOutput':
final output = call.arguments['output'] as String?;
if (output != null) {
_outputBuffer.add(output);
debugPrint('Iperf3Service output: ${output.trim()}');
}
break;
case 'onError':
final error = call.arguments['error'] as String?;
if (error != null) {
debugPrint('Iperf3Service error: $error');
_outputBuffer.add('[ERROR] $error');
// Don't complete yet — the test might still be running
}
break;
case 'onComplete':
debugPrint('Iperf3Service: test completed');
_isRunning = false;
_runCompleter!.complete(
Iperf3Result(
rawOutput: _outputBuffer.join(''),
exitCode: 0,
),
);
break;
}
}
});
// Start the test
try { try {
await _channel.invokeMethod('runIperf', {'args': args}); await _channel.invokeMethod('runIperf', {'args': args});
} catch (e, stackTrace) { } catch (e, stackTrace) {
debugPrint('Iperf3Service exception: $e'); _log('Iperf3Service exception: $e');
debugPrint('Iperf3Service stack: $stackTrace'); _log('Iperf3Service stack: $stackTrace');
_isRunning = false; _finishRun();
if (!_runCompleter!.isCompleted) {
_runCompleter!.completeError(e);
}
return Iperf3Result( return Iperf3Result(
rawOutput: 'Error starting iperf3: $e', rawOutput: 'Error starting iperf3: $e',
exitCode: -1, exitCode: exitCodeStartFailed,
); );
} }
// Wait for the native layer to signal completion (with a timeout) // Wait for the native layer to signal completion. The grace period covers
// DNS resolution and the 30s native connect timeout.
try { try {
final result = await _runCompleter!.future.timeout( return await completer.future.timeout(
config.duration + const Duration(seconds: 30), config.duration + const Duration(seconds: 45),
onTimeout: () { onTimeout: () {
debugPrint('Iperf3Service: timeout, stopping test'); _log('Iperf3Service: timeout, stopping test');
stop(); // Ask the native layer to wind down, then release local state so a
// stuck run can never lock the UI out of starting another test.
_channel.invokeMethod('stopIperf').catchError((Object e) {
_log('Iperf3Service: error stopping after timeout: $e');
return null;
});
_publish(const Iperf3Error('Timed out waiting for iperf3 to finish.'));
return Iperf3Result( return Iperf3Result(
rawOutput: 'Test timed out after ${config.duration.inSeconds}s', rawOutput: _rawOutput(),
exitCode: -3, exitCode: exitCodeTimeout,
); );
}, },
); );
return result;
} catch (e) { } catch (e) {
debugPrint('Iperf3Service: error waiting for completion: $e'); _log('Iperf3Service: error waiting for completion: $e');
return Iperf3Result( return Iperf3Result(
rawOutput: 'Test failed: $e', rawOutput: 'Test failed: $e',
exitCode: -1, exitCode: exitCodeStartFailed,
); );
} finally { } finally {
_runCompleter = null; _finishRun();
} }
} }
/// Handle a callback pushed up from the Kotlin/JNI layer.
Future<void> _handleNativeCall(MethodCall call) async {
switch (call.method) {
case 'onOutput':
final output = call.arguments['output'] as String?;
if (output != null) {
_rawLines.add(output);
final event = Iperf3Event.parse(output);
// iperf3 reports a failed test as an `error` event rather than a
// non-zero return, so this is where most failures surface.
if (event is Iperf3Error) _sawError = true;
_publish(event);
}
case 'onError':
final error = call.arguments['error'] as String?;
if (error != null) {
// Not terminal on its own — iperf3 reports the failure reason and
// then completes, so wait for onComplete before resolving.
_sawError = true;
_publish(Iperf3Error(error));
}
case 'onComplete':
_log('Iperf3Service: test completed');
final completer = _runCompleter;
if (completer != null && !completer.isCompleted) {
completer.complete(
Iperf3Result(
rawOutput: _rawOutput(),
// A user-requested stop is a normal outcome, not a failure.
exitCode:
(_sawError && !_stopRequested) ? exitCodeNativeError : 0,
stopped: _stopRequested,
),
);
}
default:
_log('Iperf3Service: unhandled native call ${call.method}');
}
}
void _publish(Iperf3Event event) {
if (!_eventController.isClosed) {
_eventController.add(event);
}
}
/// The raw JSON stream as received, one event per line.
String _rawOutput() => _rawLines
.map((line) => line.endsWith('\n') ? line : '$line\n')
.join();
void _finishRun() {
_isRunning = false;
_runCompleter = null;
}
} }
void _log(String message) =>
developer.log(message, name: 'Iperf3Service');
+238
View File
@@ -0,0 +1,238 @@
import 'dart:convert';
/// One report from iperf3's `--json-stream` output.
///
/// iperf3 emits `{"event": "<name>", "data": {...}}` per report; the events a
/// client run produces are `start`, `interval`, `end` and `error`. The bridge
/// adds `jni_stopped` when a run ended because the user stopped it.
sealed class Iperf3Event {
const Iperf3Event();
/// Parse one line of `--json-stream` output.
///
/// Returns an [Iperf3LogLine] for anything that is not a recognised JSON
/// event, so unexpected output is surfaced rather than dropped.
static Iperf3Event parse(String line) {
final trimmed = line.trim();
if (trimmed.isEmpty) return const Iperf3LogLine('');
if (!trimmed.startsWith('{')) return Iperf3LogLine(trimmed);
final Object? decoded;
try {
decoded = jsonDecode(trimmed);
} on FormatException {
return Iperf3LogLine(trimmed);
}
if (decoded is! Map<String, dynamic>) return Iperf3LogLine(trimmed);
final event = decoded['event'];
final data = decoded['data'];
if (event is! String) return Iperf3LogLine(trimmed);
final map = data is Map<String, dynamic> ? data : const <String, dynamic>{};
return switch (event) {
'start' => Iperf3Start.fromJson(map),
'interval' => Iperf3Interval.fromJson(map),
'end' => Iperf3End.fromJson(map),
'error' => Iperf3Error(_stringOf(data) ?? 'unknown error'),
'jni_stopped' => const Iperf3Stopped(),
_ => Iperf3LogLine(trimmed),
};
}
static String? _stringOf(Object? value) {
if (value is String) return value;
if (value is Map<String, dynamic>) {
final error = value['error'];
if (error is String) return error;
}
return null;
}
}
/// Connection details, emitted once at the start of a test.
class Iperf3Start extends Iperf3Event {
final String? targetHost;
final int? targetPort;
final int? streamCount;
final int? durationSeconds;
final String? protocol;
const Iperf3Start({
this.targetHost,
this.targetPort,
this.streamCount,
this.durationSeconds,
this.protocol,
});
factory Iperf3Start.fromJson(Map<String, dynamic> data) {
final connecting = data['connecting_to'];
final testStart = data['test_start'];
return Iperf3Start(
targetHost: connecting is Map ? connecting['host'] as String? : null,
targetPort: connecting is Map ? _asInt(connecting['port']) : null,
streamCount: testStart is Map ? _asInt(testStart['num_streams']) : null,
durationSeconds: testStart is Map ? _asInt(testStart['duration']) : null,
protocol: testStart is Map ? testStart['protocol'] as String? : null,
);
}
}
/// One reporting interval, summed across all streams.
class Iperf3Interval extends Iperf3Event {
final double start;
final double end;
final int bytes;
final double bitsPerSecond;
final int? retransmits;
final bool omitted;
/// Per-stream figures, when more than one stream is running.
final List<Iperf3StreamInterval> streams;
const Iperf3Interval({
required this.start,
required this.end,
required this.bytes,
required this.bitsPerSecond,
this.retransmits,
this.omitted = false,
this.streams = const [],
});
factory Iperf3Interval.fromJson(Map<String, dynamic> data) {
// Non-bidirectional runs use "sum"; bidir adds "sum_bidir_reverse".
final sum = data['sum'] ?? data['sum_bidir_reverse'];
final sumMap = sum is Map<String, dynamic> ? sum : const <String, dynamic>{};
final rawStreams = data['streams'];
return Iperf3Interval(
start: _asDouble(sumMap['start']) ?? 0,
end: _asDouble(sumMap['end']) ?? 0,
bytes: _asInt(sumMap['bytes']) ?? 0,
bitsPerSecond: _asDouble(sumMap['bits_per_second']) ?? 0,
retransmits: _asInt(sumMap['retransmits']),
omitted: sumMap['omitted'] == true,
streams: rawStreams is List
? rawStreams
.whereType<Map<String, dynamic>>()
.map(Iperf3StreamInterval.fromJson)
.toList(growable: false)
: const [],
);
}
}
/// One stream's contribution to an interval.
class Iperf3StreamInterval {
final int? socket;
final int bytes;
final double bitsPerSecond;
final int? retransmits;
final int? sendCwnd;
final int? rtt;
const Iperf3StreamInterval({
this.socket,
required this.bytes,
required this.bitsPerSecond,
this.retransmits,
this.sendCwnd,
this.rtt,
});
factory Iperf3StreamInterval.fromJson(Map<String, dynamic> data) {
return Iperf3StreamInterval(
socket: _asInt(data['socket']),
bytes: _asInt(data['bytes']) ?? 0,
bitsPerSecond: _asDouble(data['bits_per_second']) ?? 0,
retransmits: _asInt(data['retransmits']),
sendCwnd: _asInt(data['snd_cwnd']),
rtt: _asInt(data['rtt']),
);
}
}
/// Totals for the whole test, emitted once at the end.
class Iperf3End extends Iperf3Event {
final Iperf3Total? sent;
final Iperf3Total? received;
final double? hostCpuPercent;
final double? remoteCpuPercent;
const Iperf3End({
this.sent,
this.received,
this.hostCpuPercent,
this.remoteCpuPercent,
});
factory Iperf3End.fromJson(Map<String, dynamic> data) {
final cpu = data['cpu_utilization_percent'];
return Iperf3End(
sent: Iperf3Total.fromJson(
data['sum_sent'] ?? data['sum_sent_bidir_reverse']),
received: Iperf3Total.fromJson(
data['sum_received'] ?? data['sum_received_bidir_reverse']),
hostCpuPercent: cpu is Map ? _asDouble(cpu['host_total']) : null,
remoteCpuPercent: cpu is Map ? _asDouble(cpu['remote_total']) : null,
);
}
}
/// A sent or received total from the `end` event.
class Iperf3Total {
final double seconds;
final int bytes;
final double bitsPerSecond;
final int? retransmits;
const Iperf3Total({
required this.seconds,
required this.bytes,
required this.bitsPerSecond,
this.retransmits,
});
static Iperf3Total? fromJson(Object? data) {
if (data is! Map<String, dynamic>) return null;
return Iperf3Total(
seconds: _asDouble(data['seconds']) ?? 0,
bytes: _asInt(data['bytes']) ?? 0,
bitsPerSecond: _asDouble(data['bits_per_second']) ?? 0,
retransmits: _asInt(data['retransmits']),
);
}
}
/// iperf3 reported an error for this test.
class Iperf3Error extends Iperf3Event {
final String message;
const Iperf3Error(this.message);
}
/// The run ended because the user stopped it.
class Iperf3Stopped extends Iperf3Event {
const Iperf3Stopped();
}
/// Output that was not a recognised JSON event.
class Iperf3LogLine extends Iperf3Event {
final String text;
const Iperf3LogLine(this.text);
}
int? _asInt(Object? value) => switch (value) {
int v => v,
double v => v.round(),
String v => int.tryParse(v),
_ => null,
};
double? _asDouble(Object? value) => switch (value) {
double v => v,
int v => v.toDouble(),
String v => double.tryParse(v),
_ => null,
};
+5
View File
@@ -3,8 +3,13 @@ class Iperf3Result {
final String rawOutput; final String rawOutput;
final int exitCode; final int exitCode;
/// Whether the run ended because the user asked it to stop, rather than
/// finishing its configured duration or failing.
final bool stopped;
const Iperf3Result({ const Iperf3Result({
required this.rawOutput, required this.rawOutput,
required this.exitCode, required this.exitCode,
this.stopped = false,
}); });
} }
+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,
+36
View File
@@ -0,0 +1,36 @@
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_result.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart';
/// Runs iperf3 tests and reports what happens.
///
/// This is the whole contract the UI depends on. It is deliberately pure Dart —
/// no Flutter import — so the presentation layer never names a transport. The
/// Android build supplies a MethodChannel-and-JNI implementation
/// (`Iperf3Service`); a test, a desktop build or a headless tool can supply
/// its own without touching anything above this line.
abstract interface class Iperf3Runner {
/// Reports parsed from the running test, one event per iperf3 report.
///
/// A broadcast stream: it may have no listener, and late listeners miss
/// earlier events rather than replaying them.
Stream<Iperf3Event> get events;
/// Whether a test is in flight right now.
bool get isRunning;
/// Run a test to completion and resolve with its outcome.
///
/// Resolves rather than throws for an iperf3-level failure — the failure is
/// carried in [Iperf3Result.exitCode]. Implementations must always resolve,
/// including on internal error, so a caller can never be left awaiting.
Future<Iperf3Result> run(TestConfiguration config);
/// Ask a running test to stop early. A no-op when nothing is running.
///
/// The run's future still resolves, with [Iperf3Result.stopped] set.
Future<void> stop();
/// Release resources. The instance is unusable afterwards.
Future<void> dispose();
}
+10 -6
View File
@@ -1,18 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart';
import 'package:iperf3gui/domain/services/iperf3_runner.dart';
import 'package:iperf3gui/ui/features/test_run/test_run_screen.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_screen.dart';
/// Application entry point. /// Application entry point, and the composition root.
/// ///
/// iperf3 is now compiled as a native shared library (libiperf3jni.so) /// This is the only place that names a concrete [Iperf3Runner]. Everything
/// and accessed via JNI — no binary extraction or chmod needed. /// above the data layer depends on the interface, so swapping the transport —
/// or running the UI against a fake — touches nothing but this file.
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
runApp(const Iperf3GuiApp()); runApp(Iperf3GuiApp(runner: Iperf3Service()));
} }
class Iperf3GuiApp extends StatelessWidget { class Iperf3GuiApp extends StatelessWidget {
const Iperf3GuiApp({super.key}); const Iperf3GuiApp({required this.runner, super.key});
final Iperf3Runner runner;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -22,7 +26,7 @@ class Iperf3GuiApp extends StatelessWidget {
colorSchemeSeed: Colors.blue, colorSchemeSeed: Colors.blue,
useMaterial3: true, useMaterial3: true,
), ),
home: const TestRunScreen(), home: TestRunScreen(runner: runner),
); );
} }
} }
+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');
@@ -0,0 +1,95 @@
import 'package:iperf3gui/domain/models/iperf3_event.dart';
/// Renders parsed iperf3 events as the kind of line iperf3 itself would print.
///
/// The app consumes `--json-stream`, so the familiar text layout has to be
/// rebuilt here. Keeping it in one pure function makes it testable and leaves
/// the structured events untouched for anything else that wants them.
String? formatIperf3Event(Iperf3Event event) {
return switch (event) {
Iperf3Start e => _formatStart(e),
Iperf3Interval e => _formatInterval(e),
Iperf3End e => _formatEnd(e),
Iperf3Error e => 'iperf3 error: ${e.message}',
Iperf3Stopped _ => 'Stopped by user.',
Iperf3LogLine e => e.text.isEmpty ? null : e.text,
};
}
String _formatStart(Iperf3Start e) {
final parts = <String>[];
if (e.targetHost != null) {
final port = e.targetPort != null ? ', port ${e.targetPort}' : '';
parts.add('Connecting to host ${e.targetHost}$port');
}
final detail = <String>[
if (e.protocol != null) e.protocol!,
if (e.streamCount != null)
'${e.streamCount} stream${e.streamCount == 1 ? '' : 's'}',
if (e.durationSeconds != null) '${e.durationSeconds}s',
];
if (detail.isNotEmpty) parts.add(detail.join(' · '));
return parts.isEmpty ? '' : parts.join('\n');
}
String _formatInterval(Iperf3Interval e) {
final window = '${_seconds(e.start)}-${_seconds(e.end)} sec';
final line = StringBuffer('[SUM] $window '
'${formatBytes(e.bytes)} ${formatBitrate(e.bitsPerSecond)}');
if (e.retransmits != null) {
line.write(' ${e.retransmits} retr');
}
if (e.omitted) line.write(' (omitted)');
return line.toString();
}
String _formatEnd(Iperf3End e) {
final lines = <String>['- ' * 20];
final sent = e.sent;
final received = e.received;
if (sent != null) {
lines.add('[SUM] 0.00-${_seconds(sent.seconds)} sec '
'${formatBytes(sent.bytes)} ${formatBitrate(sent.bitsPerSecond)}'
'${sent.retransmits != null ? ' ${sent.retransmits} retr' : ''}'
' sender');
}
if (received != null) {
lines.add('[SUM] 0.00-${_seconds(received.seconds)} sec '
'${formatBytes(received.bytes)} '
'${formatBitrate(received.bitsPerSecond)} receiver');
}
if (e.hostCpuPercent != null) {
lines.add('CPU: local ${e.hostCpuPercent!.toStringAsFixed(1)}%'
'${e.remoteCpuPercent != null ? ', remote ${e.remoteCpuPercent!.toStringAsFixed(1)}%' : ''}');
}
return lines.join('\n');
}
String _seconds(double value) => value.toStringAsFixed(2);
/// Byte totals the way iperf3 prints them — `93.8 MBytes`.
String formatBytes(int bytes) {
const units = ['Bytes', 'KBytes', 'MBytes', 'GBytes', 'TBytes'];
var value = bytes.toDouble();
var unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
final precision = value >= 100 ? 0 : (value >= 10 ? 1 : 2);
return '${value.toStringAsFixed(precision)} ${units[unit]}';
}
/// Bitrates the way iperf3 prints them — `786 Mbits/sec`. Decimal units, since
/// network rates are powers of ten while transfer totals are powers of two.
String formatBitrate(double bitsPerSecond) {
const units = ['bits/sec', 'Kbits/sec', 'Mbits/sec', 'Gbits/sec'];
var value = bitsPerSecond;
var unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit++;
}
final precision = value >= 100 ? 0 : (value >= 10 ? 1 : 2);
return '${value.toStringAsFixed(precision)} ${units[unit]}';
}
+217 -67
View File
@@ -1,22 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:iperf3gui/data/services/iperf3_service.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/ui/features/test_run/iperf3_event_formatter.dart';
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
/// Main test run screen. /// Main test run screen.
/// ///
/// iperf3 is now compiled as a native shared library (libiperf3jni.so) /// Takes an [Iperf3Runner] rather than constructing one, so this layer names no
/// and accessed via JNI. No binary extraction or chmod needed. /// transport: the Android app injects the JNI-backed implementation from
/// main(), and a test injects a fake.
class TestRunScreen extends StatelessWidget { class TestRunScreen extends StatelessWidget {
const TestRunScreen({super.key}); const TestRunScreen({required this.runner, super.key});
final Iperf3Runner runner;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ChangeNotifierProvider( return ChangeNotifierProvider(
create: (_) => TestRunViewModel( create: (_) => TestRunViewModel(iperf3Service: runner),
iperf3Service: Iperf3Service(),
),
child: const _TestRunContent(), child: const _TestRunContent(),
); );
} }
@@ -30,109 +33,227 @@ class _TestRunContent extends StatefulWidget {
} }
class _TestRunContentState extends State<_TestRunContent> { class _TestRunContentState extends State<_TestRunContent> {
final _serverController = TextEditingController(text: 'speedtest.uztelecom.uz'); static const _defaults = TestConfiguration();
final _portController = TextEditingController(text: '5201');
final _serverController =
TextEditingController(text: _defaults.server);
final _portController =
TextEditingController(text: _defaults.port.toString());
final _durationController =
TextEditingController(text: _defaults.duration.inSeconds.toString());
final _streamsController =
TextEditingController(text: _defaults.parallelStreams.toString());
final _outputScrollController = ScrollController();
@override @override
void dispose() { void dispose() {
_serverController.dispose(); _serverController.dispose();
_portController.dispose(); _portController.dispose();
_durationController.dispose();
_streamsController.dispose();
_outputScrollController.dispose();
super.dispose(); super.dispose();
} }
TestConfiguration _readConfiguration() {
return TestConfiguration(
server: _serverController.text.trim(),
port: int.tryParse(_portController.text) ?? _defaults.port,
duration: Duration(
seconds: int.tryParse(_durationController.text) ??
_defaults.duration.inSeconds,
),
parallelStreams:
int.tryParse(_streamsController.text) ?? _defaults.parallelStreams,
);
}
/// Keep the newest output in view, but only once the frame that added it has
/// been laid out.
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_outputScrollController.hasClients) return;
_outputScrollController
.jumpTo(_outputScrollController.position.maxScrollExtent);
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('iperf3-gui'), title: const Text('iperf3-gui'),
actions: [
Consumer<TestRunViewModel>(
builder: (context, vm, _) => IconButton(
tooltip: 'Copy output',
icon: const Icon(Icons.copy_all),
onPressed: vm.output.isEmpty
? null
: () {
Clipboard.setData(ClipboardData(text: vm.output));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Output copied')),
);
},
),
),
],
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Server input
TextField( TextField(
controller: _serverController, controller: _serverController,
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),
Row(
children: [
Expanded(
child: TextField(
controller: _portController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Port'),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _durationController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Seconds'),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _streamsController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Streams'),
),
),
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Port input
TextField(
controller: _portController,
decoration: const InputDecoration(
labelText: 'Port',
hintText: '5201',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
// Go button
Consumer<TestRunViewModel>( Consumer<TestRunViewModel>(
builder: (context, vm, _) { builder: (context, vm, _) {
if (vm.isRunning) {
return OutlinedButton.icon(
onPressed: vm.stopTest,
icon: const Icon(Icons.stop),
label: const Text('STOP'),
);
}
return ElevatedButton( return ElevatedButton(
onPressed: vm.state == TestRunState.running onPressed: () => vm.runTest(_readConfiguration()),
? null child: const Text('GO'),
: () {
final config = TestConfiguration(
server: _serverController.text,
port: int.tryParse(_portController.text) ?? 5201,
);
vm.runTest(config);
},
child: vm.state == TestRunState.running
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('GO'),
); );
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
// Status indicator // Status line
Consumer<TestRunViewModel>( Consumer<TestRunViewModel>(
builder: (context, vm, _) { builder: (context, vm, _) {
final statusText = switch (vm.state) { final scheme = Theme.of(context).colorScheme;
TestRunState.idle => '', final (String label, Color color) = switch (vm.state) {
TestRunState.running => 'Running...', TestRunState.idle => ('Ready', scheme.onSurfaceVariant),
TestRunState.completed => 'Test complete', TestRunState.running => ('Running…', scheme.primary),
TestRunState.error => 'Error', TestRunState.completed => (
vm.wasStopped ? 'Stopped' : 'Test complete',
vm.wasStopped ? scheme.onSurfaceVariant : scheme.primary,
),
TestRunState.error => (
'Error${vm.result != null ? ' (exit ${vm.result!.exitCode})' : ''}',
scheme.error,
),
}; };
return Text( return Row(
statusText, children: [
style: TextStyle( if (vm.isRunning)
color: vm.state == TestRunState.error const Padding(
? Colors.red padding: EdgeInsets.only(right: 8),
: Colors.grey, child: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
Text(label, style: TextStyle(color: color)),
],
);
},
),
const SizedBox(height: 12),
// Live throughput figures. Every number here is read off the
// session; none are computed in the widget tree.
Consumer<TestRunViewModel>(
builder: (context, vm, _) {
final session = vm.session;
final latest = session.latestBitsPerSecond;
final mean = session.meanBitsPerSecond;
final peak = session.peakBitsPerSecond;
if (latest == null || mean == null || peak == null) {
return const SizedBox.shrink();
}
final total = session.summary?.sent;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_Stat(
label: total != null ? 'total' : 'now',
value: formatBitrate(total?.bitsPerSecond ?? latest),
),
_Stat(label: 'avg', value: formatBitrate(mean)),
_Stat(label: 'peak', value: formatBitrate(peak)),
_Stat(
label: 'samples', value: '${session.sampleCount}'),
],
), ),
); );
}, },
), ),
const SizedBox(height: 16),
// Raw output text box // Live output
Expanded( Expanded(
child: Consumer<TestRunViewModel>( child: Container(
builder: (context, vm, _) { width: double.infinity,
final output = vm.result?.rawOutput ?? ''; padding: const EdgeInsets.all(8),
return TextField( decoration: BoxDecoration(
controller: TextEditingController(text: output), border: Border.all(
decoration: const InputDecoration( color: Theme.of(context).colorScheme.outlineVariant,
labelText: 'Raw Output', ),
border: OutlineInputBorder(), borderRadius: BorderRadius.circular(4),
), ),
maxLines: null, child: Consumer<TestRunViewModel>(
readOnly: true, builder: (context, vm, _) {
); _scrollToBottom();
}, return SingleChildScrollView(
controller: _outputScrollController,
child: SelectableText(
vm.output.isEmpty
? 'Output will appear here.'
: vm.output,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
);
},
),
), ),
), ),
], ],
@@ -141,3 +262,32 @@ class _TestRunContentState extends State<_TestRunContent> {
); );
} }
} }
/// One figure in the throughput row.
class _Stat extends StatelessWidget {
const _Stat({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelSmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
Text(
value,
style: theme.textTheme.titleSmall
?.copyWith(fontFeatures: const [FontFeature.tabularFigures()]),
),
],
);
}
}
@@ -1,14 +1,37 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/test_configuration.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/services/iperf3_runner.dart';
import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart';
/// State of the test run. /// State of the test run.
enum TestRunState { idle, running, completed, error } enum TestRunState { idle, running, completed, error }
/// ViewModel for managing test run state. /// ViewModel for managing test run state.
///
/// Presentation only. What happened during a run is accumulated by
/// [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 Iperf3Service iperf3Service; final Iperf3Runner iperf3Service;
/// How often to notify listeners while output is arriving. iperf3 can report
/// several times a second across many streams, and each rebuild re-renders
/// the whole transcript — so repaints are coalesced instead of one per event.
static const Duration displayInterval = Duration(milliseconds: 250);
TestRunViewModel({required this.iperf3Service}) {
_eventSubscription = iperf3Service.events.listen(_onEvent);
}
StreamSubscription<Iperf3Event>? _eventSubscription;
Timer? _repaintTimer;
bool _dirty = false;
TestRunState _state = TestRunState.idle; TestRunState _state = TestRunState.idle;
TestRunState get state => _state; TestRunState get state => _state;
@@ -19,39 +42,110 @@ class TestRunViewModel extends ChangeNotifier {
TestConfiguration? _currentConfig; TestConfiguration? _currentConfig;
TestConfiguration? get currentConfig => _currentConfig; TestConfiguration? get currentConfig => _currentConfig;
TestRunViewModel({required this.iperf3Service}); final StringBuffer _transcript = StringBuffer();
/// Human-readable output accumulated so far. Display only — the structured
/// record of the run is [session].
String get output => _transcript.toString();
final Iperf3Session _session = Iperf3Session();
/// The accumulated run: interval series, totals, errors and their statistics.
Iperf3Session get session => _session;
/// Whether a test is currently in flight.
bool get isRunning => _state == TestRunState.running;
/// Whether the most recent finished run ended because the user stopped it.
bool get wasStopped => _result?.stopped ?? false;
/// Start a test with the given configuration. /// Start a test with the given configuration.
Future<void> runTest(TestConfiguration config) async { Future<void> runTest(TestConfiguration config) async {
if (_state == TestRunState.running) return;
if (!config.isValid) { if (!config.isValid) {
_resetRunData();
_transcript
.writeln('Invalid configuration: check the server address and port.');
_state = TestRunState.error; _state = TestRunState.error;
notifyListeners(); notifyListeners();
return; return;
} }
_resetRunData();
_state = TestRunState.running; _state = TestRunState.running;
_currentConfig = config; _currentConfig = config;
_result = null;
notifyListeners(); notifyListeners();
try { try {
final result = await iperf3Service.run(config); final result = await iperf3Service.run(config);
_result = result; _result = result;
_state = result.exitCode == 0 if (_transcript.isEmpty && result.rawOutput.isNotEmpty) {
? TestRunState.completed _transcript.write(result.rawOutput);
: TestRunState.error; }
_state =
result.exitCode == 0 ? TestRunState.completed : TestRunState.error;
} catch (e) { } catch (e) {
_transcript.writeln('Test failed: $e');
_state = TestRunState.error; _state = TestRunState.error;
} finally { } finally {
_stopRepaintTimer();
notifyListeners(); notifyListeners();
} }
} }
/// Request that a running test stop early.
Future<void> stopTest() async {
if (_state != TestRunState.running) return;
await iperf3Service.stop();
}
/// Reset to idle state. /// Reset to idle state.
void reset() { void reset() {
_resetRunData();
_state = TestRunState.idle; _state = TestRunState.idle;
_result = null;
_currentConfig = null; _currentConfig = null;
notifyListeners(); notifyListeners();
} }
void _resetRunData() {
_result = null;
_transcript.clear();
_session.clear();
_dirty = false;
}
void _onEvent(Iperf3Event event) {
_session.add(event);
final line = formatIperf3Event(event);
if (line != null && line.isNotEmpty) {
_transcript.writeln(line);
}
_scheduleRepaint();
}
/// Coalesce repaints: mark dirty and let a timer flush at [displayInterval].
void _scheduleRepaint() {
_dirty = true;
_repaintTimer ??= Timer.periodic(displayInterval, (_) {
if (!_dirty) return;
_dirty = false;
notifyListeners();
});
}
void _stopRepaintTimer() {
_repaintTimer?.cancel();
_repaintTimer = null;
_dirty = false;
}
@override
void dispose() {
_stopRepaintTimer();
_eventSubscription?.cancel();
iperf3Service.dispose();
super.dispose();
}
} }
-1
View File
@@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)
+1 -193
View File
@@ -1,14 +1,6 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -41,14 +33,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -57,14 +41,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -73,14 +49,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -99,38 +67,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
jni:
dependency: transitive
description:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -163,14 +99,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -203,22 +131,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -227,70 +139,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -299,22 +147,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -368,14 +200,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -392,22 +216,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.3.0" version: "15.3.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.13.1 <4.0.0" dart: ">=3.13.1 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.18.0-18.0.pre.54"
+1 -2
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
@@ -11,7 +11,6 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
path_provider: ^2.1.4
provider: ^6.1.2 provider: ^6.1.2
dev_dependencies: dev_dependencies:
+144
View File
@@ -0,0 +1,144 @@
// Enforces the layering. A convention nobody checks is a convention that
// decays, so these read the real import statements in lib/ and fail on a
// violation rather than trusting review to catch it.
//
// The rule, in one line: dependencies point inward. ui → domain ← data, and
// never the reverse.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('layering', () {
test('domain imports no Flutter at all', () {
final offenders = _importsMatching(
'lib/domain',
(import) => import.startsWith('package:flutter/'),
);
expect(
offenders,
isEmpty,
reason: 'The domain layer must stay pure Dart so it can be reused '
'without a Flutter runtime. Offending imports:\n'
'${_render(offenders)}',
);
});
test('domain does not depend on data or ui', () {
final offenders = _importsMatching(
'lib/domain',
(import) =>
import.contains('/data/') ||
import.contains('/ui/') ||
import.contains('main.dart'),
);
expect(offenders, isEmpty,
reason: 'The domain layer is the innermost layer; it may depend on '
'nothing else in this package.\n${_render(offenders)}');
});
test('data does not depend on ui', () {
final offenders = _importsMatching(
'lib/data',
(import) => import.contains('/ui/') || import.contains('main.dart'),
);
expect(offenders, isEmpty,
reason: 'The data layer must not know about presentation.\n'
'${_render(offenders)}');
});
test('data imports no Flutter widget libraries', () {
// flutter/services is allowed: MethodChannel lives there, and that is
// the whole reason this layer is Flutter-aware at all.
const allowed = {'package:flutter/services.dart'};
final offenders = _importsMatching(
'lib/data',
(import) =>
import.startsWith('package:flutter/') && !allowed.contains(import),
);
expect(
offenders,
isEmpty,
reason: 'The data layer may import package:flutter/services.dart only. '
'Anything else drags presentation into the transport.\n'
'${_render(offenders)}',
);
});
test('ui does not depend on a concrete data implementation', () {
// The UI talks to Iperf3Runner. main.dart is the composition root and is
// the one file allowed to name an implementation.
final offenders = _importsMatching(
'lib/ui',
(import) => import.contains('/data/'),
);
expect(
offenders,
isEmpty,
reason: 'Presentation must depend on domain/services/iperf3_runner.dart '
'rather than on a transport. Only main.dart may pick an '
'implementation.\n${_render(offenders)}',
);
});
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', () {
// Guards against a new top-level layer appearing and silently escaping
// every rule above.
final layers = Directory('lib')
.listSync()
.whereType<Directory>()
.map((d) => d.path.split(Platform.pathSeparator).last)
.toSet();
expect(
layers,
equals({'domain', 'data', 'ui'}),
reason: 'A new layer appeared under lib/. Add rules for it here.',
);
});
});
}
/// Every `(file, import)` pair under [directory] whose import satisfies [test].
List<({String file, String import})> _importsMatching(
String directory,
bool Function(String import) test, {
Set<String>? onlyFiles,
}) {
final results = <({String file, String import})>[];
final dir = Directory(directory);
if (!dir.existsSync()) return results;
final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true);
for (final entity in dir.listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
if (onlyFiles != null && !onlyFiles.contains(entity.path)) continue;
final source = entity.readAsStringSync();
for (final match in pattern.allMatches(source)) {
final import = match.group(1)!;
if (test(import)) {
results.add((file: entity.path, import: import));
}
}
}
return results;
}
String _render(List<({String file, String import})> offenders) =>
offenders.map((o) => ' ${o.file} → ${o.import}').join('\n');
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
void main() {
group('Iperf3Event.parse', () {
test('parses a start event', () {
const line = '{"event":"start","data":{'
'"connecting_to":{"host":"192.168.2.182","port":5201},'
'"test_start":{"protocol":"TCP","num_streams":4,"duration":10}}}';
final event = Iperf3Event.parse(line);
expect(event, isA<Iperf3Start>());
final start = event as Iperf3Start;
expect(start.targetHost, equals('192.168.2.182'));
expect(start.targetPort, equals(5201));
expect(start.streamCount, equals(4));
expect(start.durationSeconds, equals(10));
expect(start.protocol, equals('TCP'));
});
test('parses an interval event with per-stream detail', () {
const line = '{"event":"interval","data":{'
'"streams":[{"socket":143,"bytes":49283072,'
'"bits_per_second":394264576.0,"retransmits":0,"snd_cwnd":2023424,'
'"rtt":3521}],'
'"sum":{"start":0.0,"end":1.000151,"seconds":1.000151,'
'"bytes":98566144,"bits_per_second":788410352.0,"retransmits":0,'
'"omitted":false,"sender":true}}}';
final event = Iperf3Event.parse(line);
expect(event, isA<Iperf3Interval>());
final interval = event as Iperf3Interval;
expect(interval.bytes, equals(98566144));
expect(interval.bitsPerSecond, closeTo(788410352.0, 0.1));
expect(interval.retransmits, equals(0));
expect(interval.omitted, isFalse);
expect(interval.streams, hasLength(1));
expect(interval.streams.single.socket, equals(143));
expect(interval.streams.single.rtt, equals(3521));
});
test('marks omitted intervals', () {
const line = '{"event":"interval","data":{"streams":[],'
'"sum":{"start":0.0,"end":1.0,"bytes":1,"bits_per_second":8.0,'
'"omitted":true}}}';
final interval = Iperf3Event.parse(line) as Iperf3Interval;
expect(interval.omitted, isTrue);
});
test('parses an end event with sent and received totals', () {
const line = '{"event":"end","data":{'
'"sum_sent":{"seconds":10.0,"bytes":987654321,'
'"bits_per_second":790123456.0,"retransmits":7},'
'"sum_received":{"seconds":10.01,"bytes":987000000,'
'"bits_per_second":789000000.0},'
'"cpu_utilization_percent":{"host_total":12.5,"remote_total":3.25}}}';
final end = Iperf3Event.parse(line) as Iperf3End;
expect(end.sent!.bytes, equals(987654321));
expect(end.sent!.retransmits, equals(7));
expect(end.received!.bitsPerSecond, closeTo(789000000.0, 0.1));
expect(end.hostCpuPercent, closeTo(12.5, 0.01));
expect(end.remoteCpuPercent, closeTo(3.25, 0.01));
});
test('falls back to bidir-reverse keys when present', () {
const line = '{"event":"interval","data":{"streams":[],'
'"sum_bidir_reverse":{"start":0.0,"end":1.0,"bytes":42,'
'"bits_per_second":336.0}}}';
final interval = Iperf3Event.parse(line) as Iperf3Interval;
expect(interval.bytes, equals(42));
});
test('parses an error event', () {
const line = '{"event":"error","data":"unable to connect to server"}';
final event = Iperf3Event.parse(line);
expect(event, isA<Iperf3Error>());
expect((event as Iperf3Error).message,
equals('unable to connect to server'));
});
test('parses the bridge stop marker', () {
final event = Iperf3Event.parse('{"event":"jni_stopped","data":{}}');
expect(event, isA<Iperf3Stopped>());
});
test('surfaces non-JSON output as a log line rather than dropping it', () {
final event = Iperf3Event.parse('iperf3: something unexpected');
expect(event, isA<Iperf3LogLine>());
expect((event as Iperf3LogLine).text,
equals('iperf3: something unexpected'));
});
test('surfaces malformed JSON as a log line', () {
final event = Iperf3Event.parse('{"event":"interval",');
expect(event, isA<Iperf3LogLine>());
});
test('treats an unknown event name as a log line', () {
final event = Iperf3Event.parse('{"event":"future_thing","data":{}}');
expect(event, isA<Iperf3LogLine>());
});
test('tolerates a missing data object', () {
final event = Iperf3Event.parse('{"event":"interval"}');
expect(event, isA<Iperf3Interval>());
expect((event as Iperf3Interval).bytes, equals(0));
});
});
}
@@ -11,5 +11,15 @@ void main() {
expect(result.rawOutput, equals('Test complete')); expect(result.rawOutput, equals('Test complete'));
expect(result.exitCode, equals(0)); expect(result.exitCode, equals(0));
}); });
test('stopped defaults to false', () {
const result = Iperf3Result(rawOutput: 'x', exitCode: 0);
expect(result.stopped, isFalse);
});
test('stopped is preserved when set', () {
const result = Iperf3Result(rawOutput: 'x', exitCode: 0, stopped: true);
expect(result.stopped, isTrue);
});
}); });
} }
+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);
});
});
}
+62
View File
@@ -0,0 +1,62 @@
import 'dart:async';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_result.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart';
import 'package:iperf3gui/domain/services/iperf3_runner.dart';
/// In-memory [Iperf3Runner] for tests.
///
/// Implements the interface rather than the concrete service, so it tracks the
/// five members the UI actually depends on instead of every public member the
/// MethodChannel implementation happens to have.
class FakeIperf3Runner implements Iperf3Runner {
FakeIperf3Runner({this.onRun});
/// Supplies the result for each [run], and may [emit] events first.
Future<Iperf3Result> Function(TestConfiguration)? onRun;
final StreamController<Iperf3Event> _controller =
StreamController<Iperf3Event>.broadcast();
/// Configurations passed to [run], in order.
final List<TestConfiguration> runCalls = [];
int stopCallCount = 0;
bool disposed = false;
bool _isRunning = false;
int get runCallCount => runCalls.length;
/// Push an event as if the native layer had reported it.
void emit(Iperf3Event event) => _controller.add(event);
@override
Stream<Iperf3Event> get events => _controller.stream;
@override
bool get isRunning => _isRunning;
@override
Future<Iperf3Result> run(TestConfiguration config) async {
runCalls.add(config);
_isRunning = true;
try {
if (onRun != null) return await onRun!(config);
return const Iperf3Result(rawOutput: '', exitCode: 0);
} finally {
_isRunning = false;
}
}
@override
Future<void> stop() async {
stopCallCount++;
}
@override
Future<void> dispose() async {
disposed = true;
await _controller.close();
}
}
@@ -0,0 +1,87 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart';
void main() {
group('formatBytes', () {
test('scales in binary units the way iperf3 prints totals', () {
expect(formatBytes(0), equals('0.00 Bytes'));
expect(formatBytes(1024), equals('1.00 KBytes'));
expect(formatBytes(98566144), equals('94.0 MBytes'));
expect(formatBytes(1073741824), equals('1.00 GBytes'));
});
});
group('formatBitrate', () {
test('scales in decimal units, as network rates are quoted', () {
expect(formatBitrate(0), equals('0.00 bits/sec'));
expect(formatBitrate(8000), equals('8.00 Kbits/sec'));
expect(formatBitrate(790000000), equals('790 Mbits/sec'));
expect(formatBitrate(2500000000), equals('2.50 Gbits/sec'));
});
});
group('formatIperf3Event', () {
test('renders a start event', () {
const event = Iperf3Start(
targetHost: '192.168.2.182',
targetPort: 5201,
protocol: 'TCP',
streamCount: 1,
durationSeconds: 10,
);
final text = formatIperf3Event(event)!;
expect(text, contains('Connecting to host 192.168.2.182, port 5201'));
expect(text, contains('TCP'));
expect(text, contains('1 stream'));
});
test('renders an interval with retransmits', () {
const event = Iperf3Interval(
start: 0,
end: 1,
bytes: 98566144,
bitsPerSecond: 788410352,
retransmits: 3,
);
final text = formatIperf3Event(event)!;
expect(text, contains('0.00-1.00 sec'));
expect(text, contains('94.0 MBytes'));
expect(text, contains('788 Mbits/sec'));
expect(text, contains('3 retr'));
});
test('marks an omitted interval', () {
const event = Iperf3Interval(
start: 0, end: 1, bytes: 1, bitsPerSecond: 8, omitted: true);
expect(formatIperf3Event(event), contains('omitted'));
});
test('renders sender and receiver lines from an end event', () {
const event = Iperf3End(
sent: Iperf3Total(
seconds: 10, bytes: 987654321, bitsPerSecond: 790000000,
retransmits: 7),
received: Iperf3Total(
seconds: 10.01, bytes: 987000000, bitsPerSecond: 789000000),
hostCpuPercent: 12.5,
);
final text = formatIperf3Event(event)!;
expect(text, contains('sender'));
expect(text, contains('receiver'));
expect(text, contains('7 retr'));
expect(text, contains('CPU: local 12.5%'));
});
test('renders errors and stops', () {
expect(formatIperf3Event(const Iperf3Error('boom')),
equals('iperf3 error: boom'));
expect(formatIperf3Event(const Iperf3Stopped()),
equals('Stopped by user.'));
});
test('drops an empty log line', () {
expect(formatIperf3Event(const Iperf3LogLine('')), isNull);
});
});
}
@@ -1,39 +1,40 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/data/services/iperf3_service.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/test_configuration.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart';
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
import '../../../support/fake_iperf3_runner.dart';
void main() { void main() {
group('TestRunViewModel', () { group('TestRunViewModel', () {
test('starts in idle state', () { test('starts in idle state', () {
final vm = TestRunViewModel( final vm = TestRunViewModel(iperf3Service: FakeIperf3Runner());
iperf3Service: _MockIperf3Service(),
);
expect(vm.state, equals(TestRunState.idle)); expect(vm.state, equals(TestRunState.idle));
expect(vm.result, isNull); expect(vm.result, isNull);
expect(vm.currentConfig, isNull); expect(vm.currentConfig, isNull);
expect(vm.output, isEmpty);
}); });
test('can be reset', () { test('can be reset', () {
final vm = TestRunViewModel( final vm = TestRunViewModel(iperf3Service: FakeIperf3Runner());
iperf3Service: _MockIperf3Service(),
);
vm.reset(); vm.reset();
expect(vm.state, equals(TestRunState.idle)); expect(vm.state, equals(TestRunState.idle));
}); });
test('invalid config sets error state without running', () async { test('invalid config sets error state without running', () async {
final vm = TestRunViewModel( final mock = FakeIperf3Runner();
iperf3Service: _MockIperf3Service(), final vm = TestRunViewModel(iperf3Service: mock);
);
const config = TestConfiguration(server: ''); const config = TestConfiguration(server: '');
await vm.runTest(config); await vm.runTest(config);
expect(vm.state, equals(TestRunState.error)); expect(vm.state, equals(TestRunState.error));
expect(mock.runCallCount, equals(0));
}); });
test('runTest with valid config calls service', () async { test('runTest with valid config calls service', () async {
final mock = _MockIperf3Service( final mock = FakeIperf3Runner(
onRun: (config) async { onRun: (config) async {
expect(config.server, equals('192.168.1.1')); expect(config.server, equals('192.168.1.1'));
return const Iperf3Result(rawOutput: 'done', exitCode: 0); return const Iperf3Result(rawOutput: 'done', exitCode: 0);
@@ -45,17 +46,161 @@ void main() {
expect(vm.state, equals(TestRunState.completed)); expect(vm.state, equals(TestRunState.completed));
expect(vm.result, isNotNull); expect(vm.result, isNotNull);
}); });
test('non-zero exit code lands in error state', () async {
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'boom', exitCode: 1),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.state, equals(TestRunState.error));
expect(vm.result!.exitCode, equals(1));
});
test('streamed events accumulate into output during the run', () async {
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Start(targetHost: '192.168.1.1', targetPort: 5201));
mock.emit(const Iperf3Interval(
start: 0,
end: 1,
bytes: 11744051,
bitsPerSecond: 94100000,
));
// Let the broadcast stream deliver before the run resolves.
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: 'ignored', exitCode: 0);
};
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.output, contains('Connecting to host 192.168.1.1'));
expect(vm.output, contains('94.1 Mbits/sec'));
// The streamed transcript wins over the service's own copy.
expect(vm.output, isNot(contains('ignored')));
});
test('events are fed into the session', () async {
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
mock.emit(const Iperf3End(
sent: Iperf3Total(seconds: 1, bytes: 1000, bitsPerSecond: 8000),
));
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.session.sampleCount, equals(1));
expect(vm.session.summary, isNotNull);
});
test('a new run clears the previous session', () async {
final mock = FakeIperf3Runner();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
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.session.sampleCount, equals(1));
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.session.sampleCount, equals(1),
reason: 'not appended to the old run');
});
test('falls back to result output when nothing streamed', () async {
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'summary only', exitCode: 0),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.output, contains('summary only'));
});
test('output is cleared when a new run starts', () async {
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'first', exitCode: 0),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.output, contains('first'));
mock.onRun = (_) async =>
const Iperf3Result(rawOutput: 'second', exitCode: 0);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.output, isNot(contains('first')));
expect(vm.output, contains('second'));
});
test('stopTest forwards to the service only while running', () async {
final mock = FakeIperf3Runner();
final vm = TestRunViewModel(iperf3Service: mock);
await vm.stopTest();
expect(mock.stopCallCount, equals(0), reason: 'idle: nothing to stop');
final gate = Completer<Iperf3Result>();
mock.onRun = (_) => gate.future;
final run = vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.isRunning, isTrue);
await vm.stopTest();
expect(mock.stopCallCount, equals(1));
gate.complete(const Iperf3Result(rawOutput: 'stopped', exitCode: 0));
await run;
});
test('wasStopped reflects a user-requested stop', () async {
final mock = FakeIperf3Runner(
onRun: (_) async => const Iperf3Result(
rawOutput: 'stopped early',
exitCode: 0,
stopped: true,
),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
// A user stop is a normal outcome: completed, not error.
expect(vm.state, equals(TestRunState.completed));
expect(vm.wasStopped, isTrue);
});
test('wasStopped is false for a run that finished on its own', () async {
final mock = FakeIperf3Runner(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'done', exitCode: 0),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.wasStopped, isFalse);
});
test('a second runTest while running is ignored', () async {
final mock = FakeIperf3Runner();
final gate = Completer<Iperf3Result>();
mock.onRun = (_) => gate.future;
final vm = TestRunViewModel(iperf3Service: mock);
final run = vm.runTest(const TestConfiguration(server: '192.168.1.1'));
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(mock.runCallCount, equals(1));
gate.complete(const Iperf3Result(rawOutput: 'done', exitCode: 0));
await run;
});
}); });
} }
class _MockIperf3Service implements Iperf3Service {
_MockIperf3Service({this.onRun});
final Future<Iperf3Result> Function(TestConfiguration)? onRun;
@override
Future<Iperf3Result> run(TestConfiguration config) async {
if (onRun != null) return onRun!(config);
return const Iperf3Result(rawOutput: '', exitCode: 0);
}
}
+78 -6
View File
@@ -1,17 +1,89 @@
// This is a basic Flutter widget test. // Widget-level smoke tests.
// //
// To perform an interaction with a widget in your test, use the WidgetTester // These drive the real UI against a FakeIperf3Runner: no MethodChannel, no
// utility in the flutter_test package. For example, you can send tap and scroll // native library, no device. That is only possible because the presentation
// gestures. You can also use WidgetTester to find child widgets in the widget // layer depends on the Iperf3Runner interface rather than on Iperf3Service.
// tree, read text, and verify that the values of widget properties are correct.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_result.dart';
import 'package:iperf3gui/main.dart'; import 'package:iperf3gui/main.dart';
import 'support/fake_iperf3_runner.dart';
void main() { void main() {
testWidgets('App smoke test', (WidgetTester tester) async { testWidgets('App smoke test', (WidgetTester tester) async {
await tester.pumpWidget(const Iperf3GuiApp()); await tester.pumpWidget(Iperf3GuiApp(runner: FakeIperf3Runner()));
expect(find.text('iperf3-gui'), findsOneWidget); expect(find.text('iperf3-gui'), findsOneWidget);
expect(find.text('GO'), findsOneWidget);
expect(find.text('Ready'), findsOneWidget);
});
testWidgets('runs a test and renders streamed results', (tester) async {
final runner = FakeIperf3Runner();
runner.onRun = (config) async {
runner.emit(Iperf3Start(
targetHost: config.server,
targetPort: config.port,
protocol: 'TCP',
streamCount: config.parallelStreams,
));
runner.emit(const Iperf3Interval(
start: 0,
end: 1,
bytes: 98566144,
bitsPerSecond: 788410352,
));
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0);
};
await tester.pumpWidget(Iperf3GuiApp(runner: runner));
await tester.tap(find.text('GO'));
await tester.pumpAndSettle();
expect(runner.runCallCount, equals(1));
expect(find.text('Test complete'), findsOneWidget);
expect(find.textContaining('788 Mbits/sec'), findsWidgets);
// The stat row is driven by the parsed interval series.
expect(find.text('peak'), findsOneWidget);
});
testWidgets('shows a stop button while running and can stop', (tester) async {
final runner = FakeIperf3Runner();
final gate = Completer<Iperf3Result>();
runner.onRun = (_) => gate.future;
await tester.pumpWidget(Iperf3GuiApp(runner: runner));
await tester.tap(find.text('GO'));
await tester.pump();
expect(find.text('STOP'), findsOneWidget);
expect(find.text('Running…'), findsOneWidget);
await tester.tap(find.text('STOP'));
await tester.pump();
expect(runner.stopCallCount, equals(1));
gate.complete(const Iperf3Result(
rawOutput: '', exitCode: 0, stopped: true));
await tester.pumpAndSettle();
expect(find.text('Stopped'), findsOneWidget);
});
testWidgets('rejects an empty server address', (tester) async {
final runner = FakeIperf3Runner();
await tester.pumpWidget(Iperf3GuiApp(runner: runner));
await tester.enterText(find.byType(TextField).first, '');
await tester.tap(find.text('GO'));
await tester.pumpAndSettle();
expect(runner.runCallCount, equals(0));
expect(find.textContaining('Invalid configuration'), findsOneWidget);
}); });
} }
+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
-1
View File
@@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)