From 5e9f7e01e0addd6f9fe7708afa5b98af599704ca Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 01:48:22 +0100 Subject: [PATCH] Preserve capture camera and session ownership through late callbacks --- .../com/utun/chat/data/StreamCaptureEngine.kt | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt index 6fbf4329..5cf5cb29 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamCaptureEngine.kt @@ -255,34 +255,48 @@ class StreamCaptureEngine( try { manager.openCamera(mode.camera, object : CameraDevice.StateCallback() { override fun onOpened(device: CameraDevice) { - if (openingGeneration == token) { opening = false; camera = device } - if (!current(token)) { device.close(); return } + if (device in closedCameras) { LogManager.addLog("WARN", "video", "closed capture camera callback ignored id=$id token=$token"); return } + if (openingGeneration == token) opening = false + if (!current(token)) { closingCameras.add(device); queueCleanup(0); return } + camera = device createSession(device, config, token) } override fun onClosed(device: CameraDevice) { + if (!closedCameras.add(device)) { LogManager.videoTrace("duplicate capture camera close id=$id token=$token"); return } if (camera === device) camera = null + closingCameras.remove(device) val pending = cameraSessions.values.filter { it.first === device }.map { it.second } LogManager.videoTrace("camera closed id=$id generation=$token retiringSessions=${pending.size}") pending.forEach { it("cameraClosed") } - if (releaseDone != null && camera == null && !opening) finishRelease(true) - maybeQuit() - } - override fun onDisconnected(device: CameraDevice) { - if (openingGeneration == token) { opening = false; camera = device } - device.close(); captureError(token, "camera disconnected") - } - override fun onError(device: CameraDevice, error: Int) { - if (openingGeneration == token) { opening = false; camera = device } - device.close(); captureError(token, "camera error=$error") + ownedSessions.entries.removeAll { it.value === device }; closingSessions.removeAll { it !in ownedSessions } + queueCleanup(0) } + override fun onDisconnected(device: CameraDevice) { cameraFailure(device, token, "camera disconnected") } + override fun onError(device: CameraDevice, error: Int) { cameraFailure(device, token, "camera error=$error") } }, handler) - } catch (e: Exception) { opening = false; throw e } - handler.postDelayed({ if (current(token) && (waitingFirst || confirming)) captureError(token, "keyframe/capture rate validation timeout") }, 8000) + } catch (e: Exception) { + val awaitsCallback = e is CameraAccessException && e.reason in listOf(CameraAccessException.CAMERA_IN_USE, + CameraAccessException.CAMERA_DISABLED, CameraAccessException.CAMERA_DISCONNECTED) + if (!awaitsCallback && openingGeneration == token) opening = false + LogManager.addLog("ERROR", "video", "capture camera open failed id=$id token=$token awaitingCallback=$awaitsCallback: $e") + throw e + } + check(handler.postDelayed({ if (current(token) && (waitingFirst || confirming)) + captureError(token, "keyframe/capture rate validation timeout") }, 8000)) { "Capture validation timer rejected" } } catch (e: Exception) { captureError(token, "video setup: ${e.message}") } } private fun current(token: Long) = token == generation && !stopping && !stopped && !terminalFailure && releaseDone == null + private fun cameraFailure(device: CameraDevice, token: Long, detail: String) { + if (device in closedCameras) { LogManager.addLog("WARN", "video", "closed capture camera error ignored id=$id token=$token: $detail"); return } + if (openingGeneration == token) opening = false + closingCameras.add(device) + if (current(token)) { camera = device; captureError(token, detail) } + else LogManager.addLog("WARN", "video", "stale capture camera error id=$id token=$token/$generation: $detail") + queueCleanup(0) + } + /** Меняется только набор Camera2 outputs; encoder, audio и origin не перезапускаются. */ private fun createSession(device: CameraDevice, config: StreamCaptureConfig, token: Long, withPreview: Boolean = true) { val mode = config.mode @@ -294,7 +308,7 @@ class StreamCaptureEngine( if (completed) return completed = true; cameraSessions.remove(serial); output?.release() LogManager.videoTrace("camera session output released id=$id session=$serial reason=$reason remaining=${cameraSessions.size}") - maybeQuit() + if (releaseDone != null || stopping) queueCleanup(0) } cameraSessions[serial] = device to ::complete fun valid() = current(token) && serial == sessionGeneration @@ -305,6 +319,7 @@ class StreamCaptureEngine( preview.update(format, failed = true) createSession(device, config, token, withPreview = false) } + var submitted = false try { if (output != null) { val characteristics = manager.getCameraCharacteristics(mode.camera) @@ -323,7 +338,9 @@ class StreamCaptureEngine( val targets = listOfNotNull(checkNotNull(input) { "Missing encoder surface" }, output?.surface) val callback = object : CameraCaptureSession.StateCallback() { override fun onConfigured(configured: CameraCaptureSession) { - if (!valid()) { configured.close(); return } + if (completed) { LogManager.addLog("WARN", "video", "closed capture session callback ignored id=$id serial=$serial"); return } + ownedSessions[configured] = device + if (!valid()) { closingSessions.add(configured); queueCleanup(0); return } session = configured try { val request = device.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply { @@ -360,26 +377,27 @@ class StreamCaptureEngine( LogManager.addLog("INFO", "video", "capture started id=$id generation=$token session=$serial camera=${mode.camera} ${mode.label} " + "range=${mode.minimumFps}..${mode.fps} highSpeed=${mode.highSpeed} encoder=${encoder?.name} " + "preview=${output != null} previewSize=${format?.let { "${it.width}x${it.height}" }} origin=$originNs") - } catch (e: Exception) { configured.close(); rejected("camera request: ${e.message}") } + } catch (e: Exception) { closingSessions.add(configured); queueCleanup(0); rejected("camera request: ${e.message}") } } override fun onConfigureFailed(session: CameraCaptureSession) { complete("configureFailed"); rejected("camera session configuration") } override fun onClosed(closed: CameraCaptureSession) { + ownedSessions.remove(closed); closingSessions.remove(closed) if (session === closed) session = null complete("sessionClosed") } } LogManager.videoTrace("camera session configuring id=$id generation=$token session=$serial outputs=${targets.size}") + submitted = true if (mode.highSpeed) device.createConstrainedHighSpeedCaptureSession(targets, callback, handler) else device.createCaptureSession(targets, callback, handler) - } catch (e: Exception) { complete("exception"); rejected("camera session: ${e.message}") } - } - - private fun maybeQuit() { - if (stopped && camera == null && !opening && cameraSessions.isEmpty()) { - LogManager.addLog("INFO", "video", "capture handler released id=$id pendingSessions=0") - thread.quitSafely() + } catch (e: Exception) { + if (!submitted) { complete("exception before submit"); rejected("camera session: ${e.message}") } + else { + LogManager.addLog("ERROR", "video", "capture session submit failed id=$id serial=$serial; outputs retained until device closes: $e") + captureError(token, "camera session: ${e.message}") + } } } @@ -390,7 +408,7 @@ class StreamCaptureEngine( if (previous == null || reverting) { failed(detail); return } waitingFirst = true; reverting = true LogManager.addLog("WARN", "video", "restoring camera=${previous.mode.camera} ${previous.mode.label} orientation=${previous.orientation} id=$id") - releaseVideo { closed -> if (closed) configure(previous) else failed("camera close timed out during restore") } + releaseVideo { configure(previous) } } private fun encoded(bytes: ByteArray, info: MediaCodec.BufferInfo, config: StreamCaptureConfig, pose: StreamPreviewCamera, token: Long) {