From 53383ec75440fc59ed5d3c23c2925f33133eb87a Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 01:00:07 +0100 Subject: [PATCH] Reproduce premature video shutdown acknowledgments and lost camera cleanup owners --- .../android/hardware/camera2/HostCamera2.kt | 6 +- .../chat/data/CallVideoEngineLifecycleTest.kt | 117 ++++++++++++++++++ 2 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/CallVideoEngineLifecycleTest.kt diff --git a/tools/chatgui-android/video-tests/src/main/kotlin/android/hardware/camera2/HostCamera2.kt b/tools/chatgui-android/video-tests/src/main/kotlin/android/hardware/camera2/HostCamera2.kt index 05207b8b..ae359d40 100644 --- a/tools/chatgui-android/video-tests/src/main/kotlin/android/hardware/camera2/HostCamera2.kt +++ b/tools/chatgui-android/video-tests/src/main/kotlin/android/hardware/camera2/HostCamera2.kt @@ -40,7 +40,7 @@ class CaptureRequest { /** Реальные callback-поколения SDK воспроизводит тест; камера/сессия закрываются независимо. */ class CameraManager { companion object { var shared = CameraManager() } - data class Open(val device: CameraDevice, val callback: CameraDevice.StateCallback, val handler: Handler) + data class Open(val device: CameraDevice, val callback: CameraDevice.StateCallback, val handler: Handler, @Volatile var delivered: Boolean = false) val opened = CopyOnWriteArrayList() val sessions = CopyOnWriteArrayList() val characteristics = CameraCharacteristics() @@ -57,10 +57,10 @@ class CameraManager { check("open" !in failures) { "camera open failed" } val device = CameraDevice(id, this, callback, handler).also { it.failures = nextDeviceFailures } opened.add(Open(device, callback, handler)) - if (autoOpened) handler.post { callback.onOpened(device) } + if (autoOpened) deliverOpened(opened.lastIndex) check("open after" !in failures) { "camera open failed after effect" } } - fun deliverOpened(index: Int) { val r = opened[index]; r.handler.post { r.callback.onOpened(r.device) } } + fun deliverOpened(index: Int) { val r = opened[index]; r.handler.post { r.delivered = true; r.callback.onOpened(r.device) } } fun deliverClosed(index: Int) { val r = opened[index]; r.handler.post { r.callback.onClosed(r.device) } } } class CameraDevice(val id: String, private val owner: CameraManager, private val callback: StateCallback, private val handler: Handler) { diff --git a/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/CallVideoEngineLifecycleTest.kt b/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/CallVideoEngineLifecycleTest.kt new file mode 100644 index 00000000..fd20fe5a --- /dev/null +++ b/tools/chatgui-android/video-tests/src/test/kotlin/com/utun/chat/data/CallVideoEngineLifecycleTest.kt @@ -0,0 +1,117 @@ +package com.utun.chat.data + +import android.content.Context +import android.hardware.camera2.CameraManager +import android.media.ImageReader +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.view.OrientationEventListener +import com.utun.chat.ChatApplication +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 + +/** Реальные TX/RX/camera callbacks; hardware, capability probe и JNI задаются на границах. */ +class CallVideoEngineLifecycleTest { + private val engines = mutableListOf() + private val uncaught = CopyOnWriteArrayList() + private val manager get() = CameraManager.shared + private val main get() = Looper.getMainLooper() + @Before fun prepare() { + main.advanceBy(0); assertSame(main, Looper.myLooper()) + CameraManager.shared = CameraManager(); ImageReader.created.clear(); ImageReader.nextFailures = emptySet() + CallVideoCodec.created.clear(); CallVideoCodec.nextFailures = emptySet(); CallVideoCodec.onProbe = {} + OrientationEventListener.created.clear(); OrientationEventListener.failures = emptySet() + ChatApplication.instance.configProvider.failures = emptySet() + CallVideoNative.reset(); Handler.rejectDelayed = false; Handler.rejectImmediate = false; LogManager.entries.clear() + } + private fun thread(engine: CallVideoEngine) = CallVideoEngine::class.java.getDeclaredField("thread") + .apply { isAccessible = true }.get(engine) as HandlerThread + private fun await(message: String, condition: () -> Boolean) { + val end = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (!condition() && System.nanoTime() < end) { main.advanceBy(10); Thread.sleep(2) } + assertTrue(message, condition()) + } + private fun engine(): CallVideoEngine = CallVideoEngine(Context(), 7).also { engine -> + engines.add(engine) + thread(engine).uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { _, error -> + uncaught.add(error); LogManager.addLog("ERROR", "VideoEngineTest", "uncaught worker error: $error") + } + await("video capabilities ready") { engine.state.value.ready } + } + private fun capture(): CallVideoEngine { + val engine = engine(); engine.select(engine.state.value.settings.copy(enabled = true)) + await("camera is repeating") { manager.sessions.any { it.repeating } } + return engine + } + @After fun release() { + Handler.rejectDelayed = false; Handler.rejectImmediate = false + OrientationEventListener.failures = emptySet(); ChatApplication.instance.configProvider.failures = emptySet() + CallVideoNative.failures = emptySet(); CallVideoNative.onOpen = {}; CallVideoNative.onClose = {} + CallVideoCodec.onProbe = {}; CallVideoCodec.created.forEach { it.failures = emptySet() } + manager.failures = emptySet(); manager.autoClosed = true + manager.opened.forEachIndexed { index, open -> + open.device.failures = emptySet() + if (!open.delivered && !open.device.closed) manager.deliverOpened(index) + if (open.device.closed) manager.deliverClosed(index) + } + manager.sessions.forEach { it.failures = emptySet() }; ImageReader.created.forEach { it.failures = emptySet() } + for (engine in engines) { + await("confirmed video stop") { engine.stop() && !thread(engine).isAlive } + } + main.advanceBy(30_000) + assertTrue("uncaught worker exceptions: $uncaught", uncaught.isEmpty()) + assertTrue(manager.opened.all { it.device.closed }); assertTrue(ImageReader.created.all { it.closed }) + assertTrue(CallVideoCodec.created.all { it.closed }); assertTrue(CallVideoNative.ports.isEmpty()) + assertTrue(OrientationEventListener.created.none { it.enabled }); assertTrue(CallVideoNative.live.isEmpty()) + } + + @Test fun stopWaitsForCameraClosedBeforeAcknowledgingAndClosingTheNativePort() { + val engine = capture(); manager.autoClosed = false + assertFalse(engine.stop()) + await("close requested") { manager.opened.single().device.closed } + assertFalse(engine.stop()); assertTrue(7L in CallVideoNative.ports) + manager.deliverClosed(0) + await("camera closure acknowledged") { engine.stop() } + assertFalse(7L in CallVideoNative.ports) + } + + @Test fun failedReaderReleaseDoesNotSkipCameraCloseOrLoseTheReader() { + val engine = capture(); val reader = ImageReader.created.single().also { it.failures = setOf("close") } + engine.stop() + await("camera close despite reader failure") { manager.opened.single().device.closed } + assertFalse(engine.stop()); assertFalse(reader.closed); assertTrue(7L in CallVideoNative.ports) + reader.failures = emptySet(); await("reader retry completes stop") { engine.stop() } + assertTrue(reader.closed) + } + + @Test fun failedDeviceCloseKeepsOwnershipAndTheNativePortUntilRetry() { + val engine = capture(); val device = manager.opened.single().device.also { it.failures = setOf("close") } + engine.stop(); await("device close attempted") { device.closeCalls > 0 } + assertFalse(device.closed); assertFalse(engine.stop()); assertTrue(7L in CallVideoNative.ports) + device.failures = emptySet(); await("device close retries") { engine.stop() } + assertTrue(device.closed) + } + + @Test fun encoderReleaseErrorCannotAbortIndependentCameraAndSensorCleanup() { + val engine = capture(); val encoder = CallVideoCodec.created.single().also { it.failures = setOf("close") } + engine.stop() + await("camera closes even when encoder release fails") { manager.opened.single().device.closed } + assertTrue(OrientationEventListener.created.none { it.enabled }) + assertFalse(encoder.closed); assertFalse(engine.stop()); assertTrue(thread(engine).isAlive) + encoder.failures = emptySet(); await("encoder release retries") { engine.stop() } + } + + @Test fun settingsSaveFailureIsContainedAndDoesNotApplyAnUncommittedSelection() { + val engine = engine(); val previous = engine.state.value.settings + ChatApplication.instance.configProvider.failures = setOf("settings save") + engine.select(previous.copy(enabled = true)) + await("selection rejection reported") { engine.state.value.notice != null } + assertTrue(thread(engine).isAlive); assertEquals(previous, engine.state.value.settings) + assertTrue(manager.opened.isEmpty()) + } +}