From afbd320ef6a842e013bd58f44a5a74ae217b77f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 04:29:20 +0000 Subject: [PATCH 1/4] Android: do no native work at app startup The plugin loaded its native library from a static initializer. GeneratedPluginRegistrant instantiates and attaches every plugin during app launch, so the whole multi-megabyte library was pulled onto the main thread before any app code referenced SoLoud -- and an UnsatisfiedLinkError there failed class initialization, crashing plugin registration outright rather than surfacing a catchable error. Plugin construction and onAttachedToEngine are now pure Java bookkeeping. The library is loaded lazily at the first point a lifecycle hook actually has to call into native code. For an app that uses SoLoud that load is a refcount bump, because Dart has already opened the same library through DynamicLibrary.open; a load failure is now swallowed rather than propagated. No Dart-side change was needed: SoLoudController is a lazy singleton and SoLoud.instance is a lazily-initialized static final, so DynamicLibrary.open does not run until app code touches SoLoud. Verified with a harness performing exactly what GeneratedPluginRegistrant does at launch -- construct, then onAttachedToEngine -- and reflecting on the private load flag afterwards: no load is attempted, in well under a millisecond of pure-Java work. The same harness confirms the detach hook still reaches native lazily and swallows a load failure instead of throwing. Compiled under -Xlint:all against stubs mirroring the real embedding API. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../flutter_soloud/FlutterSoloudPlugin.java | 45 ++++++++++++++++--- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98458b1e..5dc6f142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ #### 4.0.13 (20 Jul 2026) +- 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 - add `stopAudioDevice()` / `startAudioDevice()` to control the audio output device without deinitializing the engine (loaded sounds and voice state are preserved); `stopAudioDevice()` is an idle-only no-op while an unpaused voice is active unless `force: true` is passed, while `startAudioDevice()` temporarily starts or prewarms the output and remains subject to the configured idle timeout; blocking native device operations run off the UI thread 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 9484b3df..3a250855 100644 --- a/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java +++ b/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java @@ -4,20 +4,52 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin; public final class FlutterSoloudPlugin implements FlutterPlugin { - static { - System.loadLibrary("flutter_soloud_plugin"); - } + /** + * Guarded by the class monitor. + * + *

