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) Clear Untitled 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) +}