diff --git a/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/StreamCaptureEngineLifecycleTest.kt b/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/StreamCaptureEngineLifecycleTest.kt new file mode 100644 index 00000000..e1256db7 --- /dev/null +++ b/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/StreamCaptureEngineLifecycleTest.kt @@ -0,0 +1,151 @@ +package com.utun.chat.data + +import android.content.Context +import android.graphics.SurfaceTexture +import android.hardware.camera2.CameraManager +import android.media.MediaCodec +import android.media.MediaFormat +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.view.OrientationEventListener +import java.nio.ByteBuffer +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Настоящий capture worker/SDK callbacks; камера, MediaCodec, PCM и JNI управляются на границах. */ +class StreamCaptureEngineLifecycleTest { + private val engines = mutableListOf() + private val targets = mutableListOf() + private val failures = CopyOnWriteArrayList() + private val uncaught = CopyOnWriteArrayList() + private val confirmed = CopyOnWriteArrayList() + private val manager get() = CameraManager.shared + private val config = StreamCaptureConfig(StreamCaptureMode()) + @Before fun prepare() { + CameraManager.shared = CameraManager(); MediaCodec.created.clear(); MediaCodec.nextFailures = emptySet() + MediaCodec.onInput = {}; HostStreams.reset(); Handler.rejectImmediate = false; Handler.rejectDelayed = false + OrientationEventListener.created.clear(); OrientationEventListener.failures = emptySet() + MediaAudioController.failures = emptySet(); MediaAudioController.subscriptions.clear() + } + private fun worker(engine: StreamCaptureEngine) = StreamCaptureEngine::class.java.getDeclaredField("thread") + .apply { isAccessible = true }.get(engine) as HandlerThread + private fun await(message: String, condition: () -> Boolean) { + val until = System.nanoTime() + TimeUnit.SECONDS.toNanos(4) + while (!condition() && System.nanoTime() < until) { Looper.getMainLooper().advanceBy(10); Thread.sleep(2) } + assertTrue(message, condition()) + } + private fun engine(audio: Boolean = false, preview: StreamPreview = StreamPreview(), start: Boolean = true) = + StreamCaptureEngine(Context(), 77, 7, config, audio, preview, Any(), { value, _ -> confirmed.add(value) }, { failures.add(it) }).also { + engines.add(it); worker(it).uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { _, e -> uncaught.add(e) } + if (start) it.start() + } + private fun capture(audio: Boolean = false): StreamCaptureEngine = engine(audio).also { + await("camera capture configured") { manager.sessions.any { it.repeating } } + } + private fun confirm() { + val codec = MediaCodec.created.last() + codec.emitFormat(MediaFormat().apply { setByteBuffer("csd-0", ByteBuffer.wrap(byteArrayOf(0, 0, 0, 1, 7, 1, 0, 0, 0, 1, 8, 1))) }) + codec.emitFrame(byteArrayOf(0, 0, 0, 1, 5, 1)) + await("capture KEY acknowledged") { confirmed.isNotEmpty() } + } + @After fun release() { + Handler.rejectImmediate = false; Handler.rejectDelayed = false; OrientationEventListener.failures = emptySet() + MediaAudioController.failures = emptySet(); MediaAudioController.subscriptions.forEach { it.failures = emptySet() } + HostStreams.failures = emptySet(); manager.autoClosed = true; manager.failures = emptySet() + MediaCodec.created.forEach { it.failures = emptySet(); it.inputSurface?.failures = emptySet() } + manager.opened.forEachIndexed { i, open -> + open.device.failures = emptySet() + if (!open.delivered && !open.device.closed) manager.deliverOpened(i) + if (open.device.closed) manager.deliverClosed(i) + } + manager.sessions.forEach { it.failures = emptySet() } + engines.forEach { engine -> await("capture stop acknowledged") { engine.stop() && !worker(engine).isAlive } } + targets.forEach { it.release() }; Looper.getMainLooper().advanceBy(30_000); NativeLib.streamStop(77) + assertTrue("uncaught exceptions=$uncaught", uncaught.isEmpty()) + assertTrue(MediaCodec.created.all { it.released && it.inputSurface?.isValid != true }) + assertTrue(manager.opened.all { it.device.closed }); assertTrue(HostStreams.opuses.isEmpty()) + assertTrue(MediaAudioController.subscriptions.all { it.closed }); assertTrue(OrientationEventListener.created.none { it.enabled }) + } + + @Test fun passiveOwnerStopsWithoutStartingWorkersOrNativeResources() { + val engine = engine(start = false); assertTrue(engine.stop()); assertTrue(HostStreams.ports.isEmpty()) + assertTrue(MediaCodec.created.isEmpty()) + } + @Test fun cameraCloseTimeoutCannotReleaseTheEncoderOrInputSurfaceWithoutAck() { + val engine = capture(); manager.autoClosed = false; assertFalse(engine.stop()) + await("camera close requested") { manager.opened.single().device.closed } + Thread.sleep(3200) + val codec = MediaCodec.created.single(); assertFalse(engine.stop()); assertFalse(codec.released); assertTrue(codec.inputSurface!!.isValid) + manager.deliverClosed(0); await("late camera ACK releases resources") { engine.stop() } + } + @Test fun failedCameraCloseRetainsTheDeviceUntilRetry() { + val engine = capture(); val device = manager.opened.single().device; device.failures = setOf("close") + engine.stop(); await("close attempted") { device.closeCalls > 0 }; assertFalse(engine.stop()); assertFalse(device.closed) + device.failures = emptySet(); await("device retry acknowledged") { engine.stop() } + } + @Test fun failedEncoderReleaseProtectsTheInputSurfaceAndDoesNotSkipCameraOrSensor() { + val engine = capture(); val codec = MediaCodec.created.single(); codec.failures = setOf("release") + engine.stop(); await("camera closes") { manager.opened.single().device.closed } + assertFalse(engine.stop()); assertTrue(codec.inputSurface!!.isValid); assertTrue(OrientationEventListener.created.none { it.enabled }) + codec.failures = emptySet(); await("encoder close retried") { engine.stop() } + } + @Test fun inputSurfaceReleaseFailureAfterEffectRequiresAnAcknowledgedRetry() { + val engine = capture(); val codec = MediaCodec.created.single(); val surface = codec.inputSurface!! + surface.failures = setOf("release after"); engine.stop(); await("encoder releases") { codec.released } + assertFalse(engine.stop()); assertFalse(surface.isValid) + surface.failures = emptySet(); await("Surface close retry") { engine.stop() } + } + @Test fun partialEncoderStartupRetainsItsCodecEvenBeforeCreatingAnInputSurface() { + MediaCodec.nextFailures = setOf("callback after", "release") + val engine = engine(); await("setup failure reported") { failures.isNotEmpty() } + assertFalse(engine.stop()); assertFalse(MediaCodec.created.single().released) + MediaCodec.created.single().failures = emptySet(); await("partial codec cleanup completes") { engine.stop() } + } + @Test fun pendingCameraOpenAndErrorCallbackRemainOwnedAfterStop() { + manager.autoOpened = false + val engine = engine(); await("open requested") { manager.opened.isNotEmpty() }; assertFalse(engine.stop()) + manager.deliverOpened(0); await("late opened device cleaned up") { engine.stop() }; assertTrue(manager.sessions.isEmpty()) + } + @Test fun synchronousOpenFailureWithQueuedOnErrorDoesNotLoseTheCamera() { + manager.failures = setOf("open after") + val engine = engine(); await("open failure reported") { failures.isNotEmpty() } + await("error callback device released") { engine.stop() }; assertTrue(manager.opened.single().device.closed) + } + @Test fun previewConfigurationFailureFallsBackAndReleasesTheFailedSession() { + manager.sessionFailures.add(setOf("configure")); val preview = StreamPreview() + val target = preview.attach(SurfaceTexture(1)).also { targets.add(it) }; val engine = engine(preview = preview) + await("preview fallback capture starts") { manager.sessions.size == 2 && manager.sessions.last().repeating } + assertTrue(failures.isEmpty()); await("fallback cleanup") { engine.stop() }; assertTrue(target.surface.isValid) + } + @Test fun microphoneCloseFailureDoesNotPreventCameraAndOpusCleanup() { + val engine = capture(audio = true); val subscription = MediaAudioController.subscriptions.single() + subscription.failures = setOf("close"); engine.stop() + await("independent camera and DSP cleanup") { manager.opened.single().device.closed && HostStreams.opuses.isEmpty() } + assertFalse(engine.stop()); subscription.failures = emptySet(); await("subscription close retry") { engine.stop() } + } + @Test fun opusReleaseFailureKeepsItsWorkerAndCaptureCompletionOwned() { + val engine = capture(audio = true); await("Opus allocated") { HostStreams.opuses.isNotEmpty() } + HostStreams.failures = setOf("opus close"); engine.stop() + await("camera closes independently") { manager.opened.single().device.closed } + assertFalse(engine.stop()); assertTrue(HostStreams.opuses.isNotEmpty()) + HostStreams.failures = emptySet(); await("Opus retry permits final stop") { engine.stop() } + } + @Test fun stopCancelsAPendingModeSwitchUntilTheOldCameraAcknowledgesClosure() { + val engine = capture(); confirm(); manager.autoClosed = false + engine.change(config.copy(orientation = StreamOrientation.RIGHT)) + await("switch waits for camera closure") { manager.opened.single().device.closed } + assertFalse(engine.stop()); manager.deliverClosed(0); await("cancelled switch cleanup") { engine.stop() } + assertEquals(1, MediaCodec.created.size); assertEquals(1, manager.opened.size) + } + @Test fun rejectedCleanupPostCannotAcknowledgeALiveCapture() { + val engine = capture(); Handler.rejectImmediate = true + assertThrows(IllegalStateException::class.java) { engine.stop() } + assertFalse(manager.opened.single().device.closed) + Handler.rejectImmediate = false; await("explicit cleanup retry") { engine.stop() } + } +}