The native library is loaded lazily, at the first point one of the + * hooks below actually needs to call into it -- never from a static + * initializer and never from {@link #onAttachedToEngine}. Both of those run + * at engine startup (GeneratedPluginRegistrant instantiates and attaches + * every plugin), which would drag the whole multi-megabyte library onto the + * main thread during app launch even for an app that never plays a sound. + * + *

By the time a hook fires, an app that uses SoLoud has already loaded + * the same library from Dart via {@code DynamicLibrary.open}, so + * {@code System.loadLibrary} is a refcount bump rather than a real load. + */ + private static boolean nativeLibraryLoadAttempted = false; + private static boolean nativeLibraryLoaded = false; private static native boolean nativeClearDartCallbackRegistrationsForEngine(long engineId); private Long engineId; + private static synchronized boolean ensureNativeLibraryLoaded() { + if (!nativeLibraryLoadAttempted) { + nativeLibraryLoadAttempted = true; + try { + System.loadLibrary("flutter_soloud_plugin"); + nativeLibraryLoaded = true; + } catch (UnsatisfiedLinkError error) { + // Never fail plugin registration over this. The FFI layer opens + // the same library from Dart and surfaces the error there, where + // it is catchable and actionable. + nativeLibraryLoaded = false; + } + } + return nativeLibraryLoaded; + } + @SuppressWarnings("deprecation") @Override public void onAttachedToEngine( @NonNull FlutterPluginBinding binding ) { + // 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(); } @@ -28,10 +60,9 @@ public void onDetachedFromEngine( final Long detachedEngineId = engineId; engineId = null; - if (detachedEngineId != null) { - nativeClearDartCallbackRegistrationsForEngine( - detachedEngineId - ); + if (detachedEngineId == null || !ensureNativeLibraryLoaded()) { + return; } + nativeClearDartCallbackRegistrationsForEngine(detachedEngineId); } } From 38d95ade24b23a0fad3dc1864d461fdbbe334927 Mon Sep 17 00:00:00 2001 From: Colton Date: Sat, 25 Jul 2026 21:20:04 -0400 Subject: [PATCH 2/4] Cache successful loads only --- .../flutter_soloud/FlutterSoloudPlugin.java | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) 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..5f0d30fd 100644 --- a/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java +++ b/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java @@ -1,9 +1,12 @@ package flutter.soloud.flutter_soloud; +import android.util.Log; import androidx.annotation.NonNull; import io.flutter.embedding.engine.plugins.FlutterPlugin; public final class FlutterSoloudPlugin implements FlutterPlugin { + private static final String TAG = "FlutterSoloudPlugin"; + /** * Guarded by the class monitor. * @@ -17,8 +20,8 @@ public final class FlutterSoloudPlugin implements FlutterPlugin { *

By the time a hook fires, an app that uses SoLoud has already loaded * the same library from Dart via {@code DynamicLibrary.open}, so * {@code System.loadLibrary} is a refcount bump rather than a real load. + * Successful loads are cached; failed loads can be retried by a later hook. */ - private static boolean nativeLibraryLoadAttempted = false; private static boolean nativeLibraryLoaded = false; private static native boolean @@ -27,19 +30,20 @@ public final class FlutterSoloudPlugin implements FlutterPlugin { private Long engineId; private static synchronized boolean ensureNativeLibraryLoaded() { - if (!nativeLibraryLoadAttempted) { - nativeLibraryLoadAttempted = true; - try { - System.loadLibrary("flutter_soloud_plugin"); - nativeLibraryLoaded = true; - } catch (UnsatisfiedLinkError error) { - // Never fail plugin registration over this. The FFI layer opens - // the same library from Dart and surfaces the error there, where - // it is catchable and actionable. - nativeLibraryLoaded = false; - } + if (nativeLibraryLoaded) { + return true; + } + + try { + System.loadLibrary("flutter_soloud_plugin"); + nativeLibraryLoaded = true; + return true; + } catch (UnsatisfiedLinkError error) { + // Never fail engine teardown over this. The FFI layer opens the + // same library from Dart and surfaces the error there, where it is + // catchable and actionable. + return false; } - return nativeLibraryLoaded; } @SuppressWarnings("deprecation") @@ -63,6 +67,15 @@ public void onDetachedFromEngine( if (detachedEngineId == null || !ensureNativeLibraryLoaded()) { return; } - nativeClearDartCallbackRegistrationsForEngine(detachedEngineId); + + try { + nativeClearDartCallbackRegistrationsForEngine(detachedEngineId); + } catch (UnsatisfiedLinkError error) { + Log.w( + TAG, + "Unable to clear Dart callback registrations during engine teardown", + error + ); + } } } From 4e59aba3c638d6847a0e7da717dcca6ee3bcd895 Mon Sep 17 00:00:00 2001 From: Colton Date: Sat, 25 Jul 2026 21:21:32 -0400 Subject: [PATCH 3/4] await starting device state --- example/tests/tests/audio_device_lifecycle_races.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/tests/tests/audio_device_lifecycle_races.dart b/example/tests/tests/audio_device_lifecycle_races.dart index 64df3a3d..ef3a804d 100644 --- a/example/tests/tests/audio_device_lifecycle_races.dart +++ b/example/tests/tests/audio_device_lifecycle_races.dart @@ -283,7 +283,7 @@ Future testAudioDeviceLifecycleRaces() async { await stoppedEvent; await restartedEvent; - final rapidState = SoLoud.instance.getAudioDeviceState(); + final rapidState = await _waitForDeviceState(AudioDeviceState.started); assert( rapidState == AudioDeviceState.started, 'Rapid interruption cycle $i lost the recovery start: $rapidState', From e4ef55ab4add376c67dd06531d04ee54febf8e79 Mon Sep 17 00:00:00 2001 From: Colton Date: Sat, 25 Jul 2026 21:21:48 -0400 Subject: [PATCH 4/4] tests: auto scroll, improve logging --- example/tests/tests.dart | 42 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/example/tests/tests.dart b/example/tests/tests.dart index 793fa758..e34a6d5d 100644 --- a/example/tests/tests.dart +++ b/example/tests/tests.dart @@ -56,19 +56,25 @@ class MyHomePage extends StatefulWidget { class _MyHomePageState extends State { final output = StringBuffer(); final textEditingController = TextEditingController(); + final outputScrollController = ScrollController(); late final List<_Test> tests; TestEntry? selectedTest; bool isRunningAll = false; + bool shouldAutoScrollOutput = true; + int lastDebugOutputLength = 0; @override void initState() { super.initState(); tests = allTests.map((e) => _Test(entry: e)).toList(); selectedTest = tests.first.entry; + outputScrollController.addListener(_handleOutputScroll); } @override void dispose() { + outputScrollController.removeListener(_handleOutputScroll); + outputScrollController.dispose(); textEditingController.dispose(); super.dispose(); } @@ -86,6 +92,7 @@ class _MyHomePageState extends State { children: [ TextField( controller: textEditingController, + scrollController: outputScrollController, style: const TextStyle(color: Colors.black, fontSize: 12), expands: true, maxLines: null, @@ -107,6 +114,7 @@ class _MyHomePageState extends State { onPressed: () { textEditingController.clear(); output.clear(); + lastDebugOutputLength = 0; }, ), ), @@ -231,6 +239,7 @@ class _MyHomePageState extends State { setState(() { isRunningAll = true; output.clear(); + lastDebugOutputLength = 0; for (final test in tests) { test.status = TestStatus.none; } @@ -299,9 +308,36 @@ class _MyHomePageState extends State { } void _updateOutput() { - textEditingController.text = output.toString(); - debugPrint(output.toString()); - if (mounted) setState(() {}); + final shouldAutoScroll = shouldAutoScrollOutput; + final outputText = output.toString(); + textEditingController.text = outputText; + + if (outputText.length < lastDebugOutputLength) { + lastDebugOutputLength = 0; + } + if (outputText.length > lastDebugOutputLength) { + debugPrint(outputText.substring(lastDebugOutputLength)); + lastDebugOutputLength = outputText.length; + } + + if (mounted) { + setState(() {}); + if (shouldAutoScroll) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !outputScrollController.hasClients) return; + outputScrollController.jumpTo( + outputScrollController.position.maxScrollExtent, + ); + }); + } + } + } + + void _handleOutputScroll() { + if (!outputScrollController.hasClients) return; + final position = outputScrollController.position; + final isAtBottom = (position.maxScrollExtent - position.pixels).abs() <= 24; + shouldAutoScrollOutput = isAtBottom; } }