Merge pull request #4287 from vitorpamplona/claude/exciting-turing-72nx4o

fix(browser): ask before any page-initiated download is saved
This commit is contained in:
Vitor Pamplona
2026-09-30 16:38:40 -04:00
committed by GitHub
14 changed files with 1112 additions and 90 deletions
@@ -40,3 +40,17 @@ data class EmbeddedPermissionRequest(
val origin: String,
val permissions: Set<BrowserSitePermission>,
)
/**
* A download an embedded page started, waiting for consent before anything is fetched or written into
* the shared Downloads collection. [fileName]/[sizeBytes] are the sanitized name and size (-1 when
* unknown) the sandbox reports; [origin] is the WebView-reported origin, not a page field.
*/
data class EmbeddedDownloadRequest(
val id: Long,
val origin: String,
val fileName: String,
val sizeBytes: Long,
val sourceHost: String?,
val risky: Boolean,
)
@@ -129,6 +129,9 @@ class EmbeddedWebAppController(
/** The camera / microphone / location request the page is waiting on, if any. */
val pendingPermission = mutableStateOf<EmbeddedPermissionRequest?>(null)
/** The download the page started that awaits the user's consent, if any. */
val pendingDownload = mutableStateOf<EmbeddedDownloadRequest?>(null)
/** The certificate of the page on screen, once page info asked for it (null for none, or not yet). */
val pageCertificate = mutableStateOf<CertificateInfo?>(null)
@@ -181,6 +184,7 @@ class EmbeddedWebAppController(
consoleLogs.clear()
pendingDialog.value = null
pendingPermission.value = null
pendingDownload.value = null
isFullscreen.value = false
}
@@ -367,6 +371,30 @@ class EmbeddedWebAppController(
val id = msg.data?.getLong(NappletBrowserContract.KEY_PERMISSION_ID)
if (pendingPermission.value?.id == id) pendingPermission.value = null
}
NappletBrowserContract.MSG_DOWNLOAD_CONSENT -> {
val data = msg.data ?: return true
val id = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID)
val origin = data.getString(NappletBrowserContract.KEY_BROWSER_ORIGIN)
val name = data.getString(NappletBrowserContract.KEY_DOWNLOAD_NAME).orEmpty()
// An unnamed/absent origin means the sandbox couldn't even state who is asking: refuse.
if (origin == null || name.isEmpty() || pendingDownload.value != null || pendingDialog.value != null || pendingPermission.value != null) {
answerDownload(id, allowed = false)
return true
}
pendingDownload.value =
EmbeddedDownloadRequest(
id = id,
origin = origin,
fileName = name,
sizeBytes = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, -1L),
sourceHost = data.getString(NappletBrowserContract.KEY_DOWNLOAD_SOURCE),
risky = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, false),
)
}
NappletBrowserContract.MSG_DOWNLOAD_CANCEL -> {
val id = msg.data?.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID)
if (pendingDownload.value?.id == id) pendingDownload.value = null
}
NappletBrowserContract.MSG_FULLSCREEN -> isFullscreen.value = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false
NappletBrowserContract.MSG_MAGNIFIER_FRAME -> {
val data = msg.data ?: return true
@@ -486,6 +514,18 @@ class EmbeddedWebAppController(
}
}
/** Answers the download-consent card for [id]: true lets the sandbox fetch or write the file it described. */
fun answerDownload(
id: Long,
allowed: Boolean,
) {
if (pendingDownload.value?.id == id) pendingDownload.value = null
send(NappletBrowserContract.MSG_DOWNLOAD_CONSENT_RESULT) {
putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id)
putBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, allowed)
}
}
fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) }
override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) }
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions
import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageInfoSheet
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PermissionPromptCard
@@ -324,8 +325,9 @@ private fun EmbeddedWebAppTab(
/**
* Everything an embedded page asks the user for, drawn by the main process because the provider has no
* window: JS dialogs, camera / microphone / location prompts (remembered per origin in
* [WebSitePermissionRegistry], then Android's own runtime permission), and page info — the shared
* [PageDialogCard], [PermissionPromptCard] and [PageInfoSheet].
* [WebSitePermissionRegistry], then Android's own runtime permission), inline-download consent, and
* page info — the shared [PageDialogCard], [PermissionPromptCard], [DownloadPromptCard] and
* [PageInfoSheet].
*/
@RequiresApi(Build.VERSION_CODES.R)
@Composable
@@ -408,6 +410,25 @@ private fun EmbeddedPageUi(
}
}
// A download the page started: nothing is fetched or saved until this is answered. The card shows the
// file name that would be saved, its size and source host, headed by the WebView-reported origin (never
// a page-supplied field), with a warning for names that can be installed or run ("invoice.apk").
val downloadRequest by controller.pendingDownload
downloadRequest?.let { request ->
Dialog(onDismissRequest = { controller.answerDownload(request.id, allowed = false) }) {
DownloadPromptCard(
host = hostLabel(request.origin),
security = ui.security,
fileName = request.fileName,
sizeBytes = request.sizeBytes,
sourceHost = request.sourceHost,
risky = request.risky,
onAllow = { controller.answerDownload(request.id, allowed = true) },
onDeny = { controller.answerDownload(request.id, allowed = false) },
)
}
}
if (showPageInfo) {
val certificate by controller.pageCertificate
val origin = browserOrigin(ui.chrome.url)
@@ -0,0 +1,275 @@
/*
* 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.io.encoding.Base64
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource
/**
* The platform-free rules behind the in-app browser's download consent: what a saved file may be named,
* which names deserve a warning, how a page-supplied `data:` URL turns into the bytes a consent card
* describes, and how often one site may ask.
*
* A page can start a download without any gesture (a navigation to an attachment, a script `.click()`
* on an `<a download>`, or a hand-forged bridge envelope), so every page-initiated save asks the user
* first. The card shows exactly the name and size these rules produce, and the save writes exactly that.
*/
object BrowserDownloadRules {
/** Cap for bytes a page hands over inline (a `blob:`/`data:` download travels as base64 over the bridge). */
const val MAX_INLINE_BYTES = 25 * 1024 * 1024
/** Longest file name kept; longer ones are shortened in the middle so the extension survives. */
const val MAX_NAME_LENGTH = 120
/**
* Extensions something can install, run, or open as a live page from. The consent card flags them;
* the name itself is never rewritten, so the user judges the real one.
*/
val RISKY_EXTENSIONS =
setOf(
// Android
"apk",
"apks",
"apkm",
"xapk",
"aab",
"jar",
"dex",
"so",
// Windows
"exe",
"msi",
"msix",
"appx",
"bat",
"cmd",
"com",
"cpl",
"pif",
"reg",
"scr",
"hta",
"ps1",
"js",
"jse",
"vbs",
"vbe",
"wsf",
"lnk",
"url",
// Apple
"dmg",
"pkg",
"app",
"ipa",
"command",
// Linux
"deb",
"rpm",
"appimage",
"run",
"sh",
"zsh",
"bash",
"desktop",
// Pages that run script when opened
"html",
"htm",
"xhtml",
"xht",
"mht",
"mhtml",
"svg",
"svgz",
)
/** Whether [fileName]'s last extension is one a user should double-check before saving. */
fun isRisky(fileName: String): Boolean = fileName.substringAfterLast('.', "").trim().lowercase() in RISKY_EXTENSIONS
// Path-reserved characters become "_"; C0/C1 controls, DEL, bidi controls and zero-width characters
// are dropped outright, since they can make "invoice[RLO]gpj.apk" render as "invoicekpa.jpg".
private val reservedNameChars = Regex("[:*?\"<>|]")
private val invisibleNameChars = Regex("[\\u0000-\\u001f\\u007f-\\u009f\\u061c\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u2069\\ufeff]")
/**
* A plain file name from a page's suggestion: no path parts, no reserved or invisible characters, at
* most [MAX_NAME_LENGTH] long with its extension kept. Null when nothing usable is left.
*/
fun safeFileName(suggested: String?): String? {
val base =
suggested
?.substringAfterLast('/')
?.substringAfterLast('\\')
?.replace(invisibleNameChars, "")
?.replace(reservedNameChars, "_")
?.trim()
?.takeIf { it.isNotEmpty() && it != "." && it != ".." }
?: return null
if (base.length <= MAX_NAME_LENGTH) return base
val ext = base.substringAfterLast('.', "")
return if (ext.isNotEmpty() && ext.length <= 16) {
base.take(MAX_NAME_LENGTH - ext.length - 1).trimEnd() + "." + ext
} else {
base.take(MAX_NAME_LENGTH)
}
}
/** A decoded `data:` URL: the declared MIME type (null when absent) and the payload bytes. */
class DataUrl(
val mimeType: String?,
val bytes: ByteArray,
)
private val lenientBase64 = Base64.Mime.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
/**
* Decodes `data:[<mime>][;param=v…][;base64],<payload>`, base64 or percent-encoded. Null when it is
* not a data URL, is malformed, or decodes to more than [maxBytes] — a refused download, never a
* truncated one. The encoded length is checked first, so an oversized payload is never decoded.
*/
fun decodeDataUrl(
dataUrl: String,
maxBytes: Int = MAX_INLINE_BYTES,
): DataUrl? {
if (!dataUrl.startsWith("data:", ignoreCase = true)) return null
val comma = dataUrl.indexOf(',')
if (comma < 0) return null
val params = dataUrl.substring(5, comma).split(';')
val mime =
params
.first()
.trim()
.lowercase()
.takeIf { it.isNotEmpty() }
val isBase64 = params.size > 1 && params.last().trim().equals("base64", ignoreCase = true)
val payloadLength = dataUrl.length - comma - 1
val bytes =
if (isBase64) {
// 4 chars per 3 bytes, plus room for the CRLF a MIME encoder puts every 76 chars.
if (payloadLength.toLong() > maxBytes / 3 * 4L + maxBytes / 57 * 2L + 1024) return null
runCatching { lenientBase64.decode(dataUrl, comma + 1, dataUrl.length) }.getOrNull() ?: return null
} else {
// A percent escape is 3 chars per byte; the exact size is counted before allocating.
if (payloadLength.toLong() > maxBytes * 3L) return null
percentDecode(dataUrl, comma + 1, maxBytes) ?: return null
}
if (bytes.size > maxBytes) return null
return DataUrl(mime, bytes)
}
/**
* RFC 3986 percent-decoding of [text] from [start] (a `+` stays a `+`, other characters are UTF-8).
* Null on a broken escape or when the result would exceed [maxBytes], checked before allocating it.
*/
private fun percentDecode(
text: String,
start: Int,
maxBytes: Int,
): ByteArray? {
var size = 0L
var i = start
while (i < text.length) {
val c = text[i]
if (c == '%') {
if (i + 2 >= text.length || hexValue(text[i + 1]) < 0 || hexValue(text[i + 2]) < 0) return null
size += 1
i += 3
} else if (c.isHighSurrogate() && i + 1 < text.length && text[i + 1].isLowSurrogate()) {
size += 4
i += 2
} else {
// A lone surrogate encodes as U+FFFD, 3 bytes, like any other char from U+0800 up.
size +=
when {
c.code < 0x80 -> 1
c.code < 0x800 -> 2
else -> 3
}
i++
}
if (size > maxBytes) return null
}
val out = ByteArray(size.toInt())
var written = 0
i = start
var runStart = start
while (i <= text.length) {
if (i == text.length || text[i] == '%') {
if (i > runStart) {
val run = text.encodeToByteArray(runStart, i)
run.copyInto(out, written)
written += run.size
}
if (i == text.length) break
out[written++] = ((hexValue(text[i + 1]) shl 4) or hexValue(text[i + 2])).toByte()
i += 3
runStart = i
} else {
i++
}
}
return if (written == out.size) out else out.copyOf(written)
}
private fun hexValue(c: Char): Int =
when (c) {
in '0'..'9' -> c - '0'
in 'a'..'f' -> c - 'a' + 10
in 'A'..'F' -> c - 'A' + 10
else -> -1
}
}
/**
* How often one browser surface (a window, or one embedded tab) lets a site show a download card. After
* the user refuses a site's card, it waits [base] before it may ask again, and each further refusal
* doubles that, up to [max]; a Save resets it. Without this a refused page could re-ask in a loop until
* the user gives in. Main-thread only.
*/
class DownloadCooldown(
private val base: Duration = 1.seconds,
private val max: Duration = 1.minutes,
private val timeSource: TimeSource = TimeSource.Monotonic,
) {
private class Hold(
val since: TimeMark,
val window: Duration,
)
private val holds = HashMap<String, Hold>()
/** Whether [origin] may show a card now. */
fun allows(origin: String): Boolean = holds[origin]?.let { it.since.elapsedNow() >= it.window } ?: true
/** The user answered [origin]'s card: [allowed] resets its wait, a refusal (or dismissal) doubles it. */
fun answered(
origin: String,
allowed: Boolean,
) {
val previous = holds[origin]?.window
val window = if (allowed || previous == null) base else minOf(previous * 2, max)
holds[origin] = Hold(timeSource.markNow(), window)
}
}
@@ -0,0 +1,161 @@
/*
* 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.io.encoding.Base64
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
class BrowserDownloadRulesTest {
@Test
fun flagsInstallableAndScriptExtensions() {
assertTrue(BrowserDownloadRules.isRisky("invoice.apk"))
assertTrue(BrowserDownloadRules.isRisky("invoice.pdf.APK"))
assertTrue(BrowserDownloadRules.isRisky("page.html"))
assertFalse(BrowserDownloadRules.isRisky("photo.jpg"))
assertFalse(BrowserDownloadRules.isRisky("apk"))
assertFalse(BrowserDownloadRules.isRisky("download"))
}
@Test
fun decodesBase64DataUrl() {
val payload = "hello world".encodeToByteArray()
val url = "data:text/plain;charset=utf-8;base64," + Base64.encode(payload)
val decoded = BrowserDownloadRules.decodeDataUrl(url)!!
assertEquals("text/plain", decoded.mimeType)
assertContentEquals(payload, decoded.bytes)
}
@Test
fun decodesUnpaddedAndWrappedBase64() {
assertContentEquals("hi".encodeToByteArray(), BrowserDownloadRules.decodeDataUrl("data:;base64,aGk")!!.bytes)
assertContentEquals("hello world".encodeToByteArray(), BrowserDownloadRules.decodeDataUrl("data:;base64,aGVsbG8g\r\nd29ybGQ=")!!.bytes)
}
@Test
fun decodesPercentEncodedDataUrl() {
val decoded = BrowserDownloadRules.decodeDataUrl("DATA:,a%20b+c%C3%A9")!!
assertNull(decoded.mimeType)
assertEquals("a b+cé", decoded.bytes.decodeToString())
}
@Test
fun refusesMalformedDataUrls() {
assertNull(BrowserDownloadRules.decodeDataUrl("https://example.com/a.apk"))
assertNull(BrowserDownloadRules.decodeDataUrl("data:text/plain;base64"))
assertNull(BrowserDownloadRules.decodeDataUrl("data:,broken%2"))
assertNull(BrowserDownloadRules.decodeDataUrl("data:,broken%zz"))
}
@Test
fun refusesOversizedPayloadsInsteadOfTruncating() {
val bytes = ByteArray(100) { it.toByte() }
val url = "data:application/octet-stream;base64," + Base64.encode(bytes)
assertEquals(100, BrowserDownloadRules.decodeDataUrl(url, maxBytes = 100)!!.bytes.size)
assertNull(BrowserDownloadRules.decodeDataUrl(url, maxBytes = 99))
assertNull(BrowserDownloadRules.decodeDataUrl("data:," + "a".repeat(11), maxBytes = 10))
}
@Test
fun base64MarkerMustBeTheLastParameter() {
assertEquals("aGk", BrowserDownloadRules.decodeDataUrl("data:text/plain;base64;x=y,aGk")!!.bytes.decodeToString())
}
@Test
fun percentDecodingCountsBytesBeforeAllocating() {
assertEquals(3, BrowserDownloadRules.decodeDataUrl("data:,%E4%B8%AD", maxBytes = 3)!!.bytes.size)
assertEquals("中", BrowserDownloadRules.decodeDataUrl("data:,中", maxBytes = 3)!!.bytes.decodeToString())
assertNull(BrowserDownloadRules.decodeDataUrl("data:,中中", maxBytes = 5))
assertEquals(4, BrowserDownloadRules.decodeDataUrl("data:,\uD83D\uDE00", maxBytes = 4)!!.bytes.size)
// A lone surrogate must not overflow the pre-counted buffer.
assertTrue(BrowserDownloadRules.decodeDataUrl("data:,a\uDE00b%41")!!.bytes.isNotEmpty())
}
@Test
fun safeFileNameStripsPathsAndInvisibleCharacters() {
assertEquals("evil.apk", BrowserDownloadRules.safeFileName("../../evil.apk"))
assertEquals("evil.apk", BrowserDownloadRules.safeFileName("C:\\x\\evil.apk"))
assertEquals("a_b_.pdf", BrowserDownloadRules.safeFileName("a:b?.pdf"))
assertEquals("invoicegpj.apk", BrowserDownloadRules.safeFileName("invoice\u202Egpj.apk"))
assertEquals("ab.txt", BrowserDownloadRules.safeFileName("a\u200Bb\u0085.txt"))
assertNull(BrowserDownloadRules.safeFileName(".."))
assertNull(BrowserDownloadRules.safeFileName(" \u200F "))
assertNull(BrowserDownloadRules.safeFileName(null))
}
@Test
fun safeFileNameKeepsTheExtensionWhenShortening() {
val name = BrowserDownloadRules.safeFileName("photo.jpg" + "x".repeat(300) + ".apk")!!
assertEquals(BrowserDownloadRules.MAX_NAME_LENGTH, name.length)
assertTrue(name.endsWith(".apk"))
assertTrue(BrowserDownloadRules.isRisky(name))
}
@Test
fun cooldownHoldsOffOnlyTheAnsweredOrigin() {
val clock = TestTimeSource()
val cooldown = DownloadCooldown(1.seconds, 1.minutes, clock)
assertTrue(cooldown.allows("https://a.example"))
cooldown.answered("https://a.example", allowed = true)
assertFalse(cooldown.allows("https://a.example"))
assertTrue(cooldown.allows("https://b.example"))
clock += 999.milliseconds
assertFalse(cooldown.allows("https://a.example"))
clock += 1.milliseconds
assertTrue(cooldown.allows("https://a.example"))
}
@Test
fun cooldownDoublesOnEachRefusalAndResetsOnSave() {
val clock = TestTimeSource()
val cooldown = DownloadCooldown(1.seconds, 4.seconds, clock)
val site = "https://a.example"
cooldown.answered(site, allowed = false) // 1s
clock += 1.seconds
assertTrue(cooldown.allows(site))
cooldown.answered(site, allowed = false) // 2s
clock += 1.seconds
assertFalse(cooldown.allows(site))
clock += 1.seconds
assertTrue(cooldown.allows(site))
cooldown.answered(site, allowed = false) // 4s
cooldown.answered(site, allowed = false) // capped at 4s
clock += 3.seconds
assertFalse(cooldown.allows(site))
clock += 1.seconds
assertTrue(cooldown.allows(site))
cooldown.answered(site, allowed = true) // back to 1s
clock += 1.seconds
assertTrue(cooldown.allows(site))
}
}
@@ -6355,4 +6355,10 @@
<string name="browser_permission_ask">Ask</string>
<string name="browser_permission_allowed">Allowed</string>
<string name="browser_permission_blocked">Blocked</string>
<!-- Inline download consent (browser.* bridge downloads into the shared Downloads collection) -->
<string name="browser_pill_download_title">Download this file?</string>
<string name="browser_pill_download_risky">This file type can run code. Check that the name matches what you meant to get.</string>
<string name="browser_pill_download_save">Save</string>
<string name="browser_pill_download_from">From %1$s</string>
</resources>
@@ -0,0 +1,147 @@
/*
* 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.ui.pill
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.browser_pill_cancel
import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_from
import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_risky
import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_save
import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_title
import com.vitorpamplona.amethyst.commons.ui.note.types.formatBytes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
/**
* A download the page started, waiting for consent before anything is fetched or written to the shared
* Downloads collection. A page can start one without any gesture, so the card names the site (the
* WebView-reported origin, never a page-supplied field), the exact file name that would be saved, its
* size ([sizeBytes] is -1 when the server didn't say) and, for a network file, the host it comes from
* ([sourceHost]) when that isn't [host]. Nothing is saved until the user taps Save.
*/
@Composable
fun DownloadPromptCard(
host: String?,
security: BrowserChrome.Security,
fileName: String,
sizeBytes: Long,
sourceHost: String?,
risky: Boolean,
onAllow: () -> Unit,
onDeny: () -> Unit,
) {
// Save ignores taps for a moment after the card appears, so a tap meant for whatever was under the
// finger (say, a page dialog's button the page just replaced with this card) can't land on it.
var armed by remember(fileName) { mutableStateOf(false) }
LaunchedEffect(fileName) {
delay(ARM_DELAY)
armed = true
}
PageCard {
if (host != null) {
OriginBadge(host, security)
Spacer(Modifier.height(20.dp))
}
Text(
stringRes(Res.string.browser_pill_download_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(MaterialSymbols.Download, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, filled = true)
}
Spacer(Modifier.width(14.dp))
Column {
// One line, cut in the middle: the extension is what tells "invoice.pdf" from "invoice.pdf.apk".
Text(fileName, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.MiddleEllipsis)
val details =
listOfNotNull(
sizeBytes.takeIf { it >= 0 }?.let { formatBytes(it) },
// A network file can come from another host than the page asking (an ad frame, a CDN).
sourceHost?.takeUnless { it.equals(host, ignoreCase = true) }?.let { stringRes(Res.string.browser_pill_download_from, it) },
)
if (details.isNotEmpty()) {
Text(details.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.MiddleEllipsis)
}
}
}
if (risky) {
Spacer(Modifier.height(16.dp))
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PillActionIcon(BrowserChrome.Action.ACCESS_INFO, tint = MaterialTheme.colorScheme.onErrorContainer, size = 18.dp)
Spacer(Modifier.width(10.dp))
Text(stringRes(Res.string.browser_pill_download_risky), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer, fontWeight = FontWeight.Medium)
}
}
Spacer(Modifier.height(24.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDeny) { Text(stringRes(Res.string.browser_pill_cancel)) }
Spacer(Modifier.width(8.dp))
Button(onClick = onAllow, enabled = armed) { Text(stringRes(Res.string.browser_pill_download_save)) }
}
}
}
private val ARM_DELAY = 500.milliseconds
@@ -137,7 +137,7 @@ fun OriginBadge(
/** The card frame every page-initiated prompt shares. */
@Composable
private fun PageCard(content: @Composable () -> Unit) {
internal fun PageCard(content: @Composable () -> Unit) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleSheet
import com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard
import com.vitorpamplona.amethyst.commons.browser.ui.pill.FindInPagePill
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType
@@ -130,6 +131,21 @@ class BrowserChromeHost(
val answer: (allow: Boolean, remember: Boolean) -> Unit,
)
/**
* A download the page started, waiting for consent before anything is fetched or written into the
* shared Downloads collection. [fileName]/[sizeBytes] (-1 when unknown) describe exactly what would
* be saved; nothing is saved until [answer] runs with true.
*/
class PendingDownload(
val host: String?,
val security: BrowserChrome.Security,
val fileName: String,
val sizeBytes: Long,
val sourceHost: String?,
val risky: Boolean,
val answer: (allow: Boolean) -> Unit,
)
var ui by mutableStateOf(initial)
var expanded by mutableStateOf(false)
private set
@@ -146,6 +162,7 @@ class BrowserChromeHost(
var dialog by mutableStateOf<PendingDialog?>(null)
var permissionPrompt by mutableStateOf<PendingPermission?>(null)
var downloadPrompt by mutableStateOf<PendingDownload?>(null)
private var pageInfoOpen by mutableStateOf(false)
private var accessInfo by mutableStateOf<AccessInfo?>(null)
private var certificate by mutableStateOf<CertificateInfo?>(null)
@@ -345,6 +362,29 @@ class BrowserChromeHost(
)
}
}
downloadPrompt?.let { pending ->
Dialog(onDismissRequest = {
downloadPrompt = null
pending.answer(false)
}) {
DownloadPromptCard(
host = pending.host,
security = pending.security,
fileName = pending.fileName,
sizeBytes = pending.sizeBytes,
sourceHost = pending.sourceHost,
risky = pending.risky,
onAllow = {
downloadPrompt = null
pending.answer(true)
},
onDeny = {
downloadPrompt = null
pending.answer(false)
},
)
}
}
accessInfo?.let { info ->
Dialog(onDismissRequest = { accessInfo = null }, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
@@ -27,17 +27,18 @@ import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Base64
import android.webkit.MimeTypeMap
import android.webkit.URLUtil
import android.widget.Toast
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.browser.BrowserDownloadRules
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Request
import java.io.File
import java.io.OutputStream
import java.net.URLDecoder
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
@@ -45,6 +46,12 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR
* `blob:` URLs (which only the page can read, so the browser-extras script hands their bytes over) — into
* the system Downloads collection, the way Chrome does.
*
* A page can start a download without any gesture, so everything it starts arrives as a [DownloadOffer]
* that the surface shows on a consent card; nothing is fetched or written until the user taps Save. That
* covers both entry points: the WebView `DownloadListener` ([offerListenerDownload]) and the
* `browser.download` bridge envelope ([offerInline]), which any top-frame script can post directly. The
* long-press "Download image" item ([download]) is the one ungated path: the user's own tap starts it.
*
* Network downloads follow the page's own route: through the Tor SOCKS proxy when the site is on Tor (OkHttp
* leaves SOCKS hosts unresolved, so even DNS goes through Tor), directly otherwise. They carry the page's
* cookies from its own per-account storage profile and its user agent, so a logged-in download works.
@@ -53,14 +60,42 @@ object BrowserDownloads {
private const val TAG = "BrowserDownloads"
/** Cap for bytes a page hands over for a blob:/data: download (they travel as base64 over the bridge). */
const val MAX_INLINE_BYTES = 25 * 1024 * 1024
const val MAX_INLINE_BYTES = BrowserDownloadRules.MAX_INLINE_BYTES
private val io = Executors.newSingleThreadExecutor { Thread(it, "napplet-downloads").apply { isDaemon = true } }
// Decoding an inline payload (up to 25 MiB) is kept off the main thread, and off [io] so a long
// transfer already running there doesn't hold the next consent card back.
private val decoder = Executors.newSingleThreadExecutor { Thread(it, "napplet-download-decode").apply { isDaemon = true } }
private val main = Handler(Looper.getMainLooper())
/**
* A WebView `DownloadListener` hit. [cookie] must be read on the main thread (from the tab's own
* profile) before calling; the transfer itself runs on a background thread.
* A page-initiated download, resolved to what its consent card shows: [save] stores one file named
* [fileName], typed by that name's extension (so the system can't append a different one), and until
* it runs no byte has been fetched or written. [save] runs at most once, however often it is called.
*/
class DownloadOffer internal constructor(
val fileName: String,
/** The size in bytes: exact for inline data, the server's advertised length otherwise; -1 when unknown. */
val sizeBytes: Long,
/** The host a network download is fetched from (it can differ from the page's), or null for inline data. */
val sourceHost: String?,
private val start: (Context) -> Unit,
) {
private val started = AtomicBoolean(false)
/** Whether [fileName] is something that can be installed or run, which the card warns about. */
val risky: Boolean get() = BrowserDownloadRules.isRisky(fileName)
fun save(context: Context) {
if (started.compareAndSet(false, true)) start(context.applicationContext)
}
}
/**
* The long-press "Download image" item: the user's tap is the gesture, so it saves without a card.
* [cookie] must be read on the main thread (from the tab's own profile) before calling; the transfer
* itself runs on a background thread.
*/
fun download(
context: Context,
@@ -76,8 +111,85 @@ object BrowserDownloads {
saveDataUrl(app, url, null)
return
}
if (!url.startsWith("https://", ignoreCase = true) && !url.startsWith("http://", ignoreCase = true)) return
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
if (!isHttp(url)) return
startNetworkDownload(app, url, networkName(url, contentDisposition, mimeType), userAgent, cookie, proxyPort)
}
/**
* A WebView `DownloadListener` hit: a download the page started. Calls [onReady] exactly once, on the
* main thread, with the offer for the consent card (null when the URL can't be saved). [contentLength] is
* the size the listener reported (<= 0 when unknown); [cookie] must be read on the main thread from
* the tab's own profile. Nothing is fetched until the offer's save runs.
*/
fun offerListenerDownload(
url: String,
userAgent: String?,
contentDisposition: String?,
mimeType: String?,
contentLength: Long,
cookie: String?,
proxyPort: Int,
onReady: (DownloadOffer?) -> Unit,
) {
if (url.startsWith("data:", ignoreCase = true)) {
offerInline(url, null, onReady)
return
}
if (!isHttp(url)) {
onReady(null)
return
}
val name = networkName(url, contentDisposition, mimeType)
onReady(
DownloadOffer(name, contentLength.takeIf { it > 0 } ?: -1L, url.toUri().host) { app ->
startNetworkDownload(app, url, name, userAgent, cookie, proxyPort)
},
)
}
/**
* A page's inline download (a `browser.download` envelope's `data:` URL). Decodes it off the main
* thread, then calls [onReady] exactly once, on the main thread, with the offer (its size is the true
* decoded length), or null when the payload is malformed or oversized and is refused without a card.
*/
fun offerInline(
dataUrl: String,
suggestedName: String?,
onReady: (DownloadOffer?) -> Unit,
) {
decoder.execute {
// Throwable, not Exception: an OutOfMemoryError here must refuse the download, not kill the
// process (and every tab in it) or leave the caller waiting on a callback that never comes.
val offer =
try {
BrowserDownloadRules.decodeDataUrl(dataUrl)?.let { data ->
val name = safeName(suggestedName, data.mimeType)
DownloadOffer(name, data.bytes.size.toLong(), null) { app -> writeInBackground(app, name, data.bytes) }
}
} catch (e: Throwable) {
Log.w(TAG, "Inline download refused", e)
null
}
main.post { onReady(offer) }
}
}
private fun isHttp(url: String) = url.startsWith("https://", ignoreCase = true) || url.startsWith("http://", ignoreCase = true)
private fun networkName(
url: String,
contentDisposition: String?,
mimeType: String?,
) = safeName(URLUtil.guessFileName(url, contentDisposition, mimeType), mimeType)
private fun startNetworkDownload(
app: Context,
url: String,
name: String,
userAgent: String?,
cookie: String?,
proxyPort: Int,
) {
toast(app, app.getString(CommonsR.string.browser_download_started, name))
io.execute {
val ok =
@@ -101,8 +213,7 @@ object BrowserDownloads {
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}")
val type = mimeType?.takeIf { it.isNotBlank() && it != "application/octet-stream" } ?: response.body.contentType()?.let { "${it.type}/${it.subtype}" }
write(app, name, type) { out -> response.body.byteStream().use { it.copyTo(out) } }
write(app, name) { out -> response.body.byteStream().use { it.copyTo(out) } }
}
}.onFailure { Log.w(TAG, "Download failed for $url", it) }
.getOrDefault(false)
@@ -110,39 +221,26 @@ object BrowserDownloads {
}
}
/** Saves a `data:` URL (`data:[mime][;base64],payload`). */
fun saveDataUrl(
context: Context,
/** Saves a `data:` URL (`data:[mime][;base64],payload`) the user asked for directly, decoded off the main thread. */
private fun saveDataUrl(
app: Context,
dataUrl: String,
suggestedName: String?,
) {
val app = context.applicationContext
val header = dataUrl.substringBefore(',', "")
val payload = dataUrl.substringAfter(',', "")
val mime = header.removePrefix("data:").substringBefore(';').ifBlank { "application/octet-stream" }
val bytes =
runCatching {
if (header.endsWith(";base64", ignoreCase = true)) {
Base64.decode(payload, Base64.DEFAULT)
} else {
URLDecoder.decode(payload, "UTF-8").toByteArray()
}
}.getOrNull() ?: return
saveBytes(app, suggestedName, mime, bytes)
decoder.execute {
val data = runCatching { BrowserDownloadRules.decodeDataUrl(dataUrl) }.getOrNull() ?: return@execute
writeInBackground(app, safeName(suggestedName, data.mimeType), data.bytes)
}
}
/** Saves bytes a page handed over (a `blob:` download, via the browser-extras script). */
fun saveBytes(
context: Context,
suggestedName: String?,
mimeType: String?,
private fun writeInBackground(
app: Context,
name: String,
bytes: ByteArray,
) {
if (bytes.size > MAX_INLINE_BYTES) return
val app = context.applicationContext
val name = safeName(suggestedName, mimeType)
io.execute {
val ok = runCatching { write(app, name, mimeType) { it.write(bytes) } }.getOrDefault(false)
val ok = runCatching { write(app, name) { it.write(bytes) } }.getOrDefault(false)
toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name))
}
}
@@ -155,7 +253,6 @@ object BrowserDownloads {
private fun write(
context: Context,
name: String,
mimeType: String?,
body: (OutputStream) -> Unit,
): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -163,7 +260,9 @@ object BrowserDownloads {
val values =
ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
mimeType?.let { put(MediaStore.Downloads.MIME_TYPE, it) }
// Typed by the approved name alone: a page- or server-supplied type that disagrees with
// the extension would make MediaStore append its own ("invoice.pdf" -> "invoice.pdf.apk").
put(MediaStore.Downloads.MIME_TYPE, mimeTypeOf(name))
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
put(MediaStore.Downloads.IS_PENDING, 1)
}
@@ -183,23 +282,28 @@ object BrowserDownloads {
return true
}
/** A plain filename: the page's suggestion without path parts, else "download" + the MIME's extension. */
/**
* The file name to save under: the page's suggestion made safe ([BrowserDownloadRules.safeFileName]),
* else "download" + the MIME's extension.
*/
private fun safeName(
suggested: String?,
mimeType: String?,
): String {
val base =
suggested
?.substringAfterLast('/')
?.substringAfterLast('\\')
?.replace(Regex("[\\u0000-\\u001f:*?\"<>|]"), "_")
?.trim()
?.takeIf { it.isNotEmpty() && it != "." && it != ".." }
if (base != null) return base.take(120)
BrowserDownloadRules.safeFileName(suggested)?.let { return it }
val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
return if (ext != null) "download.$ext" else "download"
}
/** The MIME type [name]'s extension implies, or octet-stream, which MediaStore stores under the name as given. */
private fun mimeTypeOf(name: String): String =
name
.substringAfterLast('.', "")
.lowercase()
.takeIf { it.isNotEmpty() }
?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) }
?: "application/octet-stream"
private fun toast(
context: Context,
text: String,
@@ -29,7 +29,9 @@ package com.vitorpamplona.amethyst.napplethost
* - `<meta name="theme-color">` — reported (`browser.themeColor`, normalized to `rgb(r, g, b)`) whenever it
* or the colour scheme changes, so the window can tint its system bars and Recents entry.
* - `blob:` / `data:` downloads — only the page can read a `blob:` URL, so a click on (or a programmatic
* `.click()` of) an `<a download>` pointing at one is turned into its bytes (`browser.download`).
* `.click()` of) an `<a download>` pointing at one is turned into its bytes (`browser.download`). The
* native side treats it as a request: the user confirms every save on a consent card, so a page that
* posts the envelope itself gains nothing over using the script.
*
* Messages travel over the same origin-scoped native bridge as NIP-07; the host handles `browser.*` types
* itself and never forwards them to the broker.
@@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision
import com.vitorpamplona.amethyst.commons.browser.DownloadCooldown
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
@@ -225,6 +226,8 @@ class NappletBrowserActivity : ComponentActivity() {
private val originTokens = mutableMapOf<String, String>()
private val pendingByOrigin = mutableMapOf<String, MutableList<Message>>()
private val mintInFlight = mutableSetOf<String>()
private val downloadCooldown = DownloadCooldown()
private var preparingDownload = false
/**
* Back walks out of fullscreen video, then the find bar, then the page's history, then leaves. Enabled
@@ -361,8 +364,20 @@ class NappletBrowserActivity : ComponentActivity() {
wv.webViewClient = BrowserClient()
wv.webChromeClient = BrowserChromeClient()
wv.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) }
wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), if (useTor) proxyPort else -1)
wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength ->
// The page's origin; a fresh popup still on about:blank has none, so name the file's own.
val origin =
chrome
?.ui
?.chrome
?.url
?.let(BrowserChrome::originOf) ?: BrowserChrome.originOf(url) ?: return@setDownloadListener
if (!canOfferDownload(origin)) return@setDownloadListener
// Read on the main thread: the cookie jar is the tab's own per-account WebView profile.
val cookie = BrowserWebTools.cookieManager(wv).getCookie(url)
prepareDownloadOffer(origin) { ready ->
BrowserDownloads.offerListenerDownload(url, userAgent, contentDisposition, mimeType, contentLength, cookie, if (useTor) proxyPort else -1, ready)
}
}
wv.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
wv.dropSystemBarInsets()
@@ -423,6 +438,7 @@ class NappletBrowserActivity : ComponentActivity() {
// A dialog still up would leak its window and leave the page's JS blocked on an unanswered result.
chrome?.dialog?.answer?.invoke(false, null, false)
chrome?.permissionPrompt?.answer?.invoke(false, false)
chrome?.downloadPrompt?.answer?.invoke(false)
customViewCallback?.onCustomViewHidden()
destroyWebView()
super.onDestroy()
@@ -892,15 +908,27 @@ class NappletBrowserActivity : ComponentActivity() {
val raw = message.data ?: return
val envelope = parseJsonObjectOrNull(raw) ?: return
// Browser conveniences (share, theme colour, blob downloads) are handled here, never brokered.
if (envelope.stringOrNull("type").orEmpty().startsWith("browser.")) {
onBrowserMessage(envelope)
return
}
// The origin the WebView reports, which the page can't forge, keys every decision below.
val origin = trustedOrigin(sourceOrigin) ?: return
val scheme = sourceOrigin.scheme ?: return
val host = sourceOrigin.host ?: return
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""
// Browser conveniences (share, theme colour, blob downloads) are handled here, never brokered.
// A download still asks the user first ([offerInlineDownload]): any top-frame script can post one.
when (envelope.stringOrNull("type")) {
"browser.share" -> {
if (resumed) BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url"))
return
}
"browser.themeColor" -> {
themeColor = BrowserChrome.parseCssRgb(envelope.stringOrNull("color"))
applyThemeColor(themeColor)
updateTaskDescription()
return
}
"browser.download" -> {
offerInlineDownload(origin, envelope)
return
}
}
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
val msg =
@@ -923,27 +951,69 @@ class NappletBrowserActivity : ComponentActivity() {
}
}
/** A `browser.*` message from [BrowserExtrasScript]. */
private fun onBrowserMessage(envelope: JsonObject) {
when (envelope.stringOrNull("type")) {
"browser.share" ->
if (resumed) {
BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url"))
}
"browser.themeColor" -> {
themeColor = BrowserChrome.parseCssRgb(envelope.stringOrNull("color"))
applyThemeColor(themeColor)
updateTaskDescription()
}
"browser.download" -> {
val data = envelope.stringOrNull("data") ?: return
if (data.startsWith("data:") && data.length <= BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 256) {
BrowserDownloads.saveDataUrl(this, data, envelope.stringOrNull("name"))
}
}
/** `scheme://host[:port]` of the WebView-reported origin, or null when it has no usable one. */
private fun trustedOrigin(sourceOrigin: Uri): String? {
val scheme = sourceOrigin.scheme ?: return null
val host = sourceOrigin.host ?: return null
return "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""
}
/**
* A `browser.download` envelope: the bytes a page wants saved (a `blob:` download the extras script
* read, or one a script forged). Keyed on the WebView-reported [origin], never an envelope field.
*/
private fun offerInlineDownload(
origin: String,
envelope: JsonObject,
) {
if (!canOfferDownload(origin)) return
val data = envelope.stringOrNull("data") ?: return
prepareDownloadOffer(origin) { ready -> BrowserDownloads.offerInline(data, envelope.stringOrNull("name"), ready) }
}
/**
* Whether [origin] may put a download card up now: never over another page prompt (so a page can't swap
* the name under the user's finger, or pop the card where a dialog's button just was), never while an
* offer is still being prepared (so a page can't queue decodes), and not within its cooldown.
*/
private fun canOfferDownload(origin: String): Boolean {
val host = chrome ?: return false
return host.downloadPrompt == null && host.dialog == null && host.permissionPrompt == null && !preparingDownload && downloadCooldown.allows(origin)
}
/** Runs [prepare] (which answers exactly once, on the main thread) and shows the offer it produces. */
private fun prepareDownloadOffer(
origin: String,
prepare: ((BrowserDownloads.DownloadOffer?) -> Unit) -> Unit,
) {
preparingDownload = true
prepare { offer ->
preparingDownload = false
if (offer != null) showDownloadOffer(origin, offer)
}
}
/** Shows [offer]'s consent card; the file is fetched or written only if the user taps Save. */
private fun showDownloadOffer(
origin: String,
offer: BrowserDownloads.DownloadOffer,
) {
val host = chrome ?: return
if (isDestroyed || !canOfferDownload(origin)) return
host.downloadPrompt =
BrowserChromeHost.PendingDownload(
host = BrowserChrome.displayHost(origin),
security = BrowserChrome.security(host.ui.chrome),
fileName = offer.fileName,
sizeBytes = offer.sizeBytes,
sourceHost = offer.sourceHost,
risky = offer.risky,
) { allowed ->
downloadCooldown.answered(origin, allowed)
if (allowed) offer.save(this)
}
}
private fun requestBrowserToken(origin: String) {
if (!mintInFlight.add(origin)) return
val msg =
@@ -1652,6 +1722,7 @@ class NappletBrowserActivity : ComponentActivity() {
companion object {
private const val TAG = "NappletBrowserActivity"
private const val ACTIVITY_CLASS = "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity"
/** How often a resumed browser renews its foreground lease (well under the broker's 90s TTL). */
@@ -184,6 +184,22 @@ object NappletBrowserContract {
/** Leave HTML fullscreen (the user pressed back). */
const val MSG_EXIT_FULLSCREEN = 33
/**
* Provider → client: a download the page started (an attachment, `<a download>`, or an inline
* `browser.download`) needs the user's consent before anything is fetched or written into the shared
* Downloads collection. Carries [KEY_DOWNLOAD_ID], the WebView-reported [KEY_BROWSER_ORIGIN] (never a
* page-supplied field), the sanitized [KEY_DOWNLOAD_NAME], [KEY_DOWNLOAD_SIZE], [KEY_DOWNLOAD_SOURCE], and
* [KEY_DOWNLOAD_RISKY] (an install-or-script-like extension). Answered with
* [MSG_DOWNLOAD_CONSENT_RESULT] on every outcome; the bytes themselves never leave the `:napplet` process.
*/
const val MSG_DOWNLOAD_CONSENT = 34
/** Client → provider: the user's answer to [MSG_DOWNLOAD_CONSENT]: [KEY_DOWNLOAD_ID] + [KEY_DOWNLOAD_ALLOWED]. */
const val MSG_DOWNLOAD_CONSENT_RESULT = 35
/** Provider → client: withdraw the [MSG_DOWNLOAD_CONSENT] card [KEY_DOWNLOAD_ID] (its tab closed). */
const val MSG_DOWNLOAD_CANCEL = 36
const val KEY_CAN_GO_FORWARD = "canGoForward"
const val KEY_FIND_QUERY = "findQuery"
const val KEY_FIND_FORWARD = "findForward"
@@ -206,6 +222,24 @@ object NappletBrowserContract {
const val KEY_PERMISSIONS = "permissions"
const val KEY_BROWSER_ORIGIN = "browserOrigin"
/** Correlates a [MSG_DOWNLOAD_CONSENT] prompt with its [MSG_DOWNLOAD_CONSENT_RESULT] answer. */
const val KEY_DOWNLOAD_ID = "downloadId"
/** The sanitized file name the consented download will be stored under (already path/control-char stripped). */
const val KEY_DOWNLOAD_NAME = "downloadName"
/** The size in bytes the consented download will write, or -1 when the server didn't say. */
const val KEY_DOWNLOAD_SIZE = "downloadSize"
/** The host a network download is fetched from (absent for inline data), shown when it isn't the page's. */
const val KEY_DOWNLOAD_SOURCE = "downloadSource"
/** Whether [KEY_DOWNLOAD_NAME]'s extension is one the user should double-check (installer/script-like). */
const val KEY_DOWNLOAD_RISKY = "downloadRisky"
/** The user's answer in [MSG_DOWNLOAD_CONSENT_RESULT]: true = save, false = discard. */
const val KEY_DOWNLOAD_ALLOWED = "downloadAllowed"
const val KEY_FILE_CHOOSER_ID = "fileChooserId"
const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept"
const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple"
@@ -65,6 +65,7 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.DownloadCooldown
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
@@ -146,6 +147,11 @@ class NappletBrowserService : Service() {
val pendingByOrigin = mutableMapOf<String, MutableList<Message>>()
val mintInFlight = mutableSetOf<String>()
// Page-initiated downloads: how often each site may ask, and whether an offer is being prepared
// (decoded) — one at a time, so a page can't queue up decodes.
val downloadCooldown = DownloadCooldown()
var preparingDownload = false
val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) })
}
@@ -154,6 +160,17 @@ class NappletBrowserService : Service() {
// WebView's PermissionRequest → our relay id, so a page's cancellation can withdraw the prompt.
private val pendingWebPermissions = mutableMapOf<PermissionRequest, Long>()
// Download consent cards the main process is showing, by relay id, until the client answers or the
// tab closes. The offer holds the decoded bytes (or the URL to fetch) the card describes.
private class PendingDownload(
val sessionId: String,
val origin: String,
val offer: BrowserDownloads.DownloadOffer,
)
private val pendingDownloads = mutableMapOf<Long, PendingDownload>()
private var downloadSeq = 0L
// The shim never changes; read+decode it once instead of per tab on the main thread.
private val shimJs: String by lazy { readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() }
@@ -295,6 +312,14 @@ class NappletBrowserService : Service() {
.toSet(),
)
}
NappletBrowserContract.MSG_DOWNLOAD_CONSENT_RESULT -> {
val data = msg.data ?: return true
val pending = pendingDownloads.remove(data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID)) ?: return true
val tab = tabs[pending.sessionId] ?: return true
val allowed = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, false)
tab.downloadCooldown.answered(pending.origin, allowed)
if (allowed) pending.offer.save(this)
}
NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) }
NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload()
NappletBrowserContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
@@ -446,6 +471,12 @@ class NappletBrowserService : Service() {
// Release a picker still waiting on this surface before its WebView goes away.
tab.fileChooser.cancel()
cancelPending(tab)
// An unanswered download card dies with its tab: nothing is saved, its bytes are freed, and the
// client is told so its card doesn't linger with a Save that does nothing.
pendingDownloads.entries.filter { it.value.sessionId == sessionId }.forEach { (id, _) ->
pendingDownloads.remove(id)
sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CANCEL) { putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) }
}
tab.customViewCallback?.onCustomViewHidden()
tab.customViewCallback = null
tab.customView = null
@@ -461,9 +492,16 @@ class NappletBrowserService : Service() {
BrowserWebTools.applyBrowserSettings(wv)
wv.webViewClient = BrowserClient(tab)
wv.webChromeClient = BrowserChromeClient(tab)
wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
val route = if (tab != null && tab.useTor) tab.proxyPort else -1
BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), route)
wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength ->
if (tab == null) return@setDownloadListener
// The page's origin; a fresh popup still on about:blank has none, so name the file's own.
val origin = wv.url?.let(BrowserChrome::originOf) ?: BrowserChrome.originOf(url) ?: return@setDownloadListener
if (!canOfferDownload(tab, origin)) return@setDownloadListener
// Read on the main thread: the cookie jar is the tab's own per-account WebView profile.
val cookie = BrowserWebTools.cookieManager(wv).getCookie(url)
prepareDownloadOffer(tab, origin) { ready ->
BrowserDownloads.offerListenerDownload(url, userAgent, contentDisposition, mimeType, contentLength, cookie, if (tab.useTor) tab.proxyPort else -1, ready)
}
}
}
@@ -953,21 +991,22 @@ class NappletBrowserService : Service() {
val raw = message.data ?: return
val envelope = parseJsonObjectOrNull(raw) ?: return
// Browser conveniences (share, blob downloads) are handled here, never brokered. The theme colour
// only matters to a window with system bars, which an embedded tab doesn't own.
// The origin the WebView reports, which the page can't forge, keys every decision below.
val origin = trustedOrigin(sourceOrigin) ?: return
// Browser conveniences (share, blob downloads) are handled here, never brokered; a download still
// asks the user first ([offerInlineDownload]), since any top-frame script can post one. The theme
// colour only matters to a window with system bars, which an embedded tab doesn't own.
when (envelope.stringOrNull("type")) {
"browser.share" -> {
BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url"))
return
}
"browser.themeColor" -> return
"browser.download" -> {
val data = envelope.stringOrNull("data") ?: return
if (data.startsWith("data:") && data.length <= BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 256) {
BrowserDownloads.saveDataUrl(this, data, envelope.stringOrNull("name"))
}
offerInlineDownload(tab, origin, envelope)
return
}
"browser.themeColor" -> return
}
// IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client.
@@ -980,10 +1019,6 @@ class NappletBrowserService : Service() {
return
}
val scheme = sourceOrigin.scheme ?: return
val host = sourceOrigin.host ?: return
val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""
val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
@@ -1005,6 +1040,78 @@ class NappletBrowserService : Service() {
}
}
/** `scheme://host[:port]` of the WebView-reported origin, or null when it has no usable one. */
private fun trustedOrigin(sourceOrigin: Uri): String? {
val scheme = sourceOrigin.scheme ?: return null
val host = sourceOrigin.host ?: return null
return "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else ""
}
/**
* A `browser.download` envelope: the bytes a page wants saved (a `blob:` download the extras script
* read, or one a script forged). Keyed on the WebView-reported [origin], never an envelope field.
*/
private fun offerInlineDownload(
tab: BrowserTab,
origin: String,
envelope: JsonObject,
) {
if (!canOfferDownload(tab, origin)) return
val data = envelope.stringOrNull("data") ?: return
prepareDownloadOffer(tab, origin) { ready -> BrowserDownloads.offerInline(data, envelope.stringOrNull("name"), ready) }
}
/**
* Whether [origin] may put a download card up in [tab] now: never over another of the tab's page prompts
* (so a page can't swap the name under the user's finger, or pop the card where a dialog's button just
* was), never while an offer is still being prepared (so a page can't queue decodes), and not within
* its cooldown.
*/
private fun canOfferDownload(
tab: BrowserTab,
origin: String,
) = !tab.preparingDownload &&
tab.jsDialogs.isEmpty() &&
tab.permissionRequests.isEmpty() &&
pendingDownloads.values.none { it.sessionId == tab.sessionId } &&
tab.downloadCooldown.allows(origin)
/** Runs [prepare] (which answers exactly once, on the main thread) and relays the offer it produces. */
private fun prepareDownloadOffer(
tab: BrowserTab,
origin: String,
prepare: ((BrowserDownloads.DownloadOffer?) -> Unit) -> Unit,
) {
tab.preparingDownload = true
prepare { offer ->
tab.preparingDownload = false
if (offer != null) showDownloadOffer(tab, origin, offer)
}
}
/**
* Asks the main process to show [offer]'s consent card (this provider has no window of its own); the
* file is fetched or written only if the user taps Save there.
*/
private fun showDownloadOffer(
tab: BrowserTab,
origin: String,
offer: BrowserDownloads.DownloadOffer,
) {
if (tabs[tab.sessionId] !== tab || !canOfferDownload(tab, origin)) return
val id = ++downloadSeq
val sent =
sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CONSENT) {
putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id)
putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin)
putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, offer.fileName)
putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, offer.sizeBytes)
putString(NappletBrowserContract.KEY_DOWNLOAD_SOURCE, offer.sourceHost)
putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, offer.risky)
}
if (sent) pendingDownloads[id] = PendingDownload(tab.sessionId, origin, offer)
}
private fun requestBrowserToken(
tab: BrowserTab,
origin: String,