diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt index 4ba8e50e9404..d7e3ee77906e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt @@ -21,7 +21,6 @@ import androidx.camera.video.FileOutputOptions import androidx.camera.video.Quality import androidx.camera.video.QualitySelector import androidx.camera.video.Recorder -import androidx.camera.video.Recording import androidx.camera.video.VideoCapture import androidx.camera.video.VideoRecordEvent import androidx.core.content.ContextCompat @@ -44,6 +43,54 @@ import kotlin.math.roundToInt /** * CameraX-backed capture service used by gateway camera commands. */ +internal class CameraClipSession( + private val unbind: () -> Unit, + private val deleteTemporaryFile: (File) -> Unit, +) : AutoCloseable { + private var recording: AutoCloseable? = null + private var temporaryFile: File? = null + private var closed = false + + fun ownRecording(recording: AutoCloseable) { + check(!closed) { "camera clip session is closed" } + this.recording = recording + } + + fun ownFile(file: File): File { + check(!closed) { "camera clip session is closed" } + check(temporaryFile == null) { "camera clip session already owns a file" } + temporaryFile = file + return file + } + + fun transferFile(): File { + check(!closed) { "camera clip session is closed" } + return checkNotNull(temporaryFile) { "camera clip session has no file" } + .also { temporaryFile = null } + } + + override fun close() { + if (closed) return + closed = true + + var failure: Throwable? = null + + fun cleanup(action: () -> Unit) { + try { + action() + } catch (err: Throwable) { + failure?.addSuppressed(err) ?: run { failure = err } + } + } + + // Keep teardown symmetric across bind, warmup, recording, finalize, and success exits. + cleanup { recording?.close() } + cleanup(unbind) + temporaryFile?.let { file -> cleanup { deleteTemporaryFile(file) } } + failure?.let { throw it } + } +} + class CameraCaptureManager( private val context: Context, ) { @@ -235,87 +282,90 @@ class CameraCaptureManager( androidx.camera.core.Preview .Builder() .build() - // Provide a dummy SurfaceTexture so the preview pipeline activates - val surfaceTexture = android.graphics.SurfaceTexture(0) - surfaceTexture.setDefaultBufferSize(640, 480) + // Allocate the dummy preview surface only after CameraX requests it; its result owns release. preview.setSurfaceProvider { request -> + val surfaceTexture = android.graphics.SurfaceTexture(0) + surfaceTexture.setDefaultBufferSize(640, 480) val surface = android.view.Surface(surfaceTexture) - request.provideSurface(surface, context.mainExecutor()) { result -> + request.provideSurface(surface, context.mainExecutor()) { surface.release() surfaceTexture.release() } } provider.unbindAll() - android.util.Log.w("CameraCaptureManager", "clip: binding preview + videoCapture to lifecycle") - val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture) - android.util.Log.w("CameraCaptureManager", "clip: bound, cameraInfo=${camera.cameraInfo}") + CameraClipSession( + unbind = { provider.unbind(preview, videoCapture) }, + deleteTemporaryFile = { file -> + check(!file.exists() || file.delete()) { "failed to delete temporary camera clip" } + }, + ).use { session -> + android.util.Log.w("CameraCaptureManager", "clip: binding preview + videoCapture to lifecycle") + val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture) + android.util.Log.w("CameraCaptureManager", "clip: bound, cameraInfo=${camera.cameraInfo}") - // Give camera pipeline time to initialize before recording - android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...") - kotlinx.coroutines.delay(1_500) + // Give camera pipeline time to initialize before recording + android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...") + kotlinx.coroutines.delay(1_500) - val file = File.createTempFile("openclaw-clip-", ".mp4", context.cacheDir) - val outputOptions = FileOutputOptions.Builder(file).build() + val clipFile = session.ownFile(File.createTempFile("openclaw-clip-", ".mp4", context.cacheDir)) + val outputOptions = FileOutputOptions.Builder(clipFile).build() - val finalized = kotlinx.coroutines.CompletableDeferred() - android.util.Log.w("CameraCaptureManager", "clip: starting recording to ${file.absolutePath}") - val recording: Recording = - videoCapture.output - .prepareRecording(context, outputOptions) - .apply { - if (includeAudio) withAudioEnabled() - }.start(context.mainExecutor()) { event -> - android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}") - if (event is VideoRecordEvent.Status) { - android.util.Log.w("CameraCaptureManager", "clip: recording status update") + val finalized = kotlinx.coroutines.CompletableDeferred() + android.util.Log.w("CameraCaptureManager", "clip: starting recording to ${clipFile.absolutePath}") + val recording = + videoCapture.output + .prepareRecording(context, outputOptions) + .apply { + if (includeAudio) withAudioEnabled() + }.start(context.mainExecutor()) { event -> + android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}") + if (event is VideoRecordEvent.Status) { + android.util.Log.w("CameraCaptureManager", "clip: recording status update") + } + if (event is VideoRecordEvent.Finalize) { + android.util.Log.w( + "CameraCaptureManager", + "clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}", + ) + finalized.complete(event) + } } - if (event is VideoRecordEvent.Finalize) { - android.util.Log.w( - "CameraCaptureManager", - "clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}", - ) - finalized.complete(event) - } - } + session.ownRecording(recording) - android.util.Log.w("CameraCaptureManager", "clip: recording started, delaying ${durationMs}ms") - try { + android.util.Log.w("CameraCaptureManager", "clip: recording started, delaying ${durationMs}ms") kotlinx.coroutines.delay(durationMs.toLong()) - } finally { android.util.Log.w("CameraCaptureManager", "clip: stopping recording") - recording.stop() - } + recording.close() - val finalizeEvent = - try { - withTimeout(15_000) { finalized.await() } - } catch (err: Throwable) { - android.util.Log.e("CameraCaptureManager", "clip: finalize timed out", err) - withContext(Dispatchers.IO) { file.delete() } - provider.unbindAll() - throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out") + val finalizeEvent = + try { + withTimeout(15_000) { finalized.await() } + } catch (err: kotlinx.coroutines.TimeoutCancellationException) { + android.util.Log.e("CameraCaptureManager", "clip: finalize timed out", err) + throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out") + } + if (finalizeEvent.hasError()) { + android.util.Log.e( + "CameraCaptureManager", + "clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}", + finalizeEvent.cause, + ) + // Check file size for debugging + val fileSize = withContext(Dispatchers.IO) { if (clipFile.exists()) clipFile.length() else -1 } + android.util.Log.e("CameraCaptureManager", "clip: file exists=${clipFile.exists()} size=$fileSize") + throw IllegalStateException("UNAVAILABLE: camera clip failed (error=${finalizeEvent.error})") } - if (finalizeEvent.hasError()) { - android.util.Log.e( - "CameraCaptureManager", - "clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}", - finalizeEvent.cause, + + val fileSize = withContext(Dispatchers.IO) { clipFile.length() } + android.util.Log.w("CameraCaptureManager", "clip: SUCCESS file size=$fileSize") + + FilePayload( + file = session.transferFile(), + durationMs = durationMs.toLong(), + hasAudio = includeAudio, ) - // Check file size for debugging - val fileSize = withContext(Dispatchers.IO) { if (file.exists()) file.length() else -1 } - android.util.Log.e("CameraCaptureManager", "clip: file exists=${file.exists()} size=$fileSize") - withContext(Dispatchers.IO) { file.delete() } - provider.unbindAll() - throw IllegalStateException("UNAVAILABLE: camera clip failed (error=${finalizeEvent.error})") } - - val fileSize = withContext(Dispatchers.IO) { file.length() } - android.util.Log.w("CameraCaptureManager", "clip: SUCCESS file size=$fileSize") - - provider.unbindAll() - - FilePayload(file = file, durationMs = durationMs.toLong(), hasAudio = includeAudio) } private fun rotateBitmapByExif( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt index 5a60562b421d..e7cb698ee0a7 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt @@ -2,8 +2,10 @@ package ai.openclaw.app.node import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File class CameraHandlerTest { @Test @@ -22,4 +24,62 @@ class CameraHandlerTest { fun cameraClipMaxRawBytes_matchesExpectedBudget() { assertEquals(18L * 1024L * 1024L, CAMERA_CLIP_MAX_RAW_BYTES) } + + @Test + fun cameraClipSession_closesRecordingUnbindsAndDeletesOwnedFile() { + val tempFile = File.createTempFile("openclaw-clip-test-", ".mp4") + val cleanup = mutableListOf() + val session = + CameraClipSession( + unbind = { cleanup += "unbind" }, + deleteTemporaryFile = { file -> + cleanup += "file" + assertSame(tempFile, file) + file.delete() + }, + ) + session.ownRecording(AutoCloseable { cleanup += "recording" }) + session.ownFile(tempFile) + + session.close() + session.close() + + assertEquals(listOf("recording", "unbind", "file"), cleanup) + assertFalse(tempFile.exists()) + } + + @Test + fun cameraClipSession_unbindsBeforeRecordingStarts() { + val cleanup = mutableListOf() + + CameraClipSession( + unbind = { cleanup += "unbind" }, + deleteTemporaryFile = { cleanup += "file" }, + ).close() + + assertEquals(listOf("unbind"), cleanup) + } + + @Test + fun cameraClipSession_keepsFileTransferredToCaller() { + val tempFile = File.createTempFile("openclaw-clip-test-", ".mp4") + try { + val cleanup = mutableListOf() + val session = + CameraClipSession( + unbind = { cleanup += "unbind" }, + deleteTemporaryFile = { cleanup += "file" }, + ) + session.ownRecording(AutoCloseable { cleanup += "recording" }) + session.ownFile(tempFile) + + assertSame(tempFile, session.transferFile()) + session.close() + + assertEquals(listOf("recording", "unbind"), cleanup) + assertTrue(tempFile.exists()) + } finally { + tempFile.delete() + } + } }