Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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";

Expand All @@ -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) {
Expand All @@ -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
);
}
Expand Down
28 changes: 28 additions & 0 deletions example/tests/tests/audio_device_lifecycle_races.dart
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,34 @@ Future<StringBuffer> 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);

Expand Down
15 changes: 12 additions & 3 deletions lib/src/bindings/bindings_player_ffi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ffi.NativeFunction<ffi.Void Function()>>('prepareEngineInit');
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Int64)>>(
'prepareEngineInit',
);
late final _prepareEngineInit = _prepareEngineInitPtr
.asFunction<void Function()>();
.asFunction<void Function(int)>();

@override
void requestEngineShutdown() => _requestEngineShutdown();
Expand Down
Loading
Loading