diff --git a/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/StreamPlayerEngineLifecycleTest.kt b/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/StreamPlayerEngineLifecycleTest.kt new file mode 100644 index 00000000..c1ecb10c --- /dev/null +++ b/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/StreamPlayerEngineLifecycleTest.kt @@ -0,0 +1,106 @@ +package com.utun.chat.data + +import android.media.AudioTrack +import android.media.MediaCodec +import android.os.Looper +import android.view.Surface +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Настоящие viewer/video/mixer workers; управляемые границы JNI и Android SDK. */ +class StreamPlayerEngineLifecycleTest { + private val viewers = mutableListOf() + private val surfaces = mutableListOf() + private val main get() = Looper.getMainLooper() + @Before fun prepare() { + main.advanceBy(30_000); assertFalse(StreamPlayerEngine.viewing.value) + HostStreams.reset(); CallController.blocked = false + MediaCodec.created.clear(); MediaCodec.nextFailures = emptySet(); MediaCodec.onInput = {} + AudioTrack.created.clear(); AudioTrack.nextFailures = emptySet(); MediaAudioController.failures = emptySet() + } + @After fun release() { + HostStreams.failures = emptySet(); HostStreams.onPull = {}; HostStreams.onWait = {} + MediaCodec.created.forEach { it.failures = emptySet() }; AudioTrack.created.forEach { it.failures = emptySet() } + MediaAudioController.failures = emptySet(); viewers.forEach { it.stop() } + await("viewer registry cleanup") { !StreamPlayerEngine.viewing.value } + StreamAudioMixer.awaitIdle(); surfaces.forEach { it.release() }; main.advanceBy(30_000) + assertTrue(HostStreams.ports.isEmpty()); assertTrue(HostStreams.signals.isEmpty()); assertTrue(HostStreams.playbacks.isEmpty()) + assertTrue(MediaCodec.created.all { it.released }); assertTrue(AudioTrack.created.all { it.released }) + assertTrue(MediaAudioController.outputs.isEmpty()) + } + private fun await(message: String, condition: () -> Boolean) { + val until = System.nanoTime() + TimeUnit.SECONDS.toNanos(4) + while (!condition() && System.nanoTime() < until) { main.advanceBy(10); Thread.sleep(2) } + main.advanceBy(0); assertTrue(message, condition()) + } + private fun record(kind: Int, data: ByteArray = byteArrayOf()) = ByteBuffer.allocate(28 + data.size).order(ByteOrder.BIG_ENDIAN) + .put(kind.toByte()).put(1).putShort(640).putShort(480).putShort(0).putLong(1000).putInt(data.size).putInt(1) + .put(1).put(0).put(0).put(0).put(data).array() + private fun viewer(key: Boolean = false, audio: Boolean = false): StreamPlayerEngine { + HostStreams.status = true; HostStreams.audioEnabled = audio + if (key) HostStreams.records.add(record(1, StreamVideoCodec.H264.keyFrame(byteArrayOf(0, 0, 0, 1, 5, 1), + byteArrayOf(0, 0, 0, 1, 7, 1, 0, 0, 0, 1, 8, 1)))) + return StreamPlayerEngine(1, 2, 3).also { + viewers.add(it); val surface = Surface().also { surfaces.add(it) }; it.start(surface) + } + } + + @Test fun failedOpenStillCompletesCleanupAndTheReleaseCallback() { + HostStreams.failures = setOf("open"); val viewer = viewer(); val released = AtomicInteger() + viewer.stop { released.incrementAndGet() }; await("failed open callback") { released.get() == 1 } + assertTrue(HostStreams.signals.isEmpty()) + } + @Test fun nativeStopFailureCannotSkipIndependentPlaybackCleanupOrLoseTheSignal() { + val viewer = viewer(); await("native playback opened") { HostStreams.playbacks.isNotEmpty() } + HostStreams.failures = setOf("stop"); val released = AtomicInteger(); viewer.stop { released.incrementAndGet() } + await("independent audio cleanup") { HostStreams.playbacks.isEmpty() } + assertEquals(0, released.get()); assertTrue(HostStreams.signals.isNotEmpty()); assertTrue(StreamPlayerEngine.viewing.value) + HostStreams.failures = emptySet(); await("native stop retry acknowledged") { released.get() == 1 } + } + @Test fun decoderReleaseFailureKeepsTheSurfaceCallbackAndNotificationOwned() { + val viewer = viewer(key = true); await("decoder is active") { MediaCodec.created.any { it.started } } + MediaCodec.created.single().failures = setOf("release") + val released = AtomicInteger(); viewer.stop { released.incrementAndGet() } + await("playback closes independently") { HostStreams.playbacks.isEmpty() } + assertEquals(0, released.get()); assertTrue(HostStreams.signals.isNotEmpty()) + MediaCodec.created.single().failures = emptySet(); await("decoder retry acknowledged") { released.get() == 1 } + } + @Test fun audioResetFailureDoesNotDropTheOriginalNativeHandle() { + val viewer = viewer(); await("playback created") { HostStreams.playbacks.size == 1 } + val original = HostStreams.playbacks.single(); HostStreams.failures = setOf("playback close") + HostStreams.records.add(record(3)) + await("failed reset reported") { viewer.status.value == com.utun.chat.R.string.stream_failed } + assertEquals(setOf(original), HostStreams.playbacks.toSet()) + val released = AtomicInteger(); viewer.stop { released.incrementAndGet() }; assertEquals(0, released.get()) + HostStreams.failures = emptySet(); await("original handle release retries") { released.get() == 1 } + } + @Test fun signalCloseFailureDoesNotAcknowledgeTheSurfaceOrViewingBarrier() { + val viewer = viewer(); await("viewer opened") { HostStreams.signals.size == 1 } + HostStreams.failures = setOf("signal close"); val released = AtomicInteger(); viewer.stop { released.incrementAndGet() } + await("other native owners closed") { HostStreams.playbacks.isEmpty() && HostStreams.ports.isEmpty() } + assertEquals(0, released.get()); assertTrue(StreamPlayerEngine.viewing.value) + HostStreams.failures = emptySet(); await("signal close retry") { released.get() == 1 } + } + @Test fun failedReleaseCallbackIsRetriedBeforeTheViewerIsAcknowledged() { + val viewer = viewer(); await("viewer opened") { HostStreams.signals.size == 1 } + val attempts = AtomicInteger(); val released = AtomicInteger() + viewer.stop { if (attempts.incrementAndGet() == 1) error("Surface release failed"); released.incrementAndGet() } + await("release callback retries") { released.get() == 1 }; assertEquals(2, attempts.get()) + } + @Test fun failedAudioOutputReleaseBlocksAMixerReplacementUntilTheOldDeviceAcknowledges() { + val first = viewer(key = true, audio = true); await("first shared output active") { AudioTrack.created.size == 1 } + val old = AudioTrack.created.single(); old.failures = setOf("release") + first.stop(); await("first viewer released") { !StreamPlayerEngine.viewing.value } + assertThrows(IllegalStateException::class.java) { StreamAudioMixer.awaitIdle() } + val second = viewer(key = true, audio = true); await("second playback ready") { HostStreams.playbacks.isNotEmpty() } + assertEquals(1, AudioTrack.created.size); assertFalse(old.released) + old.failures = emptySet(); await("second output opens after old release") { AudioTrack.created.size == 2 } + assertTrue(old.released); second.stop() + } +}