From fca68080bcefff754d1d644c34cfcabb7a91b1d9 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 09:53:27 +0100 Subject: [PATCH] Own recorded video track inspection until extractor release succeeds --- .../com/utun/chat/data/VideoFileTracks.kt | 56 +++++++++++++++++++ .../kotlin/android/media/HostFileVideo.kt | 14 +++++ .../kotlin/android/media/HostVideoCodec.kt | 4 ++ 3 files changed, 74 insertions(+) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileTracks.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileTracks.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileTracks.kt new file mode 100644 index 000000000..d06990e53 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileTracks.kt @@ -0,0 +1,56 @@ +package com.utun.chat.data + +import android.media.MediaExtractor +import android.media.MediaFormat +import android.os.Handler +import android.os.Looper +import java.util.concurrent.ConcurrentHashMap + +/** IO caller проверяет дорожки готового файла внутри use; extractor сохраняется до успешного release. + * Ошибка чтения не маскируется cleanup; повтор close работает также после отказа main-таймера. */ +internal class VideoFileTracks : AutoCloseable { + companion object { private val retained = ConcurrentHashMap.newKeySet() } + private var extractor: MediaExtractor? = MediaExtractor() + private var closing = false + private val main = Handler(Looper.getMainLooper()) + private var delayMs = 250L + private var scheduled = false + private val retry = Runnable { + synchronized(this) { scheduled = false } + try { close() } catch (e: Exception) { LogManager.addLog("WARN", "Capture", "Track reader cleanup pending: $e") } + } + + @Synchronized fun verify(path: String, expectedMime: String): String { + check(!closing) { "Track reader closing" } + val current = checkNotNull(extractor) { "Track reader closed" } + current.setDataSource(path) + val video = (0 until current.trackCount).map(current::getTrackFormat) + .firstOrNull { it.getString(MediaFormat.KEY_MIME)?.startsWith("video/") == true } + checkNotNull(video) { "Recorded file has no video track" } + val mime = video.getString(MediaFormat.KEY_MIME) + check(mime == expectedMime) { "Recorded codec mismatch expected=$expectedMime actual=$mime" } + val rotation = if (video.containsKey(MediaFormat.KEY_ROTATION)) video.getInteger(MediaFormat.KEY_ROTATION) else 0 + val width = video.getInteger(MediaFormat.KEY_WIDTH); val height = video.getInteger(MediaFormat.KEY_HEIGHT) + check(width > 0 && height > 0 && rotation in listOf(0, 90, 180, 270)) { "Invalid recorded geometry ${width}x$height rotation=$rotation" } + return "$mime ${width}x${height} rotation=$rotation" + } + + @Synchronized override fun close() { + closing = true + val current = extractor ?: return + try { + current.release(); extractor = null + main.removeCallbacks(retry); scheduled = false; retained.remove(this) + LogManager.addLog("DEBUG", "Capture", "Track reader released owner=${System.identityHashCode(this)}") + } catch (e: Exception) { + retained.add(this) + LogManager.addLog("ERROR", "Capture", "Track reader release failed; owner retained: $e") + if (!scheduled) { + scheduled = main.postDelayed(retry, delayMs) + if (scheduled) delayMs = (delayMs * 2).coerceAtMost(30_000) + else LogManager.addLog("ERROR", "Capture", "Track reader cleanup timer rejected; owner retained") + } + throw e + } + } +} diff --git a/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostFileVideo.kt b/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostFileVideo.kt index 09bf35fea..335bc22ce 100644 --- a/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostFileVideo.kt +++ b/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostFileVideo.kt @@ -1,5 +1,19 @@ package android.media +class MediaExtractor { + companion object { val created = mutableListOf(); var nextFailures = emptySet() } + var failures = nextFailures + var released = false + var formats = listOf(MediaFormat().apply { + values[MediaFormat.KEY_MIME] = "video/avc"; values[MediaFormat.KEY_WIDTH] = 1280; values[MediaFormat.KEY_HEIGHT] = 720 + }) + init { created.add(this) } + val trackCount: Int get() = formats.size + fun setDataSource(path: String) { check("source" !in failures) { "Track source rejected" } } + fun getTrackFormat(index: Int) = formats[index] + fun release() { check("release" !in failures) { "Track release rejected" }; released = true } +} + import android.view.Surface /** Только управляемая граница MediaPlayer: callbacks и отказы SDK, без декодирования. */ diff --git a/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostVideoCodec.kt b/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostVideoCodec.kt index e7081ff3b..6f0e1cdc0 100644 --- a/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostVideoCodec.kt +++ b/tools/chatgui-android/video-tests/src/main/kotlin/android/media/HostVideoCodec.kt @@ -8,6 +8,7 @@ import java.util.concurrent.ConcurrentLinkedQueue /** Минимальный формат для настоящего StreamVideoCodec; данные сохраняются для проверки configure. */ class MediaFormat { companion object { + const val KEY_MIME = "mime"; const val KEY_WIDTH = "width"; const val KEY_HEIGHT = "height" const val KEY_ROTATION = "rotation"; const val KEY_MAX_INPUT_SIZE = "max-input-size" const val KEY_LOW_LATENCY = "low-latency" const val KEY_COLOR_FORMAT = "color"; const val KEY_PROFILE = "profile"; const val KEY_BIT_RATE = "bitrate" @@ -18,6 +19,9 @@ class MediaFormat { fun setInteger(key: String, value: Int) { values[key] = value } fun setByteBuffer(key: String, value: ByteBuffer) { values[key] = value } fun getByteBuffer(key: String) = values[key] as? ByteBuffer + fun containsKey(key: String) = values.containsKey(key) + fun getString(key: String) = values[key] as? String + fun getInteger(key: String) = values[key] as Int } class MediaCodecInfo(val name: String = "host.decoder") { val isEncoder = false