Cover call audio teardown and partial device startup failures
This commit is contained in:
1 parent
83a671b72f
commit
f385d3dc5e
1 file changed
+208
+208
@@ -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<CountDownLatch>()
|
||||
private val main get() = Looper.getMainLooper()
|
||||
private val manager get() = AudioManager.shared
|
||||
private val failures = mutableListOf<String>()
|
||||
|
||||
@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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user