From 09386ee3924572a7df9ce317050774d64896948e Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 09:49:09 +0100 Subject: [PATCH] Retain camera recording and cache ownership until writer completion --- .../utun/chat/data/CameraCaptureResources.kt | 124 ++++++++++++++++++ .../video-tests/build.gradle.kts | 1 + 2 files changed, 125 insertions(+) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraCaptureResources.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraCaptureResources.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraCaptureResources.kt new file mode 100644 index 00000000..e0525fa2 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraCaptureResources.kt @@ -0,0 +1,124 @@ +package com.utun.chat.data + +import android.os.Handler +import android.os.Looper +import androidx.camera.core.UseCase +import androidx.camera.lifecycle.ProcessCameraProvider +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** Main: файлы захвата живут до OnImageSaved/onError или VideoRecordEvent.Finalize. + * discard/close запрещают передачу sender, но не удаляют файл под работающим CameraX writer. + * Владение Recording сохраняется до успешного close либо Finalize; binding снимает только свои use cases. + * Неудачная очистка остаётся в process registry и повторяется без UI/native core. */ +internal class CameraCaptureResources : AutoCloseable { + companion object { private val retained = ConcurrentHashMap.newKeySet() } + private class Capture(val file: File) { + var pending = true + var discarded = false + var recording: AutoCloseable? = null + } + private data class Binding(val provider: ProcessCameraProvider, val cases: Array) + private val files = mutableMapOf() + private var binding: Binding? = null + private var closed = false + private val main = Handler(Looper.getMainLooper()) + private var scheduled = false + private var delayMs = 250L + private val retry = Runnable { scheduled = false; cleanup() } + + fun own(file: File) { + check(!closed && file !in files) { "Capture owner closed or file already owned" } + files[file] = Capture(file) + LogManager.addLog("DEBUG", "Capture", "Writer owns cache path=$file") + } + + fun recording(file: File, recording: AutoCloseable) { + val capture = checkNotNull(files[file]) { "Recording has no file owner" } + if (!capture.pending) return // Синхронный Finalize уже подтвердил закрытый Recording. + capture.recording = recording + if (capture.discarded || closed) cleanup() + } + + /** Подтверждение writer; false означает discard/close и запрещает показывать либо отправлять результат. */ + fun completed(file: File): Boolean { + val capture = files[file] ?: return false + capture.pending = false; capture.recording = null + val usable = !closed && !capture.discarded + LogManager.addLog("INFO", "Capture", "Writer finished path=$file usable=$usable") + cleanup() + return usable + } + + fun discard(file: File?) { + val capture = files[file] ?: return + capture.discarded = true + LogManager.addLog("INFO", "Capture", "Discard requested path=$file writerPending=${capture.pending}") + cleanup() + } + + /** Вызывать только после успешного приёма sender: после этого владелец больше не удаляет файл. */ + fun transferred(file: File) { + val capture = checkNotNull(files[file]) { "Transferred file has no owner" } + check(!capture.pending && !capture.discarded && !closed) { "Capture cannot be transferred" } + files.remove(file) + LogManager.addLog("INFO", "Capture", "Cache transferred to sender path=$file") + } + + /** Сохранить use cases до bindToLifecycle: исключение после эффекта получает независимый rollback. */ + fun binding(provider: ProcessCameraProvider, vararg cases: UseCase) { + check(!closed && binding == null) { "Previous camera binding still owned" } + binding = Binding(provider, cases) + } + + fun unbind(): Boolean { + val current = binding ?: return true + try { + current.provider.unbind(*current.cases); binding = null + LogManager.addLog("INFO", "Capture", "Own camera use cases unbound count=${current.cases.size}") + } catch (e: Exception) { + LogManager.addLog("ERROR", "Capture", "Camera unbind failed; owner retained: $e") + schedule() + } + return binding == null + } + + override fun close() { + closed = true + files.values.forEach { it.discarded = true } + cleanup() + } + + private fun cleanup() { + if (closed) unbind() + for (capture in files.values.toList()) { + if (!capture.discarded) continue + capture.recording?.let { record -> + try { record.close(); capture.recording = null } + catch (e: Exception) { LogManager.addLog("ERROR", "Capture", "Recording close failed path=${capture.file}; retained: $e") } + } + if (!capture.pending) try { + check(capture.file.delete() || !capture.file.exists()) { "File deletion rejected" } + files.remove(capture.file) + LogManager.addLog("DEBUG", "Capture", "Discarded cache removed path=${capture.file}") + } catch (e: Exception) { LogManager.addLog("ERROR", "Capture", "Cache cleanup failed path=${capture.file}; retained: $e") } + } + schedule() + } + + private fun schedule() { + val pending = (closed && binding != null) || files.values.any { it.discarded } + if (!pending) { + main.removeCallbacks(retry); scheduled = false; delayMs = 250; retained.remove(this) + return + } + retained.add(this) + // Ожидание callback не опрашиваем: owner удерживается до подтверждения writer. + val retryable = binding != null || files.values.any { it.discarded && (!it.pending || it.recording != null) } + if (!scheduled && retryable) { + scheduled = main.postDelayed(retry, delayMs) + if (scheduled) delayMs = (delayMs * 2).coerceAtMost(30_000) + else LogManager.addLog("ERROR", "Capture", "Cleanup timer rejected; owner retained") + } + } +} diff --git a/tools/chatgui-android/video-tests/build.gradle.kts b/tools/chatgui-android/video-tests/build.gradle.kts index 7760f8ab..6b5d7d24 100644 --- a/tools/chatgui-android/video-tests/build.gradle.kts +++ b/tools/chatgui-android/video-tests/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { "com/utun/chat/data/TelecomCallManager.kt", "com/utun/chat/data/UtunConnection.kt", "com/utun/chat/data/UtunConnectionService.kt", "com/utun/chat/data/QrScannerSession.kt", "com/utun/chat/data/VideoFilePlayer.kt", "com/utun/chat/data/VideoFileMetadata.kt", "com/google/**") + kotlin.include("com/utun/chat/data/CameraCaptureResources.kt") } } dependencies {