diff --git a/README.md b/README.md index ae83ea0..dde45a0 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,16 @@ # 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 - Client-mode network throughput testing -- Simple UI: enter server address, port, and press GO -- Raw iperf3 output display -- Bundled iperf3 v3.21 binaries for arm64-v8a and armeabi-v7a +- Configurable server, port, duration and parallel streams +- Live iperf3 output, streamed line by line while the test runs +- 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 @@ -15,33 +18,62 @@ A Flutter-based Android GUI wrapper for iperf3, the popular network performance lib/ ├── domain/ │ ├── models/ # TestConfiguration, Iperf3Result -│ └── services/ # Iperf3Service (Process runner) +├── data/ +│ └── services/ # Iperf3Service (MethodChannel → JNI) ├── ui/ │ └── features/ │ └── test_run/ # TestRunScreen, TestRunViewModel -├── main.dart # App entry point -└── pubspec.yaml # Dependencies: provider, path_provider +└── main.dart # App entry point + +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 Prerequisites: -- Flutter 3.47+ +- Flutter 3.47+ - Android SDK with platform android-36 - JDK 17 +- NDK r28+ and CMake 3.22.1 (for the native build) ```bash -flutter create . --org novoyuuparosk --project-name iperf3gui flutter pub get flutter analyze +flutter test 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) -and bundled in `assets/iperf3/` (arm64-v8a and armeabi-v7a for Android 28+). +```bash +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 -MIT — bundled iperf3 v3.21 is BSD-3-Clause licensed. +MIT — bundled iperf3 3.19 is BSD-3-Clause licensed. diff --git a/android/app/src/main/cpp/iperf_jni.c b/android/app/src/main/cpp/iperf_jni.c index a8f874b..5b4cfac 100644 --- a/android/app/src/main/cpp/iperf_jni.c +++ b/android/app/src/main/cpp/iperf_jni.c @@ -5,12 +5,25 @@ * the Flutter app to run iperf3 tests natively inside the app process * (no Process.start needed, no SELinux restrictions). * + * 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. + * + * PIPE / SHUTDOWN NOTE: + * iperf's output goes to a pipe drained by a reader thread that blocks in + * fgets(). The reader only sees EOF once the write end is closed, so the + * write end MUST be closed before pthread_join() — joining first deadlocks. + * The state struct is owned solely by the JNI caller, which frees it after + * joining the reader; the reader must never free it. + * * 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). + * 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 @@ -23,6 +36,7 @@ #include #include #include +#include #include "iperf.h" #include "iperf_api.h" @@ -33,31 +47,39 @@ #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, 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 pthread_mutex_t g_test_mutex = PTHREAD_MUTEX_INITIALIZER; 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 shared between the JNI caller and the reader thread. * We store the callback object (global ref) and method IDs. * Callbacks are invoked via CallVoidMethod, NOT as function pointers. */ struct iperf_run_state { - JNIEnv *env; jobject callback_ref; /* Global ref to the callback object */ jmethodID mid_output; /* Method ID for onOutput(String) */ jmethodID mid_error; /* Method ID for onError(String) */ jmethodID mid_complete; /* Method ID for onComplete() */ - FILE *outfile; + FILE *outfile; /* Pipe write end; NULL once closed */ int pipe_fd[2]; JavaVM *jvm; pthread_t reader_thread; + int reader_started; /* Only join if pthread_create succeeded */ }; /* Helper: invoke the output callback via CallVoidMethod */ -static void signal_output(struct iperf_run_state *state, const char *msg) { - if (!state || !msg || !msg[0]) return; - JNIEnv *env = state->env; - if (!env || !state->mid_output) return; +static void signal_output(JNIEnv *env, struct iperf_run_state *state, + const char *msg) { + if (!env || !state || !msg || !msg[0] || !state->mid_output) return; jstring jline = (*env)->NewStringUTF(env, msg); if (jline) { @@ -67,10 +89,9 @@ static void signal_output(struct iperf_run_state *state, const char *msg) { } /* Helper: invoke the error callback via CallVoidMethod */ -static void signal_error(struct iperf_run_state *state, const char *msg) { - if (!state || !msg) return; - JNIEnv *env = state->env; - if (!env || !state->mid_error) return; +static void signal_error(JNIEnv *env, struct iperf_run_state *state, + const char *msg) { + if (!env || !state || !msg || !state->mid_error) return; jstring jmsg = (*env)->NewStringUTF(env, msg); if (jmsg) { @@ -80,10 +101,8 @@ static void signal_error(struct iperf_run_state *state, const char *msg) { } /* Helper: invoke the complete callback via CallVoidMethod */ -static void signal_complete(struct iperf_run_state *state) { - if (!state || !state->mid_complete) return; - JNIEnv *env = state->env; - if (!env) return; +static void signal_complete(JNIEnv *env, struct iperf_run_state *state) { + if (!env || !state || !state->mid_complete) return; (*env)->CallVoidMethod(env, state->callback_ref, state->mid_complete); } @@ -91,6 +110,9 @@ static void signal_complete(struct iperf_run_state *state) { /* ------------------------------------------------------------------ */ /* Output reader thread — reads from iperf's output pipe and forwards */ /* each line to the Java callback via JNI. */ +/* */ +/* Exits when the write end of the pipe is closed (EOF). Does NOT own */ +/* `state`: the JNI caller frees it after joining this thread. */ /* ------------------------------------------------------------------ */ static void *reader_thread_func(void *arg) { struct iperf_run_state *state = (struct iperf_run_state *) arg; @@ -101,6 +123,10 @@ static void *reader_thread_func(void *arg) { ret = (*state->jvm)->AttachCurrentThread(state->jvm, &env, NULL); if (ret != JNI_OK) { LOGE("AttachCurrentThread failed: %d", ret); + /* Still drain the pipe so writers never block on a full pipe. */ + char drain[1024]; + while (read(state->pipe_fd[0], drain, sizeof(drain)) > 0) { } + close(state->pipe_fd[0]); return NULL; } @@ -118,49 +144,52 @@ static void *reader_thread_func(void *arg) { (*env)->DeleteLocalRef(env, line); } } - fclose(fp); + fclose(fp); /* also closes pipe_fd[0] */ + } else { + close(state->pipe_fd[0]); } /* Detach this native thread */ (*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, - int *out_argc) { - int argc = (*env)->GetArrayLength(env, str_array); - if (argc <= 0) { +static char **build_argv(JNIEnv *env, jobjectArray str_array, int *out_argc) { + int user_argc = (*env)->GetArrayLength(env, str_array); + if (user_argc < 0) user_argc = 0; + + int argc = user_argc + 1; /* +1 for argv[0] */ + char **argv = malloc((argc + 1) * sizeof(char *)); + if (!argv) { *out_argc = 0; return NULL; } - char **argv = malloc((argc + 1) * sizeof(char *)); - if (!argv) return NULL; - - for (int i = 0; i < argc; i++) { - jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, - str_array, i); - const char *cstr = (*env)->GetStringUTFChars(env, jstr, NULL); + argv[0] = strdup("iperf3"); + for (int i = 0; i < user_argc; i++) { + jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, str_array, i); + const char *cstr = jstr ? (*env)->GetStringUTFChars(env, jstr, NULL) : NULL; if (cstr) { - argv[i] = strdup(cstr); + argv[i + 1] = strdup(cstr); (*env)->ReleaseStringUTFChars(env, jstr, cstr); } 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 */ *out_argc = argc; 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) { if (!argv) return; for (int i = 0; i < argc; i++) { @@ -170,42 +199,79 @@ static void free_argv(char **argv, int argc) { } /* ------------------------------------------------------------------ */ -/* Internal: clean up after iperf_run_client returns (normal path). */ -/* Must be called only on the normal return path (NOT after longjmp). */ +/* Close the pipe write end. Idempotent — both the normal and the */ +/* longjmp path call it, and the second call must be a no-op. */ +/* */ +/* Closing this is what makes the reader thread see EOF and exit, so */ +/* it must happen before any pthread_join(). */ +/* ------------------------------------------------------------------ */ +static void close_outfile(struct iperf_run_state *state) { + if (state && state->outfile) { + fflush(state->outfile); + fclose(state->outfile); + state->outfile = NULL; + } +} + +/* 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) { + /* outfile is a closed FILE* by now — point it somewhere harmless so + * nothing in the teardown path can write to a dead stream. */ + test->outfile = stdout; + iperf_free_test(test); + } + pthread_mutex_unlock(&g_test_mutex); +} + +/* ------------------------------------------------------------------ */ +/* Internal: clean up after iperf_run_client returns and report the */ +/* outcome to Java. Called from both the normal and the longjmp path. */ +/* Frees `state`; do not touch it afterwards. */ /* ------------------------------------------------------------------ */ static void iperf_cleanup_and_signal(JNIEnv *env, struct iperf_run_state *state, char **argv, int argc, int ret, int stopped) { - /* Wait for reader thread to finish */ - pthread_join(state->reader_thread, NULL); + /* Close the write end FIRST: the reader thread is blocked in fgets() + * and only unblocks on EOF. Joining before this would deadlock. */ + close_outfile(state); - /* Flush and close output */ - fflush(state->outfile); - fclose(state->outfile); + if (state->reader_started) { + pthread_join(state->reader_thread, NULL); + state->reader_started = 0; + } - /* Clean up iperf */ - iperf_free_test(g_test); - g_test = NULL; + /* Restore default signal handlers before g_sigend_jmp_buf goes stale. */ + iperf_catch_sigend(SIG_DFL); - /* Free argv */ + retire_test(); free_argv(argv, argc); + /* A user-requested stop shuts down the control socket, so + * iperf_run_client() returns -1 with "Broken pipe" — expected, not a + * failure. Check g_stop so that path is reported as a stop, not an error. */ + int user_stopped = stopped || g_stop; + g_stop = 0; + /* Signal result to Java */ - if (stopped) { - signal_output(state, "[iPerf JNI] Test was stopped by user.\n"); + if (user_stopped) { + signal_output(env, state, "[iPerf JNI] Test was stopped by user.\n"); } else if (ret < 0) { char errbuf[256]; snprintf(errbuf, sizeof(errbuf), "[iPerf JNI] Test failed: %s\n", iperf_strerror(i_errno)); - signal_error(state, errbuf); + signal_error(env, state, errbuf); } else { - signal_output(state, "[iPerf JNI] Test completed successfully.\n"); + signal_output(env, state, "[iPerf JNI] Test completed successfully.\n"); } /* Signal completion to Java */ - signal_complete(state); + signal_complete(env, state); /* Free global ref and state struct */ (*env)->DeleteGlobalRef(env, state->callback_ref); @@ -228,80 +294,93 @@ static void sigend_handler(int sig) { /* ------------------------------------------------------------------ */ /* 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 = (*env)->GetMethodID( + env, callback_cls, "onOutput", "(Ljava/lang/String;)V"); + jmethodID mid_error = (*env)->GetMethodID( + env, callback_cls, "onError", "(Ljava/lang/String;)V"); + jmethodID mid_complete = (*env)->GetMethodID( + env, callback_cls, "onComplete", "()V"); + + /* 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 below 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("[iPerf JNI] A test is already running."); + return; + } + /* Create the iperf test object */ struct iperf_test *test = iperf_new_test(); if (!test) { - const char *err = "Failed to create iperf test"; - LOGE("%s", err); + FAIL_EARLY("[iPerf JNI] Failed to create iperf test"); return; } iperf_defaults(test); - g_test = test; - /* - * Convert Java String[] -> char** argv, prepending "iperf3" as argv[0]. - * iperf_parse_arguments() expects argv[0] to be the program name; - * passing args directly from Dart means argv[0] would be "-c", - * which iperf3 misinterprets as the program name, not the client flag. - */ - int dart_argc = (*env)->GetArrayLength(env, arguments); - int total_argc = dart_argc + 1; /* +1 for "iperf3" */ - char **argv = malloc((total_argc + 1) * sizeof(char *)); + 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) { - const char *err = "Failed to allocate argv"; - LOGE("%s", err); - iperf_free_test(test); - g_test = NULL; + retire_test(); + FAIL_EARLY("[iPerf JNI] Failed to allocate argv"); return; } - argv[0] = strdup("iperf3"); - for (int i = 0; i < dart_argc; i++) { - jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, arguments, i); - const char *cstr = (*env)->GetStringUTFChars(env, jstr, NULL); - if (cstr) { - argv[i + 1] = strdup(cstr); - (*env)->ReleaseStringUTFChars(env, jstr, cstr); - } else { - argv[i + 1] = strdup(""); - } - (*env)->DeleteLocalRef(env, jstr); - } - argv[total_argc] = NULL; /* NULL-terminated for iperf */ - /* Set a reasonable connect timeout (30s) to avoid hanging on DNS resolution. - * Default iperf3 connect timeout is 120s which is far too long. */ + /* 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)); - LOGE("Parse error: %s", errbuf); + "[iPerf JNI] Argument parse error: %s", iperf_strerror(i_errno)); free_argv(argv, total_argc); - iperf_free_test(test); - g_test = NULL; + retire_test(); + FAIL_EARLY(errbuf); return; } - /* Create a pipe to capture iperf's stdout/stderr */ + /* Create a pipe to capture iperf's output */ int pipefd[2]; if (pipe(pipefd) != 0) { + char errbuf[256]; + snprintf(errbuf, sizeof(errbuf), + "[iPerf JNI] Failed to create output pipe: %s", strerror(errno)); free_argv(argv, total_argc); - const char *err = "Failed to create output pipe"; - LOGE("pipe() failed: %s", strerror(errno)); - - signal_error((struct iperf_run_state *) 0, err); - - iperf_free_test(test); - g_test = NULL; + retire_test(); + FAIL_EARLY(errbuf); return; } @@ -312,28 +391,33 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive( free_argv(argv, total_argc); close(pipefd[0]); close(pipefd[1]); - iperf_free_test(test); - g_test = NULL; + retire_test(); + FAIL_EARLY("[iPerf JNI] Failed to allocate run state"); 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"); + FILE *outfile = fdopen(pipefd[1], "w"); + if (!outfile) { + free(state); + free_argv(argv, total_argc); + close(pipefd[0]); + close(pipefd[1]); + retire_test(); + FAIL_EARLY("[iPerf JNI] Failed to open output stream"); + return; + } - /* Get JavaVM for thread attachment */ + /* Get JavaVM for reader-thread attachment */ JavaVM *jvm; (*env)->GetJavaVM(env, &jvm); - state->env = env; + state->mid_output = mid_output; + state->mid_error = mid_error; + state->mid_complete = mid_complete; state->callback_ref = (*env)->NewGlobalRef(env, callback); - state->outfile = fdopen(pipefd[1], "w"); + state->outfile = outfile; state->pipe_fd[0] = pipefd[0]; + state->pipe_fd[1] = pipefd[1]; state->jvm = jvm; /* Redirect iperf's output to our pipe */ @@ -346,42 +430,44 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive( * the strdup internally. */ iperf_set_test_template(test, g_tmp_template); + /* Spawn reader thread to forward iperf output to Java. Started before + * the test so early output is not lost, and before setjmp so the + * longjmp path can rely on reader_started being accurate. */ + if (pthread_create(&state->reader_thread, NULL, + reader_thread_func, state) != 0) { + close_outfile(state); + close(pipefd[0]); + free_argv(argv, total_argc); + retire_test(); + (*env)->DeleteGlobalRef(env, state->callback_ref); + free(state); + FAIL_EARLY("[iPerf JNI] Failed to start output reader thread"); + return; + } + state->reader_started = 1; + + #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) * - * Both paths share the same `state`, `argv`, `argc` variables, - * so the stack frame is preserved across the longjmp. + * `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); - /* Notify Java that the test is starting */ - signal_output(state, "[iPerf JNI] Starting iPerf3 client...\n"); + signal_output(env, state, "[iPerf JNI] Starting iPerf3 client...\n"); - /* Spawn reader thread to forward iperf output to Java */ - if (pthread_create(&state->reader_thread, NULL, - reader_thread_func, state) != 0) { - free_argv(argv, total_argc); - close(pipefd[0]); - close(pipefd[1]); - const char *err = "Failed to start output reader thread"; - LOGE("%s", err); - signal_error(state, err); - iperf_free_test(test); - g_test = NULL; - free(state); - return; - } - - /* Run the test */ g_stop = 0; int ret = iperf_run_client(test); - LOGI("iperf_run_client returned: %d i_errno=%d strerror=%s ctrl_sck=%d", ret, i_errno, iperf_strerror(i_errno), test->ctrl_sck); + LOGI("iperf_run_client returned: %d i_errno=%d strerror=%s", + ret, i_errno, iperf_strerror(i_errno)); /* * We get here after iperf_run_client returns normally. @@ -392,47 +478,43 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive( } else { /* * We got here via longjmp() from sigend_handler. - * Clean up resources allocated before the setjmp. - * NOTE: state, argv, argc are still valid on the stack. + * iperf_cleanup_and_signal() closes the pipe, joins the reader and + * frees everything, so there is nothing to unwind by hand here. */ LOGI("Test interrupted by signal %d", g_signed_sig); - - /* Stop flag for cleanup */ g_stop = 1; - - /* Close the output file (the pipe write end) */ - if (state->outfile) { - fclose(state->outfile); - } - - /* Free argv (argv is still valid across longjmp since it's on stack) */ - free_argv(argv, total_argc); - - /* Run the cleanup with stopped=true. - * Note: argv is NULL, argc=0 so free_argv is a no-op. - * The state struct will be freed inside this function. */ - iperf_cleanup_and_signal(env, state, NULL, 0, 0, 1); + iperf_cleanup_and_signal(env, state, argv, total_argc, 0, 1); } } /* ------------------------------------------------------------------ */ /* JNI: Gracefully stop a running iperf3 test. */ /* 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 Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest( JNIEnv *env, jobject thiz, jobject callback) { - if (!g_test || g_test->done) return; - /* Set the stop flag */ - g_stop = 1; - g_test->done = 1; - iperf_set_send_state(g_test, IPERF_DONE); + pthread_mutex_lock(&g_test_mutex); + int requested = 0; + if (g_test && !g_test->done) { + g_stop = 1; + g_test->done = 1; + iperf_set_send_state(g_test, IPERF_DONE); - /* Try to unblock select() on the control socket */ - if (g_test->ctrl_sck >= 0) { - shutdown(g_test->ctrl_sck, SHUT_RDWR); + /* Try to unblock select() on the control socket */ + if (g_test->ctrl_sck >= 0) { + shutdown(g_test->ctrl_sck, SHUT_RDWR); + } + requested = 1; } + pthread_mutex_unlock(&g_test_mutex); + + if (!requested) return; /* Notify Java */ jclass callback_cls = (*env)->GetObjectClass(env, callback); @@ -440,9 +522,11 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest( 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); + "[iPerf JNI] Requested graceful stop.\n"); + if (msg) { + (*env)->CallVoidMethod(env, callback, mid_output, msg); + (*env)->DeleteLocalRef(env, msg); + } } } @@ -453,7 +537,10 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest( JNIEXPORT jboolean JNICALL Java_com_novoyuuparosk_iperf3gui_IperfRunner_isTestRunning(JNIEnv *env, 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; } /* ------------------------------------------------------------------ */ diff --git a/android/app/src/main/kotlin/com/novoyuuparosk/iperf3gui/IperfRunner.kt b/android/app/src/main/kotlin/com/novoyuuparosk/iperf3gui/IperfRunner.kt index 2e57025..988f81f 100644 --- a/android/app/src/main/kotlin/com/novoyuuparosk/iperf3gui/IperfRunner.kt +++ b/android/app/src/main/kotlin/com/novoyuuparosk/iperf3gui/IperfRunner.kt @@ -4,24 +4,38 @@ import android.os.Handler import android.os.Looper import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.Executors /** * Kotlin binding layer for native iperf3 via JNI. * * iperf3 runs natively inside the app process — no Process.start() needed, - * no SELinux restrictions. Output is streamed live to the Flutter UI via - * the IperfCallback interface, with all callbacks posted to the main thread. + * no SELinux restrictions. + * + * 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 { + private const val CHANNEL = "com.novoyuuparosk.iperf3gui/iperf" + 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 { try { android.util.Log.i("IperfRunner", "Loading native library...") 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}") @@ -31,62 +45,87 @@ object IperfRunner : FlutterPlugin { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { appContext = binding.applicationContext - channel = MethodChannel(binding.binaryMessenger, "com.novoyuuparosk.iperf3gui/iperf") + channel = MethodChannel(binding.binaryMessenger, CHANNEL) channel?.setMethodCallHandler { call, result -> when (call.method) { - "runIperf" -> { - val args = call.argument>("args")?.toTypedArray() - if (args != null) { - // Set temp dir to app's cache dir — /data/local/tmp is NOT writable - appContext?.cacheDir?.absolutePath?.let { cacheDir -> - setTempDir(cacheDir) - } - // Wrap callbacks so they always run on the main thread. - // The native reader thread cannot call invokeMethod directly. - val safeCallback = MainThreadCallback( - onOutput = { line -> - channel?.invokeMethod("onOutput", mapOf("output" to line)) - }, - onError = { error -> - channel?.invokeMethod("onError", mapOf("error" to error)) - }, - onComplete = { - channel?.invokeMethod("onComplete", emptyMap()) - } - ) - runIperfLive(args, safeCallback) - result.success(null) - } else { - result.error("INVALID_ARGS", "args must be a String array", null) - } - } - "stopIperf" -> { - val safeCallback = MainThreadCallback( - onOutput = { line -> - channel?.invokeMethod("onOutput", mapOf("output" to line)) - }, - onError = { error -> - channel?.invokeMethod("onError", mapOf("error" to error)) - }, - onComplete = { - channel?.invokeMethod("onComplete", emptyMap()) - } - ) - forceStopIperfTest(safeCallback) - result.success(null) - } - "isTestRunning" -> { - result.success(isTestRunning()) - } + "runIperf" -> handleRunIperf(call.argument>("args"), result) + "stopIperf" -> handleStopIperf(result) + "isTestRunning" -> result.success(nativeLibraryLoaded && isTestRunning()) else -> result.notImplemented() } } } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + // Stop any in-flight test: its callbacks would otherwise fire into a + // dead channel, and the worker thread would keep the socket open. + if (nativeLibraryLoaded && isTestRunning()) { + forceStopIperfTest(NoopCallback) + } + channel?.setMethodCallHandler(null) channel = null + appContext = null } + private fun handleRunIperf(args: List?, 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()) }, + ) + // ───────────────────────────────────────────────────────────────── // Native method bindings // ───────────────────────────────────────────────────────────────── @@ -107,23 +146,35 @@ object IperfRunner : FlutterPlugin { } /** - * Thread-safe wrapper: posts all callbacks to the main thread. - * This is required because the native reader thread cannot safely - * call MethodChannel.invokeMethod from a background thread. + * 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 onOutput: (String) -> Unit, - private val onError: (String) -> Unit, - private val onComplete: () -> Unit + private val emitOutput: (String) -> Unit, + private val emitError: (String) -> Unit, + private val emitComplete: () -> Unit, ) : IperfCallback { override fun onOutput(line: String) { - mainHandler.post { onOutput(line) } + mainHandler.post { emitOutput(line) } } + override fun onError(error: String) { - mainHandler.post { onError(error) } + mainHandler.post { emitError(error) } } + override fun onComplete() { - mainHandler.post { onComplete() } + mainHandler.post { emitComplete() } } } -} \ No newline at end of file + + /** 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 + } +} diff --git a/docs/README.md b/docs/README.md index a0bdc5d..321cfbc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,9 +11,9 @@ lib/ # Flutter/Dart application layer │ ├── models/ │ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams │ │ └── iperf3_result.dart # Raw output + exit code +├── data/ │ └── services/ -│ ├── iperf3_service.dart # Dart → MethodChannel bridge (async with Completer) -│ └── iperf3_binary_path_provider.dart # Deprecated (kept for reference) +│ └── iperf3_service.dart # Dart → MethodChannel bridge (async with Completer) └── ui/ # Presentation layer └── features/ └── test_run/ @@ -39,10 +39,11 @@ android/app/src/main/ User taps GO → Dart TestRunViewModel.runTest(config) → Iperf3Service.run(config) via MethodChannel - → Kotlin IperfRunner.runIperfLive(args, callback) - → JNI C: java_string_array_to_argv() → iperf_parse_arguments() - → iperf_run_client(test) - → Reader thread pipes stdout → CallVoidMethod → Dart onOutput callbacks + → Kotlin IperfRunner: submits to the iperf3-run worker thread, returns at once + → JNI C: build_argv() (prepends argv[0]) → iperf_parse_arguments() + → iperf_run_client(test) [blocks on the worker thread] + → Reader thread pipes output → CallVoidMethod → main-thread hop → Dart onOutput + → Dart streams each line to the UI via Iperf3Service.outputLines → On completion: Dart Completer resolves with Iperf3Result ``` @@ -51,11 +52,31 @@ User taps GO ### 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. +### 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 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 -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. +iperf3 output is redirected via pipe to a background reader thread that attaches +to the Java VM via `AttachCurrentThread` and invokes callbacks via +`CallVoidMethod`. Dart re-publishes each line on `Iperf3Service.outputLines`, so +the transcript grows while the test runs. + +The reader blocks in `fgets()` and only unblocks on EOF, so the pipe's write end +must be closed **before** `pthread_join()` — joining first deadlocks the worker +thread. The JNI caller solely owns the run-state struct and frees it after the +join; the reader must never free it. + +### 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 Dart uses `Completer` 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 +93,10 @@ Dart uses `Completer` to await native completion. The MethodChanne | 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() | | `isTestRunning()` | `isTestRunning` | Check if test is in progress | +| `setTempDir(String)` | `setTempDir` | Point `mkstemp()` at the app cache dir | ## Building @@ -93,7 +115,7 @@ adb install build/app/outputs/flutter-apk/app-debug.apk ## Dependencies -- Dart: `provider`, `path_provider` +- Dart: `provider` - Android: CMake external native build - Native: iperf3 3.19, cJSON (single file) diff --git a/lib/data/services/iperf3_binary_path_provider.dart b/lib/data/services/iperf3_binary_path_provider.dart deleted file mode 100644 index 05c5333..0000000 --- a/lib/data/services/iperf3_binary_path_provider.dart +++ /dev/null @@ -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. diff --git a/lib/data/services/iperf3_service.dart b/lib/data/services/iperf3_service.dart index 2c22025..a3e6296 100644 --- a/lib/data/services/iperf3_service.dart +++ b/lib/data/services/iperf3_service.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -11,13 +10,38 @@ import 'package:iperf3gui/domain/models/test_configuration.dart'; /// On Android, iperf3 is compiled into the app as a shared library (.so). /// This avoids SELinux restrictions on executing ELF binaries from assets. /// -/// The service communicates with the native layer via a MethodChannel. -/// Output is streamed live to the Flutter UI through callbacks. +/// The service talks to the native layer over a MethodChannel. Native output +/// arrives line by line on [outputLines] while the test runs; [run] resolves +/// with the accumulated transcript once the native layer reports completion. class Iperf3Service { + Iperf3Service() { + _channel.setMethodCallHandler(_handleNativeCall); + } + static const MethodChannel _channel = MethodChannel('com.novoyuuparosk.iperf3gui/iperf'); - /// Collect output lines during a test run. + /// 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; + + /// Lines emitted by the native layer during the current run. + final StreamController _outputController = + StreamController.broadcast(); + + /// Live output, one line per event. Use this to render progress; [run]'s + /// result carries the same text joined together. + Stream get outputLines => _outputController.stream; + + /// Transcript of the current (or most recent) run. final List _outputBuffer = []; /// Completer that resolves when the native iperf3 test finishes. @@ -25,13 +49,18 @@ class Iperf3Service { /// Whether iperf3 is currently running. bool _isRunning = false; - - /// Whether iperf3 is currently running. 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. Future stop() async { if (!_isRunning) return; + _stopRequested = true; try { await _channel.invokeMethod('stopIperf'); } catch (e) { @@ -39,104 +68,128 @@ class Iperf3Service { } } + /// Release the stream controller. Call from the owner's dispose(). + Future dispose() async { + _channel.setMethodCallHandler(null); + await _outputController.close(); + } + /// 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. Future run(TestConfiguration config) async { if (_isRunning) { - return Iperf3Result( + return const Iperf3Result( rawOutput: 'Another test is already running.', - exitCode: -2, + exitCode: exitCodeBusy, ); } _isRunning = true; + _sawError = false; + _stopRequested = false; _outputBuffer.clear(); - final args = [ - '-c', config.server, - '-p', config.port.toString(), - '-t', config.duration.inSeconds.toString(), - '-i', config.interval.toString(), - '-P', config.parallelStreams.toString(), - ]; - + final args = config.toCliArgs(); debugPrint('Iperf3Service: running: ${args.join(" ")}'); - // Set up the completer to await completion - _runCompleter = Completer(); + final completer = Completer(); + _runCompleter = completer; - // Listen for events from native layer - _channel.setMethodCallHandler((call) async { - if (!_runCompleter!.isCompleted) { - switch (call.method) { - case 'onOutput': - final output = call.arguments['output'] as String?; - if (output != null) { - _outputBuffer.add(output); - debugPrint('Iperf3Service output: ${output.trim()}'); - } - break; - case 'onError': - final error = call.arguments['error'] as String?; - if (error != null) { - debugPrint('Iperf3Service error: $error'); - _outputBuffer.add('[ERROR] $error'); - // Don't complete yet — the test might still be running - } - break; - case 'onComplete': - debugPrint('Iperf3Service: test completed'); - _isRunning = false; - _runCompleter!.complete( - Iperf3Result( - rawOutput: _outputBuffer.join(''), - exitCode: 0, - ), - ); - break; - } - } - }); - - // Start the test try { await _channel.invokeMethod('runIperf', {'args': args}); } catch (e, stackTrace) { debugPrint('Iperf3Service exception: $e'); debugPrint('Iperf3Service stack: $stackTrace'); - _isRunning = false; - if (!_runCompleter!.isCompleted) { - _runCompleter!.completeError(e); - } + _finishRun(); return Iperf3Result( rawOutput: 'Error starting iperf3: $e', - exitCode: -1, + exitCode: exitCodeStartFailed, ); } - // Wait for the native layer to signal completion (with a timeout) + // Wait for the native layer to signal completion. The grace period covers + // DNS resolution and the 30s native connect timeout. try { - final result = await _runCompleter!.future.timeout( - config.duration + const Duration(seconds: 30), + return await completer.future.timeout( + config.duration + const Duration(seconds: 45), onTimeout: () { debugPrint('Iperf3Service: timeout, stopping test'); - stop(); + // Ask the native layer to wind down, then release local state so a + // stuck run can never lock the UI out of starting another test. + _channel.invokeMethod('stopIperf').catchError((Object e) { + debugPrint('Iperf3Service: error stopping after timeout: $e'); + return null; + }); + _emit('[ERROR] Timed out waiting for iperf3 to finish.'); return Iperf3Result( - rawOutput: 'Test timed out after ${config.duration.inSeconds}s', - exitCode: -3, + rawOutput: _transcript(), + exitCode: exitCodeTimeout, ); }, ); - return result; } catch (e) { debugPrint('Iperf3Service: error waiting for completion: $e'); return Iperf3Result( rawOutput: 'Test failed: $e', - exitCode: -1, + exitCode: exitCodeStartFailed, ); } finally { - _runCompleter = null; + _finishRun(); } } + + /// Handle a callback pushed up from the Kotlin/JNI layer. + Future _handleNativeCall(MethodCall call) async { + switch (call.method) { + case 'onOutput': + final output = call.arguments['output'] as String?; + if (output != null) { + _emit(output); + } + case 'onError': + final error = call.arguments['error'] as String?; + if (error != null) { + // Not terminal on its own — iperf3 reports the failure reason and + // then completes, so wait for onComplete before resolving. + _sawError = true; + _emit('[ERROR] $error'); + } + case 'onComplete': + debugPrint('Iperf3Service: test completed'); + final completer = _runCompleter; + if (completer != null && !completer.isCompleted) { + completer.complete( + Iperf3Result( + rawOutput: _transcript(), + // A user-requested stop is a normal outcome, not a failure. + exitCode: + (_sawError && !_stopRequested) ? exitCodeNativeError : 0, + stopped: _stopRequested, + ), + ); + } + default: + debugPrint('Iperf3Service: unhandled native call ${call.method}'); + } + } + + void _emit(String line) { + _outputBuffer.add(line); + if (!_outputController.isClosed) { + _outputController.add(line); + } + debugPrint('Iperf3Service output: ${line.trimRight()}'); + } + + /// Native output already carries its own newlines; keep it verbatim except + /// for lines we synthesised ourselves. + String _transcript() => _outputBuffer + .map((line) => line.endsWith('\n') ? line : '$line\n') + .join(); + + void _finishRun() { + _isRunning = false; + _runCompleter = null; + } } diff --git a/lib/domain/models/iperf3_result.dart b/lib/domain/models/iperf3_result.dart index 2c54feb..ef1b990 100644 --- a/lib/domain/models/iperf3_result.dart +++ b/lib/domain/models/iperf3_result.dart @@ -3,8 +3,13 @@ class Iperf3Result { final String rawOutput; 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({ required this.rawOutput, required this.exitCode, + this.stopped = false, }); } diff --git a/lib/main.dart b/lib/main.dart index 9a770a3..da2d2fb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_screen.dart'; /// Application entry point. diff --git a/lib/ui/features/test_run/test_run_screen.dart b/lib/ui/features/test_run/test_run_screen.dart index 715ca5f..fb2a85d 100644 --- a/lib/ui/features/test_run/test_run_screen.dart +++ b/lib/ui/features/test_run/test_run_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; @@ -6,8 +7,8 @@ import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; /// Main test run screen. /// -/// iperf3 is now compiled as a native shared library (libiperf3jni.so) -/// and accessed via JNI. No binary extraction or chmod needed. +/// iperf3 is compiled as a native shared library (libiperf3jni.so) and accessed +/// via JNI, so there is no binary extraction or chmod step. class TestRunScreen extends StatelessWidget { const TestRunScreen({super.key}); @@ -30,109 +31,196 @@ class _TestRunContent extends StatefulWidget { } class _TestRunContentState extends State<_TestRunContent> { - final _serverController = TextEditingController(text: 'speedtest.uztelecom.uz'); - final _portController = TextEditingController(text: '5201'); + static const _defaults = TestConfiguration(); + + 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 void dispose() { _serverController.dispose(); _portController.dispose(); + _durationController.dispose(); + _streamsController.dispose(); + _outputScrollController.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 Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('iperf3-gui'), + actions: [ + Consumer( + 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( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Server input TextField( controller: _serverController, + autocorrect: false, decoration: const InputDecoration( labelText: 'Server', hintText: 'e.g. iperf.es.net or 192.168.1.1', ), ), - const SizedBox(height: 16), + const SizedBox(height: 12), - // Port input - TextField( - controller: _portController, - decoration: const InputDecoration( - labelText: 'Port', - hintText: '5201', - ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 16), - - // Go button - Consumer( - 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( - 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, + Row( + children: [ + Expanded( + child: TextField( + controller: _portController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Port'), ), - ); - }, + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _durationController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Seconds'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _streamsController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Streams'), + ), + ), + ], ), const SizedBox(height: 16), - // Raw output text box - Expanded( - child: Consumer( - builder: (context, vm, _) { - final output = vm.result?.rawOutput ?? ''; - return TextField( - controller: TextEditingController(text: output), - decoration: const InputDecoration( - labelText: 'Raw Output', - border: OutlineInputBorder(), - ), - maxLines: null, - readOnly: true, + Consumer( + 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( + 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 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( + builder: (context, vm, _) { + _scrollToBottom(); + return SingleChildScrollView( + controller: _outputScrollController, + child: SelectableText( + vm.output.isEmpty + ? 'Output will appear here.' + : vm.output, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ); + }, + ), ), ), ], diff --git a/lib/ui/features/test_run/test_run_view_model.dart b/lib/ui/features/test_run/test_run_view_model.dart index 04f2aa0..ec267ec 100644 --- a/lib/ui/features/test_run/test_run_view_model.dart +++ b/lib/ui/features/test_run/test_run_view_model.dart @@ -1,15 +1,26 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; -import 'package:iperf3gui/domain/models/test_configuration.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart'; +import 'package:iperf3gui/domain/models/test_configuration.dart'; /// State of the test run. enum TestRunState { idle, running, completed, error } /// ViewModel for managing test run state. +/// +/// Subscribes to [Iperf3Service.outputLines] so the transcript grows while the +/// test runs rather than appearing all at once at the end. class TestRunViewModel extends ChangeNotifier { final Iperf3Service iperf3Service; + TestRunViewModel({required this.iperf3Service}) { + _outputSubscription = iperf3Service.outputLines.listen(_onOutputLine); + } + + StreamSubscription? _outputSubscription; + TestRunState _state = TestRunState.idle; TestRunState get state => _state; @@ -19,12 +30,28 @@ class TestRunViewModel extends ChangeNotifier { TestConfiguration? _currentConfig; TestConfiguration? get currentConfig => _currentConfig; - TestRunViewModel({required this.iperf3Service}); + final StringBuffer _liveOutput = StringBuffer(); + + /// Output accumulated so far. Grows line by line during a run, and holds the + /// finished transcript once the test completes. + String get output => _liveOutput.toString(); + + /// 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. Future runTest(TestConfiguration config) async { + if (_state == TestRunState.running) return; + if (!config.isValid) { + _liveOutput + ..clear() + ..writeln('Invalid configuration: check the server address and port.'); _state = TestRunState.error; + _result = null; notifyListeners(); return; } @@ -32,26 +59,51 @@ class TestRunViewModel extends ChangeNotifier { _state = TestRunState.running; _currentConfig = config; _result = null; + _liveOutput.clear(); notifyListeners(); try { final result = await iperf3Service.run(config); _result = result; - _state = result.exitCode == 0 - ? TestRunState.completed - : TestRunState.error; + // Prefer the streamed transcript — it is what the user watched build up. + // Fall back to the service's copy if nothing streamed through. + if (_liveOutput.isEmpty && result.rawOutput.isNotEmpty) { + _liveOutput.write(result.rawOutput); + } + _state = + result.exitCode == 0 ? TestRunState.completed : TestRunState.error; } catch (e) { + _liveOutput.writeln('Test failed: $e'); _state = TestRunState.error; } finally { notifyListeners(); } } + /// Request that a running test stop early. + Future stopTest() async { + if (_state != TestRunState.running) return; + await iperf3Service.stop(); + } + /// Reset to idle state. void reset() { _state = TestRunState.idle; _result = null; _currentConfig = null; + _liveOutput.clear(); notifyListeners(); } + + void _onOutputLine(String line) { + _liveOutput.write(line.endsWith('\n') ? line : '$line\n'); + notifyListeners(); + } + + @override + void dispose() { + _outputSubscription?.cancel(); + iperf3Service.dispose(); + super.dispose(); + } } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index be1ee3e..2e1de87 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.lock b/pubspec.lock index 9b71df9..a4537c9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" async: dependency: transitive description: @@ -41,14 +33,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -57,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" fake_async: dependency: transitive description: @@ -73,14 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" flutter: dependency: "direct main" description: flutter @@ -99,38 +67,6 @@ packages: description: flutter source: sdk 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: dependency: transitive description: @@ -163,14 +99,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" matcher: dependency: transitive description: @@ -203,22 +131,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -227,70 +139,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -299,22 +147,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: flutter @@ -368,14 +200,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -392,22 +216,6 @@ packages: url: "https://pub.dev" source: hosted 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: dart: ">=3.13.1 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml index f12b5ee..3af8d77 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,6 @@ dependencies: flutter: sdk: flutter - path_provider: ^2.1.4 provider: ^6.1.2 dev_dependencies: diff --git a/test/domain/models/iperf3_result_test.dart b/test/domain/models/iperf3_result_test.dart index 68567d7..89070e7 100644 --- a/test/domain/models/iperf3_result_test.dart +++ b/test/domain/models/iperf3_result_test.dart @@ -11,5 +11,15 @@ void main() { expect(result.rawOutput, equals('Test complete')); 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); + }); }); } diff --git a/test/ui/features/test_run/test_run_view_model_test.dart b/test/ui/features/test_run/test_run_view_model_test.dart index 46b7532..6f83d36 100644 --- a/test/ui/features/test_run/test_run_view_model_test.dart +++ b/test/ui/features/test_run/test_run_view_model_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart'; @@ -7,29 +9,26 @@ import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; void main() { group('TestRunViewModel', () { test('starts in idle state', () { - final vm = TestRunViewModel( - iperf3Service: _MockIperf3Service(), - ); + final vm = TestRunViewModel(iperf3Service: _MockIperf3Service()); expect(vm.state, equals(TestRunState.idle)); expect(vm.result, isNull); expect(vm.currentConfig, isNull); + expect(vm.output, isEmpty); }); test('can be reset', () { - final vm = TestRunViewModel( - iperf3Service: _MockIperf3Service(), - ); + final vm = TestRunViewModel(iperf3Service: _MockIperf3Service()); vm.reset(); expect(vm.state, equals(TestRunState.idle)); }); test('invalid config sets error state without running', () async { - final vm = TestRunViewModel( - iperf3Service: _MockIperf3Service(), - ); + final mock = _MockIperf3Service(); + final vm = TestRunViewModel(iperf3Service: mock); const config = TestConfiguration(server: ''); await vm.runTest(config); expect(vm.state, equals(TestRunState.error)); + expect(mock.runCallCount, equals(0)); }); test('runTest with valid config calls service', () async { @@ -45,17 +44,165 @@ void main() { expect(vm.state, equals(TestRunState.completed)); expect(vm.result, isNotNull); }); + + test('non-zero exit code lands in error state', () async { + final mock = _MockIperf3Service( + 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 lines accumulate into output during the run', () async { + final mock = _MockIperf3Service(); + mock.onRun = (_) async { + mock.emit('Connecting to host 192.168.1.1\n'); + mock.emit('[ 5] 0.00-1.00 sec 11.2 MBytes 94.1 Mbits/sec\n'); + // Let the broadcast stream deliver before the run resolves. + await Future.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')); + 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('falls back to result output when nothing streamed', () async { + final mock = _MockIperf3Service( + 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 = _MockIperf3Service( + 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 = _MockIperf3Service(); + final vm = TestRunViewModel(iperf3Service: mock); + + await vm.stopTest(); + expect(mock.stopCallCount, equals(0), reason: 'idle: nothing to stop'); + + final gate = Completer(); + 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 = _MockIperf3Service( + 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 = _MockIperf3Service( + 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 = _MockIperf3Service(); + final gate = Completer(); + 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 Function(TestConfiguration)? onRun; + Future Function(TestConfiguration)? onRun; + + final StreamController _controller = + StreamController.broadcast(); + + int runCallCount = 0; + int stopCallCount = 0; + bool disposed = false; + bool _isRunning = false; + + /// Push a line as if the native layer had emitted it. + void emit(String line) => _controller.add(line); + + @override + Stream get outputLines => _controller.stream; + + @override + bool get isRunning => _isRunning; @override Future run(TestConfiguration config) async { - if (onRun != null) return onRun!(config); - return const Iperf3Result(rawOutput: '', exitCode: 0); + runCallCount++; + _isRunning = true; + try { + if (onRun != null) return await onRun!(config); + return const Iperf3Result(rawOutput: '', exitCode: 0); + } finally { + _isRunning = false; + } + } + + @override + Future stop() async { + stopCallCount++; + } + + @override + Future dispose() async { + disposed = true; + await _controller.close(); } } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 3ad69c6..b93c4c3 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES)