diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/CallAudioLifecycleTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/CallAudioLifecycleTest.kt new file mode 100644 index 000000000..9646f1331 --- /dev/null +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/CallAudioLifecycleTest.kt @@ -0,0 +1,208 @@ +package com.utun.chat.data + +import android.content.Context +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.media.AudioRecord +import android.media.AudioTrack +import android.os.Build +import android.os.Handler +import android.os.Looper +import com.utun.chat.ChatApplication +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Настоящие engine/output/workers; Android-устройства и DSP допускают отказы до/после эффекта. */ +class CallAudioLifecycleTest { + private lateinit var engine: CallAudioEngine + private val gates = mutableListOf() + private val main get() = Looper.getMainLooper() + private val manager get() = AudioManager.shared + private val failures = mutableListOf() + + @Before fun prepare() { + assertSame(main, Looper.myLooper()) + Build.VERSION.SDK_INT = 30 + ChatApplication.instance = Context() + AudioManager.shared = AudioManager() + AudioTrack.created.clear(); AudioTrack.nextFailures = emptySet(); AudioTrack.minimumBuffer = 1920 + AudioRecord.created.clear(); AudioRecord.nextFailures = emptySet() + NativeLib.callAudioCommands.clear(); NativeLib.callAudioFailures = emptySet(); NativeLib.onCallFeed = {} + TelecomCallManager.connection = null + Handler.rejectImmediate = false; Handler.rejectDelayed = false + LogManager.entries.clear(); LogManager.console = true + engine = CallAudioEngine() + engine.onFailure = { _, detail -> failures.add(detail) } + } + + @After fun release() { + gates.forEach { it.countDown() } + Thread.interrupted() + NativeLib.callAudioFailures = emptySet(); NativeLib.onCallFeed = {} + manager.failures = emptySet() + AudioRecord.created.forEach { it.failures = emptySet() } + AudioTrack.created.forEach { it.failures = emptySet(); it.onWrite = {} } + engine.stop(); main.advanceBy(10_000) + assertTrue(AudioRecord.created.all { it.released }) + assertTrue(AudioTrack.created.all { it.released }) + assertNull(field("captureThread")); assertNull(field("playThread")) + assertEquals(0L, field("callId")) + Build.VERSION.SDK_INT = 36 + } + + private fun field(name: String): Any? = CallAudioEngine::class.java.getDeclaredField(name) + .apply { isAccessible = true }.get(engine) + private fun await(message: String, condition: () -> Boolean) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (!condition() && System.nanoTime() < deadline) { main.dispatchAll(); Thread.sleep(2) } + main.dispatchAll(); assertTrue(message, condition()) + } + private fun start(id: Long = 7) { + assertTrue(engine.start(id)) + await("both call workers ready") { field("captureReady") == true && field("playReady") == true } + } + private fun diagnostic(text: String) = synchronized(LogManager.entries) { + LogManager.entries.any { it.level in listOf("WARN", "ERROR") && text in it.message } + } + private fun stops() = NativeLib.callAudioCommands.count { it.first == "stop" } + private fun rejected(block: () -> Unit) = assertThrows(IllegalStateException::class.java, block) + + @Test fun bothFailedReleasesRetainOwnersAndBlockReplacementUntilAcknowledged() { + start() + val record = AudioRecord.created.single().also { it.failures = setOf("release") } + val track = AudioTrack.created.single().also { it.failures = setOf("release") } + rejected { engine.stop() } + assertFalse(engine.isActive()); assertSame(record, field("audioRecord")); assertNotNull(field("audioTrack")) + assertFalse(record.released); assertFalse(track.released); assertEquals(0, stops()) + assertTrue(diagnostic("record release failed")); assertTrue(diagnostic("track release failed")) + rejected { engine.start(8) } + assertEquals(listOf("start" to 7L), NativeLib.callAudioCommands.toList()) + record.failures = emptySet(); track.failures = emptySet() + engine.stop() + assertTrue(record.released); assertTrue(track.released); assertEquals(1, stops()) + assertNull(field("audioRecord")); assertNull(field("audioTrack")) + engine.stop(); assertEquals(1, stops()) + start(8) + } + + @Test fun outputReleaseRetriesTheUnderlyingTrackInsteadOfClosingTheWrapperEarly() { + val output = CallAudioOutput.open(manager, 0, false) + val track = output.track!! + track.failures = setOf("release") + rejected { output.release() } + assertFalse(track.released) + track.failures = emptySet(); output.release(); output.release() + assertTrue(track.released); assertEquals(2, track.releaseCalls) + } + + @Test fun failuresAfterReleaseKeepOwnershipUntilTheNextSuccessfulRelease() { + start() + val record = AudioRecord.created.single().also { it.failures = setOf("release after") } + val track = AudioTrack.created.single().also { it.failures = setOf("release after") } + rejected { engine.stop() } + assertTrue(record.released); assertTrue(track.released); assertEquals(0, stops()) + assertSame(record, field("audioRecord")); assertNotNull(field("audioTrack")) + record.failures = emptySet(); track.failures = emptySet() + engine.stop(); assertEquals(2, record.releaseCalls); assertEquals(2, track.releaseCalls) + assertEquals(1, stops()) + } + + @Test fun communicationListenerFailureDoesNotPreventDeviceReleaseAndRemainsRetryable() { + Build.VERSION.SDK_INT = 36 + manager.devices += AudioDeviceInfo(11, AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, true, false, "earpiece") + engine.router.setRoute(AudioRoute.EARPIECE) + start() + manager.failures = setOf("communication remove") + rejected { engine.stop() } + assertTrue(AudioRecord.created.single().released); assertTrue(AudioTrack.created.single().released) + assertEquals(1, manager.communicationListeners.size); assertEquals(0, stops()) + assertTrue(diagnostic("communication listener removal failed")) + manager.failures = emptySet(); engine.stop() + assertTrue(manager.communicationListeners.isEmpty()); assertEquals(1, stops()) + } + + @Test fun partialCommunicationRegistrationRemainsOwnedWhenStartupRollbackFails() { + Build.VERSION.SDK_INT = 36 + manager.failures = setOf("communication add after", "communication remove") + rejected { engine.start(7) } + assertFalse(engine.isActive()); assertEquals(1, manager.communicationListeners.size) + assertEquals(0, stops()); assertTrue(AudioRecord.created.isEmpty()) + manager.failures = emptySet(); engine.stop() + assertTrue(manager.communicationListeners.isEmpty()); assertEquals(1, stops()) + } + + @Test fun modeRestoreFailuresBeforeAndAfterTheEffectRemainRetryable() { + for (failure in listOf("speaker=false", "mode=0", "mode after=0")) { + start() + manager.failures = setOf(failure) + val previousStops = stops() + rejected { engine.stop() } + assertEquals(true, field("modeOwned")); assertEquals(previousStops, stops()) + assertTrue(AudioRecord.created.all { it.released }); assertTrue(diagnostic("restore audio mode failed")) + manager.failures = emptySet(); engine.stop() + assertEquals(previousStops + 1, stops()); assertEquals(AudioManager.MODE_NORMAL, manager.mode) + } + } + + @Test fun nativeStopFailureKeepsCallIdentityAndRetriesAfterDevicesAreReleased() { + start() + NativeLib.callAudioFailures = setOf("stop") + rejected { engine.stop() } + assertEquals(7L, field("callId")); assertTrue(AudioRecord.created.single().released) + assertTrue(AudioTrack.created.single().released); assertTrue(diagnostic("native stop failed")) + NativeLib.callAudioFailures = emptySet(); engine.stop() + assertEquals(2, stops()); assertEquals(0L, field("callId")) + } + + @Test fun blockedCaptureKeepsDevicesAndNativeUntilTheWorkerActuallyExits() { + val entered = CountDownLatch(1) + val unblock = CountDownLatch(1).also { gates.add(it) } + NativeLib.onCallFeed = { entered.countDown(); unblock.await() } + start(); assertTrue(entered.await(3, TimeUnit.SECONDS)) + rejected { engine.stop() } + assertNotNull(field("captureThread")); assertFalse(AudioRecord.created.single().released) + assertFalse(AudioTrack.created.single().released); assertEquals(0, stops()) + assertTrue(diagnostic("worker join pending")); assertTrue(diagnostic("cleanup pending")) + unblock.countDown() + await("capture exits") { (field("captureThread") as Thread).isAlive.not() } + engine.stop(); assertEquals(1, stops()) + } + + @Test fun recordSetupFailureRetainsEvenAnUninitializedDeviceUntilReleaseSucceeds() { + for (failure in listOf("uninitialized", "state", "add listener", "add listener after", "start")) { + AudioRecord.nextFailures = setOf(failure, "release") + assertTrue(engine.start(7)) + await("capture setup failure $failure") { failures.isNotEmpty() } + val record = AudioRecord.created.last() + rejected { engine.stop() } + assertSame(record, field("audioRecord")); assertFalse(record.released) + record.failures = emptySet(); engine.stop(); failures.clear() + } + } + + @Test fun trackSetupFailureRetainsEvenAnUninitializedDeviceUntilReleaseSucceeds() { + for (failure in listOf("uninitialized", "state", "add listener", "add listener after", "play")) { + AudioTrack.nextFailures = setOf(failure, "release") + assertTrue(engine.start(7)) + await("playback setup failure $failure") { failures.isNotEmpty() } + val track = AudioTrack.created.last() + rejected { engine.stop() } + assertNotNull(field("audioTrack")); assertFalse(track.released) + track.failures = emptySet(); engine.stop(); failures.clear() + } + } + + @Test fun stopAndPauseFailuresStillReleaseBothDevicesAfterWorkersExit() { + start() + AudioRecord.created.single().failures = setOf("stop") + AudioTrack.created.single().failures = setOf("pause", "remove listener") + engine.stop() + assertTrue(AudioRecord.created.single().released); assertTrue(AudioTrack.created.single().released) + assertTrue(diagnostic("record stop failed")); assertTrue(diagnostic("track pause failed")) + assertEquals(1, stops()) + } +}