From 5e9dad2e9e3bf2fa048b76fe045000dc63f1c5b3 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 01:08:31 +0100 Subject: [PATCH] Handle camera error callbacks promised alongside synchronous open failures --- .../com/utun/chat/data/CallVideoEngine.kt | 57 +++++++++++++------ .../android/hardware/camera2/HostCamera2.kt | 9 ++- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt index c4a26d4f..c5181701 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallVideoEngine.kt @@ -5,6 +5,7 @@ import android.content.Context import android.graphics.ImageFormat import android.graphics.SurfaceTexture import android.hardware.camera2.CameraCaptureSession +import android.hardware.camera2.CameraAccessException import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraDevice import android.hardware.camera2.CameraManager @@ -79,10 +80,12 @@ class CallVideoEngine(private val context: Context, private val id: Long) { private var portOwned = false // TX: выставляется до native open, снимается только после close. private val ownedReaders = linkedSetOf() private val closingCameras = linkedSetOf() + private val closedCameras = linkedSetOf() // Отсекает повтор уже подтверждённого SDK callback. private val ownedSessions = linkedMapOf() private val closingSessions = linkedSetOf() private val pendingEncoders = linkedSetOf() private var openingToken = 0L + private var nativeUnavailable = false private val cleanupTask = Runnable { synchronized(this) { cleanupQueued = false } cleanupResources() @@ -283,12 +286,22 @@ class CallVideoEngine(private val context: Context, private val id: Long) { fun range(value: CallVideoBitrateRange) { if (!handler.post { if (stopped) { LogManager.addLog("WARN", "call", "Bitrate selection after stop id=$id range=$value"); return@post } - if (CallVideoNative.range(id, value.minimum * 1000, value.maximum * 1000)) { - mutable.value = mutable.value.copy(range = value) - ChatApplication.instance.configProvider.saveCallVideoBitrateRange(value) - NativeLib.setChatSetting("call_video_bitrate_range_kbps", value.encode()) - LogManager.addLog("INFO", "call", "Video bitrate selected id=$id range=$value") - } else LogManager.addLog("WARN", "call", "Bitrate range rejected id=$id range=$value") + try { + if (CallVideoNative.range(id, value.minimum * 1000, value.maximum * 1000)) { + mutable.value = mutable.value.copy(range = value) + try { ChatApplication.instance.configProvider.saveCallVideoBitrateRange(value) } catch (e: Exception) { + LogManager.addLog("ERROR", "call", "video bitrate applied but not saved id=$id range=$value: $e") + mutable.value = mutable.value.copy(notice = R.string.call_video_unavailable) + } + try { NativeLib.setChatSetting("call_video_bitrate_range_kbps", value.encode()) } catch (e: Exception) { + LogManager.addLog("ERROR", "call", "video bitrate default update failed id=$id range=$value: $e") + } + LogManager.addLog("INFO", "call", "Video bitrate selected id=$id range=$value") + } else LogManager.addLog("WARN", "call", "Bitrate range rejected id=$id range=$value") + } catch (e: Exception) { + LogManager.addLog("ERROR", "call", "video bitrate selection failed id=$id range=$value: $e") + mutable.value = mutable.value.copy(notice = R.string.call_video_unavailable) + } }) LogManager.addLog("WARN", "call", "Bitrate selection rejected by stopped worker id=$id range=$value") } private fun report(message: String) { @@ -309,10 +322,13 @@ class CallVideoEngine(private val context: Context, private val id: Long) { } private fun tick() { - if (stopped) return + if (stopped || nativeUnavailable) return try { - val s = CallVideoNative.status(id) ?: return - check(s[5] == 0L) { "Core rejected video format" } + val s = CallVideoNative.status(id) ?: run { + nativeUnavailable = true; mutable.value = mutable.value.copy(ready = false) + fail("Video native port unavailable"); return + } + if (s[5] != 0L && active != null) { fail("Core rejected video format generation=${format?.generation}"); return } if (orientation.confirm(SystemClock.elapsedRealtime())) logOrientation() canEncode = s[10] != 0L && s[4] == format?.generation if (s[6] >= 300000 && s[7] >= s[6]) mutable.value = mutable.value.copy(range = CallVideoBitrateRange((s[6] / 1000).toInt(), (s[7] / 1000).toInt())) @@ -499,31 +515,39 @@ class CallVideoEngine(private val context: Context, private val id: Long) { opening = true; openingToken = token; openedAt = System.nanoTime(); lastCaptureAt = openedAt try { manager.openCamera(settings.camera, object : CameraDevice.StateCallback() { override fun onOpened(device: CameraDevice) { + if (device in closedCameras) { LogManager.addLog("WARN", "call", "closed camera opened callback ignored id=$id token=$token"); return } if (openingToken == token) opening = false if (stopped || token != cameraToken) { closingCameras.add(device); closing = true; queueCleanup(0); return } camera = device + LogManager.addLog("INFO", "call", "camera opened id=$id device=${device.id} token=$token") createSession(device, nextReader, token) } override fun onDisconnected(device: CameraDevice) { cameraFailure(device, token, "Camera disconnected") } override fun onError(device: CameraDevice, error: Int) { cameraFailure(device, token, "Camera error $error") } override fun onClosed(device: CameraDevice) { + if (!closedCameras.add(device)) { LogManager.addLog("DEBUG", "call", "duplicate camera closed ignored id=$id token=$token"); return } sessions.values.filter { it.first === device }.map { it.second }.forEach { it() } ownedSessions.entries.removeAll { it.value === device } closingSessions.removeAll { it !in ownedSessions } if (camera === device) camera = null closingCameras.remove(device) + LogManager.addLog("INFO", "call", "camera closed id=$id device=${device.id} token=$token current=$cameraToken") queueCleanup(0) } }, handler) } catch (e: Exception) { - if (openingToken == token) opening = false + val awaitsCallback = e is CameraAccessException && e.reason in listOf(CameraAccessException.CAMERA_IN_USE, + CameraAccessException.CAMERA_DISABLED, CameraAccessException.CAMERA_DISCONNECTED) + if (openingToken == token && !awaitsCallback) opening = false + LogManager.addLog("ERROR", "call", "camera open failed id=$id token=$token awaitingCallback=$awaitsCallback: $e") queueCleanup(0) throw e } } private fun cameraFailure(device: CameraDevice, token: Long, detail: String) { + if (device in closedCameras) { LogManager.addLog("WARN", "call", "closed camera error ignored id=$id token=$token detail=$detail"); return } if (openingToken == token) opening = false closingCameras.add(device) if (!stopped && token == cameraToken) { camera = device; fail(detail) } @@ -673,9 +697,10 @@ class CallVideoEngine(private val context: Context, private val id: Long) { } catch (e: Exception) { LogManager.addLog("ERROR", "call", "camera reader close failed id=$id; retained: $e") } if (ownedReaders.isEmpty() && sessions.isEmpty() && ownedSessions.isEmpty()) closing = false } + if (camera != null && camera !in closingCameras && !opening) closing = false for (owned in pendingEncoders.toList()) releaseEncoder(owned) if (!wanted.enabled) encoder?.let { releaseEncoder(it) } - val pending = opening || closing || closingSessions.isNotEmpty() || pendingEncoders.isNotEmpty() || + val pending = opening || closing || closingCameras.isNotEmpty() || closingSessions.isNotEmpty() || pendingEncoders.isNotEmpty() || (!wanted.enabled && (encoder != null || orientationEnabled)) if (stopped && !pending) { if (portOwned) try { CallVideoNative.close(id); portOwned = false } catch (e: Exception) { @@ -702,14 +727,15 @@ class CallVideoEngine(private val context: Context, private val id: Long) { } /** Один ближайший запрос; сильный владелец при stop остаётся также при отказе Handler. */ - @Synchronized private fun queueCleanup(delay: Long) { - if (cleanupComplete) return + @Synchronized private fun queueCleanup(delay: Long): Boolean { + if (cleanupComplete) return true val due = System.nanoTime() + delay * 1_000_000 - if (cleanupQueued && cleanupDue <= due) return + if (cleanupQueued && cleanupDue <= due) return true handler.removeCallbacks(cleanupTask) cleanupDue = due cleanupQueued = if (delay == 0L) handler.post(cleanupTask) else handler.postDelayed(cleanupTask, delay) if (!cleanupQueued) LogManager.addLog("ERROR", "call", "video cleanup request rejected id=$id delayMs=$delay; owner retained") + return cleanupQueued } /** Main: запретить producers и подтвердить RX cleanup, затем запросить TX teardown. @@ -718,8 +744,7 @@ class CallVideoEngine(private val context: Context, private val id: Long) { fun stop(): Boolean { if (cleanupComplete) return true retained.add(this); stopped = true; player.stop() - queueCleanup(0) - check(cleanupQueued || cleanupComplete) { + check(queueCleanup(0)) { LogManager.addLog("ERROR", "call", "video stop cleanup rejected id=$id; stop remains retryable") "Video cleanup worker rejected stop" } 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 ae359d40..fad7a4d5 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 @@ -7,6 +7,10 @@ import android.util.Range import android.view.Surface import java.util.concurrent.CopyOnWriteArrayList +class CameraAccessException(val reason: Int) : Exception("camera access failure reason=$reason") { + companion object { const val CAMERA_IN_USE = 4; const val CAMERA_DISABLED = 1; const val CAMERA_DISCONNECTED = 2 } +} + class HostStreamMap { fun getOutputSizes(format: Int) = arrayOf(Size(640, 480)) fun getOutputSizes(type: Class) = arrayOf(Size(640, 480)) @@ -57,8 +61,11 @@ 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 ("open after" in failures) { + handler.post { opened.last().delivered = true; callback.onError(device, 1) } + throw CameraAccessException(CameraAccessException.CAMERA_IN_USE) + } 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.delivered = true; r.callback.onOpened(r.device) } } fun deliverClosed(index: Int) { val r = opened[index]; r.handler.post { r.callback.onClosed(r.device) } }