Reproduce premature video shutdown acknowledgments and lost camera cleanup owners

This commit is contained in:
evgeny committed 2026-10-29 13:34:24 +01:00
1 parent 43f61f6f0b
commit 53383ec754
2 files changed
+120 -3

No files matched your search

@@ -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<Open>()
val sessions = CopyOnWriteArrayList<CameraCaptureSession>()
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) {
@@ -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<CallVideoEngine>()
private val uncaught = CopyOnWriteArrayList<Throwable>()
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())
}
}