From 2b3f20d3a02ec585876d2d6d200d1b8728d5d552 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 22:43:35 +0900 Subject: [PATCH 1/2] fix(android): keep the JNI callback names through R8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release builds aborted on the first callback of every test: NoSuchMethodError: no non-static method "Lp0;.onOutput(Ljava/lang/String;)V" R8 had renamed MainThreadCallback to p0 along with its methods, so GetMethodID(env, cls, "onOutput", ...) in iperf_jni.c resolved nothing. The names are a contract between C and Kotlin that the minifier cannot see. Adds keep rules for IperfRunner, the IperfCallback interface and — the part that matters — everything implementing it, since the lookup happens on the concrete object's class rather than on the interface. A general rule keeps any class declaring a native method, so a future one is covered without a new rule. Debug builds do not minify, which is why every on-device check so far passed while the shipping artifact was broken. Found by running the release APK during release preparation. Co-Authored-By: Claude Opus 5 --- android/app/build.gradle.kts | 7 +++++++ android/app/proguard-rules.pro | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 android/app/proguard-rules.pro diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 04af821..6a15324 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -37,6 +37,13 @@ android { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") + + // proguard-rules.pro keeps the names JNI resolves at runtime. + // Without it a release build aborts on the first callback. + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..9f188aa --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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 ; +} From ff13d633338aa3767b92ff6729ad4912afc5c78d Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 22:43:35 +0900 Subject: [PATCH 2/2] fix(jni): fail cleanly when a callback method does not resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetMethodID raises NoSuchMethodError when a lookup fails, and any further JNI call made while an exception is pending aborts the process. runIperfLive resolved three method IDs back to back without checking, so the first failure made the second call fatal — turning a diagnosable configuration problem into a SIGABRT with a stack that pointed at the JNI entry rather than the cause. resolve_method() clears the pending exception and returns NULL, and the caller now bails out with a log naming which lookups failed and pointing at the keep rules. The R8 problem this exposed is fixed separately; this is about the failure being reportable rather than fatal, whatever the cause. Co-Authored-By: Claude Opus 5 --- android/app/src/main/cpp/iperf_jni.c | 33 +++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/cpp/iperf_jni.c b/android/app/src/main/cpp/iperf_jni.c index ec6dca4..caae63c 100644 --- a/android/app/src/main/cpp/iperf_jni.c +++ b/android/app/src/main/cpp/iperf_jni.c @@ -86,6 +86,21 @@ struct iperf_run_state { * iperf_run_client() and cleared after it returns. */ static struct iperf_run_state *g_current_state = NULL; +/* Resolve a method ID, clearing the exception a failed lookup leaves behind. + * + * GetMethodID raises NoSuchMethodError on failure, and any further JNI call + * made while an exception is pending aborts the process. Returning NULL and + * clearing lets the caller fail on its own terms. */ +static jmethodID resolve_method(JNIEnv *env, jclass cls, + const char *name, const char *sig) { + jmethodID mid = (*env)->GetMethodID(env, cls, name, sig); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + return NULL; + } + return mid; +} + /* Helper: invoke the output callback via CallVoidMethod */ static void signal_output(JNIEnv *env, struct iperf_run_state *state, const char *msg) { @@ -272,13 +287,25 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive( /* Resolve callback method IDs up front so failures can be reported. */ jclass callback_cls = (*env)->GetObjectClass(env, callback); - jmethodID mid_output = (*env)->GetMethodID( + jmethodID mid_output = resolve_method( env, callback_cls, "onOutput", "(Ljava/lang/String;)V"); - jmethodID mid_error = (*env)->GetMethodID( + jmethodID mid_error = resolve_method( env, callback_cls, "onError", "(Ljava/lang/String;)V"); - jmethodID mid_complete = (*env)->GetMethodID( + jmethodID mid_complete = resolve_method( env, callback_cls, "onComplete", "()V"); + /* If the names did not resolve there is no way to report anything: the + * reporting path is the thing that is missing. Bail out loudly instead. + * + * In practice this means R8 renamed the callback in a release build and + * android/app/proguard-rules.pro has stopped covering it. */ + if (!mid_output || !mid_error || !mid_complete) { + LOGE("Callback methods did not resolve (onOutput=%p onError=%p " + "onComplete=%p) — check the -keep rules for IperfCallback", + mid_output, mid_error, mid_complete); + return; + } + /* Report a startup failure directly on the callback object and finish, * so Dart's completer always resolves instead of waiting for a timeout. */ #define FAIL_EARLY(msg) do { \