From e5b6d8ac25b889ae5ac4c0637584ea15eb0dd1b0 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Tue, 17 Mar 2026 15:24:44 +0100 Subject: [PATCH] improve lifecycle management --- .../kotlin/eu/weblibre/gecko/MainActivity.kt | 84 +++++++++++++++++-- .../GeckoPlatformView.kt | 6 +- .../api/GeckoBrowserApiImpl.kt | 23 ++--- .../flutter_tor/PluggableTransportManager.kt | 19 ++++- .../eu/weblibre/flutter_tor/TorManager.kt | 2 +- .../eu/weblibre/flutter_tor/TorService.kt | 17 +++- 6 files changed, 128 insertions(+), 23 deletions(-) diff --git a/app/android/app/src/main/kotlin/eu/weblibre/gecko/MainActivity.kt b/app/android/app/src/main/kotlin/eu/weblibre/gecko/MainActivity.kt index c2085d30..1527c93c 100644 --- a/app/android/app/src/main/kotlin/eu/weblibre/gecko/MainActivity.kt +++ b/app/android/app/src/main/kotlin/eu/weblibre/gecko/MainActivity.kt @@ -20,18 +20,53 @@ package eu.weblibre.gecko import android.content.Context +import android.os.Bundle +import android.util.Log import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.embedding.engine.FlutterJNI import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.plugin.common.MethodChannel class MainActivity: FlutterFragmentActivity() { + companion object { + private const val TAG = "MainActivity" + } + private val TRIM_MEMORY_CHANNEL = "eu.weblibre.flutter_mozilla_components/trim_memory" private val ACTIVITY_CHANNEL = "eu.weblibre.gecko/activity" private val ENGINE_ID = "engine_id" private var trimMemoryChannel: MethodChannel? = null + private fun engineTag(engine: FlutterEngine?): String { + return engine?.let { "0x${System.identityHashCode(it).toString(16)}" } ?: "null" + } + + override fun onCreate(savedInstanceState: Bundle?) { + Log.d(TAG, "onCreate: savedInstanceState=${savedInstanceState != null}, " + + "cachedEngine=${engineTag(FlutterEngineCache.getInstance().get(ENGINE_ID))}") + + super.onCreate(null) + } + + /** + * Check whether the FlutterEngine's native JNI layer is still attached. + * Note: binaryMessenger.send() does NOT throw when JNI is detached — it just + * logs a warning. We must use reflection to access FlutterJNI.isAttachedToJni(). + */ + private fun isEngineNativeAlive(engine: FlutterEngine): Boolean { + return try { + val field = FlutterEngine::class.java.getDeclaredField("flutterJNI") + field.isAccessible = true + val jni = field.get(engine) as FlutterJNI + jni.isAttached + } catch (e: Exception) { + Log.w(TAG, "Could not check JNI attachment state: ${e.message}") + false + } + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -48,6 +83,31 @@ class MainActivity: FlutterFragmentActivity() { } } + override fun onPause() { + Log.d(TAG, "onPause") + super.onPause() + } + + override fun onDestroy() { + Log.d(TAG, "onDestroy: isFinishing=$isFinishing, " + + "cachedEngine=${engineTag(FlutterEngineCache.getInstance().get(ENGINE_ID))}") + super.onDestroy() + + if (!isFinishing) { + val cache = FlutterEngineCache.getInstance() + val engine = cache.get(ENGINE_ID) + if (engine != null) { + Log.d(TAG, "onDestroy: system-initiated destroy, clearing stale engine") + cache.remove(ENGINE_ID) + try { + engine.destroy() + } catch (e: Exception) { + Log.w(TAG, "Error destroying engine in onDestroy", e) + } + } + } + } + override fun onTrimMemory(level: Int) { super.onTrimMemory(level) trimMemoryChannel?.invokeMethod("onTrimMemory", level) @@ -56,13 +116,26 @@ class MainActivity: FlutterFragmentActivity() { override fun provideFlutterEngine(context: Context): FlutterEngine { val cache = FlutterEngineCache.getInstance() val cachedEngine = cache.get(ENGINE_ID) - if (cachedEngine != null && cachedEngine.dartExecutor.isExecutingDart) { - return cachedEngine - } - if (cachedEngine != null) { + val isHealthy = try { + cachedEngine.dartExecutor.isExecutingDart && isEngineNativeAlive(cachedEngine) + } catch (e: Exception) { + Log.w(TAG, "Cached engine health check failed", e) + false + } + + if (isHealthy) { + Log.d(TAG, "provideFlutterEngine: reusing cached engine ${engineTag(cachedEngine)}") + return cachedEngine + } + + Log.w(TAG, "provideFlutterEngine: cached engine ${engineTag(cachedEngine)} is stale, creating fresh") cache.remove(ENGINE_ID) - cachedEngine.destroy() + try { + cachedEngine.destroy() + } catch (e: Exception) { + Log.w(TAG, "Error destroying stale engine", e) + } } val flutterEngine = FlutterEngine(context.applicationContext) @@ -72,6 +145,7 @@ class MainActivity: FlutterFragmentActivity() { ) cache.put(ENGINE_ID, flutterEngine) + Log.d(TAG, "provideFlutterEngine: created new engine ${engineTag(flutterEngine)}") return flutterEngine } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GeckoPlatformView.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GeckoPlatformView.kt index a72994d5..79fac6df 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GeckoPlatformView.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GeckoPlatformView.kt @@ -18,13 +18,15 @@ import io.flutter.plugin.platform.PlatformView import io.flutter.plugin.platform.PlatformViewFactory class GeckoViewFactory( - private val activity: Activity, + private val activityProvider: () -> Activity?, private val containerId: Int, private val flutterEvents: GeckoStateEvents ) : PlatformViewFactory( StandardMessageCodec.INSTANCE) { override fun create(context: Context?, id: Int, args: Any?): PlatformView { - return NativeFragmentView(this.activity, this.containerId, this.flutterEvents) + val activity = activityProvider() + ?: throw IllegalStateException("No activity available when creating GeckoView platform view") + return NativeFragmentView(activity, this.containerId, this.flutterEvents) } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index b7d291ea..b2305161 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -134,26 +134,27 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { _flutterPluginBinding = flutterPluginBinding _flutterEvents = GeckoStateEvents(_flutterPluginBinding.binaryMessenger) + // Register platform view factory once per engine binding. + // The factory resolves the current activity lazily via activityProvider, + // so it always uses the latest activity after recreation/config changes. + _flutterPluginBinding.platformViewRegistry.registerViewFactory( + "eu.weblibre/gecko", GeckoViewFactory( + activityProvider = { this.activity }, + FRAGMENT_CONTAINER_ID, + _flutterEvents + ) + ) + isPlatformViewRegistered = true + isGeckoInitialized = false } fun attachActivity(activity: Activity) { this.activity = activity - - _flutterPluginBinding.platformViewRegistry.registerViewFactory( - "eu.weblibre/gecko", GeckoViewFactory( - activity, - FRAGMENT_CONTAINER_ID, - _flutterEvents - ) - ) - - isPlatformViewRegistered = true } fun detachActivity() { this.activity = null - isPlatformViewRegistered = false } override fun getGeckoVersion(): String { diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PluggableTransportManager.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PluggableTransportManager.kt index 17238210..2946e9f3 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PluggableTransportManager.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/PluggableTransportManager.kt @@ -10,8 +10,14 @@ import java.io.File /** * Manages pluggable transports via IPtProxy * Supports: obfs4, snowflake, meek, webtunnel + * + * IMPORTANT: This is a process-level singleton. The IPtProxy.Controller (Go object bound + * via gomobile) uses reference tracking that breaks if multiple Controller instances are + * created in the same process. By keeping a single PluggableTransportManager (and thus a + * single lazy Controller), we avoid "trackGoRef called with Java refnum" crashes when + * TorService is destroyed and recreated. */ -class PluggableTransportManager(private val context: Context) { +class PluggableTransportManager private constructor(private val context: Context) { companion object { private const val TAG = "PTManager" @@ -23,6 +29,17 @@ class PluggableTransportManager(private val context: Context) { private val SNOWFLAKE_FRONTS = listOf("foursquare.com", "github.githubassets.com") private val SNOWFLAKE_AMP_FRONTS = listOf("www.google.com") private const val SNOWFLAKE_ICE_SERVERS = "stun:stun.l.google.com:19302,stun:stun.antisip.com:3478,stun:stun.bluesip.net:3478,stun:stun.dus.net:3478,stun:stun.epygi.com:3478,stun:stun.sonetel.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.voys.nl:3478" + + @Volatile + private var instance: PluggableTransportManager? = null + + fun getInstance(context: Context): PluggableTransportManager { + return instance ?: synchronized(this) { + instance ?: PluggableTransportManager(context.applicationContext).also { + instance = it + } + } + } } private val stateDir = File(context.cacheDir, "iptproxy") diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt index 02bd3816..303cbd57 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorManager.kt @@ -35,7 +35,7 @@ class TorManager( private var controlConnection: TorControlConnection? = null private var torService: TorService? = null - val pluggableTransportManager = PluggableTransportManager(context) + val pluggableTransportManager = PluggableTransportManager.getInstance(context) private val geoIpManager = GeoIpManager(context) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) diff --git a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorService.kt b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorService.kt index 3f43bbc2..c8bf6614 100644 --- a/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorService.kt +++ b/packages/flutter_tor/android/src/main/kotlin/eu/weblibre/flutter_tor/TorService.kt @@ -199,9 +199,20 @@ class TorService : Service() { super.onDestroy() Log.d(TAG, "Service destroyed") - scope.launch { - torManager?.destroy() - } + // Stop pluggable transports synchronously to release Go references. + // Previously this was scope.launch { torManager?.destroy() } followed by + // scope.cancel(), which meant the cleanup coroutine was immediately cancelled + // and never ran — causing "trackGoRef called with Java refnum" crashes when + // TorService was recreated and tried to create a new IPtProxy.Controller. + // + // Note: We only stop transports here. The PluggableTransportManager singleton + // and its Controller persist across service restarts by design. + // Full TorManager.destroy() is not called because it would deadlock + // (cleanup() uses runBlocking(Dispatchers.Main) while onDestroy runs on Main). + torManager?.pluggableTransportManager?.stopAll() + + torManager = null + logHandler = null scope.cancel() } }