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 f5510b43..6fbf4329 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 @@ -8,6 +8,7 @@ import android.hardware.camera2.CameraConstrainedHighSpeedCaptureSession import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraDevice import android.hardware.camera2.CameraManager +import android.hardware.camera2.CameraAccessException import android.hardware.camera2.CaptureRequest import android.hardware.camera2.CaptureResult import android.hardware.camera2.TotalCaptureResult @@ -18,26 +19,33 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.HandlerThread +import android.os.Looper import android.os.SystemClock import android.util.Range import android.view.OrientationEventListener import android.view.Surface import java.io.ByteArrayOutputStream +import java.util.concurrent.ConcurrentHashMap -/** Camera/codec принадлежат HandlerThread; микрофон и origin сохраняются при смене видео. - * Превью — второй выход Camera2. Поколения видео и camera session отсеивают старые callbacks. */ +/** Пассивный владелец: сервис сохраняет его до start на main. Camera/codec принадлежат HandlerThread; + * микрофон и origin сохраняются при смене видео. Поколения отсеивают старые callbacks. + * stop=true подтверждает все SDK/native DSP ресурсы; false сохраняет engine для повторного stop. + */ class StreamCaptureEngine( private val context: Context, private val id: Long, private val group: Long, private val initialConfig: StreamCaptureConfig, private val microphone: Boolean, private val preview: StreamPreview, initialAudioCompressor: RadioCompressorSettings, private val ready: (StreamCaptureConfig, Boolean) -> Unit, private val failure: (String) -> Unit ) { - private val thread = HandlerThread("stream-camera").apply { start() } - private val handler = Handler(thread.looper) + companion object { private val retained = ConcurrentHashMap.newKeySet() } + private val thread = HandlerThread("stream-camera") + private lateinit var handler: Handler + private var started = false private val manager = context.getSystemService(CameraManager::class.java) private var camera: CameraDevice? = null private var session: CameraCaptureSession? = null - private var encoder: MediaCodec? = null + private var encoderResources: VideoCodecResources? = null + private val encoder: MediaCodec? get() = encoderResources?.codec private var input: Surface? = null private var activeConfig: StreamCaptureConfig? = null private var previewTarget: StreamPreviewTarget? = null @@ -49,12 +57,19 @@ class StreamCaptureEngine( @Volatile private var audioMuted = false @Volatile var audioCompressorSettings = initialAudioCompressor @Volatile private var stopped = false - private var stopping = false + @Volatile private var stopping = false private var terminalFailure = false - private val stopCallbacks = mutableListOf<() -> Unit>() + private val closingCameras = linkedSetOf() + private val closedCameras = linkedSetOf() + private val ownedSessions = linkedMapOf() + private val closingSessions = linkedSetOf() + private var cleanupQueued = false + private var cleanupDue = 0L + private var cleanupDelay = 250L + private val cleanupTask = Runnable { synchronized(this) { cleanupQueued = false }; cleanupResources() } private var opening = false private var openingGeneration = 0L - private var releaseDone: ((Boolean) -> Unit)? = null + private var releaseDone: (() -> Unit)? = null private var generation = 0L private var videoRevision = 0L private var videoRotation = 0 @@ -97,14 +112,17 @@ class StreamCaptureEngine( private fun observeOrientation(enabled: Boolean) { if (orientationEnabled == enabled) return - orientationEnabled = enabled; orientation.resetPending() - if (!enabled) orientationListener.disable() - else if (orientationListener.canDetectOrientation()) orientationListener.enable() + orientation.resetPending() + if (!enabled) { orientationListener.disable(); orientationEnabled = false } + else if (orientationListener.canDetectOrientation()) { orientationEnabled = true; orientationListener.enable() } else LogManager.addLog("WARN", "video", "stream orientation sensor unavailable id=$id; using ${orientation.degrees}") LogManager.videoTrace("stream orientation monitoring id=$id enabled=$enabled") } fun start() { + check(Looper.myLooper() == Looper.getMainLooper() && !stopping && !stopped) { "Capture start requires main and a live owner" } + if (started) { LogManager.addLog("WARN", "stream", "duplicate capture start id=$id"); return } + started = true; thread.start(); handler = Handler(thread.looper) preview.bind { target -> if (!handler.post { if (target?.released == true) { LogManager.addLog("WARN", "video", "cancelled preview command id=$id"); return@post } @@ -121,7 +139,7 @@ class StreamCaptureEngine( target?.release() } } - handler.post { + check(handler.post { if (stopping || stopped) return@post try { val mode = initialConfig.mode @@ -133,7 +151,7 @@ class StreamCaptureEngine( if (microphone) startAudio() configure(initialConfig) } catch (e: Exception) { failed("setup: ${e.message}") } - } + }) { LogManager.addLog("ERROR", "stream", "capture startup post rejected id=$id; partial owner retained"); "Capture start rejected" } } fun change(config: StreamCaptureConfig) { @@ -155,7 +173,7 @@ class StreamCaptureEngine( } waitingFirst = true; reverting = false; switchStartedNs = System.nanoTime() LogManager.addLog("INFO", "video", "switch capture id=$id camera=${config.mode.camera} ${config.mode.label} orientation=${config.orientation}") - releaseVideo { closed -> if (closed) configure(config) else failed("camera close timed out during switch") } + releaseVideo { configure(config) } } } @@ -190,7 +208,7 @@ class StreamCaptureEngine( setInteger(MediaFormat.KEY_FRAME_RATE, mode.fps); setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) if (Build.VERSION.SDK_INT >= 29) setInteger(MediaFormat.KEY_MAX_B_FRAMES, 0) } - encoder = MediaCodec.createByCodecName(mode.codec) + encoderResources = VideoCodecResources(MediaCodec.createByCodecName(mode.codec), mode.codec) encoder!!.also { codec -> codec.setCallback(object : MediaCodec.Callback() { override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {}