fix: clean up Android camera clips on cancellation (#99153)

* fix: clean up Android camera clips on cancellation

* fix(android): own camera clip cleanup lifecycle

---------

Co-authored-by: NianJiuZst <180004567+users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
NianJiu
2026-07-03 04:53:29 +08:00
committed by GitHub
parent 46598a120f
commit 00599dd1ed
2 changed files with 173 additions and 63 deletions

View File

@@ -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<VideoRecordEvent.Finalize>()
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<VideoRecordEvent.Finalize>()
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(

View File

@@ -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<String>()
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<String>()
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<String>()
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()
}
}
}