Skip to content

Commit 3111d43

Browse files
committed
Merge remote-tracking branch 'origin/main' into codex/ios-control-chat-return
2 parents 77bb241 + 968aa51 commit 3111d43

7 files changed

Lines changed: 301 additions & 160 deletions

File tree

apps/android/app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt

Lines changed: 116 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import androidx.camera.video.FileOutputOptions
2121
import androidx.camera.video.Quality
2222
import androidx.camera.video.QualitySelector
2323
import androidx.camera.video.Recorder
24-
import androidx.camera.video.Recording
2524
import androidx.camera.video.VideoCapture
2625
import androidx.camera.video.VideoRecordEvent
2726
import androidx.core.content.ContextCompat
@@ -44,6 +43,54 @@ import kotlin.math.roundToInt
4443
/**
4544
* CameraX-backed capture service used by gateway camera commands.
4645
*/
46+
internal class CameraClipSession(
47+
private val unbind: () -> Unit,
48+
private val deleteTemporaryFile: (File) -> Unit,
49+
) : AutoCloseable {
50+
private var recording: AutoCloseable? = null
51+
private var temporaryFile: File? = null
52+
private var closed = false
53+
54+
fun ownRecording(recording: AutoCloseable) {
55+
check(!closed) { "camera clip session is closed" }
56+
this.recording = recording
57+
}
58+
59+
fun ownFile(file: File): File {
60+
check(!closed) { "camera clip session is closed" }
61+
check(temporaryFile == null) { "camera clip session already owns a file" }
62+
temporaryFile = file
63+
return file
64+
}
65+
66+
fun transferFile(): File {
67+
check(!closed) { "camera clip session is closed" }
68+
return checkNotNull(temporaryFile) { "camera clip session has no file" }
69+
.also { temporaryFile = null }
70+
}
71+
72+
override fun close() {
73+
if (closed) return
74+
closed = true
75+
76+
var failure: Throwable? = null
77+
78+
fun cleanup(action: () -> Unit) {
79+
try {
80+
action()
81+
} catch (err: Throwable) {
82+
failure?.addSuppressed(err) ?: run { failure = err }
83+
}
84+
}
85+
86+
// Keep teardown symmetric across bind, warmup, recording, finalize, and success exits.
87+
cleanup { recording?.close() }
88+
cleanup(unbind)
89+
temporaryFile?.let { file -> cleanup { deleteTemporaryFile(file) } }
90+
failure?.let { throw it }
91+
}
92+
}
93+
4794
class CameraCaptureManager(
4895
private val context: Context,
4996
) {
@@ -235,87 +282,90 @@ class CameraCaptureManager(
235282
androidx.camera.core.Preview
236283
.Builder()
237284
.build()
238-
// Provide a dummy SurfaceTexture so the preview pipeline activates
239-
val surfaceTexture = android.graphics.SurfaceTexture(0)
240-
surfaceTexture.setDefaultBufferSize(640, 480)
285+
// Allocate the dummy preview surface only after CameraX requests it; its result owns release.
241286
preview.setSurfaceProvider { request ->
287+
val surfaceTexture = android.graphics.SurfaceTexture(0)
288+
surfaceTexture.setDefaultBufferSize(640, 480)
242289
val surface = android.view.Surface(surfaceTexture)
243-
request.provideSurface(surface, context.mainExecutor()) { result ->
290+
request.provideSurface(surface, context.mainExecutor()) {
244291
surface.release()
245292
surfaceTexture.release()
246293
}
247294
}
248295

249296
provider.unbindAll()
250-
android.util.Log.w("CameraCaptureManager", "clip: binding preview + videoCapture to lifecycle")
251-
val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture)
252-
android.util.Log.w("CameraCaptureManager", "clip: bound, cameraInfo=${camera.cameraInfo}")
253-
254-
// Give camera pipeline time to initialize before recording
255-
android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...")
256-
kotlinx.coroutines.delay(1_500)
257-
258-
val file = File.createTempFile("openclaw-clip-", ".mp4", context.cacheDir)
259-
val outputOptions = FileOutputOptions.Builder(file).build()
260-
261-
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
262-
android.util.Log.w("CameraCaptureManager", "clip: starting recording to ${file.absolutePath}")
263-
val recording: Recording =
264-
videoCapture.output
265-
.prepareRecording(context, outputOptions)
266-
.apply {
267-
if (includeAudio) withAudioEnabled()
268-
}.start(context.mainExecutor()) { event ->
269-
android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}")
270-
if (event is VideoRecordEvent.Status) {
271-
android.util.Log.w("CameraCaptureManager", "clip: recording status update")
272-
}
273-
if (event is VideoRecordEvent.Finalize) {
274-
android.util.Log.w(
275-
"CameraCaptureManager",
276-
"clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}",
277-
)
278-
finalized.complete(event)
297+
CameraClipSession(
298+
unbind = { provider.unbind(preview, videoCapture) },
299+
deleteTemporaryFile = { file ->
300+
check(!file.exists() || file.delete()) { "failed to delete temporary camera clip" }
301+
},
302+
).use { session ->
303+
android.util.Log.w("CameraCaptureManager", "clip: binding preview + videoCapture to lifecycle")
304+
val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture)
305+
android.util.Log.w("CameraCaptureManager", "clip: bound, cameraInfo=${camera.cameraInfo}")
306+
307+
// Give camera pipeline time to initialize before recording
308+
android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...")
309+
kotlinx.coroutines.delay(1_500)
310+
311+
val clipFile = session.ownFile(File.createTempFile("openclaw-clip-", ".mp4", context.cacheDir))
312+
val outputOptions = FileOutputOptions.Builder(clipFile).build()
313+
314+
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
315+
android.util.Log.w("CameraCaptureManager", "clip: starting recording to ${clipFile.absolutePath}")
316+
val recording =
317+
videoCapture.output
318+
.prepareRecording(context, outputOptions)
319+
.apply {
320+
if (includeAudio) withAudioEnabled()
321+
}.start(context.mainExecutor()) { event ->
322+
android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}")
323+
if (event is VideoRecordEvent.Status) {
324+
android.util.Log.w("CameraCaptureManager", "clip: recording status update")
325+
}
326+
if (event is VideoRecordEvent.Finalize) {
327+
android.util.Log.w(
328+
"CameraCaptureManager",
329+
"clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}",
330+
)
331+
finalized.complete(event)
332+
}
279333
}
280-
}
334+
session.ownRecording(recording)
281335

282-
android.util.Log.w("CameraCaptureManager", "clip: recording started, delaying ${durationMs}ms")
283-
try {
336+
android.util.Log.w("CameraCaptureManager", "clip: recording started, delaying ${durationMs}ms")
284337
kotlinx.coroutines.delay(durationMs.toLong())
285-
} finally {
286338
android.util.Log.w("CameraCaptureManager", "clip: stopping recording")
287-
recording.stop()
288-
}
339+
recording.close()
289340

290-
val finalizeEvent =
291-
try {
292-
withTimeout(15_000) { finalized.await() }
293-
} catch (err: Throwable) {
294-
android.util.Log.e("CameraCaptureManager", "clip: finalize timed out", err)
295-
withContext(Dispatchers.IO) { file.delete() }
296-
provider.unbindAll()
297-
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
341+
val finalizeEvent =
342+
try {
343+
withTimeout(15_000) { finalized.await() }
344+
} catch (err: kotlinx.coroutines.TimeoutCancellationException) {
345+
android.util.Log.e("CameraCaptureManager", "clip: finalize timed out", err)
346+
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
347+
}
348+
if (finalizeEvent.hasError()) {
349+
android.util.Log.e(
350+
"CameraCaptureManager",
351+
"clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}",
352+
finalizeEvent.cause,
353+
)
354+
// Check file size for debugging
355+
val fileSize = withContext(Dispatchers.IO) { if (clipFile.exists()) clipFile.length() else -1 }
356+
android.util.Log.e("CameraCaptureManager", "clip: file exists=${clipFile.exists()} size=$fileSize")
357+
throw IllegalStateException("UNAVAILABLE: camera clip failed (error=${finalizeEvent.error})")
298358
}
299-
if (finalizeEvent.hasError()) {
300-
android.util.Log.e(
301-
"CameraCaptureManager",
302-
"clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}",
303-
finalizeEvent.cause,
304-
)
305-
// Check file size for debugging
306-
val fileSize = withContext(Dispatchers.IO) { if (file.exists()) file.length() else -1 }
307-
android.util.Log.e("CameraCaptureManager", "clip: file exists=${file.exists()} size=$fileSize")
308-
withContext(Dispatchers.IO) { file.delete() }
309-
provider.unbindAll()
310-
throw IllegalStateException("UNAVAILABLE: camera clip failed (error=${finalizeEvent.error})")
311-
}
312359

313-
val fileSize = withContext(Dispatchers.IO) { file.length() }
314-
android.util.Log.w("CameraCaptureManager", "clip: SUCCESS file size=$fileSize")
360+
val fileSize = withContext(Dispatchers.IO) { clipFile.length() }
361+
android.util.Log.w("CameraCaptureManager", "clip: SUCCESS file size=$fileSize")
315362

316-
provider.unbindAll()
317-
318-
FilePayload(file = file, durationMs = durationMs.toLong(), hasAudio = includeAudio)
363+
FilePayload(
364+
file = session.transferFile(),
365+
durationMs = durationMs.toLong(),
366+
hasAudio = includeAudio,
367+
)
368+
}
319369
}
320370

