Exercise capture ownership across Camera2, Surface, microphone and Opus failures
This commit is contained in:
1 parent
6194538963
commit
4af2402e20
1 file changed
+151
+151
@@ -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<StreamCaptureEngine>()
|
||||
private val targets = mutableListOf<StreamPreviewTarget>()
|
||||
private val failures = CopyOnWriteArrayList<String>()
|
||||
private val uncaught = CopyOnWriteArrayList<Throwable>()
|
||||
private val confirmed = CopyOnWriteArrayList<StreamCaptureConfig>()
|
||||
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() }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user