From bbc192054b7246b1f13293445df584fcb680599e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 04:29:41 +0000 Subject: [PATCH] Android: clear stale callback registrations on hot restart onDetachedFromEngine does not fire on hot restart. The FlutterEngine and its id are unchanged while the Dart isolate is replaced, so every registered NativeCallable silently goes stale with no notification -- and invoking a callable whose isolate is gone is undefined behaviour. The plugin now registers a FlutterEngine.EngineLifecycleListener and clears the registrations in onPreEngineRestart(). onEngineWillDestroy() clears too; it fires just before the plugin registry is destroyed, while the engine is still valid, so it is a slightly earlier point than the detach hook. The listener is removed on detach. Only the callback bridges are cleared here, not the whole engine: after a hot restart the new isolate's init() finds the native engine still initialized and deinits it itself. Compiled under -Xlint:all against stubs mirroring the real embedding API, and the JNI symbol name diffed against javac -h output. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../flutter_soloud/FlutterSoloudPlugin.java | 52 ++++++++++++++++--- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc6f142..8b95081b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ #### 4.0.13 (20 Jul 2026) +- fix: Android hot restart now clears stale Dart callback registrations. Hot restart replaces the isolate without detaching plugins, so the registered `NativeCallable`s silently went stale - Android: the plugin now does no native work at app startup. Plugin registration and `onAttachedToEngine` are pure Java bookkeeping; the native library is loaded lazily, only when an engine-lifecycle hook actually has to call into it. Previously a static initializer pulled the whole library onto the main thread during app launch even for apps that never played a sound, and a load failure there crashed plugin registration - fix: Waveform audio sources do not match engine sample rate #501. Thanks to @Colton127 - Android now stops the audio device when idle (no active voices) like every other platform, releasing the audioserver `AudioMix` partial wakelock #250; use `setAudioDeviceIdleTimeout()` to keep it running diff --git a/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java b/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java index 3a250855..e517fdc6 100644 --- a/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java +++ b/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java @@ -1,6 +1,8 @@ package flutter.soloud.flutter_soloud; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.embedding.engine.FlutterEngine; import io.flutter.embedding.engine.plugins.FlutterPlugin; public final class FlutterSoloudPlugin implements FlutterPlugin { @@ -24,7 +26,9 @@ public final class FlutterSoloudPlugin implements FlutterPlugin { private static native boolean nativeClearDartCallbackRegistrationsForEngine(long engineId); - private Long engineId; + @Nullable private FlutterEngine flutterEngine; + @Nullable private Long engineId; + @Nullable private FlutterEngine.EngineLifecycleListener lifecycleListener; private static synchronized boolean ensureNativeLibraryLoaded() { if (!nativeLibraryLoadAttempted) { @@ -49,20 +53,56 @@ public void onAttachedToEngine( ) { // Deliberately does no native work. This runs during app launch for // every app that depends on the plugin, whether or not it ever uses - // SoLoud, so it must stay pure Java bookkeeping. - engineId = binding.getFlutterEngine().getEngineId(); + // SoLoud, so it must stay pure Java bookkeeping: read the engine id and + // register a listener. + final FlutterEngine engine = binding.getFlutterEngine(); + flutterEngine = engine; + engineId = engine.getEngineId(); + + lifecycleListener = new FlutterEngine.EngineLifecycleListener() { + @Override + public void onPreEngineRestart() { + // Hot restart replaces the Dart isolate but does not detach + // plugins, and the engine id is unchanged -- so without this the + // registered NativeCallables silently go stale. Only the bridges + // are cleared: the new isolate's init() finds the native engine + // still initialized and deinits it itself. + clearDartCallbackRegistrations(); + } + + @Override + public void onEngineWillDestroy() { + // Fires just before the plugin registry is destroyed, while the + // engine is still valid. + clearDartCallbackRegistrations(); + } + }; + engine.addEngineLifecycleListener(lifecycleListener); } @Override public void onDetachedFromEngine( @NonNull FlutterPluginBinding binding ) { - final Long detachedEngineId = engineId; + final FlutterEngine engine = flutterEngine; + final FlutterEngine.EngineLifecycleListener listener = lifecycleListener; + + if (engine != null && listener != null) { + engine.removeEngineLifecycleListener(listener); + } + + clearDartCallbackRegistrations(); + + flutterEngine = null; engineId = null; + lifecycleListener = null; + } - if (detachedEngineId == null || !ensureNativeLibraryLoaded()) { + private void clearDartCallbackRegistrations() { + final Long id = engineId; + if (id == null || !ensureNativeLibraryLoaded()) { return; } - nativeClearDartCallbackRegistrationsForEngine(detachedEngineId); + nativeClearDartCallbackRegistrationsForEngine(id); } }