diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index 5a5b960d53..85a6fe3278 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -579,6 +579,16 @@
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
+
+
+ ` stays busy until it hears something back.
+ */
+class WebFileChooserActivity : ComponentActivity() {
+ private var token: String? = null
+ private var reported = false
+
+ private val picker =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ report(NappletFileChooser.parseResult(result.resultCode, result.data)?.map { it.toString() }?.toTypedArray())
+ finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val token = intent.getStringExtra(WebFileChooserCoordinator.EXTRA_TOKEN)
+ this.token = token
+ val chooser = token?.let { WebFileChooserCoordinator.chooserFor(it) }
+ if (chooser == null) {
+ // No pending request under this token: the surface went away, or the process was restarted
+ // and the request died with it. Nothing to report to.
+ reported = true
+ finish()
+ return
+ }
+
+ // A recreated instance (rotation) already has its pick in flight; re-launching would stack a
+ // second picker on top of the first.
+ if (savedInstanceState != null) return
+
+ runCatching { picker.launch(chooser) }
+ .onFailure { e ->
+ Log.w(TAG, "No activity available to pick a file", e)
+ Toast.makeText(this, getString(CommonsR.string.browser_file_chooser_unavailable), Toast.LENGTH_LONG).show()
+ report(null)
+ finish()
+ }
+ }
+
+ /** Fail-open toward the page: any unreported teardown still releases its file input. */
+ override fun finish() {
+ report(null)
+ super.finish()
+ }
+
+ private fun report(uris: Array?) {
+ if (reported) return
+ reported = true
+ token?.let { WebFileChooserCoordinator.complete(it, uris) }
+ }
+
+ private companion object {
+ private const val TAG = "WebFileChooser"
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebFileChooserCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebFileChooserCoordinator.kt
new file mode 100644
index 0000000000..e715f8fc20
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebFileChooserCoordinator.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.napplet
+
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.vitorpamplona.amethyst.napplethost.NappletFileChooser
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Runs the system file picker on behalf of an **embedded** WebView surface.
+ *
+ * The two embedded providers ([NappletBrowserService][com.vitorpamplona.amethyst.napplethost.NappletBrowserService]
+ * and [NappletHostService][com.vitorpamplona.amethyst.napplethost.NappletHostService]) host their
+ * WebView in the keyless `:napplet` process as a windowless Service, so when a page taps
+ * `` there is no Activity there to start a picker from. They send the *description*
+ * of the request over Messenger instead; this builds the Intent here in the main process, launches
+ * [WebFileChooserActivity] to collect the result, and hands the picked URIs back to the caller, which
+ * relays them to the sandbox.
+ *
+ * Mirrors [NappletConsentCoordinator]: the pending request is keyed by a one-time token so the
+ * throwaway Activity carries nothing but that token. Every request completes exactly once — a
+ * dismissed picker resolves to null, which is what releases the page's file input.
+ *
+ * URI read grants are per-UID, so the `content://` URIs granted to this process are readable by the
+ * WebView in `:napplet` without any re-granting.
+ */
+object WebFileChooserCoordinator {
+ private class Pending(
+ val chooser: Intent,
+ val onResult: (Array?) -> Unit,
+ )
+
+ private val pending = ConcurrentHashMap()
+
+ /**
+ * Shows a picker filtered by [acceptTypes] (raw HTML `accept` entries) and calls [onResult] with the
+ * picked URIs as strings, or null when the user cancelled. [onResult] always runs, including when no
+ * picker could be started at all — the page is waiting on it.
+ */
+ fun request(
+ context: Context,
+ acceptTypes: List,
+ allowMultiple: Boolean,
+ pageTitle: String?,
+ onResult: (Array?) -> Unit,
+ ) {
+ val token = UUID.randomUUID().toString()
+ pending[token] = Pending(NappletFileChooser.buildIntent(context, acceptTypes, allowMultiple, pageTitle), onResult)
+
+ val launch =
+ Intent(context, WebFileChooserActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ .putExtra(EXTRA_TOKEN, token)
+
+ runCatching { context.startActivity(launch) }
+ .onFailure { e ->
+ Log.w(TAG, "Could not start the file chooser host", e)
+ complete(token, null)
+ }
+ }
+
+ /** Called by [WebFileChooserActivity] to get the picker it should launch. */
+ fun chooserFor(token: String): Intent? = pending[token]?.chooser
+
+ /** Called by [WebFileChooserActivity] with the outcome; null = cancelled. Resolves at most once. */
+ fun complete(
+ token: String,
+ uris: Array?,
+ ) {
+ pending.remove(token)?.onResult?.invoke(uris)
+ }
+
+ const val EXTRA_TOKEN = "web_file_chooser_token"
+
+ private const val TAG = "WebFileChooser"
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt
index 82df6800dd..b50a6872c4 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt
@@ -40,6 +40,7 @@ import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
+import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleLogEntry
@@ -254,6 +255,24 @@ class EmbeddedWebAppController(
if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0)
consoleLogs.add(ConsoleLogEntry(level, message, source, line))
}
+ NappletBrowserContract.MSG_FILE_CHOOSER_REQUEST -> {
+ val data = msg.data ?: return true
+ val requestId = data.getLong(NappletBrowserContract.KEY_FILE_CHOOSER_ID)
+ // The sandbox has no Activity to run a picker from, so the main process runs it here and
+ // ships the URIs back. The reply is sent on every outcome (a cancel included) — the page's
+ // file input stays busy until it hears something.
+ WebFileChooserCoordinator.request(
+ context = appContext,
+ acceptTypes = data.getStringArray(NappletBrowserContract.KEY_FILE_CHOOSER_ACCEPT)?.toList().orEmpty(),
+ allowMultiple = data.getBoolean(NappletBrowserContract.KEY_FILE_CHOOSER_MULTIPLE, false),
+ pageTitle = data.getString(NappletBrowserContract.KEY_FILE_CHOOSER_TITLE),
+ ) { uris ->
+ send(NappletBrowserContract.MSG_FILE_CHOOSER_RESULT) {
+ putLong(NappletBrowserContract.KEY_FILE_CHOOSER_ID, requestId)
+ uris?.let { putStringArray(NappletBrowserContract.KEY_FILE_CHOOSER_URIS, it) }
+ }
+ }
+ }
NappletBrowserContract.MSG_MAGNIFIER_FRAME -> {
val data = msg.data ?: return true
val bytes = data.getByteArray(NappletBrowserContract.KEY_MAG_BYTES) ?: return true
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt
index ec509755da..39661a0bae 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt
@@ -39,6 +39,7 @@ import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
+import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
@@ -246,6 +247,24 @@ class EmbeddedNostrAppController(
val failed = msg.data?.getBoolean(NappletEmbedContract.KEY_LOAD_FAILED, false) ?: false
onLoadState(isLoading, failed)
}
+ NappletEmbedContract.MSG_FILE_CHOOSER_REQUEST -> {
+ val data = msg.data ?: return true
+ val requestId = data.getLong(NappletEmbedContract.KEY_FILE_CHOOSER_ID)
+ // The sandbox has no Activity to run a picker from, so the main process runs it here and
+ // ships the URIs back. The reply is sent on every outcome (a cancel included) — the page's
+ // file input stays busy until it hears something.
+ WebFileChooserCoordinator.request(
+ context = appContext,
+ acceptTypes = data.getStringArray(NappletEmbedContract.KEY_FILE_CHOOSER_ACCEPT)?.toList().orEmpty(),
+ allowMultiple = data.getBoolean(NappletEmbedContract.KEY_FILE_CHOOSER_MULTIPLE, false),
+ pageTitle = data.getString(NappletEmbedContract.KEY_FILE_CHOOSER_TITLE),
+ ) { uris ->
+ send(NappletEmbedContract.MSG_FILE_CHOOSER_RESULT) {
+ putLong(NappletEmbedContract.KEY_FILE_CHOOSER_ID, requestId)
+ uris?.let { putStringArray(NappletEmbedContract.KEY_FILE_CHOOSER_URIS, it) }
+ }
+ }
+ }
NappletEmbedContract.MSG_MAGNIFIER_FRAME -> {
val data = msg.data ?: return true
val bytes = data.getByteArray(NappletEmbedContract.KEY_MAG_BYTES) ?: return true
diff --git a/commons/src/androidMain/res/values/strings.xml b/commons/src/androidMain/res/values/strings.xml
index b253d177a4..35d0cd1366 100644
--- a/commons/src/androidMain/res/values/strings.xml
+++ b/commons/src/androidMain/res/values/strings.xml
@@ -5,4 +5,8 @@
Console (%1$d)ClearUntitled nApplet
+
+ Choose a file
+
+ No app available to pick a file
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
new file mode 100644
index 0000000000..3df3b1558b
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAccept.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.browser
+
+/**
+ * Translates an HTML `` list into what an Android picker Intent needs:
+ * a single `type` plus an optional `EXTRA_MIME_TYPES` array.
+ *
+ * WebView hands us the `accept` attribute pre-split into `FileChooserParams.getAcceptTypes`, but the
+ * entries are whatever the page author wrote — exact MIME types, family wildcards, bare extensions
+ * (`.heic`), or several of those comma-joined into one entry. Android's own
+ * `FileChooserParams.createIntent()` keeps only the FIRST entry and drops multi-select entirely, so a
+ * page asking for `accept="image/png,image/jpeg" multiple` would offer PNGs only, one at a time. This
+ * resolves the whole list instead.
+ *
+ * Extension → MIME lookup is injected ([resolve]'s `extensionToMime`) rather than calling
+ * `android.webkit.MimeTypeMap` directly, which keeps this pure and unit-testable in commonMain; the
+ * Android callers pass the real map.
+ */
+object FileChooserAccept {
+ /** The any-type wildcard, used when the page asked for nothing or for types with no common family. */
+ const val ANY = "*/*"
+
+ /**
+ * [primaryType] goes in `Intent.setType` — the only thing pickers that predate `EXTRA_MIME_TYPES`
+ * (and plain `ACTION_GET_CONTENT` targets) look at, so it is widened to the narrowest wildcard that
+ * still covers everything asked for. [mimeTypes] is the exact list for `EXTRA_MIME_TYPES`, empty
+ * when nothing resolved (in which case [primaryType] is [ANY] and the picker shows everything).
+ */
+ data class Resolved(
+ val primaryType: String,
+ val mimeTypes: List,
+ )
+
+ /**
+ * 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.
+ */
+ fun resolve(
+ acceptTypes: List,
+ extensionToMime: (String) -> String?,
+ ): Resolved {
+ val mimes =
+ 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()
+
+ return Resolved(primaryType = commonType(mimes), mimeTypes = mimes)
+ }
+
+ /**
+ * The narrowest single type covering [mimes]: the type itself when there is only one, the shared
+ * family's wildcard when they all belong to one family, and [ANY] when they span families (or the
+ * list is empty). Never narrower than the request — a picker filtered to `image/png` would hide a
+ * JPEG the page also accepts.
+ */
+ private fun commonType(mimes: List): String {
+ if (mimes.isEmpty()) return ANY
+ if (mimes.size == 1) return mimes.first()
+ val families = mimes.map { it.substringBefore('/') }.distinct()
+ return if (families.size == 1) "${families.first()}/*" else ANY
+ }
+}
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
new file mode 100644
index 0000000000..5d2fae93d4
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/FileChooserAcceptTest.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.browser
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class FileChooserAcceptTest {
+ /** Stands in for android.webkit.MimeTypeMap; only the extensions the tests use are known. */
+ private val map =
+ mapOf(
+ "png" to "image/png",
+ "jpg" to "image/jpeg",
+ "jpeg" to "image/jpeg",
+ "pdf" to "application/pdf",
+ "mp4" to "video/mp4",
+ )
+
+ private fun resolve(vararg accept: String) = FileChooserAccept.resolve(accept.toList(), map::get)
+
+ @Test
+ fun noAcceptMeansEverything() {
+ val resolved = resolve()
+ assertEquals(FileChooserAccept.ANY, resolved.primaryType)
+ assertEquals(emptyList(), resolved.mimeTypes)
+ }
+
+ @Test
+ fun blankEntriesAreIgnored() {
+ val resolved = resolve("", " ")
+ assertEquals(FileChooserAccept.ANY, resolved.primaryType)
+ assertEquals(emptyList(), resolved.mimeTypes)
+ }
+
+ @Test
+ fun singleMimeTypeIsUsedVerbatim() {
+ val resolved = resolve("image/png")
+ assertEquals("image/png", resolved.primaryType)
+ assertEquals(listOf("image/png"), resolved.mimeTypes)
+ }
+
+ @Test
+ fun wildcardIsKept() {
+ val resolved = resolve("image/*")
+ assertEquals("image/*", resolved.primaryType)
+ assertEquals(listOf("image/*"), resolved.mimeTypes)
+ }
+
+ @Test
+ fun extensionsResolveToMimeTypes() {
+ val resolved = resolve(".png", ".pdf")
+ assertEquals(listOf("image/png", "application/pdf"), resolved.mimeTypes)
+ }
+
+ @Test
+ fun unknownExtensionContributesNothing() {
+ // 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 oneFamilyWidensToThatFamilysWildcard() {
+ // accept="image/png,image/jpeg" must still show JPEGs, so the Intent type can't be image/png.
+ val resolved = resolve("image/png", "image/jpeg")
+ assertEquals("image/*", resolved.primaryType)
+ assertEquals(listOf("image/png", "image/jpeg"), resolved.mimeTypes)
+ }
+
+ @Test
+ fun mixedFamiliesWidenToAny() {
+ val resolved = resolve("image/png", "application/pdf")
+ assertEquals(FileChooserAccept.ANY, resolved.primaryType)
+ assertEquals(listOf("image/png", "application/pdf"), resolved.mimeTypes)
+ }
+
+ @Test
+ fun commaJoinedEntryIsSplitAgain() {
+ // Some WebView versions hand the whole accept attribute back as a single entry.
+ val resolved = resolve("image/png,video/mp4")
+ assertEquals(FileChooserAccept.ANY, resolved.primaryType)
+ assertEquals(listOf("image/png", "video/mp4"), resolved.mimeTypes)
+ }
+
+ @Test
+ fun duplicatesCollapse() {
+ val resolved = resolve(".jpg", ".jpeg", "image/jpeg")
+ assertEquals("image/jpeg", resolved.primaryType)
+ assertEquals(listOf("image/jpeg"), resolved.mimeTypes)
+ }
+
+ @Test
+ fun caseAndWhitespaceAreNormalized() {
+ val resolved = resolve(" IMAGE/PNG ", ".PNG")
+ assertEquals("image/png", resolved.primaryType)
+ assertEquals(listOf("image/png"), resolved.mimeTypes)
+ }
+}
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
index bd8cf3f48c..16b2bcd593 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
@@ -38,6 +38,7 @@ import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.webkit.ConsoleMessage
+import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
@@ -52,6 +53,7 @@ import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.graphics.scale
import androidx.core.net.toUri
@@ -106,6 +108,17 @@ class NappletBrowserActivity : ComponentActivity() {
private var mainFrameLoadFailed = false
private var lastIconHost: String? = null
+ // ---- HTML file input (``) ----
+ // Registered as a field so it is in place before onCreate returns, which is what
+ // registerForActivityResult requires. This activity hosts its WebView directly, so it can run the
+ // picker itself; the embedded surfaces have no Activity and route theirs through the main process.
+ private val pendingFileChooser = PendingFileChooser()
+
+ private val fileChooserLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ pendingFileChooser.deliver(NappletFileChooser.parseResult(result.resultCode, result.data))
+ }
+
// ---- broker bridge (per-origin NIP-07 tokens; identical to NappletBrowserService) ----
private var brokerMessenger: Messenger? = null
@@ -291,6 +304,8 @@ class NappletBrowserActivity : ComponentActivity() {
// life of the process. `unbindService` alone does not release it. See [replyMessenger].
releaseFromBroker()
runCatching { unbindService(brokerConnection) }
+ // A picker still up when the browser is torn down would otherwise leave its callback unanswered.
+ pendingFileChooser.cancel()
if (this::webView.isInitialized) {
// Detach from the view tree BEFORE destroy(). Destroying a WebView while it is still attached to
// the window corrupts the SHARED multiprocess renderer/network state, which then breaks the OTHER
@@ -364,8 +379,19 @@ class NappletBrowserActivity : ComponentActivity() {
wv.webChromeClient = BrowserChromeClient()
}
- /** Captures favicon and console output, and drives the top loading bar; all come from the WebChromeClient. */
+ /** Captures favicon and console output, drives the top loading bar, and opens the file picker. */
private inner class BrowserChromeClient : WebChromeClient() {
+ /**
+ * Without this override the base implementation returns false and WebView shows nothing at all, so
+ * every `` in the browser is a dead tap. Always returns true: we take ownership
+ * of the callback, and [showFileChooser] guarantees it is answered on every path.
+ */
+ override fun onShowFileChooser(
+ webView: WebView,
+ filePathCallback: ValueCallback>,
+ fileChooserParams: FileChooserParams,
+ ): Boolean = showFileChooser(filePathCallback, fileChooserParams)
+
override fun onProgressChanged(
view: WebView,
newProgress: Int,
@@ -398,6 +424,26 @@ class NappletBrowserActivity : ComponentActivity() {
}
}
+ /**
+ * Opens the system picker for a page's file input and routes the pick back to it. Returns true
+ * unconditionally: the callback is ours from here on, and it is delivered on every path — a real
+ * pick, a cancel, or a device with no app that can return a file (the input is released with null so
+ * the user can tap it again after installing one).
+ */
+ private fun showFileChooser(
+ filePathCallback: ValueCallback>,
+ params: WebChromeClient.FileChooserParams,
+ ): Boolean {
+ pendingFileChooser.start(filePathCallback)
+ runCatching { fileChooserLauncher.launch(NappletFileChooser.buildIntent(this, params)) }
+ .onFailure { e ->
+ Log.w(TAG, "No activity available to pick a file", e)
+ pendingFileChooser.cancel()
+ Toast.makeText(this, getString(CommonsR.string.browser_file_chooser_unavailable), Toast.LENGTH_LONG).show()
+ }
+ return true
+ }
+
private inner class BrowserClient : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt
index aa2aa1e05a..ae6d908dcc 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt
@@ -94,6 +94,31 @@ object NappletBrowserContract {
*/
const val MSG_MAGNIFIER_FRAME = 13
+ /**
+ * Provider → client: the page tapped an HTML file input and needs the system picker. The keyless
+ * `:napplet` process has no Activity of its own (this provider is windowless), and its streamed
+ * surface can't host one, so the main process runs the picker and sends the chosen URIs back.
+ *
+ * Carries the request description as data — [KEY_FILE_CHOOSER_ID], [KEY_FILE_CHOOSER_ACCEPT] (the raw `accept` entries),
+ * [KEY_FILE_CHOOSER_MULTIPLE] and [KEY_FILE_CHOOSER_TITLE] — never a ready-made Intent, so the sandbox can ask the trusted
+ * process for a file picker and for nothing else.
+ */
+ const val MSG_FILE_CHOOSER_REQUEST = 14
+
+ /**
+ * Client → provider: the picker for [KEY_FILE_CHOOSER_ID] finished. [KEY_FILE_CHOOSER_URIS] holds the picked
+ * `content://` URIs, or is absent when the user cancelled — the page's file input stays busy until
+ * one of the two arrives, so this is sent on every outcome. URI read grants are per-UID, so the URIs
+ * the main process was granted are readable by the WebView here without re-granting.
+ */
+ const val MSG_FILE_CHOOSER_RESULT = 15
+
+ const val KEY_FILE_CHOOSER_ID = "fileChooserId"
+ const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept"
+ const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple"
+ const val KEY_FILE_CHOOSER_TITLE = "fileChooserTitle"
+ const val KEY_FILE_CHOOSER_URIS = "fileChooserUris"
+
const val KEY_MAG_X = "magX"
const val KEY_MAG_Y = "magY"
const val KEY_MAG_BOX_W = "magBoxW"
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 fea6262e94..75682a2ddf 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt
@@ -38,6 +38,7 @@ import android.os.Messenger
import android.os.SystemClock
import android.util.Log
import android.webkit.ConsoleMessage
+import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
@@ -47,6 +48,7 @@ import android.webkit.WebViewClient
import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
+import androidx.core.net.toUri
import androidx.privacysandbox.ui.provider.toCoreLibInfo
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
@@ -95,6 +97,10 @@ class NappletBrowserService : Service() {
var bridgeReplyProxy: JavaScriptReplyProxy? = null
var fireSeq = 0
+ // The in-flight `` pick for this surface. The picker itself runs in the main
+ // process (this provider is windowless), so the callback waits here for MSG_FILE_CHOOSER_RESULT.
+ val fileChooser = PendingFileChooser()
+
// Last main-frame error state, pushed to the client so it can show an error/retry overlay over
// the surface (the embedded surface has no error page of its own).
var loadFailed = false
@@ -146,7 +152,10 @@ class NappletBrowserService : Service() {
runCatching { unbindService(brokerConnection) }
brokerBound = false
}
- tabs.values.forEach { it.webView?.destroy() }
+ tabs.values.forEach {
+ it.fileChooser.cancel()
+ it.webView?.destroy()
+ }
tabs.clear()
super.onDestroy()
}
@@ -195,6 +204,15 @@ class NappletBrowserService : Service() {
applyWebViewProxy(if (tab.useTor) tab.proxyPort else -1) { tab.webView?.reload() }
}
NappletBrowserContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg)
+ NappletBrowserContract.MSG_FILE_CHOOSER_RESULT -> {
+ val tab = tabFor(msg) ?: return true
+ val data = msg.data ?: return true
+ val id = data.getLong(NappletBrowserContract.KEY_FILE_CHOOSER_ID)
+ // Absent array = the user cancelled; PendingFileChooser turns that into the null the page
+ // needs to see so its file input becomes tappable again.
+ val uris = data.getStringArray(NappletBrowserContract.KEY_FILE_CHOOSER_URIS)?.map(String::toUri)
+ tab.fileChooser.deliver(id, uris?.toTypedArray())
+ }
else -> return false
}
return true
@@ -295,6 +313,8 @@ class NappletBrowserService : Service() {
fun onSessionClosed(sessionId: String) {
val tab = tabs.remove(sessionId) ?: return
tab.bridgeReplyProxy = null
+ // Release a picker still waiting on this surface before its WebView goes away.
+ tab.fileChooser.cancel()
tab.webView?.destroy()
tab.webView = null
}
@@ -336,6 +356,17 @@ class NappletBrowserService : Service() {
private inner class BrowserChromeClient(
private val tab: BrowserTab?,
) : WebChromeClient() {
+ /**
+ * Hands the page's `` to the main process, which owns the only Activity that
+ * can run a picker. Always returns true: the callback is ours now, and [requestFileChooser]
+ * answers it on every path — otherwise the input would stay dead for the life of the page.
+ */
+ override fun onShowFileChooser(
+ webView: WebView,
+ filePathCallback: ValueCallback>,
+ fileChooserParams: FileChooserParams,
+ ): Boolean = requestFileChooser(tab, filePathCallback, fileChooserParams)
+
/**
* The embedded surface captures favicons just like the full-screen browser does — a site pinned
* to a tab but never opened full-screen would otherwise never contribute an icon at all.
@@ -365,6 +396,37 @@ class NappletBrowserService : Service() {
}
}
+ /**
+ * Asks the client (main process) to run the picker for [tab] and remembers the callback until the
+ * matching [NappletBrowserContract.MSG_FILE_CHOOSER_RESULT] comes back. A surface with no client to
+ * ask — a torn-down tab, a dead Messenger — releases the input immediately instead of leaving it
+ * stuck waiting for a reply that can never arrive.
+ */
+ private fun requestFileChooser(
+ tab: BrowserTab?,
+ filePathCallback: ValueCallback>,
+ params: WebChromeClient.FileChooserParams,
+ ): Boolean {
+ val client = tab?.clientMessenger
+ if (client == null) {
+ filePathCallback.onReceiveValue(null)
+ return true
+ }
+ val id = tab.fileChooser.start(filePathCallback)
+ val msg =
+ Message.obtain(null, NappletBrowserContract.MSG_FILE_CHOOSER_REQUEST).apply {
+ data =
+ 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)
+ putString(NappletBrowserContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString())
+ }
+ }
+ if (runCatching { client.send(msg) }.isFailure) tab.fileChooser.cancel()
+ return true
+ }
+
private fun pushConsoleLog(
tab: BrowserTab,
level: String,
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt
index 9d1a247da3..02b8892acc 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt
@@ -96,6 +96,31 @@ object NappletEmbedContract {
/** Provider → client: the captured loupe frame — [KEY_MAG_BYTES] PNG, [KEY_MAG_W]/[KEY_MAG_H], [KEY_MAG_CAPTURE_MS], echoed [KEY_MAG_REQ_T]. */
const val MSG_MAGNIFIER_FRAME = 17
+ /**
+ * Provider → client: the page tapped an HTML file input and needs the system picker. The keyless
+ * `:napplet` process has no Activity of its own (this provider is windowless), and its streamed
+ * surface can't host one, so the main process runs the picker and sends the chosen URIs back.
+ *
+ * Carries the request description as data — [KEY_FILE_CHOOSER_ID], [KEY_FILE_CHOOSER_ACCEPT] (the raw `accept` entries),
+ * [KEY_FILE_CHOOSER_MULTIPLE] and [KEY_FILE_CHOOSER_TITLE] — never a ready-made Intent, so the sandbox can ask the trusted
+ * process for a file picker and for nothing else.
+ */
+ const val MSG_FILE_CHOOSER_REQUEST = 18
+
+ /**
+ * Client → provider: the picker for [KEY_FILE_CHOOSER_ID] finished. [KEY_FILE_CHOOSER_URIS] holds the picked
+ * `content://` URIs, or is absent when the user cancelled — the page's file input stays busy until
+ * one of the two arrives, so this is sent on every outcome. URI read grants are per-UID, so the URIs
+ * the main process was granted are readable by the WebView here without re-granting.
+ */
+ const val MSG_FILE_CHOOSER_RESULT = 19
+
+ const val KEY_FILE_CHOOSER_ID = "fileChooserId"
+ const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept"
+ const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple"
+ const val KEY_FILE_CHOOSER_TITLE = "fileChooserTitle"
+ const val KEY_FILE_CHOOSER_URIS = "fileChooserUris"
+
const val KEY_MAG_X = "magX"
const val KEY_MAG_Y = "magY"
const val KEY_MAG_BOX_W = "magBoxW"
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt
new file mode 100644
index 0000000000..452c2fd874
--- /dev/null
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletFileChooser.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.napplethost
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import android.webkit.WebChromeClient.FileChooserParams
+import com.vitorpamplona.amethyst.commons.browser.FileChooserAccept
+import com.vitorpamplona.amethyst.commons.R as CommonsR
+
+/**
+ * The picker behind an HTML `` in any of Amethyst's WebViews.
+ *
+ * A WebView shows no picker of its own: unless the app overrides
+ * `WebChromeClient.onShowFileChooser`, tapping a file input is a silent no-op. Every host here does
+ * override it, and they all build their Intent through this so the browser, the full-screen napplet /
+ * nSite sandbox, and both embedded surfaces filter and multi-select identically.
+ *
+ * The request is described by plain data (accept list, multi-select, title) rather than by a
+ * ready-made Intent, because the two embedded hosts have no Activity of their own and must ask the
+ * main process to launch the picker for them. Shipping data keeps the keyless `:napplet` process
+ * unable to hand the trusted process an arbitrary Intent to start — it can only ask for a file picker.
+ */
+object NappletFileChooser {
+ /**
+ * Builds the picker for a request described by [acceptTypes] / [allowMultiple].
+ *
+ * `ACTION_GET_CONTENT` (rather than `ACTION_OPEN_DOCUMENT`) so gallery and camera-roll apps that
+ * are not document providers still show up — the same trade-off Chrome makes; the page only ever
+ * needs to read the bytes once, not to hold a persistable grant. [pageTitle] is the page-supplied
+ * chooser title, used when it set one.
+ */
+ fun buildIntent(
+ context: Context,
+ acceptTypes: List,
+ allowMultiple: Boolean,
+ pageTitle: CharSequence? = null,
+ ): Intent {
+ val mimeMap = MimeTypeMap.getSingleton()
+ val resolved = FileChooserAccept.resolve(acceptTypes) { ext -> mimeMap.getMimeTypeFromExtension(ext) }
+
+ val pick =
+ Intent(Intent.ACTION_GET_CONTENT)
+ .addCategory(Intent.CATEGORY_OPENABLE)
+ .setType(resolved.primaryType)
+ // The provider sets this on the result too; asking for it up front makes the read grant
+ // explicit. Grants are per-UID, so a URI picked by the main process is readable by the
+ // WebView in `:napplet` without any re-granting.
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+
+ // Only worth sending when it says more than `type` already does.
+ if (resolved.mimeTypes.size > 1) {
+ pick.putExtra(Intent.EXTRA_MIME_TYPES, resolved.mimeTypes.toTypedArray())
+ }
+ if (allowMultiple) pick.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
+
+ val title = pageTitle?.takeIf { it.isNotBlank() } ?: context.getString(CommonsR.string.browser_file_chooser_title)
+ return Intent.createChooser(pick, title)
+ }
+
+ /** Convenience overload for the two Activity hosts, which hold the real [FileChooserParams]. */
+ fun buildIntent(
+ context: Context,
+ params: FileChooserParams,
+ ): Intent =
+ buildIntent(
+ context = context,
+ acceptTypes = params.acceptTypes?.toList().orEmpty(),
+ allowMultiple = params.mode == FileChooserParams.MODE_OPEN_MULTIPLE,
+ pageTitle = params.title,
+ )
+
+ /**
+ * 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.
+ */
+ fun parseResult(
+ resultCode: Int,
+ data: Intent?,
+ ): Array? = FileChooserParams.parseResult(resultCode, data)
+}
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt
index 6239737305..d1c2aab8d4 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt
@@ -40,6 +40,7 @@ import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.webkit.ConsoleMessage
+import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
@@ -55,6 +56,7 @@ import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.ProxyConfig
@@ -99,6 +101,18 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR
class NappletHostActivity : ComponentActivity() {
private lateinit var webView: WebView
+ // ---- HTML file input (``) ----
+ // The applet picks the file through the system picker, so the user names exactly which file crosses
+ // into the sandbox — the same user-mediated grant a browser gives a web page. Nothing extra is
+ // brokered: the applet gets the bytes of the one file that was chosen and no path around it.
+ // Registered as a field so it exists before onCreate returns (registerForActivityResult's contract).
+ private val pendingFileChooser = PendingFileChooser()
+
+ private val fileChooserLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ pendingFileChooser.deliver(NappletFileChooser.parseResult(result.resultCode, result.data))
+ }
+
private val paths = mutableListOf()
private val servers = mutableListOf()
private var author: String = ""
@@ -449,6 +463,8 @@ class NappletHostActivity : ComponentActivity() {
// unbind is in runCatching: if the index never resolved we never bound the broker.
runCatching { unbindService(brokerConnection) }
keyActions.clear()
+ // A picker still up when the applet is torn down would otherwise leave its callback unanswered.
+ pendingFileChooser.cancel()
if (this::webView.isInitialized) {
// Detach before destroy(): destroying an attached WebView corrupts the shared multiprocess
// renderer/network state and breaks the other (embedded) WebViews in this `:napplet` process
@@ -649,8 +665,19 @@ class NappletHostActivity : ComponentActivity() {
}
}
- /** Drives the top loading bar and forwards the applet/site's `console.*` output to the console panel. */
+ /** Drives the top loading bar, opens the file picker, and forwards `console.*` output to the panel. */
private inner class NappletWebChromeClient : WebChromeClient() {
+ /**
+ * Without this override WebView's base implementation returns false and shows no picker at all, so
+ * every `` in an applet or nSite is a dead tap. Always returns true: the
+ * callback is ours, and [showFileChooser] answers it on every path.
+ */
+ override fun onShowFileChooser(
+ webView: WebView,
+ filePathCallback: ValueCallback>,
+ fileChooserParams: FileChooserParams,
+ ): Boolean = showFileChooser(filePathCallback, fileChooserParams)
+
override fun onProgressChanged(
view: WebView,
newProgress: Int,
@@ -666,6 +693,25 @@ class NappletHostActivity : ComponentActivity() {
}
}
+ /**
+ * Opens the system picker for the applet's file input and routes the pick back to it. Returns true
+ * unconditionally: the callback is ours from here on, and it is answered on every path — a real pick,
+ * a cancel, or a device with no app that can return a file.
+ */
+ private fun showFileChooser(
+ filePathCallback: ValueCallback>,
+ params: WebChromeClient.FileChooserParams,
+ ): Boolean {
+ pendingFileChooser.start(filePathCallback)
+ runCatching { fileChooserLauncher.launch(NappletFileChooser.buildIntent(this, params)) }
+ .onFailure { e ->
+ Log.w(TAG, "No activity available to pick a file", e)
+ pendingFileChooser.cancel()
+ Toast.makeText(this, getString(CommonsR.string.browser_file_chooser_unavailable), Toast.LENGTH_LONG).show()
+ }
+ return true
+ }
+
/** Shows the thin top bar at [progress]% while loading, hiding it once the page is fully loaded. */
private fun updateLoadProgress(progress: Int) {
if (progress >= 100) {
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 37dd512352..2eef62cd2e 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt
@@ -38,6 +38,10 @@ import android.os.Messenger
import android.os.SystemClock
import android.util.Log
import android.view.View
+import android.webkit.JsPromptResult
+import android.webkit.JsResult
+import android.webkit.ValueCallback
+import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
@@ -46,6 +50,7 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
+import androidx.core.net.toUri
import androidx.privacysandbox.ui.provider.toCoreLibInfo
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.ProxyConfig
@@ -107,6 +112,10 @@ class NappletHostService : Service() {
var bridgeReplyProxy: JavaScriptReplyProxy? = null
var fireSeq = 0
+ // The in-flight `` pick for this surface. The picker itself runs in the main
+ // process (this provider is windowless), so the callback waits here for MSG_FILE_CHOOSER_RESULT.
+ val fileChooser = PendingFileChooser()
+
// Last main-frame error state, pushed to the client so it can show an error/retry overlay over the
// surface (the embedded surface has no error page of its own).
var loadFailed = false
@@ -148,6 +157,7 @@ class NappletHostService : Service() {
brokerBound = false
}
tabs.values.forEach {
+ it.fileChooser.cancel()
it.contentServer?.close()
it.webView?.destroy()
}
@@ -183,6 +193,15 @@ class NappletHostService : Service() {
tab.bridgeReplyProxy?.postMessage(payload)
}
NappletEmbedContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg)
+ NappletEmbedContract.MSG_FILE_CHOOSER_RESULT -> {
+ val tab = tabFor(msg) ?: return true
+ val data = msg.data ?: return true
+ val id = data.getLong(NappletEmbedContract.KEY_FILE_CHOOSER_ID)
+ // Absent array = the user cancelled; PendingFileChooser turns that into the null the page
+ // needs to see so its file input becomes tappable again.
+ val uris = data.getStringArray(NappletEmbedContract.KEY_FILE_CHOOSER_URIS)?.map(String::toUri)
+ tab.fileChooser.deliver(id, uris?.toTypedArray())
+ }
else -> return false
}
return true
@@ -328,6 +347,8 @@ class NappletHostService : Service() {
fun onSessionClosed(sessionId: String) {
val tab = tabs.remove(sessionId) ?: return
tab.bridgeReplyProxy = null
+ // Release a picker still waiting on this surface before its WebView goes away.
+ tab.fileChooser.cancel()
tab.contentServer?.close()
tab.contentServer = null
tab.webView?.destroy()
@@ -363,6 +384,90 @@ class NappletHostService : Service() {
wv.overScrollMode = View.OVER_SCROLL_NEVER
WebView.setWebContentsDebuggingEnabled(false)
wv.webViewClient = HostClient(tab)
+ wv.webChromeClient = HostChromeClient(tab)
+ }
+
+ /**
+ * The applet's file picker. This surface has no other need for a chrome client — console output and
+ * the loading bar are drawn by the main process from [NappletEmbedContract.MSG_LOAD_STATE] — but
+ * without one WebView silently ignores every ``.
+ */
+ private inner class HostChromeClient(
+ private val tab: NappletTab,
+ ) : WebChromeClient() {
+ override fun onShowFileChooser(
+ webView: WebView,
+ filePathCallback: ValueCallback>,
+ fileChooserParams: FileChooserParams,
+ ): Boolean = requestFileChooser(tab, filePathCallback, fileChooserParams)
+
+ // Setting a chrome client at all is what opts this WebView into the default JS-dialog handling,
+ // and this one is built from a Service context — there is no window token to attach a dialog to,
+ // and an applet's alert() must not be able to draw over the main app's trusted chrome anyway.
+ // Dismiss all three so the page's JS resumes instead of blocking on a dialog that never appears.
+ override fun onJsAlert(
+ view: WebView,
+ url: String?,
+ message: String?,
+ result: JsResult,
+ ): Boolean {
+ result.cancel()
+ return true
+ }
+
+ override fun onJsConfirm(
+ view: WebView,
+ url: String?,
+ message: String?,
+ result: JsResult,
+ ): Boolean {
+ result.cancel()
+ return true
+ }
+
+ override fun onJsPrompt(
+ view: WebView,
+ url: String?,
+ message: String?,
+ defaultValue: String?,
+ result: JsPromptResult,
+ ): Boolean {
+ result.cancel()
+ return true
+ }
+ }
+
+ /**
+ * Asks the client (main process) to run the picker for [tab] and holds the callback until the
+ * matching [NappletEmbedContract.MSG_FILE_CHOOSER_RESULT] arrives. A surface with no client to ask
+ * releases the input immediately rather than leaving it stuck on a reply that can never come.
+ *
+ * The user names the one file that crosses into the sandbox by picking it, so this needs no
+ * capability of its own — same user-mediated grant a browser gives a page.
+ */
+ private fun requestFileChooser(
+ tab: NappletTab,
+ filePathCallback: ValueCallback>,
+ params: WebChromeClient.FileChooserParams,
+ ): Boolean {
+ val client = tab.clientMessenger
+ if (client == null) {
+ filePathCallback.onReceiveValue(null)
+ return true
+ }
+ val id = tab.fileChooser.start(filePathCallback)
+ val msg =
+ Message.obtain(null, NappletEmbedContract.MSG_FILE_CHOOSER_REQUEST).apply {
+ data =
+ 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)
+ putString(NappletEmbedContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString())
+ }
+ }
+ if (runCatching { client.send(msg) }.isFailure) tab.fileChooser.cancel()
+ return true
}
private fun applyWebViewProxy(port: Int) {
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/PendingFileChooser.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/PendingFileChooser.kt
new file mode 100644
index 0000000000..846eb44a47
--- /dev/null
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/PendingFileChooser.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.napplethost
+
+import android.net.Uri
+import android.webkit.ValueCallback
+
+/**
+ * Holds the one in-flight `onShowFileChooser` callback for a WebView and guarantees it is invoked
+ * exactly once.
+ *
+ * This matters more than it looks. WebView treats a file input as busy until its `filePathCallback`
+ * fires, so a callback that is simply dropped — the user backed out of the picker, the session was
+ * torn down, no app could handle the Intent — leaves that `` permanently dead for the life of
+ * the page: every later tap on it is ignored, with nothing logged. Cancelling with `null` is what
+ * releases it, so every exit path here ends in a delivery.
+ *
+ * [start] returns a request id. The embedded hosts round-trip it through the main process and hand it
+ * back to [deliver], so a result that outlived its request (a second tap while the picker was already
+ * up, a session rebuilt underneath) can be recognised and dropped instead of being fed to whichever
+ * input happens to be waiting now. Main-thread only, like the WebView callbacks it serves.
+ */
+class PendingFileChooser {
+ private var requestId = 0L
+ private var callback: ValueCallback>? = null
+
+ /**
+ * Registers [newCallback] as the pending request and returns its id. Any request still in flight is
+ * cancelled first — the page asked for a new pick, so the old input must be released rather than
+ * left waiting for a result that will never be routed to it.
+ */
+ fun start(newCallback: ValueCallback>): Long {
+ cancel()
+ requestId += 1
+ callback = newCallback
+ return requestId
+ }
+
+ /** Delivers [uris] (null = the user cancelled) to the pending request, if there still is one. */
+ fun deliver(uris: Array?) {
+ val pending = callback ?: return
+ callback = null
+ pending.onReceiveValue(uris)
+ }
+
+ /** Delivers [uris] only if [id] is still the current request; a late or stale result is dropped. */
+ fun deliver(
+ id: Long,
+ uris: Array?,
+ ) {
+ if (id != requestId) return
+ deliver(uris)
+ }
+
+ /** Releases the page's file input without a file. Safe to call when nothing is pending. */
+ fun cancel() = deliver(null)
+}