diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkAudioPlayer.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkAudioPlayer.kt index c26b83587164..82cb1fd6ff8d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkAudioPlayer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkAudioPlayer.kt @@ -8,6 +8,7 @@ import android.media.MediaPlayer import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import java.io.File @@ -180,58 +181,67 @@ internal class TalkAudioPlayer( ) { // MediaPlayer needs a seekable data source for several compressed formats, // so cache the response bytes briefly instead of streaming from memory. - val tempFile = - withContext(Dispatchers.IO) { - File.createTempFile("talk-audio-", fileExtension, context.cacheDir).apply { - writeBytes(bytes) - } - } + // Own resources immediately: cancellation can discard a dispatcher result after allocation. + var tempFile: File? = null try { - val finished = CompletableDeferred() - val player = - withContext(Dispatchers.Main) { - MediaPlayer().apply { - setAudioAttributes( - AudioAttributes - .Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build(), - ) - setDataSource(tempFile.absolutePath) - setOnCompletionListener { - finished.complete(Unit) - } - setOnErrorListener { _, what, extra -> - finished.completeExceptionally(IllegalStateException("MediaPlayer error ($what/$extra)")) - true - } - prepare() + val audioFile = + withContext(Dispatchers.IO) { + File.createTempFile("talk-audio-", fileExtension, context.cacheDir).also { created -> + tempFile = created + created.writeBytes(bytes) } } - val playback = - ActivePlayback( - cancel = { - finished.completeExceptionally(CancellationException("assistant speech cancelled")) - runCatching { player.stop() } - }, - ) - register(playback) + val finished = CompletableDeferred() + var mediaPlayer: MediaPlayer? = null try { - withContext(Dispatchers.Main) { - player.start() + val player = + withContext(Dispatchers.Main) { + MediaPlayer().also { mediaPlayer = it }.apply { + setAudioAttributes( + AudioAttributes + .Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + setDataSource(audioFile.absolutePath) + setOnCompletionListener { + finished.complete(Unit) + } + setOnErrorListener { _, what, extra -> + finished.completeExceptionally(IllegalStateException("MediaPlayer error ($what/$extra)")) + true + } + prepare() + } + } + val playback = + ActivePlayback( + cancel = { + finished.completeExceptionally(CancellationException("assistant speech cancelled")) + runCatching { player.stop() } + }, + ) + register(playback) + try { + withContext(Dispatchers.Main) { + player.start() + } + finished.await() + } finally { + clear(playback) } - finished.await() } finally { - clear(playback) - withContext(Dispatchers.Main) { - runCatching { player.stop() } - player.release() + withContext(NonCancellable + Dispatchers.Main) { + mediaPlayer?.let { player -> + runCatching { player.stop() } + player.release() + } } } } finally { - withContext(Dispatchers.IO) { - tempFile.delete() + withContext(NonCancellable + Dispatchers.IO) { + tempFile?.delete() } } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkAudioPlayerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkAudioPlayerTest.kt index b19d0b0ae8d6..c7762abd209c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkAudioPlayerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkAudioPlayerTest.kt @@ -1,10 +1,52 @@ package ai.openclaw.app.voice +import android.media.MediaPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowMediaPlayer +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalCoroutinesApi::class) class TalkAudioPlayerTest { + private val context = RuntimeEnvironment.getApplication() + + @Before + fun setUpPlayback() { + Dispatchers.setMain(Dispatchers.Unconfined) + ShadowMediaPlayer.setMediaInfoProvider { ShadowMediaPlayer.MediaInfo(60_000, 0) } + } + + @After + fun cleanUpPlayback() { + speechFiles().forEach(File::delete) + ShadowMediaPlayer.resetStaticState() + Dispatchers.resetMain() + } + @Test fun resolvesPcmPlaybackFromOutputFormat() { val mode = @@ -41,4 +83,133 @@ class TalkAudioPlayerTest { assertTrue(mode is TalkPlaybackMode.Compressed) assertEquals(".webm", (mode as TalkPlaybackMode.Compressed).fileExtension) } + + @Test + fun cancelledCompressedPlaybackReleasesMediaPlayer() { + val outcome = cancelCompressedPlayback() + + assertEquals(ShadowMediaPlayer.State.END, outcome.playerState) + } + + @Test + fun cancelledCompressedPlaybackDeletesPrivateSpeechFile() { + val outcome = cancelCompressedPlayback() + + assertEquals(emptyList(), outcome.remainingSpeechFiles) + } + + @Test + fun compressedPlaybackSetupFailureReleasesAllocatedMediaPlayer() = + runBlocking { + val createdPlayer = AtomicReference() + val createdShadow = AtomicReference() + ShadowMediaPlayer.setCreateListener { player, shadow -> + createdPlayer.set(player) + createdShadow.set(shadow) + } + ShadowMediaPlayer.setMediaInfoProvider { throw IllegalStateException("synthetic media initialization failed") } + + try { + val failure = runCatching { TalkAudioPlayer(context).play(syntheticAudio()) }.exceptionOrNull() + + assertEquals("synthetic media initialization failed", failure?.message) + assertEquals(ShadowMediaPlayer.State.END, checkNotNull(createdShadow.get()).state) + assertEquals(emptyList(), speechFiles().map(File::getName)) + } finally { + runCatching { createdPlayer.get()?.release() } + speechFiles().forEach(File::delete) + } + } + + @Test + fun cancellationBeforeCreatedFileReturnsDeletesPrivateSpeechFile() = + runBlocking { + val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + val blockerScope = CoroutineScope(dispatcher) + val callerBlocked = CountDownLatch(1) + val releaseCaller = CountDownLatch(1) + val player = TalkAudioPlayer(context) + val playback = + launch(dispatcher) { + blockerScope.launch { + callerBlocked.countDown() + releaseCaller.await() + } + player.play(syntheticAudio()) + } + + try { + withTimeout(5_000) { + while (callerBlocked.count > 0 || speechFiles().isEmpty()) { + delay(10) + } + } + assertEquals(1, speechFiles().size) + + playback.cancel() + releaseCaller.countDown() + playback.join() + + assertEquals(emptyList(), speechFiles().map(File::getName)) + } finally { + releaseCaller.countDown() + playback.cancelAndJoin() + blockerScope.cancel() + dispatcher.close() + speechFiles().forEach(File::delete) + } + } + + private fun cancelCompressedPlayback(): CancelledPlaybackOutcome = + runBlocking { + val createdPlayer = AtomicReference() + val createdShadow = AtomicReference() + ShadowMediaPlayer.setCreateListener { player, shadow -> + createdPlayer.set(player) + createdShadow.set(shadow) + } + val player = TalkAudioPlayer(context) + val playback = launch(Dispatchers.Default) { player.play(syntheticAudio()) } + + try { + withTimeout(5_000) { + while (createdShadow.get()?.state != ShadowMediaPlayer.State.STARTED) { + delay(10) + } + } + val activeFiles = speechFiles() + assertEquals(1, activeFiles.size) + + playback.cancelAndJoin() + + CancelledPlaybackOutcome( + playerState = checkNotNull(createdShadow.get()).state, + remainingSpeechFiles = speechFiles().map(File::getName), + ) + } finally { + playback.cancelAndJoin() + runCatching { createdPlayer.get()?.release() } + speechFiles().forEach(File::delete) + } + } + + private fun speechFiles(): List { + val files = context.cacheDir.listFiles().orEmpty() + return files.filter { it.name.startsWith("talk-audio-") } + } + + private fun syntheticAudio(): TalkSpeakAudio = + TalkSpeakAudio( + bytes = byteArrayOf(1, 2, 3), + provider = "test", + outputFormat = null, + voiceCompatible = null, + mimeType = "audio/mpeg", + fileExtension = null, + ) + + private data class CancelledPlaybackOutcome( + val playerState: ShadowMediaPlayer.State, + val remainingSpeechFiles: List, + ) }