321371
private fun rotateBitmapByExif(

apps/android/app/src/test/java/ai/openclaw/app/node/CameraHandlerTest.kt

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package ai.openclaw.app.node
22

33
import org.junit.Assert.assertEquals
44
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertSame
56
import org.junit.Assert.assertTrue
67
import org.junit.Test
8+
import java.io.File
79

810
class CameraHandlerTest {
911
@Test
@@ -22,4 +24,62 @@ class CameraHandlerTest {
2224
fun cameraClipMaxRawBytes_matchesExpectedBudget() {
2325
assertEquals(18L * 1024L * 1024L, CAMERA_CLIP_MAX_RAW_BYTES)
2426
}
27+
28+
@Test
29+
fun cameraClipSession_closesRecordingUnbindsAndDeletesOwnedFile() {
30+
val tempFile = File.createTempFile("openclaw-clip-test-", ".mp4")
31+
val cleanup = mutableListOf<String>()
32+
val session =
33+
CameraClipSession(
34+
unbind = { cleanup += "unbind" },
35+
deleteTemporaryFile = { file ->
36+
cleanup += "file"
37+
assertSame(tempFile, file)
38+
file.delete()
39+
},
40+
)
41+
session.ownRecording(AutoCloseable { cleanup += "recording" })
42+
session.ownFile(tempFile)
43+
44+
session.close()
45+
session.close()
46+
47+
assertEquals(listOf("recording", "unbind", "file"), cleanup)
48+
assertFalse(tempFile.exists())
49+
}
50+
51+
@Test
52+
fun cameraClipSession_unbindsBeforeRecordingStarts() {
53+
val cleanup = mutableListOf<String>()
54+
55+
CameraClipSession(
56+
unbind = { cleanup += "unbind" },
57+
deleteTemporaryFile = { cleanup += "file" },
58+
).close()
59+
60+
assertEquals(listOf("unbind"), cleanup)
61+
}
62+
63+
@Test
64+
fun cameraClipSession_keepsFileTransferredToCaller() {
65+
val tempFile = File.createTempFile("openclaw-clip-test-", ".mp4")
66+
try {
67+
val cleanup = mutableListOf<String>()
68+
val session =
69+
CameraClipSession(
70+
unbind = { cleanup += "unbind" },
71+
deleteTemporaryFile = { cleanup += "file" },
72+
)
73+
session.ownRecording(AutoCloseable { cleanup += "recording" })
74+
session.ownFile(tempFile)
75+
76+
assertSame(tempFile, session.transferFile())
77+
session.close()
78+
79+
assertEquals(listOf("recording", "unbind"), cleanup)
80+
assertTrue(tempFile.exists())
81+
} finally {
82+
tempFile.delete()
83+
}
84+
}
2585
}

0 commit comments

Comments
 (0)