diff --git a/CHANGELOG.md b/CHANGELOG.md
index d705466a..a9324764 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
#### 4.0.13 (20 Jul 2026)
+- fix: a failed audio device start now rebuilds the device and retries once instead of returning an unrecoverable error. The backend keeps the output stream open across an idle stop, so a device stopped for a while can hold a stream the OS has since invalidated; on Android AAudio only reports a disconnect through the error callback of a *running* stream, so a stream torn down while stopped is never rerouted and the staleness surfaces as `startAudioDevice()` failing with an unknown error. Voices and loaded sources live in SoLoud rather than the device, so playback resumes where it left off
+- fix: `startAudioDevice()` no longer reports success while leaving the device stopped when an OS interruption is still flagged as active. iOS does not reliably deliver `AVAudioSessionInterruptionTypeEnded` -- notably when the interruption ends while the app is backgrounded -- and the flag was otherwise cleared only by `init()`/`deinit()`, so one missed notification made every later start a silent no-op for the life of the engine. An explicit start is now authoritative: it clears the flag and performs the start, surfacing a real error if the OS still refuses
- fix: the voice-ended callback is no longer invoked while SoLoud's audio mutex is held. It ran there via `stopVoice_internal()` and reached back into `Player` state, which deadlocked against `disposeSound()` (which holds `sounds_mutex` across `soloud.stop()`). The symptom was a wedged engine: handles and sources still looked valid, no audio was produced, and `deinit()` never completed. Ended voices are now queued and dispatched once the mutex is released
+- Android: destroying a `FlutterEngine` while the process keeps running (foreground-service apps such as `audio_service`) now tears the native engine down instead of leaving an initialized engine and a running output device with no Dart left to drive them. The blocking part runs on a native worker thread. A `FlutterEngine` claims the native engine for the whole of its initialization, so an engine destroyed mid-init still tears down what it built, and a detaching engine can neither dispose nor cancel the initialization of a replacement that has already claimed it
+- fix: detaching a `FlutterEngine` on Android no longer blocks the platform thread on the engine-teardown mutex, which could ANR if a device operation was in flight
+- 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 5f0d30fd..fb3ebf4b 100644
--- a/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java
+++ b/android/src/main/java/flutter/soloud/flutter_soloud/FlutterSoloudPlugin.java
@@ -2,8 +2,25 @@
import android.util.Log;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
+/**
+ * Keeps flutter_soloud's process-global native state in step with the lifetime
+ * of the FlutterEngine that owns it.
+ *
+ *
The native engine (the SoLoud player, its output device, its lifecycle
+ * scheduler and the registered Dart callback pointers) lives for the whole
+ * process, while the Dart isolate that drives it belongs to a single
+ * FlutterEngine. When an engine goes away but the process keeps running --
+ * routine for a foreground-service audio app, e.g. audio_service -- native code
+ * would otherwise keep calling into NativeCallables whose isolate is gone
+ * (undefined behaviour) and keep an output device running with nothing left to
+ * control it. Only the embedder can observe that transition: Dart's
+ * {@code detached} lifecycle state is not guaranteed to arrive first, and there
+ * is no reliable root-isolate exit hook.
+ */
public final class FlutterSoloudPlugin implements FlutterPlugin {
private static final String TAG = "FlutterSoloudPlugin";
@@ -27,7 +44,18 @@ public final class FlutterSoloudPlugin implements FlutterPlugin {
private static native boolean
nativeClearDartCallbackRegistrationsForEngine(long engineId);
- private Long engineId;
+ private static native boolean
+ nativeRequestEngineTeardownForEngine(long engineId);
+
+ @Nullable private FlutterEngine flutterEngine;
+ @Nullable private Long engineId;
+ @Nullable private FlutterEngine.EngineLifecycleListener lifecycleListener;
+
+ /**
+ * onEngineWillDestroy() and onDetachedFromEngine() both fire on a real
+ * engine destroy; the teardown must only be requested once.
+ */
+ private boolean teardownRequested = false;
private static synchronized boolean ensureNativeLibraryLoaded() {
if (nativeLibraryLoaded) {
@@ -53,27 +81,103 @@ 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. Nothing here loads the native library, opens a
+ // device, or starts a thread.
+ final FlutterEngine engine = binding.getFlutterEngine();
+ flutterEngine = engine;
+ engineId = engine.getEngineId();
+ teardownRequested = false;
+
+ 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. The engine
+ // is still valid here, so this is the earliest safe point.
+ requestEngineTeardown();
+ }
+ };
+ 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);
+ }
+
+ // Requested here too: onEngineWillDestroy() is not reached on every
+ // detach path, and requestEngineTeardown() is idempotent.
+ requestEngineTeardown();
+
+ flutterEngine = null;
engineId = null;
+ lifecycleListener = null;
+ }
+
+ private void clearDartCallbackRegistrations() {
+ final Long id = engineId;
+ if (id == null || !ensureNativeLibraryLoaded()) {
+ return;
+ }
+
+ try {
+ nativeClearDartCallbackRegistrationsForEngine(id);
+ } catch (UnsatisfiedLinkError error) {
+ Log.w(
+ TAG,
+ "Unable to clear Dart callback registrations",
+ error
+ );
+ }
+ }
+
+ /**
+ * Drops the Dart bridges and asks native code to tear the engine down. The
+ * blocking part of the teardown (stopping the device, joining the lifecycle
+ * scheduler) runs on a native worker thread, so this returns promptly and
+ * never blocks the platform thread.
+ */
+ private void requestEngineTeardown() {
+ final Long id = engineId;
+ if (id == null || teardownRequested) {
+ return;
+ }
- if (detachedEngineId == null || !ensureNativeLibraryLoaded()) {
+ // Marked as requested only once native code has accepted it. Setting it
+ // up front would make a failed library load, a failed JNI call, or a
+ // native worker that could not be spawned terminal: onDetachedFromEngine()
+ // is the retry for a hook that ran too early or hit a load that can still
+ // succeed later, and it would have found the flag already set.
+ //
+ // A native `false` is not always retryable -- it also means another
+ // engine owns the native engine, or nothing is claimed -- but retrying
+ // those costs one rejected call and keeps the recoverable cases working.
+ if (!ensureNativeLibraryLoaded()) {
return;
}
try {
- nativeClearDartCallbackRegistrationsForEngine(detachedEngineId);
+ teardownRequested = nativeRequestEngineTeardownForEngine(id);
} catch (UnsatisfiedLinkError error) {
Log.w(
TAG,
- "Unable to clear Dart callback registrations during engine teardown",
+ "Unable to request native engine teardown",
error
);
}
diff --git a/example/tests/tests/audio_device_lifecycle_races.dart b/example/tests/tests/audio_device_lifecycle_races.dart
index ef3a804d..9d822d0c 100644
--- a/example/tests/tests/audio_device_lifecycle_races.dart
+++ b/example/tests/tests/audio_device_lifecycle_races.dart
@@ -333,6 +333,34 @@ Future testAudioDeviceLifecycleRaces() async {
);
output.writeln('Idle/keep-alive interruption policies: OK');
+ // An interruption whose "ended" notification never arrives must not wedge
+ // the device permanently. iOS does not reliably deliver
+ // AVAudioSessionInterruptionTypeEnded -- notably when the interruption ends
+ // while the app is backgrounded -- and the interruption flag is otherwise
+ // only cleared by init()/deinit(). While it was latched, startAudioDevice()
+ // returned success without touching the device, so the app saw a healthy
+ // API and silence. An explicit start is authoritative and must recover.
+ SoLoud.instance.setAudioDeviceIdleTimeout(null);
+ state = await _waitForDeviceState(AudioDeviceState.started);
+ assert(
+ state == AudioDeviceState.started,
+ 'Keep-alive did not start device before the missed-end check.',
+ );
+ SoLoudController().soLoudFFI.debugTriggerAudioInterruption(began: true);
+ state = await _waitForDeviceState(AudioDeviceState.stopped);
+ assert(
+ state == AudioDeviceState.stopped,
+ 'Interruption did not stop device before the missed-end check.',
+ );
+ // Deliberately no matching `began: false`: that is the dropped one.
+ await SoLoud.instance.startAudioDevice();
+ state = await _waitForDeviceState(AudioDeviceState.started);
+ assert(
+ state == AudioDeviceState.started,
+ 'Explicit start did not recover from a missed interruption end.',
+ );
+ output.writeln('Explicit start recovers a missed interruption end: OK');
+
await SoLoud.instance.stop(handle);
await SoLoud.instance.disposeSource(waveform);
diff --git a/lib/src/bindings/bindings_player_ffi.dart b/lib/src/bindings/bindings_player_ffi.dart
index 20117046..9cbf7660 100644
--- a/lib/src/bindings/bindings_player_ffi.dart
+++ b/lib/src/bindings/bindings_player_ffi.dart
@@ -395,12 +395,21 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
>('initEngine');
@override
- void prepareEngineInit() => _prepareEngineInit();
+ void prepareEngineInit() {
+ // Claims the native engine for this FlutterEngine for the whole of the
+ // initialization that follows, so an engine destroyed mid-init can still
+ // tear down what it built. Read the same way as in
+ // [setDartEventCallbacks]; -1 on platforms that expose no engine id, which
+ // are also the platforms with no engine-lifecycle hooks to act on it.
+ _prepareEngineInit(ui.PlatformDispatcher.instance.engineId ?? -1);
+ }
late final _prepareEngineInitPtr =
- _lookup>('prepareEngineInit');
+ _lookup>(
+ 'prepareEngineInit',
+ );
late final _prepareEngineInit = _prepareEngineInitPtr
- .asFunction();
+ .asFunction();
@override
void requestEngineShutdown() => _requestEngineShutdown();
diff --git a/src/bindings.cpp b/src/bindings.cpp
index 16cc4e91..b9ffadd7 100644
--- a/src/bindings.cpp
+++ b/src/bindings.cpp
@@ -26,6 +26,7 @@
#include
#include
#include
+#include
std::mutex dart_callback_invocation_mutex;
@@ -33,11 +34,75 @@ std::mutex dart_callback_invocation_mutex;
// never be held while joining the lifecycle scheduler, starting or stopping
// the audio device, disposing Player, or destroying native audio sources.
+// Defined below with the other C-linkage globals. Declared here so the engine
+// lifecycle helpers can raise it while holding engine_lifecycle_mutex.
+extern "C" std::atomic engine_shutdown_requested;
+
namespace {
-constexpr int64_t kNoDartCallbackOwnerEngineId = -1;
+constexpr int64_t kNoEngineId = -1;
+
+// Protected by dart_callback_invocation_mutex. Decides only whose *callable
+// pointers* may be cleared -- never whether the engine may be torn down. It is
+// unset for the whole of an initialization, because Dart registers callbacks
+// only after initEngine() has returned.
+int64_t dartCallbackOwnerEngineId = kNoEngineId;
+
+// Which FlutterEngine currently claims the native engine, and a counter
+// advanced by every prepareEngineInit(). Claimed at the *start* of an
+// initialization rather than when callbacks register, so the engine has a
+// lifecycle owner during the whole init -- including the long window where
+// initEngine() has opened the device but Dart has not registered callbacks yet.
+//
+// Both fields are read and written together, so they are guarded by a mutex
+// rather than made individually atomic: a teardown must observe a consistent
+// (owner, generation) pair, and no interleaving of two atomic loads gives that.
+// This is a leaf lock -- never held while acquiring another lock or performing
+// any blocking work.
+std::mutex engine_lifecycle_mutex;
+int64_t nativeInitOwnerEngineId = kNoEngineId;
+uint64_t engineInitGeneration = 0;
+
+struct EngineLifecycleClaim {
+ int64_t ownerEngineId;
+ uint64_t generation;
+};
+
+EngineLifecycleClaim currentEngineLifecycleClaim() {
+ std::lock_guard guard(engine_lifecycle_mutex);
+ return EngineLifecycleClaim{nativeInitOwnerEngineId, engineInitGeneration};
+}
+
+/// Accept a teardown for [engine_id] and capture the claim it is tearing down.
+///
+/// Verifying the claim and raising engine_shutdown_requested in one critical
+/// section is what stops a detaching engine cancelling a *replacement* engine's
+/// initialization. prepareEngineInit() lowers that flag and re-claims under the
+/// same mutex, so the two can no longer interleave as: teardown reads the old
+/// claim, replacement re-claims and lowers the flag, teardown raises it again,
+/// and the replacement's initEngine() then refuses to initialize.
+bool tryBeginEngineTeardown(int64_t engine_id, EngineLifecycleClaim *out) {
+ std::lock_guard guard(engine_lifecycle_mutex);
+
+ if (nativeInitOwnerEngineId != engine_id)
+ return false;
+
+ *out = EngineLifecycleClaim{nativeInitOwnerEngineId, engineInitGeneration};
+ // Rejects an initialization worker of this same engine that has not entered
+ // native code yet.
+ engine_shutdown_requested.store(true, std::memory_order_release);
+ return true;
+}
-// Protected by dart_callback_invocation_mutex.
-int64_t dartCallbackOwnerEngineId = kNoDartCallbackOwnerEngineId;
+bool engineLifecycleClaimIsCurrent(const EngineLifecycleClaim &claim) {
+ std::lock_guard guard(engine_lifecycle_mutex);
+ return nativeInitOwnerEngineId == claim.ownerEngineId &&
+ engineInitGeneration == claim.generation;
+}
+
+void releaseEngineLifecycleClaim() {
+ std::lock_guard guard(engine_lifecycle_mutex);
+ nativeInitOwnerEngineId = kNoEngineId;
+}
}
#ifdef __cplusplus
@@ -219,17 +284,61 @@ setDartEventCallback(dartVoiceEndedCallback_t voice_ended_callback,
dartCallbackOwnerEngineId = owner_engine_id;
}
-static void clearDartCallbackRegistrationsLocked() {
+/// Make the three process-global Dart bridges inert.
+///
+/// The caller must hold dart_callback_invocation_mutex. This performs no
+/// blocking work, so it stays safe to run on a platform/UI thread.
+static void clearDartCallbackPointersLocked() {
dartVoiceEndedCallback.store(nullptr, std::memory_order_release);
dartFileLoadedCallback.store(nullptr, std::memory_order_release);
dartStateChangedCallback.store(nullptr, std::memory_order_release);
- dartCallbackOwnerEngineId = kNoDartCallbackOwnerEngineId;
+ dartCallbackOwnerEngineId = kNoEngineId;
+}
+/// Additionally clear the per-BufferStream Dart callbacks.
+///
+/// The caller must hold init_deinit_mutex, which owns the `player` unique_ptr
+/// that dispose() resets. BufferStream::clearDartCallbacks() is a pair of
+/// atomic stores, so it does not need dart_callback_invocation_mutex.
+static void clearPlayerDartCallbackRegistrationsLocked() {
if (player.get() != nullptr) {
player.get()->clearDartCallbackRegistrations();
}
}
+static void clearDartCallbackRegistrationsLocked() {
+ clearDartCallbackPointersLocked();
+ clearPlayerDartCallbackRegistrationsLocked();
+}
+
+/// Clear the per-BufferStream Dart callbacks once init_deinit_mutex becomes
+/// available, without making the caller wait for it.
+///
+/// Used when the caller runs on a thread that must not block (the Android
+/// platform thread) and the mutex is currently held by an unrelated operation
+/// such as loadFile() or initEngine(). The worker captures the engine lifecycle
+/// claim and gives up if a new engine has claimed the native engine meanwhile,
+/// so it can never erase callbacks that a replacement engine has just
+/// registered.
+static void queuePlayerDartCallbackClear() {
+ const EngineLifecycleClaim claim = currentEngineLifecycleClaim();
+
+ try {
+ std::thread([claim]() {
+ std::lock_guard guard(init_deinit_mutex);
+
+ if (!engineLifecycleClaimIsCurrent(claim))
+ return;
+
+ clearPlayerDartCallbackRegistrationsLocked();
+ }).detach();
+ } catch (...) {
+ // Best effort. The global bridges are already inert, which is what stops
+ // native code invoking a dead callable; a teardown or a later init()
+ // still clears the BufferStream callbacks.
+ }
+}
+
FFI_PLUGIN_EXPORT void clearDartCallbackRegistrations() {
std::lock_guard guard_init(init_deinit_mutex);
std::lock_guard guard_load(loadMutex);
@@ -238,16 +347,49 @@ FFI_PLUGIN_EXPORT void clearDartCallbackRegistrations() {
clearDartCallbackRegistrationsLocked();
}
+/// Invalidate the Dart bridges owned by [engine_id] when its FlutterEngine is
+/// detached. Returns false when a different engine owns the current
+/// registration, so a detaching engine never clears another one's callbacks.
+///
+/// This runs on the Android platform (UI) thread, so unlike
+/// clearDartCallbackRegistrations() it must never wait behind a device
+/// operation. init_deinit_mutex is held for the whole of dispose(), which joins
+/// the lifecycle scheduler and can therefore inherit a stalled
+/// ma_device_stop(); blocking on it here would ANR the app at engine teardown.
+/// Only dart_callback_invocation_mutex is taken unconditionally — it is never
+/// held across a device operation.
FFI_PLUGIN_EXPORT bool
clearDartCallbackRegistrationsForEngine(int64_t engine_id) {
- std::lock_guard guard_init(init_deinit_mutex);
- std::lock_guard guard_load(loadMutex);
- std::lock_guard callbackGuard(dart_callback_invocation_mutex);
+ {
+ std::lock_guard callbackGuard(dart_callback_invocation_mutex);
- if (dartCallbackOwnerEngineId != engine_id)
- return false;
+ if (dartCallbackOwnerEngineId != engine_id)
+ return false;
- clearDartCallbackRegistrationsLocked();
+ clearDartCallbackPointersLocked();
+ }
+
+ // The BufferStream callbacks live inside Player, so reaching them needs
+ // init_deinit_mutex, which owns the `player` unique_ptr that dispose()
+ // resets. Doing this outside dart_callback_invocation_mutex also keeps the
+ // lock order consistent with disposeSound(), which holds sounds_mutex across
+ // soloud.stop() and reaches voiceEndedCallback().
+ //
+ // Take the fast path when the mutex happens to be free, but never treat a
+ // failed try_lock as "someone else will handle it": init_deinit_mutex is held
+ // by loadFile(), loadMem(), initEngine(), changeDevice(), the device
+ // start/stop calls and even isInited() — none of which clear these
+ // callbacks. Hand the work to a worker that can afford to wait instead.
+ {
+ std::unique_lock guard_init(init_deinit_mutex,
+ std::try_to_lock);
+ if (guard_init.owns_lock()) {
+ clearPlayerDartCallbackRegistrationsLocked();
+ return true;
+ }
+ }
+
+ queuePlayerDartCallbackClear();
return true;
}
@@ -276,8 +418,23 @@ FFI_PLUGIN_EXPORT bool areXiphLibsAvailable() {
/// 2=stereo, 4=quad, 6=5.1, 8=7.1.
///
/// Returns [PlayerErrors.noError] if success.
-FFI_PLUGIN_EXPORT void prepareEngineInit() {
+/// [owner_engine_id] is the FlutterEngine that will own the engine this
+/// initialization creates, or -1 on platforms with no engine-lifecycle hooks.
+FFI_PLUGIN_EXPORT void prepareEngineInit(int64_t owner_engine_id) {
+ std::lock_guard guard(engine_lifecycle_mutex);
+ // Lowered under the same mutex that publishes the claim, so a teardown for
+ // the previous engine cannot raise it again after this point. See
+ // tryBeginEngineTeardown().
engine_shutdown_requested.store(false, std::memory_order_release);
+ // Claim the native engine for this FlutterEngine now, before initEngine()
+ // runs. Ownership must not wait for callback registration: initEngine()
+ // opens the audio device and can take seconds on Android, and Dart registers
+ // callbacks only after it returns. An engine destroyed during that window
+ // still has to be able to tear down what it just built.
+ nativeInitOwnerEngineId = owner_engine_id;
+ // Invalidate any teardown queued by a previous engine's detach so it cannot
+ // dispose the engine this initialization is about to create.
+ ++engineInitGeneration;
}
FFI_PLUGIN_EXPORT void requestEngineShutdown() {
@@ -375,7 +532,6 @@ FFI_PLUGIN_EXPORT enum AudioDeviceState getAudioDeviceState() {
// query never waits behind an initialization or lifecycle API call.
return (AudioDeviceState)SoLoud::miniaudio_getAudioDeviceState();
}
-
/// Test-only hook that sends an interruption through miniaudio's normal
/// notification callback. This is intentionally absent from the public API.
FFI_PLUGIN_EXPORT void debugTriggerAudioInterruption(unsigned int began) {
@@ -442,13 +598,8 @@ FFI_PLUGIN_EXPORT void freeListPlaybackDevices(char **devicesName,
/// Must be called when there is no more need of the player or when closing the
/// app
///
-FFI_PLUGIN_EXPORT void dispose() {
- // Preserve request ordering for asynchronous Dart init/deinit workers.
- engine_shutdown_requested.store(true, std::memory_order_release);
-
- std::lock_guard guard(init_deinit_mutex);
- std::lock_guard guard_load(loadMutex);
-
+/// Teardown body. The caller must hold init_deinit_mutex and loadMutex.
+static void disposeLocked() {
// Wait for any Dart callback currently executing, then make every bridge
// inert. Do not retain the callback mutex while stopping devices, joining
// threads, destroying sources, or resetting Player.
@@ -458,6 +609,11 @@ FFI_PLUGIN_EXPORT void dispose() {
clearDartCallbackRegistrationsLocked();
}
+ // Nothing is left for a FlutterEngine to own. A detach arriving after this
+ // finds no claim and correctly declines to tear anything down; the next
+ // prepareEngineInit() takes a fresh claim.
+ releaseEngineLifecycleClaim();
+
if (player.get() == nullptr)
return;
@@ -469,6 +625,83 @@ FFI_PLUGIN_EXPORT void dispose() {
analyzer = std::make_unique(256);
}
+FFI_PLUGIN_EXPORT void dispose() {
+ // Preserve request ordering for asynchronous Dart init/deinit workers.
+ engine_shutdown_requested.store(true, std::memory_order_release);
+
+ std::lock_guard guard(init_deinit_mutex);
+ std::lock_guard guard_load(loadMutex);
+
+ disposeLocked();
+}
+
+/// Tear the engine down because its owning FlutterEngine is being destroyed
+/// while the process keeps running (the audio_service / add-to-app case).
+///
+/// Without this the native engine stays initialized with a live output device
+/// and a running scheduler after the last Dart code that could drive it is
+/// gone. Returns false unless [engine_id] still owns the native engine.
+///
+/// Ownership here is the *lifecycle* claim taken by prepareEngineInit(), not
+/// dartCallbackOwnerEngineId. Gating on callback ownership would be wrong in
+/// both directions: it is unset for the whole of an initialization -- so an
+/// engine destroyed after initEngine() opened the device but before Dart
+/// registered callbacks could not tear down what it had just built -- and it
+/// still names the *previous* engine once a replacement has called
+/// prepareEngineInit(), so a detaching engine would be accepted and would then
+/// capture the replacement's already-bumped generation and dispose a live
+/// engine. The generation cannot rescue either case: it is bumped when an
+/// initialization starts, which is before this teardown reads it.
+///
+/// The blocking teardown is handed to a detached worker: this is invoked from
+/// the Android platform thread, which must never wait on a device operation.
+FFI_PLUGIN_EXPORT bool requestEngineTeardownForEngine(int64_t engine_id) {
+ if (engine_id == kNoEngineId)
+ return false;
+
+ // Retire this engine's callables first, whatever the lifecycle decision
+ // below turns out to be, and gated on callback ownership rather than the
+ // lifecycle claim. The isolate that created them is going away and invoking
+ // one afterwards is undefined behaviour, so this must not be conditional on
+ // also being allowed to dispose the engine.
+ //
+ // It is load-bearing that this runs before the early return: since the
+ // teardown hook replaced the plain clear on the Java detach path, this is the
+ // only thing that clears callables when a FlutterEngine is destroyed. An
+ // engine can legitimately own the callables without owning the lifecycle
+ // claim -- its initialization worker can win the mutex after a later engine
+ // has already claimed -- and gating the clear on the claim left that engine's
+ // callables live after its isolate died.
+ clearDartCallbackRegistrationsForEngine(engine_id);
+
+ // A different engine has claimed the native engine, so it is live and owns
+ // its own teardown. An unclaimed engine means Dart already deinited cleanly,
+ // leaving nothing to dispose.
+ EngineLifecycleClaim claim;
+ if (!tryBeginEngineTeardown(engine_id, &claim))
+ return false;
+
+ try {
+ std::thread([claim]() {
+ std::lock_guard guard(init_deinit_mutex);
+ std::lock_guard guard_load(loadMutex);
+
+ // A replacement engine claimed the native engine while this worker was
+ // waiting for the mutex. Its engine is live and must not be torn down.
+ if (!engineLifecycleClaimIsCurrent(claim))
+ return;
+
+ disposeLocked();
+ }).detach();
+ } catch (...) {
+ // Thread creation failed. The bridges are already inert, and the next
+ // init() still recovers by deiniting the stale engine itself.
+ return false;
+ }
+
+ return true;
+}
+
#if defined(__ANDROID__)
extern "C" JNIEXPORT jboolean JNICALL
Java_flutter_soloud_flutter_1soloud_FlutterSoloudPlugin_nativeClearDartCallbackRegistrationsForEngine(
@@ -477,6 +710,14 @@ Java_flutter_soloud_flutter_1soloud_FlutterSoloudPlugin_nativeClearDartCallbackR
? JNI_TRUE
: JNI_FALSE;
}
+
+extern "C" JNIEXPORT jboolean JNICALL
+Java_flutter_soloud_flutter_1soloud_FlutterSoloudPlugin_nativeRequestEngineTeardownForEngine(
+ JNIEnv *, jclass, jlong engine_id) {
+ return requestEngineTeardownForEngine(static_cast(engine_id))
+ ? JNI_TRUE
+ : JNI_FALSE;
+}
#endif
FFI_PLUGIN_EXPORT int isInited() {
diff --git a/src/player.cpp b/src/player.cpp
index a6f03927..c3c38ffc 100644
--- a/src/player.cpp
+++ b/src/player.cpp
@@ -1078,6 +1078,31 @@ PlayerErrors Player::performAudioDeviceStart()
// Use the normal resume hook so iOS reactivates AVAudioSession before the
// Audio Unit is restarted.
SoLoud::result result = soloud.resume();
+ if (result == SoLoud::SO_NO_ERROR)
+ return noError;
+
+ // The start failed. Rebuild the device and try once more before giving up.
+ //
+ // The backend keeps the stream open across an idle stop, so a device that
+ // has been stopped for a while can be holding a stream the OS has since
+ // invalidated. On Android this is the common case: AAudio only reports a
+ // disconnect through the error callback of a *running* stream, so a stream
+ // that is torn down while stopped -- a route change, or the framework
+ // reclaiming resources from a backgrounded app -- is never rerouted, and
+ // the staleness only surfaces here, as AAudioStream_requestStart failing.
+ //
+ // Replacing the device recreates the stream against the current default
+ // output. Voices, loaded sources and filters all live in SoLoud rather than
+ // in the device, so playback resumes where it left off.
+ //
+ // Callers hold mDeviceLifecycleOperationMutex, so this cannot interleave
+ // with another device operation. One retry only: if a freshly built device
+ // will not start either, the failure is not staleness.
+ const SoLoud::result rebuilt = soloud.miniaudio_changeDevice(nullptr);
+ if (rebuilt != SoLoud::SO_NO_ERROR)
+ return unknownError;
+
+ result = soloud.resume();
if (result != SoLoud::SO_NO_ERROR)
return unknownError;
return noError;
@@ -1139,8 +1164,24 @@ PlayerErrors Player::startAudioDevice()
{
std::lock_guard operationLock(
mDeviceLifecycleOperationMutex);
- if (mInterruptionActive.load(std::memory_order_acquire))
- return noError;
+
+ // An explicit start is an authoritative request from the app, so it
+ // clears the interruption latch rather than being suppressed by it.
+ //
+ // Returning noError here without touching the device made this API
+ // able to report success while leaving the device stopped, and the
+ // latch can be stuck: iOS does not reliably deliver
+ // AVAudioSessionInterruptionTypeEnded -- notably when the interruption
+ // ends while the app is backgrounded or suspended -- and the flag is
+ // otherwise only cleared by init()/deinit(). Once missed, every later
+ // start was a silent no-op for the lifetime of the engine.
+ //
+ // If an interruption really is still in force the OS refuses to
+ // activate the session and the start below fails, which surfaces a real
+ // error instead of a false success. A genuine new interruption arriving
+ // afterwards sets the flag again through the normal callback.
+ mInterruptionActive.store(false, std::memory_order_release);
+
// Cancel a stale delayed idle stop before prewarming the device.
invalidatePendingDeviceRequest();
result = performAudioDeviceStart();