fix(browser): never narrow the file picker below what the page accepts

Two fidelity gaps in the accept handling, both of which hid files a real
browser would have let the user pick.

An extension Android's MimeTypeMap cannot name was silently dropped from the
filter. That is harmless when it is the only entry (the filter is already
`*/*`), but `accept=".png,.sqlite3"` resolved to image/png alone — the picker
then showed PNGs and no way at all to reach the .sqlite3 the page also asked
for. MimeTypeMap is a fixed table and does not cover every extension a page
might list, so one unresolvable name now widens the whole filter to `*/*`.
`accept` is a hint in HTML, never an enforced restriction, so showing more than
asked is always recoverable and showing less is not.

MODE_OPEN_FOLDER (a `webkitdirectory` input) fell through to a single-file
pick. Android has no picker that hands a WebView the contents of a directory —
ACTION_OPEN_DOCUMENT_TREE returns a tree handle, not the file URIs the page's
callback takes — so it now opens a multi-select instead. The page loses
webkitRelativePath, but the user can finish the upload rather than being
capped at one file. Resolved in one shared helper so the two Activity hosts
and the two embedded providers cannot drift on it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxfdHeR9Ry4qALXHT5Sf1Q
This commit is contained in:
Claude
2026-08-25 21:48:24 +00:00
parent 111f3392b4
commit 3a53e2b965
5 changed files with 49 additions and 14 deletions
@@ -54,27 +54,32 @@ object FileChooserAccept {
* Resolves [acceptTypes] (raw `accept` entries) into a picker filter.
*
* [extensionToMime] maps a lower-case extension with no leading dot (`"heic"`) to a MIME type, or
* null when the platform doesn't know it — an unknown extension simply contributes nothing rather
* than narrowing the picker to something the user can't satisfy.
* null when the platform doesn't know it. Android's `MimeTypeMap` is a fixed table and does not
* know every extension a page might list, so a token that fails to resolve widens the filter to
* [ANY] instead of being dropped: `accept` is a hint in HTML, never an enforced restriction, and a
* partially-resolved list would otherwise hide exactly the files the page cannot name — the user
* would see a picker with the wanted file missing and no way to reach it.
*/
fun resolve(
acceptTypes: List<String>,
extensionToMime: (String) -> String?,
): Resolved {
val mimes =
val tokens =
acceptTypes
// A page may write accept="image/*,video/*" and some WebView versions pass that through
// as ONE entry, so split again on the separator the attribute itself uses.
.flatMap { it.split(',') }
.map { it.trim().lowercase() }
.filter { it.isNotEmpty() }
.mapNotNull { token ->
when {
token.contains('/') -> token
else -> extensionToMime(token.removePrefix("."))
}
}.filter { it.isNotEmpty() }
.distinct()
val mimes = mutableListOf<String>()
for (token in tokens) {
val mime = if (token.contains('/')) token else extensionToMime(token.removePrefix("."))
// One name we can't translate means we cannot express this page's filter faithfully. Show
// everything rather than a filter that silently excludes part of what it asked for.
if (mime.isNullOrEmpty()) return Resolved(ANY, emptyList())
if (mime !in mimes) mimes.add(mime)
}
return Resolved(primaryType = commonType(mimes), mimeTypes = mimes)
}
@@ -71,13 +71,31 @@ class FileChooserAcceptTest {
}
@Test
fun unknownExtensionContributesNothing() {
fun unknownExtensionShowsEverything() {
// Narrowing the picker to a type the platform can't name would hide every file the user has.
val resolved = resolve(".sqlite3")
assertEquals(FileChooserAccept.ANY, resolved.primaryType)
assertEquals(emptyList(), resolved.mimeTypes)
}
@Test
fun oneUnknownExtensionWidensTheWholeFilter() {
// MimeTypeMap is a fixed table and misses extensions pages do use. Filtering to just the half we
// could name would leave the user staring at a picker with the wanted file missing, and `accept`
// is only a hint in HTML — so an unnameable entry means show everything.
val resolved = resolve(".png", ".sqlite3")
assertEquals(FileChooserAccept.ANY, resolved.primaryType)
assertEquals(emptyList(), resolved.mimeTypes)
}
@Test
fun knownExtensionsStillFilterWhenAllResolve() {
// The widening above must not swallow the normal case.
val resolved = resolve(".png", ".jpg")
assertEquals("image/*", resolved.primaryType)
assertEquals(listOf("image/png", "image/jpeg"), resolved.mimeTypes)
}
@Test
fun oneFamilyWidensToThatFamilysWildcard() {
// accept="image/png,image/jpeg" must still show JPEGs, so the Intent type can't be image/png.
@@ -419,7 +419,7 @@ class NappletBrowserService : Service() {
Bundle().apply {
putLong(NappletBrowserContract.KEY_FILE_CHOOSER_ID, id)
putStringArray(NappletBrowserContract.KEY_FILE_CHOOSER_ACCEPT, params.acceptTypes ?: emptyArray())
putBoolean(NappletBrowserContract.KEY_FILE_CHOOSER_MULTIPLE, params.mode == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE)
putBoolean(NappletBrowserContract.KEY_FILE_CHOOSER_MULTIPLE, NappletFileChooser.allowsMultiple(params.mode))
putString(NappletBrowserContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString())
}
}
@@ -86,10 +86,22 @@ object NappletFileChooser {
buildIntent(
context = context,
acceptTypes = params.acceptTypes?.toList().orEmpty(),
allowMultiple = params.mode == FileChooserParams.MODE_OPEN_MULTIPLE,
allowMultiple = allowsMultiple(params.mode),
pageTitle = params.title,
)
/**
* Whether [mode] should let the user pick more than one file.
*
* `MODE_OPEN_FOLDER` is a `webkitdirectory` input. Android has no picker that hands a WebView the
* files of a directory — `ACTION_OPEN_DOCUMENT_TREE` returns a tree handle, not the file URIs the
* page's callback takes — so the closest honest answer is to let the user select the files
* themselves. They lose `webkitRelativePath`, but they can complete the upload instead of being
* limited to one file. The two embedded providers resolve this before sending the request, so all
* four surfaces agree on it.
*/
fun allowsMultiple(mode: Int): Boolean = mode == FileChooserParams.MODE_OPEN_MULTIPLE || mode == FileChooserParams.MODE_OPEN_FOLDER
/**
* Turns an activity result into the array the page's `filePathCallback` expects, or null when the
* user backed out. Handles both the single-URI and the `ClipData` multi-select shapes.
@@ -462,7 +462,7 @@ class NappletHostService : Service() {
Bundle().apply {
putLong(NappletEmbedContract.KEY_FILE_CHOOSER_ID, id)
putStringArray(NappletEmbedContract.KEY_FILE_CHOOSER_ACCEPT, params.acceptTypes ?: emptyArray())
putBoolean(NappletEmbedContract.KEY_FILE_CHOOSER_MULTIPLE, params.mode == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE)
putBoolean(NappletEmbedContract.KEY_FILE_CHOOSER_MULTIPLE, NappletFileChooser.allowsMultiple(params.mode))
putString(NappletEmbedContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString())
}
}