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..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,23 +1,59 @@ 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 { - static { - System.loadLibrary("flutter_soloud_plugin"); - } + private static final String TAG = "FlutterSoloudPlugin"; + + /** + * 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. + * Successful loads are cached; failed loads can be retried by a later hook. + */ + private static boolean nativeLibraryLoaded = false; private static native boolean nativeClearDartCallbackRegistrationsForEngine(long engineId); private Long engineId; + private static synchronized boolean ensureNativeLibraryLoaded() { + 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; + } + } + @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,9 +64,17 @@ public void onDetachedFromEngine( final Long detachedEngineId = engineId; engineId = null; - if (detachedEngineId != null) { - nativeClearDartCallbackRegistrationsForEngine( - detachedEngineId + if (detachedEngineId == null || !ensureNativeLibraryLoaded()) { + return; + } + + try { + nativeClearDartCallbackRegistrationsForEngine(detachedEngineId); + } catch (UnsatisfiedLinkError error) { + Log.w( + TAG, + "Unable to clear Dart callback registrations during engine teardown", + error ); } } 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; } } 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',