From baae40e5fcc23702c7de6d688b6609f0ecc5fe04 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Aug 2026 00:42:25 -0400 Subject: [PATCH] fix(browser): stop deleting the video a capture just returned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `accept="video/*" capture` handed the page a 0-byte file. The recording was fine — we deleted it before the page could read it. parseResult assumes a camera signals success by filling the EXTRA_OUTPUT file and returning no URI. ACTION_IMAGE_CAPTURE does exactly that. ACTION_VIDEO_CAPTURE on GoogleCamera does not: it writes the file *and* echoes the output URI back in the result. That echo lands in `picked`, which makes `captured` null, and the cleanup loop then treats every capture as unused: if (capture !== captured) NappletCaptureFiles.discard(context, capture.file) So the one file whose URI was on its way to the page was the one file deleted. The page opened it, found nothing, and a "successful" upload carried no bytes. Captures whose URI is being returned are now excluded from the discard sweep, whichever way they got there — echoed back in the result, or found by the fill check. Untouched capture files are still deleted immediately, so a dismissed or unused camera option leaves nothing behind. An echoed URI is also no longer trusted on its face: if the file behind it is empty the URI is dropped, and the request falls through to the same emptiness rules as before rather than reporting a capture that never happened. URIs that are not ours are never second-guessed. Verified on device (Pixel 8 / Android 17), after the fix: - video: 28,135,304-byte mp4 delivered and readable, was 0 bytes before - image: 781,853-byte jpeg with EXIF intact — unchanged, no regression - grants on the capture authority: 0 before, 1 while the camera holds it, 0 again once the result is in, for both media Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw --- .../napplethost/NappletFileChooser.kt | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt index 35b3266128..027e605249 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt @@ -192,24 +192,43 @@ object NappletFileChooser { null }?.takeIf { it.isNotEmpty() } - // A camera returns no URI at all — it reports success by filling the file we handed it, so an + // Not every camera reports a capture the same way. `ACTION_IMAGE_CAPTURE` returns no URI and + // simply fills the file, but `ACTION_VIDEO_CAPTURE` on at least GoogleCamera echoes the + // EXTRA_OUTPUT URI straight back in the result — including when the recording was abandoned + // and nothing was ever written. Taken at face value that hands the page a 0-byte file and the + // upload silently succeeds with no content. An echoed URI is therefore only worth anything if + // the file behind it actually has bytes; when it does not, drop it and let the emptiness rules + // below treat the request as dismissed. URIs that are not ours are never second-guessed. + val usable = + picked + ?.filter { uri -> + val own = request.captures.firstOrNull { it.uri == uri } + own == null || own.file.length() > 0 + }?.toTypedArray() + ?.takeIf { it.isNotEmpty() } + + // A camera that returns no URI at all reports success by filling the file we handed it, so an // empty one means it was dismissed. Only consulted when the picker returned nothing of its own. val captured = - if (picked != null || resultCode != Activity.RESULT_OK) { + if (usable != null || resultCode != Activity.RESULT_OK) { null } else { request.captures.firstOrNull { it.file.length() > 0 } } + // A capture whose URI is being handed to the page has to outlive this call, whether it got + // there by being echoed back ([usable]) or by being the filled file ([captured]). + val returned = usable?.toSet().orEmpty() + request.captures.forEach { capture -> // Every camera app was granted write access up front because none of them could be ruled // out yet. The outcome is known now, so none of them needs it any more — including for the // photo being returned, which this app reads back through its own provider. NappletCaptureFiles.releaseGrants(context, capture.grantedTo, capture.uri) - if (capture !== captured) NappletCaptureFiles.discard(context, capture.file) + if (capture !== captured && capture.uri !in returned) NappletCaptureFiles.discard(context, capture.file) } - return picked ?: captured?.let { arrayOf(it.uri) } + return usable ?: captured?.let { arrayOf(it.uri) } } /**