mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-08 18:24:00 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user