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