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 3a7a0680e4e4..bd4cb84d5beb 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 @@ -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, ) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt index 4a0b1ceede0b..24aacf3ac6fb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt @@ -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() 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) + } } } 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 cf3891156146..97721e7c5927 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 @@ -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()) + } }