mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Merge main into ccr-c9ac5e5e-62o6th: download consent meets the embedded-tab fixes
main's download-consent work (MSG_DOWNLOAD_CONSENT/RESULT/CANCEL) took browser message ids 34-36, which this branch had given to MSG_PAUSE, MSG_RESUME and MSG_CLOSE_SESSION. Keeping both unchanged would have made a pause read as a download prompt. main's ids stay; this branch's three move to 39-41. Other resolutions: - BrowserDownloads: take main's decoder (it already decodes off the main thread, which is the change this branch made). - bridge.onMessage: main computes the origin through trustedOrigin(); keep this branch's document-stamped request and subscription ids on top. - closeTab takes the tab now, so main's pending-download cleanup reads tab.sessionId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+14
@@ -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,
|
||||
)
|
||||
|
||||
+40
@@ -197,6 +197,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)
|
||||
|
||||
@@ -281,6 +284,7 @@ class EmbeddedWebAppController(
|
||||
private fun resetPageState() {
|
||||
pendingDialog.value = null
|
||||
pendingPermission.value = null
|
||||
pendingDownload.value = null
|
||||
isFullscreen.value = false
|
||||
_findResult.value = null
|
||||
}
|
||||
@@ -619,6 +623,30 @@ class EmbeddedWebAppController(
|
||||
if (pendingPermission.value?.id == id) pendingPermission.value = null
|
||||
}
|
||||
NappletBrowserContract.MSG_ROUTE -> routedOverTor.value = msg.data?.getBoolean(NappletBrowserContract.KEY_USE_TOR, false) ?: false
|
||||
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
|
||||
@@ -748,6 +776,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) {
|
||||
this.useTor = useTor
|
||||
send(NappletBrowserContract.MSG_SET_TOR) {
|
||||
|
||||
+23
-2
@@ -66,6 +66,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
|
||||
@@ -340,8 +341,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
|
||||
@@ -424,6 +426,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)
|
||||
|
||||
+4
@@ -186,6 +186,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderFhirResource
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderFundraiser
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGeocache
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGeocacheFoundLog
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGitStatusEvent
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGoal
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderHighlight
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderInteractiveStory
|
||||
@@ -332,6 +333,7 @@ import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
@@ -1026,6 +1028,8 @@ private fun FullBleedNoteCompose(
|
||||
RenderGitPullRequestEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
|
||||
} else if (noteEvent is GitPullRequestUpdateEvent) {
|
||||
RenderGitPullRequestUpdateEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
|
||||
} else if (noteEvent is GitStatusEvent) {
|
||||
RenderGitStatusEvent(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is AppDefinitionEvent) {
|
||||
RenderAppDefinition(baseNote, accountViewModel, nav)
|
||||
} else if (noteEvent is AppRecommendationEvent) {
|
||||
|
||||
+275
@@ -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)
|
||||
}
|
||||
}
|
||||
+161
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -6356,4 +6356,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>
|
||||
|
||||
+147
@@ -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
|
||||
+1
-1
@@ -138,7 +138,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,
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
|
||||
private fun GitStatusEvent.statusKind(): StatusKind =
|
||||
fun GitStatusEvent.statusKind(): StatusKind =
|
||||
when (this) {
|
||||
is GitStatusAppliedEvent -> StatusKind.APPLIED
|
||||
is GitStatusClosedEvent -> StatusKind.CLOSED
|
||||
|
||||
+6
@@ -194,6 +194,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderFhirResource
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderFundraiser
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGeocache
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGeocacheFoundLog
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGitStatusEvent
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGoal
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderHighlight
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderInteractiveStory
|
||||
@@ -362,6 +363,7 @@ import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
@@ -1393,6 +1395,10 @@ private fun RenderNoteRow(
|
||||
)
|
||||
}
|
||||
|
||||
is GitStatusEvent -> {
|
||||
RenderGitStatusEvent(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is EncryptedDmEvent -> {
|
||||
RenderPrivateMessage(
|
||||
baseNote,
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ fun RenderBolt12Zap(
|
||||
},
|
||||
)
|
||||
|
||||
RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav)
|
||||
RenderTargetNote(note, quotesLeft, cardBackground, accountViewModel, nav)
|
||||
|
||||
amountSats?.let { ActivityAmountRow(PlatformNumberFormatter().format(it), orange) }
|
||||
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.ui.statusKind
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.GitStatusPill
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
|
||||
|
||||
/**
|
||||
* Renders a NIP-34 status event (kinds 1630-1633). These usually carry an
|
||||
* empty `content`, so the text fallback drew a blank note: show the new
|
||||
* status as a pill, the optional comment, and the issue/patch/PR it
|
||||
* targets (linked through the event's marked-`root` `e` tag) quoted below.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderGitStatusEvent(
|
||||
note: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = note.event as? GitStatusEvent ?: return
|
||||
val kind = remember(event) { event.statusKind() }
|
||||
|
||||
GitStatusPill(kind)
|
||||
|
||||
if (event.content.isNotBlank()) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Text(
|
||||
text = event.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
if (note.replyTo?.lastOrNull() != null) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RenderTargetNote(note, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -93,7 +93,7 @@ fun RenderNutzap(
|
||||
},
|
||||
)
|
||||
|
||||
RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav)
|
||||
RenderTargetNote(note, quotesLeft, cardBackground, accountViewModel, nav)
|
||||
|
||||
ActivityAmountRow(showAmount(BigDecimal(nutzapEvent.claimedSatsTotal())), orange)
|
||||
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@ fun RenderOnchainZap(
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav)
|
||||
RenderTargetNote(note, quotesLeft, cardBackground, accountViewModel, nav)
|
||||
|
||||
AmountRow(sats = sats, orange = orange)
|
||||
|
||||
|
||||
+1
-1
@@ -81,6 +81,6 @@ fun RenderReaction(
|
||||
},
|
||||
)
|
||||
|
||||
RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav)
|
||||
RenderTargetNote(note, quotesLeft, cardBackground, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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.ui.note.types
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
|
||||
/**
|
||||
* Quotes the note that [note] acts on (the last entry of its `replyTo`) as a
|
||||
* short embedded card: the post a zap or reaction targets, the issue/patch/PR
|
||||
* a git status changes, and so on.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderTargetNote(
|
||||
note: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
note.replyTo?.lastOrNull()?.let {
|
||||
NoteCompose(
|
||||
it,
|
||||
modifier = Modifier,
|
||||
isBoostedNote = true,
|
||||
makeItShort = true,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-29
@@ -52,7 +52,6 @@ import com.vitorpamplona.amethyst.commons.ui.note.ActivityCardFrame
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ActivityHeaderRow
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.CrossfadeToDisplayComment
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.DisplayBlankAuthor
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ZapIcon
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
|
||||
@@ -70,33 +69,6 @@ import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Shows the post a zap targets above the transfer card, mirroring how
|
||||
* reactions and reposts embed their target.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderZappedPost(
|
||||
zapNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
zapNote.replyTo?.lastOrNull()?.let {
|
||||
NoteCompose(
|
||||
it,
|
||||
modifier = Modifier,
|
||||
isBoostedNote = true,
|
||||
makeItShort = true,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderZapReceipt(
|
||||
note: Note,
|
||||
@@ -155,7 +127,7 @@ fun RenderZapReceiptCard(
|
||||
},
|
||||
)
|
||||
|
||||
RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav)
|
||||
RenderTargetNote(note, quotesLeft, cardBackground, accountViewModel, nav)
|
||||
|
||||
card.amount?.let { ActivityAmountRow(it, orange) }
|
||||
|
||||
|
||||
+40
@@ -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) {
|
||||
|
||||
+147
-47
@@ -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,43 +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
|
||||
// Decoding up to MAX_INLINE_BYTES of base64 stalls whatever thread does it; callers are on the main
|
||||
// thread every sandboxed surface renders on, so decode on the io thread with the write.
|
||||
io.execute {
|
||||
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@execute
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -159,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) {
|
||||
@@ -167,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)
|
||||
}
|
||||
@@ -187,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,
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
+98
-28
@@ -83,6 +83,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
|
||||
@@ -231,6 +232,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
|
||||
|
||||
// The page's requests that act for the user (NIP-07 sign / encrypt / decrypt) made while this window was in
|
||||
// the background, as (origin, request): sent on the next resume, so a site can't sign — even with
|
||||
@@ -388,8 +391,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()
|
||||
@@ -478,6 +493,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()
|
||||
@@ -959,15 +975,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 pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
|
||||
val id = bridge.brokerIdFor(pageId)
|
||||
@@ -1013,27 +1041,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
|
||||
// The broker may never answer (it died mid-mint): fail the origin's queued calls rather than let the
|
||||
|
||||
+37
-3
@@ -186,22 +186,38 @@ 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
|
||||
|
||||
/**
|
||||
* Client → provider: the tab left the screen (parked off-screen by the tab layer). The page's WebView is
|
||||
* paused — animations, media and geolocation stop — so warm tabs in the background don't keep burning
|
||||
* CPU and battery. Mirrors [NappletEmbedContract.MSG_PAUSE] for napplets.
|
||||
*/
|
||||
const val MSG_PAUSE = 34
|
||||
const val MSG_PAUSE = 39
|
||||
|
||||
/** Client → provider: the tab is the visible one again; resume its WebView. */
|
||||
const val MSG_RESUME = 35
|
||||
const val MSG_RESUME = 40
|
||||
|
||||
/**
|
||||
* Client → provider: the tab was torn down (evicted, or rebuilt for a theme/account change). Drops the
|
||||
* session and its WebView even if the surface never opened — a session created for a view that was
|
||||
* disposed before it attached would otherwise sit in the provider forever, pinning its client.
|
||||
*/
|
||||
const val MSG_CLOSE_SESSION = 36
|
||||
const val MSG_CLOSE_SESSION = 41
|
||||
|
||||
/**
|
||||
* Client → provider: whether the user is looking at this tab ([KEY_ENABLED]) — it's the visible tab AND
|
||||
@@ -247,6 +263,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"
|
||||
|
||||
+121
-14
@@ -60,6 +60,7 @@ import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
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.NappletActingRequests
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments
|
||||
@@ -160,6 +161,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) })
|
||||
|
||||
/**
|
||||
@@ -179,6 +185,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() }
|
||||
|
||||
@@ -368,6 +385,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 -> {
|
||||
val tab = tabFor(msg) ?: return true
|
||||
@@ -571,6 +596,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 == tab.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
|
||||
@@ -586,9 +617,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1065,21 +1103,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.
|
||||
@@ -1092,10 +1131,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 pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
|
||||
val id = tab.bridge.brokerIdFor(pageId)
|
||||
// A relay subscription is named by the page, and its pushes (decrypted events included) come back
|
||||
@@ -1203,6 +1238,78 @@ class NappletBrowserService : Service() {
|
||||
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
|
||||
}
|
||||
|
||||
/** `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,
|
||||
|
||||
Reference in New Issue
Block a user