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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
_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>
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>
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>
- Set app cache dir as temp directory for mkstemp() to avoid EACCES
on Android sandbox (iperf_set_test_template).
- Wrap all MethodChannel callbacks in MainThreadCallback with
Handler(Looper.getMainLooper()) to prevent SIGABRT from calling
invokeMethod from native reader thread.
- Add setTempDir JNI function and logging for test return value.
Two root causes prevented iperf3 tests from running:
1. argv[0] bug: iperf_parse_arguments() expects argv[0] to be the
program name. We passed Dart args directly, so argv[0] was '-c',
which iperf3 misinterpreted as the program name → 'Argument parse
error: must either be a client (-c) or server (-s)'. Fixed by
prepending "iperf3" as argv[0] and shifting user args to argv[1+].
2. Signal handling: iperf_catch_sigend() installs handlers that use
longjmp() to a jmp_buf, but we never initialized the buffer with
setjmp(). Any signal during iperf_run_client() caused SIGSEGV or
hang. Added setjmp/longjmp wrapper matching main.c's pattern,
SIGPIPE=SIG_IGN, and a 30s connect_timeout to avoid DNS hangs.
Also extracted iperf_cleanup_and_signal() to deduplicate the
normal-return and longjmp paths. Added diagnostic logging to
IperfRunner.kt's library loading for faster future debugging.
The root cause of the SIGBUS (alignment fault) crash was casting jmethodID
values to function pointers and invoking them directly. jmethodID is an
opaque JNI type that must only be used via CallVoidMethod/CallObjectMethod
etc. Calling it as a function pointer dereferences garbage memory, causing
SIGBUS at address 0xb1.
Changes:
- Replace function pointer typedefs (output_callback_t etc.) with a struct
storing JNIEnv + callback global ref + jmethodIDs
- All callbacks now go through CallVoidMethod(env, ref, mid, ...)
- Remove getTestProgress() (never used, unused start_time workaround)
- Fix Kotlin arg cast: List<String> -> toTypedArray() (ClassCastException)
- Update UI default server to speedtest.uztelecom.uz
- Switch from bundled iperf3 binary (Process.start) to JNI compilation
- iperf3 3.21 source API was incompatible (start_time, iperf_strerror, etc.)
- iperf3 3.19 source is proven to work (used by CellularLab)
- CMakeLists.txt updated: config.h generated into cpp/iperf/ for correct include paths
- iperf_jni.c fixed for 3.19 API (no start_time on iperf_test)
- getTestProgress() returns g_test->duration as fallback (no real-time elapsed tracking yet)
- NDK CMake pinned to 3.22.1
- UI layer updated: removed Iperf3BinaryPathProvider references
- Tests updated: TestRunViewModelTest matches new service interface
- pubspec.yaml: removed iperf3 assets bundle, added http dependency
- AndroidManifest: INTERNET permission (needed for hostname resolution)
- Android toybox ash cannot exec ELF binaries even with -rwx permissions
- runInShell: true wraps in /system/bin/sh -c which refuses ELF
- runInShell: false calls execve() directly, bypassing sh
- Added debugPrint logging to capture actual output in logcat
- Added static analysis results (flutter analyze + flutter test)
- Added on-device test results from Sony XQ-AS72 (QV7100JQ52)
- iperf3 v3.21 executes successfully without permission errors
- Raw output displayed in text box, status shows 'Test complete'
- The original chmod call used Process.run('chmod', ...) which fails on Android
because chmod is not available in the app sandbox PATH
- /system/bin/sh is always present on Android and can execute chmod on the
extracted binary, setting executable permissions before the first run
- Move MainActivity.kt from novoyuuparosk.iperf3gui to
com.novoyuuparosk.iperf3gui package directory
- Update package declaration to match namespace
- This fixes the ClassNotFoundException crash on launch
- Add GitHub Actions CI workflow for analyze and tests
- Update README with project structure, build instructions, and license info
- Document iperf3 binary bundling process
- Use const Iperf3Result constructor in tests
- Remove unused import from widget_test.dart
- Fix unnecessary underscore warnings in Consumer builders
- Use initializing formal for iperf3Service field
- Wire up main.dart to call Iperf3BinaryPathProvider.initialize()