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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#### 4.0.13 (20 Jul 2026)
- 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, and a teardown is abandoned if a replacement engine initializes first
- 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@
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 {
/**
* Guarded by the class monitor.
Expand All @@ -26,10 +41,19 @@ public final class FlutterSoloudPlugin implements FlutterPlugin {
private static native boolean
nativeClearDartCallbackRegistrationsForEngine(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 (!nativeLibraryLoadAttempted) {
nativeLibraryLoadAttempted = true;
Expand All @@ -54,10 +78,12 @@ 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: read the engine id and
// register a listener.
// 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
Expand All @@ -72,9 +98,9 @@ public void onPreEngineRestart() {

@Override
public void onEngineWillDestroy() {
// Fires just before the plugin registry is destroyed, while the
// engine is still valid.
clearDartCallbackRegistrations();
// 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);
Expand All @@ -91,7 +117,9 @@ public void onDetachedFromEngine(
engine.removeEngineLifecycleListener(listener);
}

clearDartCallbackRegistrations();
// Requested here too: onEngineWillDestroy() is not reached on every
// detach path, and requestEngineTeardown() is idempotent.
requestEngineTeardown();

flutterEngine = null;
engineId = null;
Expand All @@ -105,4 +133,23 @@ private void clearDartCallbackRegistrations() {
}
nativeClearDartCallbackRegistrationsForEngine(id);
}

/**
* 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;
}
teardownRequested = true;

if (!ensureNativeLibraryLoaded()) {
return;
}
nativeRequestEngineTeardownForEngine(id);
}
}
177 changes: 162 additions & 15 deletions src/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
#include <memory>
#include <mutex>
#include <stdio.h>
#include <thread>

std::mutex dart_callback_invocation_mutex;

Expand All @@ -38,6 +39,11 @@ constexpr int64_t kNoDartCallbackOwnerEngineId = -1;

// Protected by dart_callback_invocation_mutex.
int64_t dartCallbackOwnerEngineId = kNoDartCallbackOwnerEngineId;

// Advances on every prepareEngineInit(). A teardown queued when a FlutterEngine
// detached captures this and aborts if a replacement engine initialized while
// the worker was waiting, so a late teardown can never kill a live engine.
std::atomic<uint64_t> engineInitGeneration{0};
}

#ifdef __cplusplus
Expand Down Expand Up @@ -219,17 +225,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;
}

/// 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 initialization
/// generation and gives up if a new engine has initialized meanwhile, so it can
/// never erase callbacks that a replacement engine has just registered.
static void queuePlayerDartCallbackClear() {
const uint64_t generation =
engineInitGeneration.load(std::memory_order_acquire);

try {
std::thread([generation]() {
std::lock_guard<std::mutex> guard(init_deinit_mutex);

if (engineInitGeneration.load(std::memory_order_acquire) != generation)
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<std::mutex> guard_init(init_deinit_mutex);
std::lock_guard<std::mutex> guard_load(loadMutex);
Expand All @@ -238,16 +288,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<std::mutex> guard_init(init_deinit_mutex);
std::lock_guard<std::mutex> guard_load(loadMutex);
std::lock_guard<std::mutex> callbackGuard(dart_callback_invocation_mutex);
{
std::lock_guard<std::mutex> 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<std::mutex> guard_init(init_deinit_mutex,
std::try_to_lock);
if (guard_init.owns_lock()) {
clearPlayerDartCallbackRegistrationsLocked();
return true;
}
}

queuePlayerDartCallbackClear();
return true;
}

Expand Down Expand Up @@ -278,6 +361,9 @@ FFI_PLUGIN_EXPORT bool areXiphLibsAvailable() {
/// Returns [PlayerErrors.noError] if success.
FFI_PLUGIN_EXPORT void prepareEngineInit() {
engine_shutdown_requested.store(false, std::memory_order_release);
// Invalidate any teardown queued by a previous engine's detach so it cannot
// dispose the engine this initialization is about to create.
engineInitGeneration.fetch_add(1, std::memory_order_acq_rel);
}

FFI_PLUGIN_EXPORT void requestEngineShutdown() {
Expand Down Expand Up @@ -375,7 +461,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) {
Expand Down Expand Up @@ -442,13 +527,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<std::mutex> guard(init_deinit_mutex);
std::lock_guard<std::mutex> 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.
Expand All @@ -469,6 +549,65 @@ FFI_PLUGIN_EXPORT void dispose() {
analyzer = std::make_unique<Analyzer>(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<std::mutex> guard(init_deinit_mutex);
std::lock_guard<std::mutex> 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 when a different engine owns the current registration.
///
/// 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) {
{
std::lock_guard<std::mutex> callbackGuard(dart_callback_invocation_mutex);

// An unowned registration is accepted: Dart may already have deinited
// cleanly, or this engine never registered callbacks at all. A registration
// owned by a *different* engine must be left alone.
if (dartCallbackOwnerEngineId != engine_id &&
dartCallbackOwnerEngineId != kNoDartCallbackOwnerEngineId)
return false;

clearDartCallbackPointersLocked();
}

// Reject an initialization worker that has not entered native code yet.
engine_shutdown_requested.store(true, std::memory_order_release);
const uint64_t generation =
engineInitGeneration.load(std::memory_order_acquire);

try {
std::thread([generation]() {
std::lock_guard<std::mutex> guard(init_deinit_mutex);
std::lock_guard<std::mutex> guard_load(loadMutex);

// A replacement engine initialized while this worker was waiting for the
// mutex. Its engine is live and must not be torn down.
if (engineInitGeneration.load(std::memory_order_acquire) != generation)
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(
Expand All @@ -477,6 +616,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<int64_t>(engine_id))
? JNI_TRUE
: JNI_FALSE;
}
#endif

FFI_PLUGIN_EXPORT int isInited() {
Expand Down