diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAccept.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAccept.kt index 3df3b1558b..3e8cffd70a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAccept.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAccept.kt @@ -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, 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() + 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) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAcceptTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAcceptTest.kt index 5d2fae93d4..aaa4b8e202 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAcceptTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAcceptTest.kt @@ -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. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 75682a2ddf..bea0f025e2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -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()) } } 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 452c2fd874..48c64b19b1 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt @@ -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. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index 2eef62cd2e..24ebeec70d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -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()) } }