fix(android): clean up cancelled camera recordings (#129948)

This commit is contained in:
Peter Steinberger
2026-08-26 00:22:31 -07:00
committed by GitHub
parent f94ad7673f
commit 2941b3d779
3 changed files with 67 additions and 20 deletions
@@ -61,10 +61,14 @@ internal class CameraClipSession(
return file
}
fun transferFile(): File {
fun transferFile(onTransfer: (File) -> Unit): File {
check(!closed) { "camera clip session is closed" }
return checkNotNull(temporaryFile) { "camera clip session has no file" }
.also { temporaryFile = null }
.also { file ->
// Claim ownership before release because cancellation can discard a dispatched FilePayload.
onTransfer(file)
temporaryFile = null
}
}
override fun close() {
@@ -224,7 +228,10 @@ class CameraCaptureManager(
/** Records a short MP4 clip into a temporary cache file for the caller to encode/delete. */
@SuppressLint("MissingPermission")
suspend fun clip(paramsJson: String?): FilePayload =
suspend fun clip(
paramsJson: String?,
onFileReady: (File) -> Unit,
): FilePayload =
withContext(Dispatchers.Main) {
ensureCameraPermission()
val params = parseJsonParamsObject(paramsJson)
@@ -306,7 +313,7 @@ class CameraCaptureManager(
}
FilePayload(
file = session.transferFile(),
file = session.transferFile(onFileReady),
durationMs = durationMs.toLong(),
hasAudio = includeAudio,
)
@@ -7,11 +7,13 @@ import ai.openclaw.app.takeUtf16Safe
import android.content.Context
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.concurrent.atomic.AtomicReference
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
private const val CAMERA_DEBUG_STACK_TRACE_MAX_CHARS = 2_000
@@ -121,6 +123,7 @@ class CameraHandler(
message = "MIC_BUSY: another audio capture is active",
)
}
val ownedClipFile = AtomicReference<java.io.File?>()
try {
clipLogFile?.writeText("") // clear
clipLog("starting, params=$paramsJson includeAudio=$includeAudio")
@@ -129,7 +132,10 @@ class CameraHandler(
val filePayload =
try {
clipLog("calling camera.clip()")
val r = camera.clip(paramsJson)
val r =
camera.clip(paramsJson) { file ->
check(ownedClipFile.compareAndSet(null, file)) { "camera clip already owns a file" }
}
clipLog("success, file size=${r.file.length()}")
r
} catch (err: CancellationException) {
@@ -144,8 +150,6 @@ class CameraHandler(
val rawBytes = filePayload.file.length()
if (!isCameraClipWithinPayloadLimit(rawBytes)) {
clipLog("payload too large: bytes=$rawBytes max=$CAMERA_CLIP_MAX_RAW_BYTES")
// Delete oversized clips before returning so cache files do not accumulate after failed invokes.
withContext(Dispatchers.IO) { filePayload.file.delete() }
showCameraHud("Clip too large", CameraHudKind.Error, 2400)
return GatewaySession.InvokeResult.error(
code = "PAYLOAD_TOO_LARGE",
@@ -154,14 +158,7 @@ class CameraHandler(
)
}
val bytes =
withContext(Dispatchers.IO) {
try {
filePayload.file.readBytes()
} finally {
filePayload.file.delete()
}
}
val bytes = withContext(Dispatchers.IO) { filePayload.file.readBytes() }
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
clipLog("returning base64 payload")
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
@@ -175,8 +172,17 @@ class CameraHandler(
clipLog("stack: ${err.stackTraceToString().takeUtf16Safe(CAMERA_DEBUG_STACK_TRACE_MAX_CHARS)}")
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera clip failed")
} finally {
// Prevent talk/transcription capture from competing with camera audio after every exit path.
if (ownsAudioCapture) setCameraAudioCaptureActive(false)
try {
ownedClipFile.getAndSet(null)?.let { file ->
// Nest dispatcher changes so cancellation cannot replace the original failure.
withContext(NonCancellable) {
withContext(Dispatchers.IO) { file.delete() }
}
}
} finally {
// Prevent talk/transcription capture from competing with camera audio after every exit path.
if (ownsAudioCapture) setCameraAudioCaptureActive(false)
}
}
}
@@ -55,7 +55,7 @@ class CameraHandlerTest {
val error =
assertThrows(IllegalStateException::class.java) {
runBlocking { CameraCaptureManager(app).clip("""{"includeAudio":false}""") }
runBlocking { CameraCaptureManager(app).clip("""{"includeAudio":false}""") {} }
}
assertEquals("CAMERA_PERMISSION_REQUIRED: grant Camera permission", error.message)
@@ -70,7 +70,7 @@ class CameraHandlerTest {
val error =
assertThrows(IllegalStateException::class.java) {
runBlocking { camera.clip("""{"includeAudio":true}""") }
runBlocking { camera.clip("""{"includeAudio":true}""") {} }
}
assertEquals("MIC_PERMISSION_REQUIRED: grant Microphone permission", error.message)
@@ -160,7 +160,7 @@ class CameraHandlerTest {
session.ownRecording(AutoCloseable { cleanup += "recording" })
session.ownFile(tempFile)
assertSame(tempFile, session.transferFile())
assertSame(tempFile, session.transferFile {})
session.close()
assertEquals(listOf("recording", "unbind"), cleanup)
@@ -169,4 +169,38 @@ class CameraHandlerTest {
tempFile.delete()
}
}
@Test
fun cameraClipSession_transfersFileToCallerBeforeReleasingOwnership() {
val tempFile = File.createTempFile("openclaw-clip-test-", ".mp4")
try {
val session = CameraClipSession(unbind = {}, deleteTemporaryFile = { it.delete() })
session.ownFile(tempFile)
var claimedFile: File? = null
assertSame(tempFile, session.transferFile { claimedFile = it })
session.close()
assertSame(tempFile, claimedFile)
assertTrue(tempFile.exists())
} finally {
tempFile.delete()
}
}
@Test
fun cameraClipSession_keepsFileWhenCallerCannotClaimOwnership() {
val tempFile = File.createTempFile("openclaw-clip-test-", ".mp4")
val session = CameraClipSession(unbind = {}, deleteTemporaryFile = { it.delete() })
session.ownFile(tempFile)
val error =
assertThrows(IllegalStateException::class.java) {
session.transferFile { error("caller already owns a camera clip") }
}
session.close()
assertEquals("caller already owns a camera clip", error.message)
assertFalse(tempFile.exists())
}
}