mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(android): clean up cancelled camera recordings (#129948)
This commit is contained in:
committed by
GitHub
parent
f94ad7673f
commit
2941b3d779
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user