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
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package flutter.soloud.flutter_soloud;

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 native boolean
nativeClearDartCallbackRegistrationsForEngine(long engineId);

private Long engineId;

@SuppressWarnings("deprecation")
@Override
public void onAttachedToEngine(
@NonNull FlutterPluginBinding binding
) {
engineId = binding.getFlutterEngine().getEngineId();
}

@Override
public void onDetachedFromEngine(
@NonNull FlutterPluginBinding binding
) {
final Long detachedEngineId = engineId;
engineId = null;

if (detachedEngineId != null) {
nativeClearDartCallbackRegistrationsForEngine(
detachedEngineId
);
}
}
}
54 changes: 36 additions & 18 deletions example/tests/tests/audio_device_idle_timeout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ Future<StringBuffer> testAudioDeviceIdleTimeout() async {

// Allow a short grace period for async stop transitions.
final deadline = DateTime.now().add(const Duration(milliseconds: 1000));
while (stoppedState != AudioDeviceState.stopped && DateTime.now().isBefore(deadline)) {
while (stoppedState != AudioDeviceState.stopped &&
DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 25));
stoppedState = SoLoud.instance.getAudioDeviceState();
}
Expand Down Expand Up @@ -69,7 +70,8 @@ Future<StringBuffer> testAudioDeviceIdleTimeout() async {
'After creating a paused handle, expected AudioDeviceState.stopped '
'but got $stateAfterPausedHandle.',
);
strBuf.writeln('State after paused handle creation: $stateAfterPausedHandle');
strBuf
.writeln('State after paused handle creation: $stateAfterPausedHandle');

