mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
+15
-10
@@ -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)
|
||||
}
|
||||
|
||||
+19
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user