fix(browser): open a file picker for HTML file inputs

Tapping `<input type="file">` anywhere in Amethyst was a silent no-op: no
picker, no error, nothing logged. An Android WebView shows no chooser of its
own — the app must override `WebChromeClient.onShowFileChooser`, and none of
the four WebView hosts did. The base implementation returns false, and for a
target of API 21+ there is no legacy fallback, so every file upload in the
in-app browser, in nSites and in napplets was impossible.

All four hosts now open the picker:

- NappletBrowserActivity (full-screen browser) and NappletHostActivity
  (full-screen napplet/nSite sandbox) own an Activity, so they run the picker
  directly through an ActivityResultLauncher.
- NappletBrowserService and NappletHostService render an embedded surface from
  a windowless Service in the keyless `:napplet` process and have no Activity
  to launch from. They send the request's *description* — accept list,
  multi-select, title — to the main process over the existing Messenger
  contract; WebFileChooserCoordinator builds the Intent there and collects the
  result in the throwaway WebFileChooserActivity. Shipping data instead of a
  ready-made Intent keeps the sandbox able to ask the trusted process for a
  file picker and for nothing else. URI read grants are per-UID, so the picked
  `content://` URIs are readable by the WebView in `:napplet` with no
  re-granting, and allowContentAccess stays off.

Two details that decide whether this actually works in practice:

- The page's `filePathCallback` must fire on every path. WebView keeps a file
  input busy until it does, so a dropped callback (user cancelled, session torn
  down, no app to handle the Intent) leaves that input permanently dead for the
  life of the page. PendingFileChooser guarantees exactly-once delivery and
  carries a request id so a result that outlived its request is dropped rather
  than fed to whichever input is waiting now.
- Android's own FileChooserParams.createIntent() keeps only the first `accept`
  entry and drops multi-select, so `accept="image/png,image/jpeg" multiple`
  would offer PNGs only, one at a time. FileChooserAccept resolves the whole
  list — extensions included — into a type plus EXTRA_MIME_TYPES, widening to a
  family wildcard rather than narrowing below what the page asked for. It is
  pure and unit-tested in commonMain.

NappletHostService had no chrome client at all, so it gains one. Its WebView is
built from a Service context with no window token to attach a dialog to, so the
new client also dismisses JS alert/confirm/prompt instead of opting into the
default dialog handling.

Camera capture (`accept` with `capture`) and getUserMedia still fall back to
the picker; `onPermissionRequest` remains unimplemented and is left for a
separate change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxfdHeR9Ry4qALXHT5Sf1Q
This commit is contained in:
Claude
2026-08-25 20:44:50 +00:00
parent 58f144f0d1
commit 111f3392b4
16 changed files with 939 additions and 3 deletions
+10
View File
@@ -579,6 +579,16 @@
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Invisible host that runs the system file picker for an embedded WebView surface. The
`:napplet` providers are windowless services with no Activity of their own, so the main
process collects the pick and relays the URIs back to the sandbox. -->
<!-- Standard launch mode on purpose: two embedded surfaces can each have a pick in flight, and
singleTop would collapse the second onto the first and strand its page's file input. -->
<activity
android:name=".napplet.WebFileChooserActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- First-connect "Connect to Nostr" dialog. -->
<activity
android:name=".connectedApps.consent.SignerConnectActivity"
@@ -0,0 +1,91 @@
/*
* 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.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import com.vitorpamplona.amethyst.napplethost.NappletFileChooser
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* Invisible main-process host for one file pick made on behalf of an **embedded** WebView surface.
*
* The surface renders from the keyless `:napplet` process, which has no Activity to start a picker
* from, so [WebFileChooserCoordinator] launches this instead. It exists only long enough to run the
* picker and report the result, and it reports on every exit — a chosen file, a cancel, a system
* teardown — because the page's `<input type="file">` 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<String>?) {
if (reported) return
reported = true
token?.let { WebFileChooserCoordinator.complete(it, uris) }
}
private companion object {
private const val TAG = "WebFileChooser"
}
}
@@ -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
* `<input type="file">` 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<String>?) -> Unit,
)
private val pending = ConcurrentHashMap<String, Pending>()
/**
* 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<String>,
allowMultiple: Boolean,
pageTitle: String?,
onResult: (Array<String>?) -> 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<String>?,
) {
pending.remove(token)?.onResult?.invoke(uris)
}
const val EXTRA_TOKEN = "web_file_chooser_token"
private const val TAG = "WebFileChooser"
}
@@ -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
@@ -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
@@ -5,4 +5,8 @@
<string name="browser_console_title">Console (%1$d)</string>
<string name="browser_console_clear">Clear</string>
<string name="napplet_untitled">Untitled nApplet</string>
<!-- Title of the system picker opened for an HTML file input inside the in-app browser. -->
<string name="browser_file_chooser_title">Choose a file</string>
<!-- Shown when the device has no app that can hand a file back to the page. -->
<string name="browser_file_chooser_unavailable">No app available to pick a file</string>
</resources>
@@ -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 `<input type="file" accept="…">` 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<String>,
)
/**
* 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<String>,
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>): 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
}
}
@@ -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)
}
}
@@ -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 (`<input type="file">`) ----
// 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 `<input type="file">` 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<Array<Uri>>,
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<Array<Uri>>,
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,
@@ -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"
@@ -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 `<input type="file">` 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 `<input type="file">` 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<Array<Uri>>,
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<Array<Uri>>,
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,
@@ -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"
@@ -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 `<input type="file">` 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<String>,
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<Uri>? = FileChooserParams.parseResult(resultCode, data)
}
@@ -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 (`<input type="file">`) ----
// 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<PathTag>()
private val servers = mutableListOf<String>()
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 `<input type="file">` 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<Array<Uri>>,
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<Array<Uri>>,
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) {
@@ -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 `<input type="file">` 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 `<input type="file">`.
*/
private inner class HostChromeClient(
private val tab: NappletTab,
) : WebChromeClient() {
override fun onShowFileChooser(
webView: WebView,
filePathCallback: ValueCallback<Array<Uri>>,
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<Array<Uri>>,
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) {
@@ -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 `<input>` 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<Array<Uri>>? = 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<Array<Uri>>): 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<Uri>?) {
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<Uri>?,
) {
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)
}