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 + } +}