Handle camera error callbacks promised alongside synchronous open failures

This commit is contained in:
evgeny committed 2026-10-29 13:34:24 +01:00
1 parent a6f41c0256
commit 5e9dad2e9e
2 files changed
+49 -17

No files matched your search

@@ -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<ImageReader>()
private val closingCameras = linkedSetOf<CameraDevice>()
private val closedCameras = linkedSetOf<CameraDevice>() // Отсекает повтор уже подтверждённого SDK callback.
private val ownedSessions = linkedMapOf<CameraCaptureSession, CameraDevice>()
private val closingSessions = linkedSetOf<CameraCaptureSession>()
private val pendingEncoders = linkedSetOf<CallVideoCodec>()
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"
}
@@ -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<SurfaceTexture>) = 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) } }