Compare commits
29 Commits
defd64a073
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ff423f8bbf | |||
| 47d849a122 | |||
| ba7f335b67 | |||
| 5b6c2c0435 | |||
| b01f8cdf87 | |||
| b960581fd1 | |||
| 7981ca2022 | |||
| ae2033865c | |||
| 86b290d978 | |||
| 32575530dc | |||
| 0dd0b95afb | |||
| 66826bbce6 | |||
| 85dc74247c | |||
| 94d96297f8 | |||
| a96f302dc7 | |||
| 7ca74366a3 | |||
| ff13d63333 | |||
| 2b3f20d3a0 | |||
| 894712b7a8 | |||
| 895b963745 | |||
| b76098c7e6 | |||
| 6e35d724dc | |||
| fc15897541 | |||
| 9485049b57 | |||
| 838c883cc0 | |||
| fe042efc29 | |||
| 0cf019379a | |||
| 95acf5ea38 | |||
| 314a3ea059 |
@@ -3,20 +3,39 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- 'lib/**.dart'
|
||||
- 'test/**.dart'
|
||||
- 'android/**'
|
||||
- 'tool/**'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
pull_request:
|
||||
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:
|
||||
analyze:
|
||||
name: Analyze & Test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: flutter-android
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.47.1'
|
||||
channel: 'stable'
|
||||
- name: Toolchain
|
||||
run: |
|
||||
flutter --version
|
||||
echo "ANDROID_HOME=${ANDROID_HOME:?not set on this runner}"
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
@@ -24,5 +43,36 @@ jobs:
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
# Includes test/architecture_test.dart, which fails the build if a layer
|
||||
# boundary is crossed.
|
||||
- name: Run tests
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -40,9 +40,6 @@ domain, so shipping an iperf3 ELF in assets and running it via `Process.start()`
|
||||
fails with "Permission denied". iperf3 is therefore compiled as a shared library
|
||||
(`libiperf3jni.so`) and called in-process over JNI.
|
||||
|
||||
`assets/iperf3/` still holds the pre-compiled binaries from that earlier
|
||||
approach. They are not declared in `pubspec.yaml`, so they are not packaged into
|
||||
the APK, and nothing in the app reads them.
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
@@ -37,6 +37,13 @@ android {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+21
@@ -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>;
|
||||
}
|
||||
@@ -86,6 +86,21 @@ struct iperf_run_state {
|
||||
* 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 */
|
||||
static void signal_output(JNIEnv *env, struct iperf_run_state *state,
|
||||
const char *msg) {
|
||||
@@ -272,13 +287,25 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
|
||||
|
||||
/* Resolve callback method IDs up front so failures can be reported. */
|
||||
jclass callback_cls = (*env)->GetObjectClass(env, callback);
|
||||
jmethodID mid_output = (*env)->GetMethodID(
|
||||
jmethodID mid_output = resolve_method(
|
||||
env, callback_cls, "onOutput", "(Ljava/lang/String;)V");
|
||||
jmethodID mid_error = (*env)->GetMethodID(
|
||||
jmethodID mid_error = resolve_method(
|
||||
env, callback_cls, "onError", "(Ljava/lang/String;)V");
|
||||
jmethodID mid_complete = (*env)->GetMethodID(
|
||||
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 { \
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+113
-8
@@ -6,19 +6,25 @@ A Flutter-based Android GUI wrapper for iperf3, the popular network performance
|
||||
|
||||
```
|
||||
lib/ # Flutter/Dart application layer
|
||||
├── main.dart # App entry point (MaterialApp)
|
||||
├── domain/ # Business logic (platform-independent)
|
||||
├── main.dart # Composition root: picks the Iperf3Runner impl
|
||||
├── main_headless.dart # Runs a test with no widgets (see Layering)
|
||||
├── domain/ # Pure Dart — no Flutter import
|
||||
│ ├── models/
|
||||
│ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams
|
||||
│ │ └── iperf3_result.dart # Raw output + exit code
|
||||
├── data/
|
||||
│ │ ├── iperf3_result.dart # Raw output, exit code, stopped flag
|
||||
│ │ ├── iperf3_event.dart # Parsed --json-stream reports
|
||||
│ │ └── iperf3_session.dart # One run accumulated: samples, totals, stats
|
||||
│ └── services/
|
||||
│ └── iperf3_service.dart # Dart → MethodChannel bridge (async with Completer)
|
||||
└── ui/ # Presentation layer
|
||||
│ └── iperf3_runner.dart # The contract the UI depends on
|
||||
├── data/ # Transport — flutter/services only
|
||||
│ └── services/
|
||||
│ └── iperf3_service.dart # Iperf3Runner over MethodChannel → JNI
|
||||
└── ui/ # Presentation
|
||||
└── features/
|
||||
└── test_run/
|
||||
├── test_run_screen.dart # Server/port inputs + GO button
|
||||
└── test_run_view_model.dart # State machine: idle → running → completed/error
|
||||
├── test_run_screen.dart # Inputs, GO/STOP, live transcript
|
||||
├── test_run_view_model.dart # idle → running → completed/error
|
||||
└── iperf3_event_formatter.dart # Events → iperf3-style text
|
||||
|
||||
android/app/src/main/
|
||||
├── kotlin/ # Kotlin plugin layer
|
||||
@@ -48,6 +54,49 @@ User taps GO
|
||||
→ On completion: Dart Completer resolves with Iperf3Result
|
||||
```
|
||||
|
||||
## Layering
|
||||
|
||||
Dependencies point inward: `ui → domain ← data`, never the reverse.
|
||||
|
||||
| Layer | May import | Holds |
|
||||
|---|---|---|
|
||||
| `domain` | nothing else in this package, no Flutter | models and the `Iperf3Runner` contract |
|
||||
| `data` | `domain`, `package:flutter/services.dart` | the MethodChannel/JNI implementation |
|
||||
| `ui` | `domain` | widgets, view model, formatting |
|
||||
| `main.dart` | everything | the single place that picks an implementation |
|
||||
|
||||
The UI depends on `Iperf3Runner`, not on `Iperf3Service`, so presentation names
|
||||
no transport. That is what lets `test/widget_test.dart` drive the real widget
|
||||
tree against an in-memory runner with no MethodChannel, native library or
|
||||
device — and what keeps a test double at five stable members instead of
|
||||
mirroring the transport's whole surface.
|
||||
|
||||
**The core does not need the UI.** What happened during a run is accumulated by
|
||||
`Iperf3Session` in the domain layer — samples, totals, errors, and the mean,
|
||||
peak and retransmit figures over them. The view model holds no run data of its
|
||||
own and the screen derives no numbers; both read the session. Replacing the
|
||||
presentation layer therefore means rewriting rendering, not re-deriving results.
|
||||
|
||||
`lib/main_headless.dart` is the standing proof: it runs a full test through
|
||||
`Iperf3Service` and `Iperf3Session` with `WidgetsFlutterBinding` but no
|
||||
`runApp` — no widget tree, no render surface. If the run path ever starts
|
||||
needing the UI, it stops working.
|
||||
|
||||
```bash
|
||||
flutter run -t lib/main_headless.dart \
|
||||
--dart-define=IPERF_SERVER=192.168.2.182 --dart-define=IPERF_STREAMS=4
|
||||
```
|
||||
|
||||
Use it when a figure has to be trustworthy. A 4-stream local run reports
|
||||
**30.2% local CPU headless against 79.9% through the UI** — the phone is near
|
||||
CPU-bound at gigabit rates, so a repainting transcript measurably depresses
|
||||
throughput. Numbers taken through the UI are pessimistic.
|
||||
|
||||
`test/architecture_test.dart` enforces all of this by reading the actual
|
||||
imports under `lib/`, including a check that no fourth layer directory has
|
||||
appeared unnoticed. Change the layering there when the layering should change;
|
||||
don't work around it.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### JNI Over Process Execution
|
||||
@@ -134,6 +183,62 @@ Dart uses `Completer<Iperf3Result>` to await native completion. The MethodChanne
|
||||
| `isTestRunning()` | `isTestRunning` | Check if test is in progress |
|
||||
| `setTempDir(String)` | `setTempDir` | Point `mkstemp()` at the app cache dir |
|
||||
|
||||
## CI
|
||||
|
||||
Both jobs run on a **self-hosted runner registered at `android-stuff` org scope**,
|
||||
targeted by the capability label `flutter-android` rather than by hostname.
|
||||
`tool/setup_ci_runner.sh` provisions it.
|
||||
|
||||
It does not run on the Gitea host. Gitea lives on an aarch64 box, and Flutter
|
||||
cannot build an Android APK on a Linux arm64 host at all — the SDK ships no
|
||||
arm64-host `gen_snapshot` ([flutter#189724](https://github.com/flutter/flutter/issues/189724),
|
||||
[#75864](https://github.com/flutter/flutter/issues/75864), open as of July 2026).
|
||||
An x86_64 runner is a requirement, not a preference.
|
||||
|
||||
The runner is **host mode**: jobs use the machine's own Flutter, JDK and Android
|
||||
SDK/NDK. CI therefore builds with the same toolchain that produced the verified
|
||||
release, and no NDK is downloaded per run. The cost is that there is no
|
||||
container boundary, which is what decides the next paragraph.
|
||||
|
||||
Jobs run as a dedicated unprivileged account, `gitea-runner`, in the
|
||||
`flutterdev` group. In host mode the service account *is* the sandbox — there
|
||||
is no other — and a workflow file is code that runs as whoever the service runs
|
||||
as. A human account would hand every workflow that user's home directory and
|
||||
SSH keys; root would be worse and would also miss the warm caches in that home,
|
||||
re-downloading dependencies every run. The dedicated account keeps its own
|
||||
Gradle and pub caches under `/var/lib/gitea-runner`, so the first Android build
|
||||
is slow by design and later ones are not.
|
||||
|
||||
`flutterdev` is what grants write access to `/opt/flutter` and
|
||||
`/opt/android-sdk`; both carry ACLs with `group:flutterdev:rwx` and matching
|
||||
`default:` entries, so new files inherit it. The setup script proves that access
|
||||
as the service account rather than assuming group membership is sufficient.
|
||||
|
||||
It installs a **system** service, not a user service: root-managed lifecycle,
|
||||
starts at boot, no lingering session. That the service is root-managed is
|
||||
unrelated to the jobs being unprivileged — `User=` in the unit settles the
|
||||
second, and conflating the two is easy to do.
|
||||
|
||||
`sudo ./tool/setup_ci_runner.sh --check` validates this machine's toolchain
|
||||
without creating anything.
|
||||
|
||||
Two jobs:
|
||||
|
||||
| Job | What it protects |
|
||||
|---|---|
|
||||
| `analyze` | `flutter analyze`, then `flutter test` — which includes `architecture_test.dart`, failing the build if a layer boundary is crossed |
|
||||
| `release-apk` | builds the **minified** APK and runs `tool/check_jni_keep_rules.sh` |
|
||||
|
||||
The second exists because of a specific escape. A release APK that aborted on
|
||||
every test shipped once, while every on-device check passed — because those
|
||||
checks all used the debug build, which does not minify. Building the release APK
|
||||
is necessary but not sufficient: a renamed JNI callback still produces a
|
||||
*successful build* and a broken app. So the guard reads R8's `mapping.txt` for
|
||||
the callback classes and methods, and the DEX string pool for the native entry
|
||||
points. It is verified to fail: delete the keep rules from
|
||||
`android/app/proguard-rules.pro`, rebuild, and it reports `MainThreadCallback`,
|
||||
`onOutput`, `onError` and `onComplete` renamed.
|
||||
|
||||
## Building
|
||||
|
||||
Prerequisites:
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter/foundation.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/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).
|
||||
/// This avoids SELinux restrictions on executing ELF binaries from assets.
|
||||
@@ -16,13 +17,17 @@ import 'package:iperf3gui/domain/models/test_configuration.dart';
|
||||
/// they are parsed and published on [events] while the test runs. [run]
|
||||
/// resolves once the native layer reports completion, carrying the raw JSON
|
||||
/// lines as the result's output.
|
||||
class Iperf3Service {
|
||||
class Iperf3Service implements Iperf3Runner {
|
||||
Iperf3Service() {
|
||||
_channel.setMethodCallHandler(_handleNativeCall);
|
||||
}
|
||||
|
||||
static const MethodChannel _channel =
|
||||
MethodChannel('com.novoyuuparosk.iperf3gui/iperf');
|
||||
/// A channel's handler is keyed by channel name in the binary messenger, so
|
||||
/// 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;
|
||||
@@ -41,6 +46,7 @@ class Iperf3Service {
|
||||
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.
|
||||
@@ -51,6 +57,7 @@ class Iperf3Service {
|
||||
|
||||
/// Whether iperf3 is currently running.
|
||||
bool _isRunning = false;
|
||||
@override
|
||||
bool get isRunning => _isRunning;
|
||||
|
||||
/// Whether the native layer reported an error during this run.
|
||||
@@ -60,17 +67,19 @@ class Iperf3Service {
|
||||
bool _stopRequested = false;
|
||||
|
||||
/// Cancel any running test.
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
if (!_isRunning) return;
|
||||
_stopRequested = true;
|
||||
try {
|
||||
await _channel.invokeMethod('stopIperf');
|
||||
} 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();
|
||||
@@ -79,6 +88,7 @@ class Iperf3Service {
|
||||
/// Run iperf3 with the given configuration.
|
||||
///
|
||||
/// Returns an [Iperf3Result] with the full output and an exit code.
|
||||
@override
|
||||
Future<Iperf3Result> run(TestConfiguration config) async {
|
||||
if (_isRunning) {
|
||||
return const Iperf3Result(
|
||||
@@ -93,7 +103,7 @@ class Iperf3Service {
|
||||
_rawLines.clear();
|
||||
|
||||
final args = config.toCliArgs();
|
||||
debugPrint('Iperf3Service: running: ${args.join(" ")}');
|
||||
_log('Iperf3Service: running: ${args.join(" ")}');
|
||||
|
||||
final completer = Completer<Iperf3Result>();
|
||||
_runCompleter = completer;
|
||||
@@ -101,8 +111,8 @@ class Iperf3Service {
|
||||
try {
|
||||
await _channel.invokeMethod('runIperf', {'args': args});
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('Iperf3Service exception: $e');
|
||||
debugPrint('Iperf3Service stack: $stackTrace');
|
||||
_log('Iperf3Service exception: $e');
|
||||
_log('Iperf3Service stack: $stackTrace');
|
||||
_finishRun();
|
||||
return Iperf3Result(
|
||||
rawOutput: 'Error starting iperf3: $e',
|
||||
@@ -116,11 +126,11 @@ class Iperf3Service {
|
||||
return await completer.future.timeout(
|
||||
config.duration + const Duration(seconds: 45),
|
||||
onTimeout: () {
|
||||
debugPrint('Iperf3Service: timeout, stopping test');
|
||||
_log('Iperf3Service: timeout, stopping test');
|
||||
// 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) {
|
||||
debugPrint('Iperf3Service: error stopping after timeout: $e');
|
||||
_log('Iperf3Service: error stopping after timeout: $e');
|
||||
return null;
|
||||
});
|
||||
_publish(const Iperf3Error('Timed out waiting for iperf3 to finish.'));
|
||||
@@ -131,7 +141,7 @@ class Iperf3Service {
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Iperf3Service: error waiting for completion: $e');
|
||||
_log('Iperf3Service: error waiting for completion: $e');
|
||||
return Iperf3Result(
|
||||
rawOutput: 'Test failed: $e',
|
||||
exitCode: exitCodeStartFailed,
|
||||
@@ -163,7 +173,7 @@ class Iperf3Service {
|
||||
_publish(Iperf3Error(error));
|
||||
}
|
||||
case 'onComplete':
|
||||
debugPrint('Iperf3Service: test completed');
|
||||
_log('Iperf3Service: test completed');
|
||||
final completer = _runCompleter;
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete(
|
||||
@@ -177,7 +187,7 @@ class Iperf3Service {
|
||||
);
|
||||
}
|
||||
default:
|
||||
debugPrint('Iperf3Service: unhandled native call ${call.method}');
|
||||
_log('Iperf3Service: unhandled native call ${call.method}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,3 +207,6 @@ class Iperf3Service {
|
||||
_runCompleter = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _log(String message) =>
|
||||
developer.log(message, name: 'Iperf3Service');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ class TestConfiguration {
|
||||
final int parallelStreams;
|
||||
|
||||
const TestConfiguration({
|
||||
this.server = 'iperf.es.net',
|
||||
this.server = 'speedtest.uztelecom.uz',
|
||||
this.port = 5201,
|
||||
this.duration = const Duration(seconds: 10),
|
||||
this.interval = 1,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+11
-6
@@ -1,17 +1,22 @@
|
||||
import 'package:flutter/material.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';
|
||||
|
||||
/// Application entry point.
|
||||
/// Application entry point, and the composition root.
|
||||
///
|
||||
/// iperf3 is now compiled as a native shared library (libiperf3jni.so)
|
||||
/// and accessed via JNI — no binary extraction or chmod needed.
|
||||
/// This is the only place that names a concrete [Iperf3Runner]. Everything
|
||||
/// 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 {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const Iperf3GuiApp());
|
||||
runApp(Iperf3GuiApp(runner: Iperf3Service()));
|
||||
}
|
||||
|
||||
class Iperf3GuiApp extends StatelessWidget {
|
||||
const Iperf3GuiApp({super.key});
|
||||
const Iperf3GuiApp({required this.runner, super.key});
|
||||
|
||||
final Iperf3Runner runner;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -21,7 +26,7 @@ class Iperf3GuiApp extends StatelessWidget {
|
||||
colorSchemeSeed: Colors.blue,
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const TestRunScreen(),
|
||||
home: TestRunScreen(runner: runner),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -1,24 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/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';
|
||||
|
||||
/// Main test run screen.
|
||||
///
|
||||
/// iperf3 is compiled as a native shared library (libiperf3jni.so) and accessed
|
||||
/// via JNI, so there is no binary extraction or chmod step.
|
||||
/// Takes an [Iperf3Runner] rather than constructing one, so this layer names no
|
||||
/// transport: the Android app injects the JNI-backed implementation from
|
||||
/// main(), and a test injects a fake.
|
||||
class TestRunScreen extends StatelessWidget {
|
||||
const TestRunScreen({super.key});
|
||||
const TestRunScreen({required this.runner, super.key});
|
||||
|
||||
final Iperf3Runner runner;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => TestRunViewModel(
|
||||
iperf3Service: Iperf3Service(),
|
||||
),
|
||||
create: (_) => TestRunViewModel(iperf3Service: runner),
|
||||
child: const _TestRunContent(),
|
||||
);
|
||||
}
|
||||
@@ -109,7 +110,7 @@ class _TestRunContentState extends State<_TestRunContent> {
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
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),
|
||||
@@ -194,29 +195,31 @@ class _TestRunContentState extends State<_TestRunContent> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Live throughput figures, derived from the parsed interval series
|
||||
// Live throughput figures. Every number here is read off the
|
||||
// session; none are computed in the widget tree.
|
||||
Consumer<TestRunViewModel>(
|
||||
builder: (context, vm, _) {
|
||||
final intervals = vm.intervals;
|
||||
if (intervals.isEmpty) return const SizedBox.shrink();
|
||||
final rates =
|
||||
intervals.map((i) => i.bitsPerSecond).toList(growable: false);
|
||||
final latest = rates.last;
|
||||
final peak = rates.reduce((a, b) => a > b ? a : b);
|
||||
final mean = rates.reduce((a, b) => a + b) / rates.length;
|
||||
final summary = vm.summary?.sent;
|
||||
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: summary != null ? 'total' : 'now',
|
||||
value: formatBitrate(summary?.bitsPerSecond ?? latest),
|
||||
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: '${rates.length}'),
|
||||
_Stat(
|
||||
label: 'samples', value: '${session.sampleCount}'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/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.
|
||||
@@ -12,10 +13,12 @@ enum TestRunState { idle, running, completed, error }
|
||||
|
||||
/// ViewModel for managing test run state.
|
||||
///
|
||||
/// Subscribes to [Iperf3Service.events] so the transcript and the interval
|
||||
/// series both grow while the test runs, rather than appearing at the end.
|
||||
/// 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 {
|
||||
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
|
||||
@@ -41,24 +44,14 @@ class TestRunViewModel extends ChangeNotifier {
|
||||
|
||||
final StringBuffer _transcript = StringBuffer();
|
||||
|
||||
/// Human-readable output accumulated so far.
|
||||
/// Human-readable output accumulated so far. Display only — the structured
|
||||
/// record of the run is [session].
|
||||
String get output => _transcript.toString();
|
||||
|
||||
final List<Iperf3Interval> _intervals = [];
|
||||
final Iperf3Session _session = Iperf3Session();
|
||||
|
||||
/// Per-interval throughput samples, in arrival order. Structured, so a chart
|
||||
/// can be driven from this without re-parsing text.
|
||||
List<Iperf3Interval> get intervals => List.unmodifiable(_intervals);
|
||||
|
||||
Iperf3Start? _start;
|
||||
|
||||
/// Connection details reported at the start of the run.
|
||||
Iperf3Start? get start => _start;
|
||||
|
||||
Iperf3End? _summary;
|
||||
|
||||
/// Totals reported at the end of the run.
|
||||
Iperf3End? get summary => _summary;
|
||||
/// 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;
|
||||
@@ -118,26 +111,12 @@ class TestRunViewModel extends ChangeNotifier {
|
||||
void _resetRunData() {
|
||||
_result = null;
|
||||
_transcript.clear();
|
||||
_intervals.clear();
|
||||
_start = null;
|
||||
_summary = null;
|
||||
_session.clear();
|
||||
_dirty = false;
|
||||
}
|
||||
|
||||
void _onEvent(Iperf3Event event) {
|
||||
switch (event) {
|
||||
case Iperf3Start e:
|
||||
_start = e;
|
||||
case Iperf3Interval e:
|
||||
// Omitted intervals are warm-up (-O) and are not part of the series.
|
||||
if (!e.omitted) _intervals.add(e);
|
||||
case Iperf3End e:
|
||||
_summary = e;
|
||||
case Iperf3Error _:
|
||||
case Iperf3Stopped _:
|
||||
case Iperf3LogLine _:
|
||||
break;
|
||||
}
|
||||
_session.add(event);
|
||||
|
||||
final line = formatIperf3Event(event);
|
||||
if (line != null && line.isNotEmpty) {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: iperf3gui
|
||||
description: "A Flutter-based Android GUI wrapper for iperf3."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
version: 0.1.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.13.1
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/test_configuration.dart';
|
||||
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
|
||||
|
||||
import '../../../support/fake_iperf3_runner.dart';
|
||||
|
||||
void main() {
|
||||
group('TestRunViewModel', () {
|
||||
test('starts in idle state', () {
|
||||
final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
|
||||
final vm = TestRunViewModel(iperf3Service: FakeIperf3Runner());
|
||||
expect(vm.state, equals(TestRunState.idle));
|
||||
expect(vm.result, isNull);
|
||||
expect(vm.currentConfig, isNull);
|
||||
@@ -18,13 +19,13 @@ void main() {
|
||||
});
|
||||
|
||||
test('can be reset', () {
|
||||
final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
|
||||
final vm = TestRunViewModel(iperf3Service: FakeIperf3Runner());
|
||||
vm.reset();
|
||||
expect(vm.state, equals(TestRunState.idle));
|
||||
});
|
||||
|
||||
test('invalid config sets error state without running', () async {
|
||||
final mock = _MockIperf3Service();
|
||||
final mock = FakeIperf3Runner();
|
||||
final vm = TestRunViewModel(iperf3Service: mock);
|
||||
const config = TestConfiguration(server: '');
|
||||
await vm.runTest(config);
|
||||
@@ -33,7 +34,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('runTest with valid config calls service', () async {
|
||||
final mock = _MockIperf3Service(
|
||||
final mock = FakeIperf3Runner(
|
||||
onRun: (config) async {
|
||||
expect(config.server, equals('192.168.1.1'));
|
||||
return const Iperf3Result(rawOutput: 'done', exitCode: 0);
|
||||
@@ -47,7 +48,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('non-zero exit code lands in error state', () async {
|
||||
final mock = _MockIperf3Service(
|
||||
final mock = FakeIperf3Runner(
|
||||
onRun: (_) async =>
|
||||
const Iperf3Result(rawOutput: 'boom', exitCode: 1),
|
||||
);
|
||||
@@ -58,7 +59,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('streamed events accumulate into output during the run', () async {
|
||||
final mock = _MockIperf3Service();
|
||||
final mock = FakeIperf3Runner();
|
||||
mock.onRun = (_) async {
|
||||
mock.emit(const Iperf3Start(targetHost: '192.168.1.1', targetPort: 5201));
|
||||
mock.emit(const Iperf3Interval(
|
||||
@@ -81,48 +82,13 @@ void main() {
|
||||
expect(vm.output, isNot(contains('ignored')));
|
||||
});
|
||||
|
||||
test('intervals are collected as structured samples', () async {
|
||||
final mock = _MockIperf3Service();
|
||||
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 Iperf3Interval(
|
||||
start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
return const Iperf3Result(rawOutput: '', exitCode: 0);
|
||||
};
|
||||
|
||||
final vm = TestRunViewModel(iperf3Service: mock);
|
||||
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
|
||||
|
||||
expect(vm.intervals, hasLength(2));
|
||||
expect(vm.intervals[1].bitsPerSecond, equals(16000));
|
||||
});
|
||||
|
||||
test('omitted warm-up intervals are excluded from the series', () async {
|
||||
final mock = _MockIperf3Service();
|
||||
mock.onRun = (_) async {
|
||||
mock.emit(const Iperf3Interval(
|
||||
start: 0, end: 1, bytes: 1, bitsPerSecond: 1, omitted: true));
|
||||
mock.emit(const Iperf3Interval(
|
||||
start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
return const Iperf3Result(rawOutput: '', exitCode: 0);
|
||||
};
|
||||
|
||||
final vm = TestRunViewModel(iperf3Service: mock);
|
||||
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
|
||||
|
||||
expect(vm.intervals, hasLength(1));
|
||||
expect(vm.intervals.single.bitsPerSecond, equals(16000));
|
||||
});
|
||||
|
||||
test('end event is retained as the summary', () async {
|
||||
final mock = _MockIperf3Service();
|
||||
mock.onRun = (_) async {
|
||||
mock.emit(const Iperf3End(
|
||||
sent: Iperf3Total(
|
||||
seconds: 10, bytes: 987654321, bitsPerSecond: 790000000),
|
||||
sent: Iperf3Total(seconds: 1, bytes: 1000, bitsPerSecond: 8000),
|
||||
));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
return const Iperf3Result(rawOutput: '', exitCode: 0);
|
||||
@@ -131,12 +97,12 @@ void main() {
|
||||
final vm = TestRunViewModel(iperf3Service: mock);
|
||||
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
|
||||
|
||||
expect(vm.summary?.sent?.bitsPerSecond, equals(790000000));
|
||||
expect(vm.output, contains('790 Mbits/sec'));
|
||||
expect(vm.session.sampleCount, equals(1));
|
||||
expect(vm.session.summary, isNotNull);
|
||||
});
|
||||
|
||||
test('a new run clears the previous interval series', () async {
|
||||
final mock = _MockIperf3Service();
|
||||
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));
|
||||
@@ -146,14 +112,15 @@ void main() {
|
||||
|
||||
final vm = TestRunViewModel(iperf3Service: mock);
|
||||
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
|
||||
expect(vm.intervals, hasLength(1));
|
||||
expect(vm.session.sampleCount, equals(1));
|
||||
|
||||
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
|
||||
expect(vm.intervals, hasLength(1), reason: 'not appended to the old run');
|
||||
expect(vm.session.sampleCount, equals(1),
|
||||
reason: 'not appended to the old run');
|
||||
});
|
||||
|
||||
test('falls back to result output when nothing streamed', () async {
|
||||
final mock = _MockIperf3Service(
|
||||
final mock = FakeIperf3Runner(
|
||||
onRun: (_) async =>
|
||||
const Iperf3Result(rawOutput: 'summary only', exitCode: 0),
|
||||
);
|
||||
@@ -163,7 +130,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('output is cleared when a new run starts', () async {
|
||||
final mock = _MockIperf3Service(
|
||||
final mock = FakeIperf3Runner(
|
||||
onRun: (_) async =>
|
||||
const Iperf3Result(rawOutput: 'first', exitCode: 0),
|
||||
);
|
||||
@@ -179,7 +146,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('stopTest forwards to the service only while running', () async {
|
||||
final mock = _MockIperf3Service();
|
||||
final mock = FakeIperf3Runner();
|
||||
final vm = TestRunViewModel(iperf3Service: mock);
|
||||
|
||||
await vm.stopTest();
|
||||
@@ -198,7 +165,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('wasStopped reflects a user-requested stop', () async {
|
||||
final mock = _MockIperf3Service(
|
||||
final mock = FakeIperf3Runner(
|
||||
onRun: (_) async => const Iperf3Result(
|
||||
rawOutput: 'stopped early',
|
||||
exitCode: 0,
|
||||
@@ -213,7 +180,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('wasStopped is false for a run that finished on its own', () async {
|
||||
final mock = _MockIperf3Service(
|
||||
final mock = FakeIperf3Runner(
|
||||
onRun: (_) async =>
|
||||
const Iperf3Result(rawOutput: 'done', exitCode: 0),
|
||||
);
|
||||
@@ -223,7 +190,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('a second runTest while running is ignored', () async {
|
||||
final mock = _MockIperf3Service();
|
||||
final mock = FakeIperf3Runner();
|
||||
final gate = Completer<Iperf3Result>();
|
||||
mock.onRun = (_) => gate.future;
|
||||
|
||||
@@ -237,49 +204,3 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _MockIperf3Service implements Iperf3Service {
|
||||
_MockIperf3Service({this.onRun});
|
||||
|
||||
Future<Iperf3Result> Function(TestConfiguration)? onRun;
|
||||
|
||||
final StreamController<Iperf3Event> _controller =
|
||||
StreamController<Iperf3Event>.broadcast();
|
||||
|
||||
int runCallCount = 0;
|
||||
int stopCallCount = 0;
|
||||
bool disposed = false;
|
||||
bool _isRunning = false;
|
||||
|
||||
/// 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 {
|
||||
runCallCount++;
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
||||
+78
-6
@@ -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
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
// These drive the real UI against a FakeIperf3Runner: no MethodChannel, no
|
||||
// native library, no device. That is only possible because the presentation
|
||||
// layer depends on the Iperf3Runner interface rather than on Iperf3Service.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.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 'support/fake_iperf3_runner.dart';
|
||||
|
||||
void main() {
|
||||
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('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);
|
||||
});
|
||||
}
|
||||
|
||||
Executable
+112
@@ -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."
|
||||
Executable
+288
@@ -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
|
||||
Reference in New Issue
Block a user