mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
+97
@@ -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"
|
||||
}
|
||||
+19
@@ -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
|
||||
|
||||
+19
@@ -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>
|
||||
|
||||
+94
@@ -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
|
||||
}
|
||||
}
|
||||
+117
@@ -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)
|
||||
}
|
||||
}
|
||||
+47
-1
@@ -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,
|
||||
|
||||
+25
@@ -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"
|
||||
|
||||
+63
-1
@@ -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,
|
||||
|
||||
+25
@@ -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"
|
||||
|
||||
+101
@@ -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)
|
||||
}
|
||||
+47
-1
@@ -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) {
|
||||
|
||||
+105
@@ -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) {
|
||||
|
||||
+75
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user