fix(android): stop interrupted assistant audio and remove cached files (#129574)

This commit is contained in:
Peter Steinberger
2026-08-25 14:07:05 -07:00
committed by GitHub
parent 8d05d6c98e
commit 52bce84ca9
2 changed files with 224 additions and 43 deletions
@@ -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<Unit>()
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<Unit>()
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()
}
}
}
@@ -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<String>(), outcome.remainingSpeechFiles)
}
@Test
fun compressedPlaybackSetupFailureReleasesAllocatedMediaPlayer() =
runBlocking {
val createdPlayer = AtomicReference<MediaPlayer>()
val createdShadow = AtomicReference<ShadowMediaPlayer>()
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<String>(), 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<String>(), 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<MediaPlayer>()
val createdShadow = AtomicReference<ShadowMediaPlayer>()
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<File> {
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<String>,
)
}