improve lifecycle management

This commit is contained in:
Fabian Freund
2026-03-17 15:24:44 +01:00
parent afe79ac7f7
commit e5b6d8ac25
6 changed files with 128 additions and 23 deletions
@@ -20,18 +20,53 @@
package eu.weblibre.gecko package eu.weblibre.gecko
import android.content.Context import android.content.Context
import android.os.Bundle
import android.util.Log
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.FlutterJNI
import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterFragmentActivity() { class MainActivity: FlutterFragmentActivity() {
companion object {
private const val TAG = "MainActivity"
}
private val TRIM_MEMORY_CHANNEL = "eu.weblibre.flutter_mozilla_components/trim_memory" private val TRIM_MEMORY_CHANNEL = "eu.weblibre.flutter_mozilla_components/trim_memory"
private val ACTIVITY_CHANNEL = "eu.weblibre.gecko/activity" private val ACTIVITY_CHANNEL = "eu.weblibre.gecko/activity"
private val ENGINE_ID = "engine_id" private val ENGINE_ID = "engine_id"
private var trimMemoryChannel: MethodChannel? = null 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) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(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) { override fun onTrimMemory(level: Int) {
super.onTrimMemory(level) super.onTrimMemory(level)
trimMemoryChannel?.invokeMethod("onTrimMemory", level) trimMemoryChannel?.invokeMethod("onTrimMemory", level)
@@ -56,13 +116,26 @@ class MainActivity: FlutterFragmentActivity() {
override fun provideFlutterEngine(context: Context): FlutterEngine { override fun provideFlutterEngine(context: Context): FlutterEngine {
val cache = FlutterEngineCache.getInstance() val cache = FlutterEngineCache.getInstance()
val cachedEngine = cache.get(ENGINE_ID) val cachedEngine = cache.get(ENGINE_ID)
if (cachedEngine != null && cachedEngine.dartExecutor.isExecutingDart) {
return cachedEngine
}
if (cachedEngine != null) { 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) 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) val flutterEngine = FlutterEngine(context.applicationContext)
@@ -72,6 +145,7 @@ class MainActivity: FlutterFragmentActivity() {
) )
cache.put(ENGINE_ID, flutterEngine) cache.put(ENGINE_ID, flutterEngine)
Log.d(TAG, "provideFlutterEngine: created new engine ${engineTag(flutterEngine)}")
return flutterEngine return flutterEngine
} }
@@ -18,13 +18,15 @@ import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory import io.flutter.plugin.platform.PlatformViewFactory
class GeckoViewFactory( class GeckoViewFactory(
private val activity: Activity, private val activityProvider: () -> Activity?,
private val containerId: Int, private val containerId: Int,
private val flutterEvents: GeckoStateEvents private val flutterEvents: GeckoStateEvents
) : PlatformViewFactory( ) : PlatformViewFactory(
StandardMessageCodec.INSTANCE) { StandardMessageCodec.INSTANCE) {
override fun create(context: Context?, id: Int, args: Any?): PlatformView { 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)
} }
} }
@@ -134,26 +134,27 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
_flutterPluginBinding = flutterPluginBinding _flutterPluginBinding = flutterPluginBinding
_flutterEvents = GeckoStateEvents(_flutterPluginBinding.binaryMessenger) _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 isGeckoInitialized = false
} }
fun attachActivity(activity: Activity) { fun attachActivity(activity: Activity) {
this.activity = activity this.activity = activity
_flutterPluginBinding.platformViewRegistry.registerViewFactory(
"eu.weblibre/gecko", GeckoViewFactory(
activity,
FRAGMENT_CONTAINER_ID,
_flutterEvents
)
)
isPlatformViewRegistered = true
} }
fun detachActivity() { fun detachActivity() {
this.activity = null this.activity = null
isPlatformViewRegistered = false
} }
override fun getGeckoVersion(): String { override fun getGeckoVersion(): String {
@@ -10,8 +10,14 @@ import java.io.File
/** /**
* Manages pluggable transports via IPtProxy * Manages pluggable transports via IPtProxy
* Supports: obfs4, snowflake, meek, webtunnel * 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 { companion object {
private const val TAG = "PTManager" 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_FRONTS = listOf("foursquare.com", "github.githubassets.com")
private val SNOWFLAKE_AMP_FRONTS = listOf("www.google.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" 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") private val stateDir = File(context.cacheDir, "iptproxy")
@@ -35,7 +35,7 @@ class TorManager(
private var controlConnection: TorControlConnection? = null private var controlConnection: TorControlConnection? = null
private var torService: TorService? = null private var torService: TorService? = null
val pluggableTransportManager = PluggableTransportManager(context) val pluggableTransportManager = PluggableTransportManager.getInstance(context)
private val geoIpManager = GeoIpManager(context) private val geoIpManager = GeoIpManager(context)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -199,9 +199,20 @@ class TorService : Service() {
super.onDestroy() super.onDestroy()
Log.d(TAG, "Service destroyed") Log.d(TAG, "Service destroyed")
scope.launch { // Stop pluggable transports synchronously to release Go references.
torManager?.destroy() // 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() scope.cancel()
} }
} }