// 4) Play the sound handle for a few seconds.
SoLoud.instance.setPause(handle, false);
Expand Down Expand Up @@ -131,8 +133,10 @@ Future<StringBuffer> testAudioDeviceIdleTimeout() async {
await Future<void>.delayed(idleTimeout);
var stateAfterPauseIdle = SoLoud.instance.getAudioDeviceState();

final pauseIdleDeadline = DateTime.now().add(const Duration(milliseconds: 1000));
while (stateAfterPauseIdle != AudioDeviceState.stopped && DateTime.now().isBefore(pauseIdleDeadline)) {
final pauseIdleDeadline =
DateTime.now().add(const Duration(milliseconds: 1000));
while (stateAfterPauseIdle != AudioDeviceState.stopped &&
DateTime.now().isBefore(pauseIdleDeadline)) {
await Future<void>.delayed(const Duration(milliseconds: 25));
stateAfterPauseIdle = SoLoud.instance.getAudioDeviceState();
}
Expand All @@ -147,8 +151,10 @@ Future<StringBuffer> testAudioDeviceIdleTimeout() async {
// 8) Resume and verify started before schedulePause.
SoLoud.instance.setPause(handle, false);
var stateBeforeSchedulePause = SoLoud.instance.getAudioDeviceState();
final startedBeforePauseDeadline = DateTime.now().add(const Duration(milliseconds: 1000));
while (stateBeforeSchedulePause != AudioDeviceState.started && DateTime.now().isBefore(startedBeforePauseDeadline)) {
final startedBeforePauseDeadline =
DateTime.now().add(const Duration(milliseconds: 1000));
while (stateBeforeSchedulePause != AudioDeviceState.started &&
DateTime.now().isBefore(startedBeforePauseDeadline)) {
await Future<void>.delayed(const Duration(milliseconds: 25));
stateBeforeSchedulePause = SoLoud.instance.getAudioDeviceState();
}
Expand All @@ -167,13 +173,16 @@ Future<StringBuffer> testAudioDeviceIdleTimeout() async {
pausedAfterSchedulePause,
'After schedulePause(1000ms), expected handle to be paused.',
);
strBuf.writeln('Handle paused after schedulePause: $pausedAfterSchedulePause');
strBuf.writeln(
'Handle paused after schedulePause: $pausedAfterSchedulePause');

// 10) Wait idle timeout again and verify stopped.
await Future<void>.delayed(idleTimeout);
var stateAfterSchedulePauseIdle = SoLoud.instance.getAudioDeviceState();
final stoppedAfterSchedulePauseDeadline = DateTime.now().add(const Duration(milliseconds: 1000));
while (stateAfterSchedulePauseIdle != AudioDeviceState.stopped && DateTime.now().isBefore(stoppedAfterSchedulePauseDeadline)) {
final stoppedAfterSchedulePauseDeadline =
DateTime.now().add(const Duration(milliseconds: 1000));
while (stateAfterSchedulePauseIdle != AudioDeviceState.stopped &&
DateTime.now().isBefore(stoppedAfterSchedulePauseDeadline)) {
await Future<void>.delayed(const Duration(milliseconds: 25));
stateAfterSchedulePauseIdle = SoLoud.instance.getAudioDeviceState();
}
Expand All @@ -189,8 +198,10 @@ Future<StringBuffer> testAudioDeviceIdleTimeout() async {
// 11) Resume and verify started before scheduleStop.
SoLoud.instance.setPause(handle, false);
var stateBeforeScheduleStop = SoLoud.instance.getAudioDeviceState();
final startedBeforeStopDeadline = DateTime.now().add(const Duration(milliseconds: 1000));
while (stateBeforeScheduleStop != AudioDeviceState.started && DateTime.now().isBefore(startedBeforeStopDeadline)) {
final startedBeforeStopDeadline =
DateTime.now().add(const Duration(milliseconds: 1000));
while (stateBeforeScheduleStop != AudioDeviceState.started &&
DateTime.now().isBefore(startedBeforeStopDeadline)) {
await Future<void>.delayed(const Duration(milliseconds: 25));
stateBeforeScheduleStop = SoLoud.instance.getAudioDeviceState();
}
Expand All @@ -204,11 +215,15 @@ Future<StringBuffer> testAudioDeviceIdleTimeout() async {
// 12) Schedule stop in 1000 ms and verify handle invalidated.
SoLoud.instance.scheduleStop(handle, const Duration(milliseconds: 1000));
await Future<void>.delayed(const Duration(milliseconds: 1100));
var isHandleValidAfterScheduleStop = SoLoud.instance.getIsValidVoiceHandle(handle);
final invalidHandleDeadline = DateTime.now().add(const Duration(milliseconds: 1000));
while (isHandleValidAfterScheduleStop && DateTime.now().isBefore(invalidHandleDeadline)) {
var isHandleValidAfterScheduleStop =
SoLoud.instance.getIsValidVoiceHandle(handle);
final invalidHandleDeadline =
DateTime.now().add(const Duration(milliseconds: 1000));
while (isHandleValidAfterScheduleStop &&
DateTime.now().isBefore(invalidHandleDeadline)) {
await Future<void>.delayed(const Duration(milliseconds: 25));
isHandleValidAfterScheduleStop = SoLoud.instance.getIsValidVoiceHandle(handle);
isHandleValidAfterScheduleStop =
SoLoud.instance.getIsValidVoiceHandle(handle);
}
assert(
!isHandleValidAfterScheduleStop,
Expand All @@ -221,8 +236,10 @@ Future<StringBuffer> testAudioDeviceIdleTimeout() async {
// 13) Wait idle timeout again and verify stopped (scheduleStop last).
await Future<void>.delayed(idleTimeout);
var stateAfterScheduleStopIdle = SoLoud.instance.getAudioDeviceState();
final stoppedAfterScheduleStopDeadline = DateTime.now().add(const Duration(milliseconds: 1000));
while (stateAfterScheduleStopIdle != AudioDeviceState.stopped && DateTime.now().isBefore(stoppedAfterScheduleStopDeadline)) {
final stoppedAfterScheduleStopDeadline =
DateTime.now().add(const Duration(milliseconds: 1000));
while (stateAfterScheduleStopIdle != AudioDeviceState.stopped &&
DateTime.now().isBefore(stoppedAfterScheduleStopDeadline)) {
await Future<void>.delayed(const Duration(milliseconds: 25));
stateAfterScheduleStopIdle = SoLoud.instance.getAudioDeviceState();
}
Expand Down Expand Up @@ -264,7 +281,8 @@ Future<StringBuffer> testAudioDeviceIdleTimeout() async {
'After starting explosion playback, expected AudioDeviceState.started '
'within 1000 ms but got $stateDuringExplosionStart.',
);
strBuf.writeln('State during explosion playback: $stateDuringExplosionStart');
strBuf
.writeln('State during explosion playback: $stateDuringExplosionStart');

// 16) Wait for full playback duration.
await Future<void>.delayed(explosionDuration);
Expand Down
129 changes: 122 additions & 7 deletions example/tests/tests/audio_device_lifecycle_races.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,44 @@ Future<StringBuffer> testAudioDeviceLifecycleRaces() async {
);
output.writeln('Failed playback leaves device stopped: OK');

// An idle-policy update arriving immediately after an unpause must not
// replace the required device start. Repeat this while a paused voice is
// kept alive so the final idle check cannot legitimately stop the device.
final raceHandle = SoLoud.instance.play(
waveform,
paused: true,
looping: true,
volume: 0.1,
);
for (var i = 0; i < 10; i++) {
SoLoud.instance.setPause(raceHandle, false);
SoLoud.instance.setAudioDeviceIdleTimeout(raceTimeout);
state = await _waitForDeviceState(AudioDeviceState.started);
assert(
state == AudioDeviceState.started &&
SoLoud.instance.getIsValidVoiceHandle(raceHandle) &&
!SoLoud.instance.getPause(raceHandle),
'Start/idle race iteration $i lost active playback: $state',
);
await Future<void>.delayed(
raceTimeout + const Duration(milliseconds: 50),
);
assert(
SoLoud.instance.getAudioDeviceState() == AudioDeviceState.started,
'Active voice was stopped by idle policy in iteration $i.',
);

SoLoud.instance.setPause(raceHandle, true);
state = await _waitForDeviceState(AudioDeviceState.stopped);
assert(
state == AudioDeviceState.stopped,
'Paused voice did not stop the device in iteration $i: $state',
);
}
await SoLoud.instance.stop(raceHandle);
output
.writeln('Start followed by idle update preserves playback (10x): OK');

// Explicit prewarming while idle must apply a fresh timeout afterward.
SoLoud.instance.setAudioDeviceIdleTimeout(raceTimeout);
await SoLoud.instance.startAudioDevice();
Expand Down Expand Up @@ -150,16 +188,31 @@ Future<StringBuffer> testAudioDeviceLifecycleRaces() async {
}
output.writeln('Concurrent start/stop serialization (5x): OK');

// Drive interruptions through miniaudio's notification callback. Active
// voice state must survive begin/end and require recovery on interruption
// end.
final handle = SoLoud.instance.play(waveform, looping: true, volume: 0.1);
// Queue a start immediately before an interruption. The interruption stop
// must win over the pending start, while the active voice remains intact
// and recovery still applies on interruption end.
SoLoud.instance.setAudioDeviceIdleTimeout(Duration.zero);

final handle = SoLoud.instance.play(
waveform,
looping: true,
volume: 0.1,
);

state = await _waitForDeviceState(AudioDeviceState.started);
assert(
state == AudioDeviceState.started,
'Playback did not start: $state',
);

SoLoud.instance.setPause(handle, true);
state = await _waitForDeviceState(AudioDeviceState.stopped);
assert(
state == AudioDeviceState.stopped,
'Could not establish a stopped device before interruption race: $state',
);

SoLoud.instance.setPause(handle, false);
final beganEvent = SoLoudController()
.soLoudFFI
.stateChangedEvents
Expand Down Expand Up @@ -196,6 +249,53 @@ Future<StringBuffer> testAudioDeviceLifecycleRaces() async {
);
output.writeln('Active interruption recovery preserves voice state: OK');

// End an interruption immediately after its begin notification. The
// recovery start may arrive while interruptionStop is still pending or in
// flight and must not be discarded.
for (var i = 0; i < 20; i++) {
final rapidBeganEvent = SoLoudController()
.soLoudFFI
.stateChangedEvents
.firstWhere(
(event) => event == PlayerStateNotification.interruptionBegan,
)
.timeout(const Duration(seconds: 2));
final stoppedEvent = SoLoudController()
.soLoudFFI
.stateChangedEvents
.firstWhere(
(event) => event == PlayerStateNotification.stopped,
)
.timeout(const Duration(seconds: 2));
final restartedEvent = SoLoudController()
.soLoudFFI
.stateChangedEvents
.firstWhere(
(event) => event == PlayerStateNotification.started,
)
.timeout(const Duration(seconds: 2));

SoLoudController().soLoudFFI.debugTriggerAudioInterruption(began: true);
await rapidBeganEvent;

// End immediately; interruptionStop may still be pending or in flight.
SoLoudController().soLoudFFI.debugTriggerAudioInterruption(began: false);

await stoppedEvent;
await restartedEvent;
final rapidState = SoLoud.instance.getAudioDeviceState();
assert(
rapidState == AudioDeviceState.started,
'Rapid interruption cycle $i lost the recovery start: $rapidState',
);
assert(
SoLoud.instance.getIsValidVoiceHandle(handle) &&
!SoLoud.instance.getPause(handle),
'Rapid interruption cycle $i changed the active voice.',
);
}
output.writeln('Rapid interruption recovery (20x): OK');

// Idle finite policy remains stopped after interruption recovery.
SoLoud.instance.setPause(handle, true);
SoLoud.instance.setAudioDeviceIdleTimeout(Duration.zero);
Expand Down Expand Up @@ -267,20 +367,35 @@ Future<StringBuffer> testAudioDeviceLifecycleRaces() async {
);
output.writeln('Teardown during active lifecycle operation: OK');

// Repeated fully asynchronous recreation must not leak scheduler threads.
for (var i = 0; i < 5; i++) {
// Repeated start/deinit races must not deadlock callback teardown or leak
// scheduler threads. Keep the timeout finite so each cycle also exercises
// the idle lifecycle path before the immediate start request.
for (var i = 0; i < 100; i++) {
await SoLoud.instance.init();
assert(
SoLoud.instance.isInitialized,
'Async cycle $i failed to initialize.',
);

final startOperation = _captureError(
SoLoud.instance.startAudioDevice(),
);
await SoLoud.instance.deinitAsync().timeout(const Duration(seconds: 5));
final startError = await startOperation;
assert(
startError == null || startError is SoLoudException,
'Async cycle $i start failed unexpectedly: $startError',
);
assert(
!SoLoud.instance.isInitialized,
'Async cycle $i failed to deinit.',
);
assert(
SoLoud.instance.getAudioDeviceState() == AudioDeviceState.uninitialized,
'Async cycle $i left the backend initialized.',
);
}
output.writeln('Repeated async init/deinit cycles (5x): OK');
output.writeln('Repeated start/deinit races (100x): OK');
} finally {
SoLoud.instance.setAudioDeviceIdleTimeout(defaultTimeout);
await SoLoud.instance.deinitAsync();
Expand Down
6 changes: 3 additions & 3 deletions lib/src/bindings/bindings_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -330,14 +330,14 @@ abstract class FlutterSoLoud {
///
/// [handle] the sound handle.
@mustBeOverridden
void pauseSwitch(SoundHandle handle);
PlayerErrors pauseSwitch(SoundHandle handle);

/// Pause or unpause already loaded sound identified by [handle].
///
/// [handle] the sound handle.
/// [pause] the new state.
@mustBeOverridden
void setPause(SoundHandle handle, int pause);
PlayerErrors setPause(SoundHandle handle, int pause);

/// Gets the pause state.
///
Expand Down Expand Up @@ -402,7 +402,7 @@ abstract class FlutterSoLoud {
///
/// [handle] the sound handle.
@mustBeOverridden
void stop(SoundHandle handle);
PlayerErrors stop(SoundHandle handle);

/// Stop all handles of the already loaded sound identified
/// by [soundHash] and dispose it.
Expand Down
Loading
Loading