From f11bdb8e496bd603cc12dfbe338a10ede20243d0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 26 Aug 2026 00:05:13 -0400 Subject: [PATCH] fix(browser): stop the embedded file pick from killing the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completing a pick on either embedded surface crashed the whole app, every time. parseResult resolved the picked URIs through `WebChromeClient.FileChooserParams.parseResult`, which is a WebView *static*: it boots Chromium in whichever process calls it. WebFileChooserActivity — the main-process chooser host that exists precisely because the `:napplet` providers are windowless and have no Activity to launch a picker from — declares no `android:process`, so that call ran in main while `:napplet` already held the WebView data directory. AwDataDirLock then threw Using WebView from more than one process at once with the same data directory is not supported as a FATAL EXCEPTION on main. Reproduced on a Pixel 8 / Android 17: the picker opens, the user selects, and on Done the process dies before the page is ever handed its file. The two Activity-owning hosts never hit it because they are themselves `android:process=":napplet"`, where WebView is already initialised — which is why the full-screen browser picked files correctly throughout. Cancelling did not hit it either, so "the picker opened" was never enough to catch this. The URIs are now read off the result Intent directly. The platform implementation reads exactly the same two fields (ClipData items, else the data URI, only on RESULT_OK), so behaviour is unchanged for the single-URI, multi-select and camera shapes; it just no longer drags WebView into a process that must not have it. This also plugs a grant leak. releaseGrants runs *inside* parseResult, after the line that was throwing, so every crashed capture left the camera apps holding a live write grant on the capture URI that nothing would ever revoke. Verified on device after the fix: - embedded pick: no crash, page reads back all 94,976 bytes of the chosen PNG with its header intact — so a URI granted to the main process is readable by the WebView in `:napplet` with no re-granting, as designed - camera capture: 892,681-byte JPEG with EXIF intact (full resolution, so EXTRA_OUTPUT is doing its job), delivered under the same name as the granted URI - grant/revoke: 0 outstanding grants on the capture authority, 1 while the camera holds it, 0 again once the result is in Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw --- .../napplethost/NappletFileChooser.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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 b212872ced..35b3266128 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt @@ -162,6 +162,17 @@ object NappletFileChooser { * shape — where the result carries no URI at all and the evidence of a capture is a scratch file * that now has bytes in it. Every capture file this request created and did not return is deleted * here, so a cancelled or unused camera option leaves nothing behind. + * + * The URIs are read off the Intent here rather than through + * [FileChooserParams.parseResult][android.webkit.WebChromeClient.FileChooserParams.parseResult], + * which is a WebView *static*: calling it boots Chromium in whatever process calls it. This runs + * in the main process too — [com.vitorpamplona.amethyst.napplet.WebFileChooserActivity] is the + * chooser host for the embedded surfaces and declares no `android:process` — while `:napplet` + * already holds the WebView data directory. That second init throws + * `Using WebView from more than one process at once with the same data directory is not + * supported` out of `AwDataDirLock` and takes the whole app down on every completed embedded + * pick. The platform implementation reads exactly these two fields, so this is behaviour-for- + * behaviour identical without dragging WebView into a process that must not have it. */ fun parseResult( context: Context, @@ -169,7 +180,17 @@ object NappletFileChooser { resultCode: Int, data: Intent?, ): Array? { - val picked = FileChooserParams.parseResult(resultCode, data)?.takeIf { it.isNotEmpty() } + val picked = + if (resultCode == Activity.RESULT_OK && data != null) { + val clip = data.clipData + if (clip != null && clip.itemCount > 0) { + Array(clip.itemCount) { clip.getItemAt(it).uri } + } else { + data.data?.let { arrayOf(it) } + } + } else { + null + }?.takeIf { it.isNotEmpty() } // A camera returns no URI at all — it 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.