fix(browser): stop deleting the video a capture just returned

`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
This commit is contained in:
Vitor Pamplona
2026-08-26 00:42:25 -04:00
co-authored by Claude Opus 5
parent f11bdb8e49
commit baae40e5fc
@@ -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) }
}
/**