feat(browser): Chrome-PWA parity in the :napplet browser surfaces

- NappletControlSheet rebuilt on BrowserChrome: title + origin/security
  header (tap: page info, long-press: copy), icon row (back, forward,
  reload/stop, star, share), menu rows incl. rarely-used Edit address,
  find in page, text size, desktop site, add to Home screen, open in
  another browser, Privacy and Developer groups; same glyphs as Compose.
- Full-screen browser: new windows for _blank/window.open (opener kept
  via BrowserPopups), intent: URIs, downloads (Tor-aware, MediaStore,
  blob/data via page script), HTML fullscreen, camera/mic/location
  prompts, theme-color system bars, Recents task description, long-press
  link/image menu, find bar, navigator.share polyfill, crash recovery.
- Embedded browser service: IPC for all of the above; JS dialogs and
  permission prompts relayed to the main process; fullscreen inside the
  surface; WebView wrapped in a container so it can be rebuilt.
- onRenderProcessGone handled in all four :napplet WebView owners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ
This commit is contained in:
Claude
2026-09-26 16:15:39 +00:00
parent 68079cf3cc
commit 2111e18e52
17 changed files with 3200 additions and 655 deletions
@@ -0,0 +1,157 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplethost
import android.content.Context
import android.graphics.Typeface
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Security
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* How each [BrowserChrome.Action] looks: its Material Symbol (or, for Tor, the onion drawable) and its
* label. Shared by both renderers of the top pill — the Compose sheet over embedded tabs and the
* plain-View sheet in the full-screen browser — so an action has one icon and one name everywhere.
*/
object BrowserChromeLabels {
/** The glyph for [action], or null when it is drawn from [drawableFor] instead. */
fun symbolFor(action: Action): MaterialSymbol? =
when (action) {
Action.BACK -> MaterialSymbols.AutoMirrored.ArrowBack
Action.FORWARD -> MaterialSymbols.AutoMirrored.ArrowForward
Action.RELOAD -> MaterialSymbols.Refresh
Action.STOP -> MaterialSymbols.Close
Action.FAVORITE -> MaterialSymbols.Star
Action.SHARE -> MaterialSymbols.Share
Action.BACK_TO_APP -> MaterialSymbols.Home
Action.COPY_LINK -> MaterialSymbols.ContentCopy
Action.EDIT_ADDRESS -> MaterialSymbols.Edit
Action.FIND_IN_PAGE -> MaterialSymbols.Search
Action.TEXT_SIZE -> MaterialSymbols.FormatSize
Action.DESKTOP_SITE -> MaterialSymbols.DesktopWindows
Action.ADD_TO_HOME_SCREEN -> MaterialSymbols.AddToHomeScreen
Action.OPEN_IN_BROWSER_APP -> MaterialSymbols.OpenInBrowser
Action.OPEN_FULL_SCREEN -> MaterialSymbols.OpenInFull
Action.TOR -> null
Action.ACCESS_INFO -> MaterialSymbols.Info
Action.SITE_SETTINGS -> MaterialSymbols.Tune
Action.CONSOLE -> MaterialSymbols.Code
}
@DrawableRes
fun drawableFor(action: Action): Int? = if (action == Action.TOR) R.drawable.ic_tor else null
/** The label for [action]; [isFavorite] and [torOn] pick the stateful wording. */
@StringRes
fun labelFor(
action: Action,
isFavorite: Boolean = false,
torOn: Boolean = false,
): Int =
when (action) {
Action.BACK -> CommonsR.string.browser_action_back
Action.FORWARD -> CommonsR.string.browser_action_forward
Action.RELOAD -> CommonsR.string.browser_action_reload
Action.STOP -> CommonsR.string.browser_action_stop
Action.FAVORITE -> if (isFavorite) CommonsR.string.browser_action_favorite_remove else CommonsR.string.browser_action_favorite_add
Action.SHARE -> CommonsR.string.browser_action_share
Action.BACK_TO_APP -> CommonsR.string.browser_action_back_to_app
Action.COPY_LINK -> CommonsR.string.browser_action_copy_link
Action.EDIT_ADDRESS -> CommonsR.string.browser_action_edit_address
Action.FIND_IN_PAGE -> CommonsR.string.browser_action_find_in_page
Action.TEXT_SIZE -> CommonsR.string.browser_action_text_size
Action.DESKTOP_SITE -> CommonsR.string.browser_action_desktop_site
Action.ADD_TO_HOME_SCREEN -> CommonsR.string.browser_action_add_to_home
Action.OPEN_IN_BROWSER_APP -> CommonsR.string.browser_action_open_in_browser_app
Action.OPEN_FULL_SCREEN -> CommonsR.string.browser_action_open_full_screen
Action.TOR -> if (torOn) CommonsR.string.browser_action_tor_on else CommonsR.string.browser_action_tor_off
Action.ACCESS_INFO -> CommonsR.string.browser_action_access_info
Action.SITE_SETTINGS -> CommonsR.string.browser_action_site_settings
Action.CONSOLE -> CommonsR.string.browser_console_title_short
}
/** Actions whose row carries an on/off switch. */
fun isToggle(action: Action): Boolean = action == Action.TOR || action == Action.DESKTOP_SITE || action == Action.CONSOLE
/** Actions that keep the sheet open when used (the text-size stepper, the address editor). */
fun keepsSheetOpen(action: Action): Boolean = action == Action.TEXT_SIZE || action == Action.EDIT_ADDRESS
fun securitySymbol(security: Security): MaterialSymbol? =
when (security) {
Security.TOR -> null
Security.HTTPS -> MaterialSymbols.Lock
Security.HTTP -> MaterialSymbols.NoEncryption
Security.SANDBOX -> MaterialSymbols.Shield
}
@DrawableRes
fun securityDrawable(security: Security): Int? = if (security == Security.TOR) R.drawable.ic_tor else null
@StringRes
fun securityLabel(security: Security): Int =
when (security) {
Security.TOR -> CommonsR.string.browser_security_tor
Security.HTTPS -> CommonsR.string.browser_security_https
Security.HTTP -> CommonsR.string.browser_security_http
Security.SANDBOX -> CommonsR.string.browser_security_sandbox
}
}
/**
* The Material Symbols font the Compose UI draws its icons from, loaded for plain Views. It ships inside
* `:commonsUI`'s compose resources (the same APK assets the napplet shim is read from), so the full-screen
* browser's controls use the very same glyphs as the embedded tab's.
*/
object BrowserGlyphs {
private const val FONT_PATH = "font/material_symbols_outlined.ttf"
@Volatile private var cached: Typeface? = null
@Volatile private var cachedFilled: Typeface? = null
/** The same font with its FILL axis at 1 — a pinned page's solid star, as the Compose icon draws it. */
fun filledTypeface(context: Context): Typeface =
cachedFilled ?: synchronized(this) {
cachedFilled ?: (
runCatching {
Typeface
.Builder(context.assets, NappletWebContract.RESOURCE_ASSET_ROOT + FONT_PATH)
.setFontVariationSettings("'FILL' 1")
.build()
}.getOrNull() ?: typeface(context)
).also { cachedFilled = it }
}
fun typeface(context: Context): Typeface =
cached ?: synchronized(this) {
cached ?: runCatching { Typeface.createFromAsset(context.assets, NappletWebContract.RESOURCE_ASSET_ROOT + FONT_PATH) }
.onFailure { Log.w("BrowserGlyphs", "Material Symbols font missing from assets", it) }
.getOrDefault(Typeface.DEFAULT)
.also { cached = it }
}
}
@@ -0,0 +1,207 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplethost
import android.content.ContentValues
import android.content.Context
import android.os.Build
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 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 com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* Saves what a page downloads — `<a download>`, `Content-Disposition: attachment`, `data:` URLs, and
* `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.
*
* 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.
*/
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
private val io = Executors.newSingleThreadExecutor { Thread(it, "napplet-downloads").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.
*/
fun download(
context: Context,
url: String,
userAgent: String?,
contentDisposition: String?,
mimeType: String?,
cookie: String?,
proxyPort: Int,
) {
val app = context.applicationContext
if (url.startsWith("data:", ignoreCase = true)) {
saveDataUrl(app, url, null)
return
}
if (!url.startsWith("https://", ignoreCase = true) && !url.startsWith("http://", ignoreCase = true)) return
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
toast(app, app.getString(CommonsR.string.browser_download_started, name))
io.execute {
val ok =
runCatching {
val request =
Request
.Builder()
.url(url)
.apply {
userAgent?.takeIf { it.isNotBlank() }?.let { header("User-Agent", it) }
cookie?.takeIf { it.isNotBlank() }?.let { header("Cookie", it) }
}.get()
.build()
// No end-to-end call timeout: a large file over Tor legitimately takes minutes. The
// client's read timeout still ends a transfer that stalls completely.
val client =
NappletBlobHttp
.client(proxyPort)
.newBuilder()
.callTimeout(0, TimeUnit.SECONDS)
.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) } }
}
}.onFailure { Log.w(TAG, "Download failed for $url", it) }
.getOrDefault(false)
toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name))
}
}
/** Saves a `data:` URL (`data:[mime][;base64],payload`). */
fun saveDataUrl(
context: 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)
}
/** Saves bytes a page handed over (a `blob:` download, via the browser-extras script). */
fun saveBytes(
context: Context,
suggestedName: String?,
mimeType: 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)
toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name))
}
}
/**
* Writes into the public Downloads collection (Android 10+, no permission needed), or into the app's
* own Downloads folder on older versions, where writing the shared one would need a storage permission
* the app doesn't hold.
*/
private fun write(
context: Context,
name: String,
mimeType: String?,
body: (OutputStream) -> Unit,
): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val values =
ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
mimeType?.let { put(MediaStore.Downloads.MIME_TYPE, it) }
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
put(MediaStore.Downloads.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: return false
return try {
resolver.openOutputStream(uri)?.use(body) ?: error("No output stream")
resolver.update(uri, ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }, null, null)
true
} catch (e: Exception) {
resolver.delete(uri, null, null)
throw e
}
}
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return false
dir.mkdirs()
File(dir, name).outputStream().use(body)
return true
}
/** A plain filename: the page's suggestion without path parts, 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)
val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
return if (ext != null) "download.$ext" else "download"
}
private fun toast(
context: Context,
text: String,
) = main.post { Toast.makeText(context, text, Toast.LENGTH_SHORT).show() }
}
@@ -0,0 +1,175 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplethost
/**
* A document-start script injected only into the **browser** surfaces (never into sandboxed napplets or
* nsites), filling in what an installed Chrome PWA gets and a WebView doesn't:
*
* - `navigator.share` / `navigator.canShare` — WebView has no Web Share. Text, title and url go to the
* Android share sheet (`browser.share`); files are declined, as `canShare` reports.
* - `<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`).
*
* 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.
*/
object BrowserExtrasScript {
val JS: String =
"""
(function () {
if (window.top !== window || window.__amethystBrowserExtras) return;
window.__amethystBrowserExtras = true;
var MAX_BYTES = ${BrowserDownloads.MAX_INLINE_BYTES};
function send(message) {
try { var b = window.__nappletBridge; if (b) b.postMessage(JSON.stringify(message)); } catch (_) {}
}
// ---- Web Share ----
if (!navigator.share) {
var hasFiles = function (data) { return !!(data && data.files && data.files.length); };
var shareUrl = function (data) {
if (!data || data.url === undefined || data.url === null) return '';
try { return new URL(String(data.url), document.baseURI).href; } catch (_) { return null; }
};
navigator.share = function (data) {
data = data || {};
var activation = navigator.userActivation;
if (activation && !activation.isActive) {
return Promise.reject(new DOMException('Must be handling a user gesture to perform a share request.', 'NotAllowedError'));
}
if (hasFiles(data)) return Promise.reject(new DOMException('Sharing files is not supported.', 'NotAllowedError'));
var url = shareUrl(data);
if (url === null) return Promise.reject(new TypeError('Invalid URL'));
if (!data.title && !data.text && !url) return Promise.reject(new TypeError('No data to share.'));
send({ type: 'browser.share', title: data.title ? String(data.title) : '', text: data.text ? String(data.text) : '', url: url });
return Promise.resolve();
};
navigator.canShare = function (data) {
if (!data || hasFiles(data) || shareUrl(data) === null) return false;
return !!(data.title || data.text || data.url);
};
}
// ---- theme-color ----
var lastRaw;
var lastColor;
function pickThemeColor() {
var metas = document.querySelectorAll('meta[name="theme-color"]');
for (var i = 0; i < metas.length; i++) {
var media = metas[i].getAttribute('media');
try { if (!media || window.matchMedia(media).matches) return metas[i].getAttribute('content'); } catch (_) {}
}
return null;
}
function normalize(color) {
if (!color) return '';
var root = document.body || document.documentElement;
if (!root) return '';
var probe = document.createElement('span');
probe.style.display = 'none';
probe.style.color = color;
if (!probe.style.color) return '';
root.appendChild(probe);
var computed = getComputedStyle(probe).color;
probe.remove();
return computed || '';
}
function reportTheme() {
var raw = pickThemeColor();
// Only a changed declaration is worth a style computation.
if (raw === lastRaw && lastColor !== undefined) return;
lastRaw = raw;
var color = normalize(raw);
if (color === lastColor) return;
lastColor = color;
send({ type: 'browser.themeColor', color: color });
}
var themeTimer = 0;
function scheduleTheme() { clearTimeout(themeTimer); themeTimer = setTimeout(reportTheme, 50); }
function watchTheme() {
reportTheme();
// <meta> lives in <head>: watching only there keeps busy pages (feeds re-rendering the body)
// from waking this up on every DOM change.
try {
if (document.head) {
new MutationObserver(scheduleTheme).observe(document.head, {
subtree: true, childList: true, attributes: true, attributeFilter: ['content', 'media', 'name']
});
}
} catch (_) {}
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { lastRaw = undefined; scheduleTheme(); });
} catch (_) {}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watchTheme, { once: true });
else watchTheme();
// ---- blob: / data: downloads ----
// Pages often revoke a blob URL right after clicking it, before an async fetch could read it, so
// keep a handle on each Blob until a minute after its URL is revoked (the page holds it until the
// revoke anyway, so this doesn't change its lifetime by more than that minute).
var blobs = new Map();
try {
var nativeCreate = URL.createObjectURL;
var nativeRevoke = URL.revokeObjectURL;
URL.createObjectURL = function (obj) {
var url = nativeCreate.apply(URL, arguments);
try { if (obj instanceof Blob) blobs.set(url, obj); } catch (_) {}
return url;
};
URL.revokeObjectURL = function (url) {
setTimeout(function () { blobs.delete(url); }, 60000);
return nativeRevoke.apply(URL, arguments);
};
} catch (_) {}
function isInlineDownload(a) {
return !!(a && a.hasAttribute && a.hasAttribute('download') && /^(blob|data):/i.test(a.href || ''));
}
function deliver(a) {
var name = a.getAttribute('download') || '';
var known = blobs.get(a.href);
(known ? Promise.resolve(known) : fetch(a.href).then(function (r) { return r.blob(); })).then(function (blob) {
if (blob.size > MAX_BYTES) return;
var reader = new FileReader();
reader.onload = function () { send({ type: 'browser.download', name: name, mime: blob.type || '', data: String(reader.result) }); };
reader.readAsDataURL(blob);
}).catch(function () {});
}
document.addEventListener('click', function (e) {
var a = e.target && e.target.closest ? e.target.closest('a[download]') : null;
if (!isInlineDownload(a)) return;
e.preventDefault();
deliver(a);
}, true);
// Libraries usually build a detached <a download href="blob:…"> and call .click() on it; a click
// on a detached element never reaches the document listener above.
var nativeClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function () {
if (!this.isConnected && isInlineDownload(this)) { deliver(this); return; }
return nativeClick.apply(this, arguments);
};
})();
""".trimIndent()
}
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplethost
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.text.Editable
import android.text.InputType
import android.text.TextWatcher
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.webkit.WebView
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* The find-in-page bar for the full-screen surfaces, docked at the bottom edge (the top edge belongs to the
* pill's grabber, and the bottom keeps it off the page's own header). Drives [WebView.findAllAsync] /
* [WebView.findNext] on [webView] and shows "n/m". Hidden until [show].
*/
@SuppressLint("ViewConstructor")
class BrowserFindBar(
context: Context,
private val webViewProvider: () -> WebView?,
private val onClosed: () -> Unit = {},
) : LinearLayout(context) {
private val onSurface = color(android.R.attr.textColorPrimary)
private val dimmed = color(android.R.attr.textColorSecondary)
private val glyphs = BrowserGlyphs.typeface(context)
private val field: EditText
private val count: TextView
init {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
visibility = View.GONE
elevation = dp(8).toFloat()
background =
GradientDrawable().apply {
cornerRadii = floatArrayOf(dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat(), 0f, 0f, 0f, 0f)
setColor(color(android.R.attr.colorBackground))
}
setPadding(dp(12), dp(4), dp(4), dp(4))
field =
EditText(context).apply {
hint = context.getString(CommonsR.string.browser_find_hint)
setTextColor(onSurface)
setHintTextColor(dimmed)
background = null
isSingleLine = true
textSize = 15f
inputType = InputType.TYPE_CLASS_TEXT
imeOptions = EditorInfo.IME_ACTION_SEARCH
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
addTextChangedListener(
object : TextWatcher {
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int,
) = Unit
override fun onTextChanged(
s: CharSequence?,
start: Int,
before: Int,
count: Int,
) = Unit
override fun afterTextChanged(s: Editable?) = search(s?.toString().orEmpty())
},
)
setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
webViewProvider()?.findNext(true)
true
} else {
false
}
}
}
count =
TextView(context).apply {
setTextColor(dimmed)
textSize = 13f
setPadding(dp(8), 0, dp(4), 0)
}
addView(field)
addView(count)
addView(button(MaterialSymbols.KeyboardArrowUp, CommonsR.string.browser_find_previous) { webViewProvider()?.findNext(false) })
addView(button(MaterialSymbols.KeyboardArrowDown, CommonsR.string.browser_find_next) { webViewProvider()?.findNext(true) })
addView(button(MaterialSymbols.Close, CommonsR.string.browser_find_close) { hide() })
}
val isShowing: Boolean get() = visibility == View.VISIBLE
fun show() {
webViewProvider()?.setFindListener { active, total, _ ->
count.text =
if (total > 0) {
context.getString(CommonsR.string.browser_find_count, active + 1, total)
} else if (field.text.isNullOrEmpty()) {
""
} else {
"0/0"
}
}
visibility = View.VISIBLE
field.requestFocus()
field.selectAll()
context.getSystemService(InputMethodManager::class.java)?.showSoftInput(field, 0)
}
fun hide() {
if (!isShowing) return
visibility = View.GONE
context.getSystemService(InputMethodManager::class.java)?.hideSoftInputFromWindow(field.windowToken, 0)
webViewProvider()?.apply {
clearMatches()
setFindListener(null)
}
count.text = ""
onClosed()
}
private fun search(query: String) {
val wv = webViewProvider() ?: return
if (query.isEmpty()) {
wv.clearMatches()
count.text = ""
} else {
wv.findAllAsync(query)
}
}
private fun button(
symbol: MaterialSymbol,
label: Int,
onClick: () -> Unit,
): View =
TextView(context).apply {
text = symbol.glyph
typeface = glyphs
setTextColor(onSurface)
setTextSize(TypedValue.COMPLEX_UNIT_DIP, 22f)
gravity = Gravity.CENTER
minWidth = dp(44)
minHeight = dp(44)
contentDescription = context.getString(label)
tooltipText = contentDescription
isClickable = true
background =
TypedValue().let { tv ->
context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, tv, true)
ContextCompat.getDrawable(context, tv.resourceId)
}
setOnClickListener { onClick() }
}
private fun color(attr: Int): Int {
val tv = TypedValue()
context.theme.resolveAttribute(attr, tv, true)
return if (tv.resourceId != 0) ContextCompat.getColor(context, tv.resourceId) else tv.data.takeIf { it != 0 } ?: Color.GRAY
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
}
@@ -28,6 +28,7 @@ import android.webkit.JsResult
import android.widget.EditText
import android.widget.FrameLayout
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* Shows a web page's `alert()` / `confirm()` / `prompt()` / `beforeunload` dialogs for the full-screen
@@ -116,9 +117,9 @@ internal class BrowserJsDialogs(
*/
fun beforeUnload(result: JsResult): Boolean =
show(result, onBlocked = { result.confirm() }) { answer ->
setTitle(R.string.napplet_js_leave_title)
setMessage(R.string.napplet_js_leave_message)
setPositiveButton(R.string.napplet_js_leave) { _, _ -> answer { result.confirm() } }
setTitle(CommonsR.string.browser_js_leave_title)
setMessage(CommonsR.string.browser_js_leave_message)
setPositiveButton(CommonsR.string.browser_js_leave) { _, _ -> answer { result.confirm() } }
setNegativeButton(android.R.string.cancel) { _, _ -> answer { result.cancel() } }
}
@@ -151,7 +152,7 @@ internal class BrowserJsDialogs(
shownOnThisPage++
val builder = AlertDialog.Builder(activity).apply { build(answer) }
if (shownOnThisPage > 1) {
builder.setNeutralButton(R.string.napplet_js_dialog_block) { _, _ ->
builder.setNeutralButton(CommonsR.string.browser_js_dialog_block) { _, _ ->
blocked = true
answer { onBlocked() }
}
@@ -173,9 +174,9 @@ internal class BrowserJsDialogs(
val scheme = uri?.scheme?.lowercase()
val host = uri?.host?.takeIf { it.isNotBlank() }
return if (host != null && (scheme == "http" || scheme == "https")) {
activity.getString(R.string.napplet_js_dialog_title, host)
activity.getString(CommonsR.string.browser_js_dialog_title, host)
} else {
activity.getString(R.string.napplet_js_dialog_title_generic)
activity.getString(CommonsR.string.browser_js_dialog_title_generic)
}
}
}
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplethost
import android.content.Context
import android.content.MutableContextWrapper
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.webkit.WebView
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import java.util.UUID
/**
* New windows a page opens (`target="_blank"` links and user-initiated `window.open()`), handed from the
* opener's WebView to the full-screen browser window that will show them — Chrome opens these as a new
* tab, we open a new [NappletBrowserActivity] task.
*
* WebView requires the popup's WebView to exist *inside* `onCreateWindow`, before the Activity that will
* show it has started, and that WebView is what keeps `window.opener` / `postMessage` wired for OAuth
* popups. So the opener builds it here, on a [MutableContextWrapper] (re-pointed at the adopting Activity
* later), with the same storage profile, settings and scripts as any browser WebView, and parks it under a
* one-shot token passed in the launch intent. Bridge messages that arrive before the Activity adopts it are
* queued, then replayed. Unclaimed popups are destroyed after [CLAIM_TIMEOUT_MS].
*
* Opener and popup always share the `:napplet` process (both browser surfaces live there), which is what
* makes handing a live WebView across possible.
*/
object BrowserPopups {
private const val CLAIM_TIMEOUT_MS = 30_000L
fun interface BridgeTarget {
fun onMessage(
view: WebView,
message: WebMessageCompat,
sourceOrigin: Uri,
isMainFrame: Boolean,
replyProxy: JavaScriptReplyProxy,
)
}
class Pending internal constructor(
val webView: WebView,
val context: MutableContextWrapper,
val proxyPort: Int,
val useTor: Boolean,
val themeType: String,
val webViewProfile: String?,
) {
private var target: BridgeTarget? = null
private val queued = mutableListOf<() -> Unit>()
internal fun dispatch(
view: WebView,
message: WebMessageCompat,
sourceOrigin: Uri,
isMainFrame: Boolean,
replyProxy: JavaScriptReplyProxy,
) {
val t = target
if (t != null) {
t.onMessage(view, message, sourceOrigin, isMainFrame, replyProxy)
} else {
queued += { target?.onMessage(view, message, sourceOrigin, isMainFrame, replyProxy) }
}
}
/** Called by the adopting Activity: from now on bridge messages go to [bridge]; queued ones replay. */
fun adopt(bridge: BridgeTarget) {
target = bridge
queued.toList().forEach { it() }
queued.clear()
}
}
private val pending = mutableMapOf<String, Pending>()
private val main = Handler(Looper.getMainLooper())
/**
* Builds the popup WebView for `onCreateWindow` and parks it. Returns the token for the launch intent and
* the WebView to put on the `WebViewTransport`.
*/
fun create(
context: Context,
shimJs: String,
proxyPort: Int,
useTor: Boolean,
themeType: String,
webViewProfile: String?,
): Pair<String, WebView> {
val app = context.applicationContext
val wrapper = MutableContextWrapper(nightThemedContext(app, themeType))
val webView = WebView(wrapper)
// Same partition as the opener: WebView only links a popup to its opener within one profile, and
// the popup must see the same logged-in session anyway.
NappletWebViewProfile.apply(app, webView, webViewProfile)
BrowserWebTools.applyBrowserSettings(webView)
val entry = Pending(webView, wrapper, proxyPort, useTor, themeType, webViewProfile)
WebViewCompat.addWebMessageListener(webView, NappletWebContract.BRIDGE_NAME, setOf("*")) { view, message, origin, isMainFrame, reply ->
entry.dispatch(view, message, origin, isMainFrame, reply)
}
WebViewCompat.addDocumentStartJavaScript(webView, BrowserWebTools.browserStartScript(shimJs, imeProxy = false), setOf("*"))
val token = UUID.randomUUID().toString()
pending[token] = entry
main.postDelayed({
pending.remove(token)?.let { orphan ->
orphan.webView.stopLoading()
orphan.webView.destroy()
}
}, CLAIM_TIMEOUT_MS)
return token to webView
}
/** Hands the parked popup to the Activity that was launched for [token] (once). */
fun take(token: String?): Pending? = token?.let { pending.remove(it) }
}
@@ -0,0 +1,376 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplethost
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.net.http.SslCertificate
import android.os.Build
import android.os.SystemClock
import android.webkit.CookieManager
import android.webkit.WebSettings
import android.webkit.WebStorage
import android.webkit.WebView
import android.widget.Toast
import androidx.core.net.toUri
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.quartz.utils.Log
import java.net.URISyntaxException
import java.text.DateFormat
import java.util.WeakHashMap
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* WebView-side behaviours shared by the full-screen browser ([NappletBrowserActivity]) and the embedded
* one ([NappletBrowserService]), so the two surfaces act the same: non-web schemes, desktop mode, text
* size, the out-of-scope "back to app" walk, site-data clearing, page info, copy and share.
*
* Everything here runs in the keyless `:napplet` process and touches only the WebView it is given.
*/
object BrowserWebTools {
private const val TAG = "BrowserWebTools"
// ---- setup ----
/**
* The settings every browser WebView gets (full-screen, embedded, and popups), so a page behaves the
* same wherever it opens. New windows are enabled — `onCreateWindow` turns them into new browser
* windows — but `window.open()` still needs a user gesture (Blink's popup blocker, as in Chrome).
* Geolocation is enabled at the WebView level; every request still goes through the per-site prompt.
*/
@Suppress("SetJavaScriptEnabled")
fun applyBrowserSettings(webView: WebView) {
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
@Suppress("DEPRECATION")
databaseEnabled = false
allowFileAccess = false
allowContentAccess = false
@Suppress("DEPRECATION")
allowFileAccessFromFileURLs = false
@Suppress("DEPRECATION")
allowUniversalAccessFromFileURLs = false
javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(true)
setGeolocationEnabled(true)
mediaPlaybackRequiresUserGesture = true
builtInZoomControls = true
displayZoomControls = false
loadWithOverviewMode = true
useWideViewPort = true
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) {
safeBrowsingEnabled = true
}
}
WebView.setWebContentsDebuggingEnabled(false)
}
/**
* The document-start script for a browser WebView: the direct-bridge flags, the NIP-07 [shimJs], and
* [BrowserExtrasScript]. [imeProxy] is set only for the embedded surface, which has no native keyboard.
*/
fun browserStartScript(
shimJs: String,
imeProxy: Boolean,
): String {
val flags = if (imeProxy) " window.__nappletImeProxy = true;" else ""
return "if (window.top === window) { window.__nappletDirectBridge = true; window.__nappletNip07 = true;$flags }\n$shimJs\n${BrowserExtrasScript.JS}"
}
// ---- non-web schemes ----
/**
* Handles a navigation to a non-http(s) [uri] the way Chrome does: `intent:` URIs are parsed (component
* and selector stripped so a page can't aim at a private activity) and fall back to their
* `browser_fallback_url` in-page when no app takes them; other schemes (`mailto:`, `tel:`, `geo:`,
* `nostr:`, …) go to the system. Only acts on a user gesture, like Chrome, so a page can't bounce the
* user into another app on load. Always consumes the navigation.
*/
fun openExternal(
context: Context,
uri: Uri,
hasGesture: Boolean,
loadInPage: (String) -> Unit,
): Boolean {
if (!hasGesture) return true
val intent =
if (uri.scheme.equals("intent", ignoreCase = true)) {
parseIntentUri(uri.toString()) ?: return true
} else {
Intent(Intent.ACTION_VIEW, uri)
}
intent.addCategory(Intent.CATEGORY_BROWSABLE)
if (context !is Activity) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
val fallback = intent.getStringExtra("browser_fallback_url")
if (fallback != null && (fallback.startsWith("https://") || fallback.startsWith("http://"))) {
loadInPage(fallback)
}
} catch (e: Exception) {
Log.w(TAG, "Could not open ${uri.scheme} link", e)
}
return true
}
/** Parses an `intent:` URI with the same hardening Chrome applies. Null when it's malformed. */
fun parseIntentUri(uri: String): Intent? =
try {
Intent.parseUri(uri, Intent.URI_INTENT_SCHEME).apply {
// A web page may only ask for something any app could handle: never an explicit
// component, never a selector (which could smuggle one in).
component = null
selector = null
// No grants of our own content to whatever answers.
flags = flags and
(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
).inv()
}
} catch (_: URISyntaxException) {
null
}
// ---- desktop site / text size ----
private val mobileUserAgents = WeakHashMap<WebView, String>()
fun isDesktopMode(webView: WebView): Boolean = mobileUserAgents.containsKey(webView)
/** Switches [webView] between its own mobile UA and [BrowserChrome.desktopUserAgent], then reloads. */
fun setDesktopMode(
webView: WebView,
desktop: Boolean,
) {
if (desktop == isDesktopMode(webView)) return
val settings = webView.settings
if (desktop) {
val mobile = settings.userAgentString
mobileUserAgents[webView] = mobile
settings.userAgentString = BrowserChrome.desktopUserAgent(mobile)
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
} else {
settings.userAgentString = mobileUserAgents.remove(webView)
}
webView.reload()
}
fun setTextZoom(
webView: WebView,
percent: Int,
) {
webView.settings.textZoom = percent.coerceIn(BrowserChrome.TEXT_ZOOM_STEPS.first(), BrowserChrome.TEXT_ZOOM_STEPS.last())
}
// ---- scope ----
/**
* Chrome's out-of-scope bar ✕: step back to the most recent history entry on [startUrl]'s origin, or
* reload [startUrl] when there is none.
*/
fun backToScope(
webView: WebView,
startUrl: String,
) {
val home = BrowserChrome.originOf(startUrl) ?: return
val history = webView.copyBackForwardList()
for (i in history.currentIndex - 1 downTo 0) {
if (BrowserChrome.originOf(history.getItemAtIndex(i).url).equals(home, ignoreCase = true)) {
webView.goBackOrForward(i - history.currentIndex)
return
}
}
webView.loadUrl(startUrl)
}
// ---- storage ----
/** The cookie jar of [webView]'s own storage profile (the per-account one), or the default jar. */
fun cookieManager(webView: WebView): CookieManager =
if (WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) {
runCatching { WebViewCompat.getProfile(webView).cookieManager }.getOrNull() ?: CookieManager.getInstance()
} else {
CookieManager.getInstance()
}
private fun webStorage(webView: WebView): WebStorage =
if (WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) {
runCatching { WebViewCompat.getProfile(webView).webStorage }.getOrNull() ?: WebStorage.getInstance()
} else {
WebStorage.getInstance()
}
/**
* Clears what the site behind [url] stored in [webView]'s profile — its origin's web storage (local
* storage, IndexedDB, cache storage, service workers) and its cookies — then reloads it logged out.
* Other sites and other accounts are untouched.
*/
fun clearSiteData(
context: Context,
webView: WebView,
url: String,
) {
val origin = BrowserChrome.originOf(url) ?: return
runCatching { webStorage(webView).deleteOrigin(origin) }
val cookies = cookieManager(webView)
val names =
cookies
.getCookie(url)
.orEmpty()
.split(';')
.mapNotNull { it.substringBefore('=').trim().takeIf(String::isNotEmpty) }
val host = BrowserChrome.displayHost(url)
// A cookie can be scoped to the host or to any parent domain; expire it on each so it really goes.
val domains = host.split('.').let { parts -> (0 until (parts.size - 1).coerceAtLeast(1)).map { parts.drop(it).joinToString(".") } }
names.forEach { name ->
cookies.setCookie(url, "$name=; Max-Age=0; Path=/")
domains.forEach { domain -> cookies.setCookie(url, "$name=; Max-Age=0; Path=/; Domain=$domain") }
}
cookies.flush()
Toast.makeText(context, CommonsR.string.browser_site_data_cleared, Toast.LENGTH_SHORT).show()
webView.reload()
}
// ---- page info ----
/** The paragraphs of the page-info sheet for the page in [webView]. */
fun pageInfo(
context: Context,
webView: WebView,
torOn: Boolean?,
): String {
val url = webView.url.orEmpty()
val lines = mutableListOf<String>()
lines +=
context.getString(
if (url.startsWith("https://", ignoreCase = true)) CommonsR.string.browser_page_info_https else CommonsR.string.browser_page_info_http,
)
if (torOn != null) {
lines += context.getString(if (torOn) CommonsR.string.browser_page_info_tor else CommonsR.string.browser_page_info_open_web)
}
webView.certificate?.let { lines += certificateLine(context, it) }
return lines.joinToString("\n\n")
}
private fun certificateLine(
context: Context,
cert: SslCertificate,
): String {
val to = cert.issuedTo?.cName?.takeIf { it.isNotBlank() } ?: cert.issuedTo?.oName.orEmpty()
val by = cert.issuedBy?.oName?.takeIf { it.isNotBlank() } ?: cert.issuedBy?.cName.orEmpty()
val until = cert.validNotAfterDate?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(it) }.orEmpty()
return context.getString(CommonsR.string.browser_page_info_certificate, to, by, until)
}
// ---- copy / share / other browser ----
fun copyToClipboard(
context: Context,
text: String,
) {
val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return
clipboard.setPrimaryClip(ClipData.newPlainText(text, text))
// Android 13+ shows its own clipboard confirmation; a toast on top of it would be noise.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(context, CommonsR.string.browser_link_copied, Toast.LENGTH_SHORT).show()
}
}
private var lastShareAt = 0L
/**
* Opens the Android share sheet for a page or a `navigator.share()` call. Throttled: a page can post
* share requests straight to the bridge (bypassing the polyfill's user-activation check), so at most
* one sheet per [SHARE_COOLDOWN_MS] is honoured.
*/
fun share(
context: Context,
title: String?,
text: String?,
url: String?,
) {
val now = SystemClock.elapsedRealtime()
if (now - lastShareAt < SHARE_COOLDOWN_MS) return
lastShareAt = now
val body = listOfNotNull(text?.takeIf { it.isNotBlank() }, url?.takeIf { it.isNotBlank() }).joinToString("\n")
if (body.isEmpty()) return
val send =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, body)
title?.takeIf { it.isNotBlank() }?.let { putExtra(Intent.EXTRA_SUBJECT, it) }
}
val chooser = Intent.createChooser(send, context.getString(CommonsR.string.browser_share_chooser))
if (context !is Activity) chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(chooser) }.onFailure { Log.w(TAG, "Share failed", it) }
}
/**
* Hands [url] to a browser other than Amethyst — the escape hatch for sites that refuse embedded
* browsers (Google sign-in, some banks). Our own activities are excluded from the chooser.
*/
fun openInOtherBrowser(
context: Context,
url: String,
) {
val view = Intent(Intent.ACTION_VIEW, url.toUri()).addCategory(Intent.CATEGORY_BROWSABLE)
val chooser =
Intent.createChooser(view, null).apply {
putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, ownBrowsableComponents(context, view))
if (context !is Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(chooser)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, CommonsR.string.browser_no_other_browser, Toast.LENGTH_SHORT).show()
}
}
private fun ownBrowsableComponents(
context: Context,
intent: Intent,
): Array<ComponentName> =
runCatching {
@Suppress("DEPRECATION")
context.packageManager
.queryIntentActivities(intent, 0)
.filter { it.activityInfo.packageName == context.packageName }
.map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) }
.toTypedArray()
}.getOrDefault(emptyArray())
private const val SHARE_COOLDOWN_MS = 1_000L
}
@@ -116,6 +116,91 @@ object NappletBrowserContract {
*/
const val MSG_FILE_CHOOSER_RESULT = 15
// ---- PWA-parity controls (see BrowserChrome). Client → provider unless noted. ----
/** Go forward in the page history. */
const val MSG_FORWARD = 16
/** Stop the current load. */
const val MSG_STOP = 17
/** Find [KEY_FIND_QUERY] in the page (empty clears). Provider answers with [MSG_FIND_RESULT]. */
const val MSG_FIND = 18
/** Move to the next ([KEY_FIND_FORWARD] = true) or previous match. */
const val MSG_FIND_NEXT = 19
/** Provider → client: [KEY_FIND_ACTIVE] (0-based) of [KEY_FIND_TOTAL] matches. */
const val MSG_FIND_RESULT = 20
/** Switch desktop-site mode ([KEY_ENABLED]); the page reloads. */
const val MSG_SET_DESKTOP = 21
/** Set the text size to [KEY_TEXT_ZOOM] percent. */
const val MSG_SET_TEXT_ZOOM = 22
/** Step back to the most recent page on [KEY_URL]'s origin (the app's home), or load it. */
const val MSG_BACK_TO_SCOPE = 23
/** Clear the current site's cookies and storage in this account's profile, then reload. */
const val MSG_CLEAR_SITE_DATA = 24
/** Ask for the page-info text (connection, Tor, certificate); answered with [MSG_PAGE_INFO]. */
const val MSG_PAGE_INFO_REQUEST = 25
/** Provider → client: [KEY_PAGE_INFO] for the page on screen. */
const val MSG_PAGE_INFO = 26
/**
* Provider → client: the page opened a JS dialog. [KEY_DIALOG_ID], [KEY_DIALOG_TYPE] (`alert`, `confirm`,
* `prompt`, `beforeunload`), [KEY_URL], [KEY_DIALOG_MESSAGE], [KEY_DIALOG_DEFAULT], and
* [KEY_DIALOG_OFFER_BLOCK] when "Block dialogs from this page" should be offered. The page's JS waits
* until [MSG_JS_DIALOG_RESULT] arrives.
*/
const val MSG_JS_DIALOG = 27
/** The user's answer: [KEY_DIALOG_ID], [KEY_DIALOG_CONFIRMED], [KEY_DIALOG_TEXT], [KEY_DIALOG_BLOCK]. */
const val MSG_JS_DIALOG_RESULT = 28
/**
* Provider → client: the page asked for camera / microphone / location. [KEY_PERMISSION_ID],
* [KEY_BROWSER_ORIGIN], [KEY_PERMISSIONS] (`BrowserSitePermission` keys). Answered with
* [MSG_PERMISSION_RESULT].
*/
const val MSG_PERMISSION_REQUEST = 29
/** The granted subset: [KEY_PERMISSION_ID], [KEY_PERMISSIONS]. */
const val MSG_PERMISSION_RESULT = 30
/** Provider → client: the page withdrew request [KEY_PERMISSION_ID]; drop its prompt. */
const val MSG_PERMISSION_CANCEL = 31
/** Provider → client: HTML fullscreen entered or left ([KEY_ENABLED]). */
const val MSG_FULLSCREEN = 32
/** Leave HTML fullscreen (the user pressed back). */
const val MSG_EXIT_FULLSCREEN = 33
const val KEY_CAN_GO_FORWARD = "canGoForward"
const val KEY_FIND_QUERY = "findQuery"
const val KEY_FIND_FORWARD = "findForward"
const val KEY_FIND_ACTIVE = "findActive"
const val KEY_FIND_TOTAL = "findTotal"
const val KEY_ENABLED = "enabled"
const val KEY_TEXT_ZOOM = "textZoom"
const val KEY_PAGE_INFO = "pageInfo"
const val KEY_DIALOG_ID = "dialogId"
const val KEY_DIALOG_TYPE = "dialogType"
const val KEY_DIALOG_MESSAGE = "dialogMessage"
const val KEY_DIALOG_DEFAULT = "dialogDefault"
const val KEY_DIALOG_OFFER_BLOCK = "dialogOfferBlock"
const val KEY_DIALOG_CONFIRMED = "dialogConfirmed"
const val KEY_DIALOG_TEXT = "dialogText"
const val KEY_DIALOG_BLOCK = "dialogBlock"
const val KEY_PERMISSION_ID = "permissionId"
const val KEY_PERMISSIONS = "permissions"
const val KEY_BROWSER_ORIGIN = "browserOrigin"
const val KEY_FILE_CHOOSER_ID = "fileChooserId"
const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept"
const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple"
@@ -27,6 +27,7 @@ import android.content.Intent
import android.content.ServiceConnection
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -36,23 +37,34 @@ import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import android.view.View
import android.view.ViewGroup
import android.webkit.ConsoleMessage
import android.webkit.GeolocationPermissions
import android.webkit.JsPromptResult
import android.webkit.JsResult
import android.webkit.PermissionRequest
import android.webkit.RenderProcessGoneDetail
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import androidx.core.net.toUri
import androidx.privacysandbox.ui.provider.toCoreLibInfo
import androidx.webkit.JavaScriptReplyProxy
import androidx.webkit.ProxyConfig
import androidx.webkit.ProxyController
import androidx.webkit.WebMessageCompat
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.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
@@ -61,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.util.withString
import com.vitorpamplona.quartz.utils.Log
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.Executor
/**
* Provider for the **embedded** in-app browser. Runs in the keyless `:napplet` process: it hosts the
@@ -97,6 +110,20 @@ class NappletBrowserService : Service() {
val webViewProfile: String?,
) {
var webView: WebView? = null
// The session's root view (holds the WebView, and the page's fullscreen view when it has one).
var container: FrameLayout? = null
var customView: View? = null
var customViewCallback: WebChromeClient.CustomViewCallback? = null
// Page-originated JS dialogs and permission requests waiting on the main process's answer.
val jsDialogs = mutableMapOf<Long, JsResult>()
var jsDialogsOnPage = 0
var jsDialogsBlocked = false
val permissionRequests = mutableMapOf<Long, (Set<BrowserSitePermission>) -> Unit>()
var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM
var desktopSite = false
var bridgeReplyProxy: JavaScriptReplyProxy? = null
var fireSeq = 0
@@ -124,6 +151,9 @@ class NappletBrowserService : Service() {
private val tabs = mutableMapOf<String, BrowserTab>()
// WebView's PermissionRequest → our relay id, so a page's cancellation can withdraw the prompt.
private val pendingWebPermissions = mutableMapOf<PermissionRequest, Long>()
// 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() }
@@ -157,6 +187,7 @@ class NappletBrowserService : Service() {
}
tabs.values.forEach {
it.fileChooser.cancel()
cancelPending(it)
it.webView?.destroy()
}
tabs.clear()
@@ -177,7 +208,7 @@ class NappletBrowserService : Service() {
url = data.getString(NappletBrowserContract.KEY_URL)?.ifBlank { ABOUT_BLANK } ?: ABOUT_BLANK,
proxyPort = data.getInt(NappletBrowserContract.KEY_PROXY_PORT, -1),
useTor = data.getBoolean(NappletBrowserContract.KEY_USE_TOR, false),
bgColor = data.getInt(NappletBrowserContract.KEY_BG_COLOR, android.graphics.Color.WHITE),
bgColor = data.getInt(NappletBrowserContract.KEY_BG_COLOR, Color.WHITE),
themeType = data.getString(NappletBrowserContract.KEY_THEME).orEmpty().ifBlank { "SYSTEM" },
webViewProfile = data.getString(NappletBrowserContract.KEY_WEBVIEW_PROFILE),
)
@@ -189,7 +220,77 @@ class NappletBrowserService : Service() {
}
replyWithAdapter(tab)
}
NappletBrowserContract.MSG_NAVIGATE -> tabFor(msg)?.webView?.loadUrl(normalizeUrl(msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()))
NappletBrowserContract.MSG_NAVIGATE -> {
val tab = tabFor(msg) ?: return true
val url = normalizeUrl(msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty())
// A renderer crash destroyed this tab's WebView; the user's retry builds a fresh one.
if (tab.webView == null) rebuildWebView(tab, url) else tab.webView?.loadUrl(url)
}
NappletBrowserContract.MSG_FORWARD -> tabFor(msg)?.webView?.let { if (it.canGoForward()) it.goForward() }
NappletBrowserContract.MSG_STOP -> tabFor(msg)?.webView?.stopLoading()
NappletBrowserContract.MSG_FIND -> {
val tab = tabFor(msg) ?: return true
val wv = tab.webView ?: return true
val query = msg.data?.getString(NappletBrowserContract.KEY_FIND_QUERY).orEmpty()
if (query.isEmpty()) {
wv.clearMatches()
wv.setFindListener(null)
} else {
wv.setFindListener { active, total, _ -> pushFindResult(tab, active, total) }
wv.findAllAsync(query)
}
}
NappletBrowserContract.MSG_FIND_NEXT -> tabFor(msg)?.webView?.findNext(msg.data?.getBoolean(NappletBrowserContract.KEY_FIND_FORWARD, true) ?: true)
NappletBrowserContract.MSG_SET_DESKTOP -> {
val tab = tabFor(msg) ?: return true
tab.desktopSite = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false
tab.webView?.let { BrowserWebTools.setDesktopMode(it, tab.desktopSite) }
}
NappletBrowserContract.MSG_SET_TEXT_ZOOM -> {
val tab = tabFor(msg) ?: return true
tab.textZoom = msg.data?.getInt(NappletBrowserContract.KEY_TEXT_ZOOM, BrowserChrome.DEFAULT_TEXT_ZOOM) ?: BrowserChrome.DEFAULT_TEXT_ZOOM
tab.webView?.let { BrowserWebTools.setTextZoom(it, tab.textZoom) }
}
NappletBrowserContract.MSG_BACK_TO_SCOPE -> {
val tab = tabFor(msg) ?: return true
tab.webView?.let { BrowserWebTools.backToScope(it, msg.data?.getString(NappletBrowserContract.KEY_URL) ?: tab.url) }
}
NappletBrowserContract.MSG_CLEAR_SITE_DATA -> {
val tab = tabFor(msg) ?: return true
tab.webView?.let { wv -> wv.url?.let { BrowserWebTools.clearSiteData(this, wv, it) } }
}
NappletBrowserContract.MSG_PAGE_INFO_REQUEST -> {
val tab = tabFor(msg) ?: return true
val wv = tab.webView ?: return true
sendToClient(tab, NappletBrowserContract.MSG_PAGE_INFO) {
putString(NappletBrowserContract.KEY_PAGE_INFO, BrowserWebTools.pageInfo(this@NappletBrowserService, wv, if (tab.proxyPort > 0) tab.useTor else null))
}
}
NappletBrowserContract.MSG_JS_DIALOG_RESULT -> {
val tab = tabFor(msg) ?: return true
val data = msg.data ?: return true
val result = tab.jsDialogs.remove(data.getLong(NappletBrowserContract.KEY_DIALOG_ID)) ?: return true
if (data.getBoolean(NappletBrowserContract.KEY_DIALOG_BLOCK, false)) tab.jsDialogsBlocked = true
val confirmed = data.getBoolean(NappletBrowserContract.KEY_DIALOG_CONFIRMED, false)
when {
!confirmed -> result.cancel()
result is JsPromptResult -> result.confirm(data.getString(NappletBrowserContract.KEY_DIALOG_TEXT).orEmpty())
else -> result.confirm()
}
}
NappletBrowserContract.MSG_PERMISSION_RESULT -> {
val tab = tabFor(msg) ?: return true
val data = msg.data ?: return true
val answer = tab.permissionRequests.remove(data.getLong(NappletBrowserContract.KEY_PERMISSION_ID)) ?: return true
answer(
data
.getStringArray(NappletBrowserContract.KEY_PERMISSIONS)
.orEmpty()
.mapNotNull(BrowserSitePermission::fromKey)
.toSet(),
)
}
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() }
NappletBrowserContract.MSG_IME_OP -> {
@@ -285,10 +386,20 @@ class NappletBrowserService : Service() {
fun createBrowserWebView(
context: Context,
sessionId: String,
container: FrameLayout,
): WebView {
// The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather
// than build a WebView that no tab tracks (it would leak).
val tab = tabs[sessionId] ?: error("No browser tab for session $sessionId")
tab.container = container
return buildTabWebView(context, tab).also { it.loadUrl(tab.url) }
}
/** Builds [tab]'s WebView with every client, bridge and script wired, without loading anything. */
private fun buildTabWebView(
context: Context,
tab: BrowserTab,
): WebView {
val wv = WebView(nightThemedContext(context, tab.themeType))
// FIRST touch after construction: setProfile throws once the WebView has loaded content (or its
// profile has otherwise been used), so the storage partition must be chosen before the
@@ -302,58 +413,182 @@ class NappletBrowserService : Service() {
WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf("*")) { view, message, sourceOrigin, isMainFrame, replyProxy ->
onBridgeMessage(tab, view, message, sourceOrigin, isMainFrame, replyProxy)
}
// __nappletImeProxy: this is the EMBEDDED surface (no native keyboard), so install the IME agent
// that relays the focused field to the host's keyboard. The full-screen browser activity sets the
// direct bridge but NOT this flag (it has a real WebView window with a native keyboard).
val startScript = "if (window.top === window) { window.__nappletDirectBridge = true; window.__nappletNip07 = true; window.__nappletImeProxy = true; }\n$shimJs"
WebViewCompat.addDocumentStartJavaScript(wv, startScript, setOf("*"))
// imeProxy: this is the EMBEDDED surface (no native keyboard), so install the IME agent that relays
// the focused field to the host's keyboard. The full-screen browser activity sets the direct bridge
// but NOT this flag (it has a real WebView window with a native keyboard).
WebViewCompat.addDocumentStartJavaScript(wv, BrowserWebTools.browserStartScript(shimJs, imeProxy = true), setOf("*"))
BrowserWebTools.setTextZoom(wv, tab.textZoom)
if (tab.desktopSite) BrowserWebTools.setDesktopMode(wv, true)
tab.webView = wv
wv.loadUrl(tab.url)
return wv
}
/** After a renderer crash: a fresh WebView in the same surface, loading [url]. */
private fun rebuildWebView(
tab: BrowserTab,
url: String,
) {
val container = tab.container ?: return
val wv = buildTabWebView(container.context, tab)
container.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
wv.loadUrl(url)
}
/** A session closed: drop the tab and destroy its own WebView (never a sibling's). */
fun onSessionClosed(sessionId: String) {
val tab = tabs.remove(sessionId) ?: return
tab.bridgeReplyProxy = null
// Release a picker still waiting on this surface before its WebView goes away.
tab.fileChooser.cancel()
cancelPending(tab)
tab.customViewCallback?.onCustomViewHidden()
tab.customViewCallback = null
tab.customView = null
tab.container = null
tab.webView?.destroy()
tab.webView = null
}
@Suppress("SetJavaScriptEnabled")
private fun configureWebView(
wv: WebView,
tab: BrowserTab?,
) {
wv.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
@Suppress("DEPRECATION")
databaseEnabled = false
allowFileAccess = false
allowContentAccess = false
@Suppress("DEPRECATION")
allowFileAccessFromFileURLs = false
@Suppress("DEPRECATION")
allowUniversalAccessFromFileURLs = false
javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(false)
setGeolocationEnabled(false)
mediaPlaybackRequiresUserGesture = true
builtInZoomControls = true
displayZoomControls = false
loadWithOverviewMode = true
useWideViewPort = true
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) {
safeBrowsingEnabled = true
}
}
WebView.setWebContentsDebuggingEnabled(false)
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)
}
}
/** Answers everything [tab] still has outstanding, so no page stays blocked on a torn-down surface. */
private fun cancelPending(tab: BrowserTab) {
tab.jsDialogs.values.forEach { it.cancel() }
tab.jsDialogs.clear()
pendingWebPermissions.values.removeAll(tab.permissionRequests.keys)
tab.permissionRequests.values.forEach { it(emptySet()) }
tab.permissionRequests.clear()
}
private inline fun sendToClient(
tab: BrowserTab,
what: Int,
crossinline block: Bundle.() -> Unit,
): Boolean {
val client = tab.clientMessenger ?: return false
val message = Message.obtain(null, what).apply { data = Bundle().apply(block) }
return runCatching { client.send(message) }.isSuccess
}
private fun pushFindResult(
tab: BrowserTab,
active: Int,
total: Int,
) {
sendToClient(tab, NappletBrowserContract.MSG_FIND_RESULT) {
putInt(NappletBrowserContract.KEY_FIND_ACTIVE, active)
putInt(NappletBrowserContract.KEY_FIND_TOTAL, total)
}
}
private var dialogSeq = 0L
/**
* Relays a page's JS dialog to the main process, which draws it over the tab (this provider has no
* window). Answers at once when dialogs are blocked for this page or no client can show one.
*/
private fun relayJsDialog(
tab: BrowserTab?,
type: String,
url: String?,
message: String?,
defaultValue: String?,
result: JsResult,
): Boolean {
if (tab == null) {
result.cancel()
return true
}
if (tab.jsDialogsBlocked) {
// A blocked page may no longer hold the user on it: leaving is allowed, everything else cancels.
if (type == "beforeunload") result.confirm() else result.cancel()
return true
}
val id = ++dialogSeq
tab.jsDialogs[id] = result
tab.jsDialogsOnPage++
val sent =
sendToClient(tab, NappletBrowserContract.MSG_JS_DIALOG) {
putLong(NappletBrowserContract.KEY_DIALOG_ID, id)
putString(NappletBrowserContract.KEY_DIALOG_TYPE, type)
putString(NappletBrowserContract.KEY_URL, url)
putString(NappletBrowserContract.KEY_DIALOG_MESSAGE, message)
putString(NappletBrowserContract.KEY_DIALOG_DEFAULT, defaultValue)
putBoolean(NappletBrowserContract.KEY_DIALOG_OFFER_BLOCK, tab.jsDialogsOnPage > 1)
}
if (!sent) tab.jsDialogs.remove(id)?.cancel()
return true
}
private var permissionSeq = 0L
/** Relays a camera / microphone / location request to the main process, which owns the prompt. */
private fun relayPermissionRequest(
tab: BrowserTab?,
origin: String?,
wanted: Set<BrowserSitePermission>,
answer: (Set<BrowserSitePermission>) -> Unit,
): Long? {
if (tab == null || origin == null || wanted.isEmpty()) {
answer(emptySet())
return null
}
val id = ++permissionSeq
tab.permissionRequests[id] = answer
val sent =
sendToClient(tab, NappletBrowserContract.MSG_PERMISSION_REQUEST) {
putLong(NappletBrowserContract.KEY_PERMISSION_ID, id)
putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin)
putStringArray(NappletBrowserContract.KEY_PERMISSIONS, wanted.map { it.key }.toTypedArray())
}
if (!sent) tab.permissionRequests.remove(id)?.invoke(emptySet())
return id
}
private fun sitePermissionFor(resource: String): BrowserSitePermission? =
when (resource) {
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> BrowserSitePermission.CAMERA
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> BrowserSitePermission.MICROPHONE
else -> null
}
/** HTML fullscreen inside the surface: the page's view covers the tab; back (from the client) leaves it. */
private fun enterFullscreen(
tab: BrowserTab?,
view: View,
callback: WebChromeClient.CustomViewCallback,
) {
val container = tab?.container
if (tab == null || container == null || tab.customView != null) {
callback.onCustomViewHidden()
return
}
tab.customView = view
tab.customViewCallback = callback
view.setBackgroundColor(Color.BLACK)
container.addView(view, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
sendToClient(tab, NappletBrowserContract.MSG_FULLSCREEN) { putBoolean(NappletBrowserContract.KEY_ENABLED, true) }
}
private fun exitFullscreen(tab: BrowserTab) {
val view = tab.customView ?: return
tab.customView = null
tab.container?.removeView(view)
val callback = tab.customViewCallback
tab.customViewCallback = null
callback?.onCustomViewHidden()
sendToClient(tab, NappletBrowserContract.MSG_FULLSCREEN) { putBoolean(NappletBrowserContract.KEY_ENABLED, false) }
}
private inner class BrowserChromeClient(
@@ -392,6 +627,91 @@ class NappletBrowserService : Service() {
title: String?,
) = pushUrl(tab, view)
// This WebView is built from a Service context, so the framework can't show JS dialogs itself
// (it needs an Activity); the main process draws them over the tab instead.
override fun onJsAlert(
view: WebView,
url: String?,
message: String?,
result: JsResult,
): Boolean = relayJsDialog(tab, "alert", url, message, null, result)
override fun onJsConfirm(
view: WebView,
url: String?,
message: String?,
result: JsResult,
): Boolean = relayJsDialog(tab, "confirm", url, message, null, result)
override fun onJsPrompt(
view: WebView,
url: String?,
message: String?,
defaultValue: String?,
result: JsPromptResult,
): Boolean = relayJsDialog(tab, "prompt", url, message, defaultValue, result)
override fun onJsBeforeUnload(
view: WebView,
url: String?,
message: String?,
result: JsResult,
): Boolean = relayJsDialog(tab, "beforeunload", url, message, null, result)
/** `_blank` / user-initiated `window.open()`: a new full-screen browser window, `opener` intact. */
override fun onCreateWindow(
view: WebView,
isDialog: Boolean,
isUserGesture: Boolean,
resultMsg: Message,
): Boolean {
val tab = tab ?: return false
if (!isUserGesture) return false
val transport = resultMsg.obj as? WebView.WebViewTransport ?: return false
val (token, child) = BrowserPopups.create(this@NappletBrowserService, shimJs, tab.proxyPort, tab.useTor, tab.themeType, tab.webViewProfile)
transport.webView = child
resultMsg.sendToTarget()
runCatching { startActivity(NappletBrowserActivity.popupIntent(this@NappletBrowserService, token)) }
.onFailure { Log.w(TAG, "Could not open the new window", it) }
return true
}
override fun onPermissionRequest(request: PermissionRequest) {
val wanted = request.resources.mapNotNull(::sitePermissionFor).toSet()
val id =
relayPermissionRequest(tab, BrowserChrome.originOf(request.origin.toString()), wanted) { granted ->
val resources = request.resources.filter { sitePermissionFor(it) in granted }.toTypedArray()
if (resources.isEmpty()) request.deny() else request.grant(resources)
}
if (id != null) pendingWebPermissions[request] = id
}
override fun onPermissionRequestCanceled(request: PermissionRequest) {
val id = pendingWebPermissions.remove(request) ?: return
val tab = tab ?: return
tab.permissionRequests.remove(id)
sendToClient(tab, NappletBrowserContract.MSG_PERMISSION_CANCEL) { putLong(NappletBrowserContract.KEY_PERMISSION_ID, id) }
}
override fun onGeolocationPermissionsShowPrompt(
origin: String,
callback: GeolocationPermissions.Callback,
) {
relayPermissionRequest(tab, BrowserChrome.originOf(origin), setOf(BrowserSitePermission.LOCATION)) { granted ->
// Never let WebView remember it: the answer lives in the main-process registry.
callback.invoke(origin, BrowserSitePermission.LOCATION in granted, false)
}
}
override fun onShowCustomView(
view: View,
callback: CustomViewCallback,
) = enterFullscreen(tab, view, callback)
override fun onHideCustomView() {
tab?.let { exitFullscreen(it) }
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
if (tab == null) return false
pushConsoleLog(
@@ -468,19 +788,18 @@ class NappletBrowserService : Service() {
val uri = request.url
val scheme = uri.scheme?.lowercase()
if (scheme == "http" || scheme == "https") return false
if (request.hasGesture()) {
runCatching { startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
return true
return BrowserWebTools.openExternal(this@NappletBrowserService, uri, request.hasGesture()) { view.loadUrl(it) }
}
override fun onPageStarted(
view: WebView,
url: String,
favicon: android.graphics.Bitmap?,
favicon: Bitmap?,
) {
// A new main-frame navigation cleared any prior error.
// A new main-frame navigation cleared any prior error, and lifts "block this page's dialogs".
tab?.loadFailed = false
tab?.jsDialogsOnPage = 0
tab?.jsDialogsBlocked = false
// Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send.
if (tab != null && OmniboxInput.hostOf(url) != tab.lastIconHost) tab.lastIconHost = null
// view.title still names the page being left; the new one's arrives via onReceivedTitle.
@@ -514,6 +833,35 @@ class NappletBrowserService : Service() {
tab?.loadFailed = true
pushLoadState(tab, view, isLoading = false)
}
/**
* The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the
* whole process — every other tab included. Drop just this tab's WebView and report the load as
* failed; the tab's retry (MSG_NAVIGATE) builds a fresh WebView in the same surface.
*/
override fun onRenderProcessGone(
view: WebView,
detail: RenderProcessGoneDetail,
): Boolean {
Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}) for an embedded tab" }
(view.parent as? ViewGroup)?.removeView(view)
view.destroy()
val tab = tab ?: return true
if (tab.webView === view) {
tab.webView = null
tab.customView?.let { tab.container?.removeView(it) }
tab.customView = null
tab.customViewCallback = null
cancelPending(tab)
tab.loadFailed = true
sendToClient(tab, NappletBrowserContract.MSG_LOAD_STATE) {
putBoolean(NappletBrowserContract.KEY_IS_LOADING, false)
putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, true)
putString(NappletBrowserContract.KEY_URL, tab.url)
}
}
return true
}
}
/** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */
@@ -548,6 +896,7 @@ class NappletBrowserService : Service() {
Bundle().apply {
putString(NappletBrowserContract.KEY_URL, url)
putBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, view.canGoBack())
putBoolean(NappletBrowserContract.KEY_CAN_GO_FORWARD, view.canGoForward())
title?.let { putString(NappletBrowserContract.KEY_TITLE, it) }
}
}
@@ -568,21 +917,13 @@ class NappletBrowserService : Service() {
onApplied()
return
}
val executor = java.util.concurrent.Executor { it.run() }
val executor = Executor { it.run() }
runCatching {
if (port > 0) {
val config =
androidx.webkit.ProxyConfig
.Builder()
.addProxyRule("socks5://127.0.0.1:$port")
.build()
androidx.webkit.ProxyController
.getInstance()
.setProxyOverride(config, executor) { onApplied() }
val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build()
ProxyController.getInstance().setProxyOverride(config, executor) { onApplied() }
} else {
androidx.webkit.ProxyController
.getInstance()
.clearProxyOverride(executor) { onApplied() }
ProxyController.getInstance().clearProxyOverride(executor) { onApplied() }
}
}.onFailure {
Log.w(TAG, "Failed to apply WebView proxy override", it)
@@ -607,6 +948,23 @@ 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.
when (envelope.stringOrNull("type")) {
"browser.share" -> {
BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url"))
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"))
}
return
}
"browser.themeColor" -> return
}
// IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client.
if (envelope.stringOrNull("type").orEmpty().startsWith("ime.")) {
val reply =
@@ -29,7 +29,6 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
@@ -61,25 +60,29 @@ class NappletBrowserUiAdapter(
// WebView creation must run on the main thread; openSession is called on a binder thread.
mainHandler.post {
runCatching {
val webView = service.createBrowserWebView(context, sessionId)
// The session's view is a container around the WebView, so HTML fullscreen can lay the
// page's custom view over it and a crashed renderer's WebView can be swapped for a new one.
val container = FrameLayout(context)
val webView = service.createBrowserWebView(context, sessionId, container)
container.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
// FrameLayout.LayoutParams (a MarginLayoutParams) — the SurfaceControlViewHost container
// measures children with measureChildWithMargins, which casts to MarginLayoutParams.
webView.layoutParams = FrameLayout.LayoutParams(initialWidth, initialHeight)
BrowserSession(sessionId, webView, service)
container.layoutParams = FrameLayout.LayoutParams(initialWidth, initialHeight)
BrowserSession(sessionId, container, service)
}.onSuccess { session -> clientExecutor.execute { client.onSessionOpened(session) } }
.onFailure { t -> clientExecutor.execute { client.onSessionError(t) } }
}
}
}
/** A single embedded browser session: the WebView is the rendered view; close tears it down. */
/** A single embedded browser session: the WebView's container is the rendered view; close tears it down. */
@RequiresApi(Build.VERSION_CODES.R)
private class BrowserSession(
private val sessionId: String,
private val webView: WebView,
private val container: FrameLayout,
private val service: NappletBrowserService,
) : SandboxedUiAdapter.Session {
override val view: View get() = webView
override val view: View get() = container
override val signalOptions: Set<String> = emptySet()
@@ -91,8 +94,8 @@ private class BrowserSession(
width: Int,
height: Int,
) {
webView.layoutParams = FrameLayout.LayoutParams(width, height)
webView.requestLayout()
container.layoutParams = FrameLayout.LayoutParams(width, height)
container.requestLayout()
}
override fun notifyZOrderChanged(isZOrderOnTop: Boolean) {
@@ -32,236 +32,275 @@ import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.Switch
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.SectionKind
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* The full-screen sandbox surfaces' **top pull-down sheet** — the native-View twin of the embedded
* tabs' Compose `TopControlSheet`. Collapsed it's just a small grabber centered at the very top edge,
* out of the corner where a site puts its own avatar/menu. Pull it down (or tap) to reveal the page's
* controls: route over Tor, reload, and "what it can access" (sandboxed apps).
* The full-screen surfaces' **top pull-down pill** — the plain-View twin of the embedded tabs' Compose
* `TopControlSheet`. Collapsed it's a small grabber centered at the top edge, out of the corner where a site
* puts its own avatar/menu. Pulled down (or tapped) it shows, like a Chrome PWA's app menu:
*
* Built in code (no XML) because `:nappletHost` hosts plain Android `View`s, not Compose, and must stay
* dependency-light. Add it to a `FrameLayout` parent at `Gravity.TOP` filling the width; it manages its
* own expand/collapse.
* - a header with the page title, its origin + connection badge (tap: page info; long-press: copy link),
* and a close button;
* - the icon row (back · forward · reload/stop · star · share);
* - the menu rows, then Privacy and Developer groups.
*
* *Which* actions appear, and in what order, comes from [BrowserChrome] — the same source the Compose sheet
* uses — and each action's icon/label from [BrowserChromeLabels]. This class only draws them. The page
* can't draw over it. Built in code (no XML, no Compose/Material) because `:nappletHost` stays light.
*/
@SuppressLint("UseSwitchCompatOrMaterialCode") // plain framework Switch: :nappletHost is Compose/Material-free
@SuppressLint("UseSwitchCompatOrMaterialCode", "ViewConstructor")
class NappletControlSheet(
context: Context,
initialState: BrowserChrome.State,
title: String,
private val isSandbox: Boolean,
private val onReload: () -> Unit,
torInitiallyOn: Boolean?,
private val onToggleTor: (Boolean) -> Unit = {},
// When non-null, the Tor row taps through to this (e.g. a confirm dialog that relaunches) instead of
// toggling inline — used by the nSite host, where switching routing rebuilds the whole session.
private val onNetworkTap: (() -> Unit)? = null,
private val onInfo: (() -> Unit)? = null,
// When non-null, a "Manage permissions" row is added that taps through to this — used to open the
// main process's editable Connected Apps detail screen for this surface.
private val onPermissions: (() -> Unit)? = null,
// The live URL of a plain-website browser. Non-null only for the direct-WebView browser (never an
// nsite/napplet), where it renders an editable address row; [onNavigate] loads what the user types.
liveUrl: String? = null,
private val onNavigate: ((String) -> Unit)? = null,
// When non-null, a "Console" toggle row is added to the pull-down sheet. The callback is invoked with
// the new visibility each time the user flips it; the count label is updated via [updateConsoleCount].
private val onConsole: ((Boolean) -> Unit)? = null,
// When non-null, a favorite toggle row is shown; called with the current URL and new isFavorite state.
private val listener: Listener,
isFavoriteInitially: Boolean = false,
private val onFavoriteToggle: ((url: String, isFavorite: Boolean) -> Unit)? = null,
/** Shows the header's close button (finishing the window). */
private val showClose: Boolean = true,
) : LinearLayout(context) {
/** What the sheet asks its host to do. Every row and icon ends up in [onAction]. */
interface Listener {
fun onAction(action: Action)
/** The user typed an address into "Edit address" and pressed Go. */
fun onNavigate(text: String) {}
/** The text-size stepper moved to [percent]. */
fun onTextZoom(percent: Int) {}
/** The origin chip was tapped: show page info (or the access summary for sandboxed apps). */
fun onOriginTap() {}
fun onClose() {}
}
private val onSurface = resolveThemeColor(android.R.attr.textColorPrimary)
private val dimmed = resolveThemeColor(android.R.attr.textColorSecondary)
private val surface = resolveThemeColor(android.R.attr.colorBackground)
private val accent = resolveThemeColor(android.R.attr.colorPrimary)
private val glyphs: Typeface = BrowserGlyphs.typeface(context)
var state: BrowserChrome.State = initialState
private set
private var title: String = title
private var isFavorite = isFavoriteInitially
private var desktopSite = false
private var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM
private var consoleShowing = false
private var consoleCount = 0
private var editingAddress = false
private var expanded = false
private var title = title
private var torOn = torInitiallyOn
private var currentUrl = liveUrl
private var isFavorite = isFavoriteInitially
private var consoleShowing = false
private val panel: LinearLayout
private var titleView: TextView? = null
private var grabber: View? = null
private var torLabel: TextView? = null
private var torSwitch: Switch? = null
private var addressField: EditText? = null
private var securityGlyph: TextView? = null
private var consoleLabel: TextView? = null
private var consoleSwitch: Switch? = null
private var favoriteLabel: TextView? = null
private val grabber: View
init {
orientation = VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
panel = buildPanel().also { addView(it) }
addView(buildGrabber().also { grabber = it })
panel =
LinearLayout(context).apply {
orientation = VERTICAL
visibility = View.GONE
elevation = dp(6).toFloat()
background =
GradientDrawable().apply {
cornerRadii = floatArrayOf(0f, 0f, 0f, 0f, dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat())
setColor(surface)
}
setPadding(dp(8), dp(6), dp(8), dp(10))
}
addView(panel)
grabber = buildGrabber()
addView(grabber)
}
private fun buildPanel(): LinearLayout =
LinearLayout(context).apply {
orientation = VERTICAL
visibility = View.GONE
elevation = dp(6).toFloat()
background =
GradientDrawable().apply {
cornerRadii = floatArrayOf(0f, 0f, 0f, 0f, dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat())
setColor(surface)
}
setPadding(dp(8), dp(6), dp(8), dp(10))
// ---- state updates from the host ----
addView(titleRow())
// Browser only: an editable address bar showing the live URL + a security glyph. nsite/napplet
// hosts pass no navigate callback, so they never get one.
onNavigate?.let { addView(addressRow(currentUrl.orEmpty(), it)) }
addView(divider())
if (torOn != null) addView(torRow())
addView(
actionRow("↻", context.getString(R.string.napplet_chrome_reload)) {
collapse()
onReload()
},
)
onInfo?.let { info ->
addView(
actionRow("ⓘ", context.getString(R.string.napplet_chrome_permissions_desc)) {
collapse()
info()
},
)
}
onPermissions?.let { manage ->
addView(
actionRow("⚙", context.getString(R.string.napplet_chrome_manage_permissions)) {
collapse()
manage()
},
)
}
onConsole?.let {
val label =
TextView(context).apply {
text = context.getString(CommonsR.string.browser_console_title_short)
setTextColor(onSurface)
textSize = 15f
setPadding(dp(8), 0, 0, 0)
// Weight 1 so the label fills and shoves the Switch to the end, like the Tor row.
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
}
consoleLabel = label
// Display-only switch (the whole row is the touch target), matching the Tor row + Compose twin.
val toggle =
Switch(context).apply {
isChecked = consoleShowing
isClickable = false
isFocusable = false
}
consoleSwitch = toggle
addView(
LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(8), dp(10), dp(8), dp(10))
isClickable = true
setOnClickListener { toggleConsole() }
addView(
TextView(context).apply {
text = ">"
setTextColor(dimmed)
textSize = 18f
width = dp(28)
gravity = Gravity.CENTER
typeface = Typeface.MONOSPACE
},
)
addView(label)
addView(toggle)
},
)
}
onFavoriteToggle?.let {
val label =
TextView(context).apply {
text = context.getString(if (isFavorite) R.string.browser_favorite_remove else R.string.browser_favorite_add)
setTextColor(onSurface)
textSize = 15f
setPadding(dp(8), 0, 0, 0)
}
favoriteLabel = label
addView(
LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(8), dp(10), dp(8), dp(10))
isClickable = true
setOnClickListener { toggleFavorite() }
addView(
TextView(context).apply {
text = "★"
setTextColor(dimmed)
textSize = 18f
width = dp(28)
gravity = Gravity.CENTER
},
)
addView(label)
},
)
/**
* The page navigated. Moving to another site swaps the title for that site's host until its page title
* arrives ([updateTitle]), and marks the pin state unknown until the host answers ([setFavorite]).
*/
fun updateUrl(url: String) {
val previous = state.url
if (url == previous) return
state = state.copy(url = url)
if (BrowserChrome.displayHost(url) != BrowserChrome.displayHost(previous)) title = BrowserChrome.displayHost(url)
// Unknown until the host answers; "Add" meanwhile. The toggle sends an explicit target state, so a
// tap in that window can only add (idempotent), never silently remove an existing pin.
isFavorite = false
refresh()
}
/** Shows the page's `<title>`, falling back to the host (WebView reports the URL for untitled pages). */
fun updateTitle(pageTitle: String?) {
val real = pageTitle?.trim()?.takeIf { it.isNotEmpty() && it != state.url }
title = real ?: BrowserChrome.displayHost(state.url)
refresh()
}
/** Applies the registry's answer for [url]; ignored once the user has moved on to another page. */
fun setFavorite(
url: String,
favorite: Boolean,
) {
if (url != state.url) return
isFavorite = favorite
refresh()
}
fun setNavigation(
canGoBack: Boolean,
canGoForward: Boolean,
) = update(state.copy(canGoBack = canGoBack, canGoForward = canGoForward))
fun setLoading(loading: Boolean) = update(state.copy(isLoading = loading))
fun setTor(on: Boolean) = update(state.copy(torOn = on))
fun setDesktopSite(on: Boolean) {
desktopSite = on
refresh()
}
fun setTextZoom(percent: Int) {
textZoom = percent
refresh()
}
fun setConsoleShowing(showing: Boolean) {
consoleShowing = showing
refresh()
}
fun updateConsoleCount(count: Int) {
consoleCount = count
refresh()
}
private fun update(next: BrowserChrome.State) {
if (next == state) return
state = next
refresh()
}
/** Rebuilds the open panel. Collapsed, nothing is drawn, so it waits for the next [expand]. */
private fun refresh() {
grabber.contentDescription = title
if (expanded) render()
}
// ---- drawing ----
private fun render() {
panel.removeAllViews()
panel.addView(header())
panel.addView(iconRow())
panel.addView(divider())
val sections =
LinearLayout(context).apply {
orientation = VERTICAL
BrowserChrome.sections(state).forEachIndexed { index, section ->
if (index > 0) addView(divider())
sectionTitle(section.kind)?.let { addView(sectionLabel(it)) }
section.actions.forEach { addView(row(it)) }
}
}
panel.addView(
MaxHeightScrollView(context, (resources.displayMetrics.heightPixels * 0.6f).toInt()).apply {
isVerticalScrollBarEnabled = false
addView(sections)
},
)
}
private fun sectionTitle(kind: SectionKind): String? =
when (kind) {
SectionKind.PAGE -> null
SectionKind.PRIVACY -> context.getString(CommonsR.string.browser_section_privacy)
SectionKind.DEVELOPER -> context.getString(CommonsR.string.browser_section_developer)
}
private fun titleRow(): View =
private fun header(): View =
LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(8), dp(8), dp(8), dp(8))
addView(
TextView(context).apply {
text = if (isSandbox) "🛡" else "🌐"
textSize = 16f
},
)
addView(
TextView(context)
.apply {
text = title
setTextColor(onSurface)
textSize = 16f
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(dp(10), 0, 0, 0)
}.also { titleView = it },
)
setPadding(dp(8), dp(6), dp(4), dp(6))
val security = BrowserChrome.security(state)
addView(iconView(BrowserChromeLabels.securitySymbol(security), BrowserChromeLabels.securityDrawable(security), dimmed, 20))
if (editingAddress) {
addView(addressField())
} else {
addView(
LinearLayout(context).apply {
orientation = VERTICAL
setPadding(dp(12), 0, dp(8), 0)
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
isClickable = true
setOnClickListener {
collapse()
listener.onOriginTap()
}
if (!state.isSandbox) {
setOnLongClickListener {
listener.onAction(Action.COPY_LINK)
true
}
}
addView(
TextView(context).apply {
text = title
setTextColor(onSurface)
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
},
)
addView(
TextView(context).apply {
text =
if (state.isSandbox) {
context.getString(BrowserChromeLabels.securityLabel(security))
} else {
BrowserChrome.displayHost(state.url) + " · " + context.getString(BrowserChromeLabels.securityLabel(security))
}
setTextColor(dimmed)
textSize = 13f
maxLines = 1
ellipsize = TextUtils.TruncateAt.MIDDLE
},
)
},
)
}
if (showClose) {
addView(
glyphButton(BrowserChromeLabels.symbolFor(Action.STOP)!!, context.getString(CommonsR.string.browser_action_close), onSurface) {
collapse()
listener.onClose()
},
)
}
}
/**
* The browser address bar: a security glyph (🧅 Tor / 🔒 https / 🌐 plain) + an editable URL field.
* Pressing Go hands the trimmed text to [onNavigate] (normalized by the caller) and collapses the sheet.
*/
private fun addressRow(
initial: String,
onNavigate: (String) -> Unit,
): View {
val glyph =
TextView(context).apply {
text = securityGlyphFor(initial)
textSize = 15f
width = dp(28)
gravity = Gravity.CENTER
}
securityGlyph = glyph
/** The rarely used editable address, swapped in for the origin chip by the "Edit address" row. */
private fun addressField(): View {
val field =
EditText(context).apply {
setText(initial)
setText(state.url)
setTextColor(onSurface)
setHintTextColor(dimmed)
hint = context.getString(CommonsR.string.browser_address_hint)
@@ -272,208 +311,232 @@ class NappletControlSheet(
background = null
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
imeOptions = EditorInfo.IME_ACTION_GO
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f).apply { marginStart = dp(8) }
setOnEditorActionListener { v, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_GO) {
val text =
v.text
?.toString()
?.trim()
.orEmpty()
if (text.isNotEmpty()) {
clearFocus()
collapse()
onNavigate(text)
}
true
} else {
false
if (actionId != EditorInfo.IME_ACTION_GO) return@setOnEditorActionListener false
val text =
v.text
?.toString()
?.trim()
.orEmpty()
if (text.isNotEmpty()) {
hideKeyboard(v)
editingAddress = false
collapse()
listener.onNavigate(text)
}
true
}
}
addressField = field
return LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(8), dp(8), dp(8), dp(8))
addView(glyph)
addView(field)
field.post {
field.requestFocus()
context.getSystemService(InputMethodManager::class.java)?.showSoftInput(field, 0)
}
return field
}
/** Updates the count shown in the Console row label so the user sees how many messages are waiting. */
fun updateConsoleCount(count: Int) {
consoleLabel?.text =
if (count > 0) {
context.getString(CommonsR.string.browser_console_title, count)
} else {
context.getString(CommonsR.string.browser_console_title_short)
}
}
/**
* Refreshes the address bar + security glyph as the page navigates. No-op without an address row.
* Moving to another site also swaps the title for that site's host until its page title arrives
* ([updateTitle]), so the sheet never names the site the user already left.
*/
fun updateUrl(url: String) {
val previous = currentUrl
currentUrl = url
// Don't fight the user while they're editing the field.
addressField?.takeIf { !it.hasFocus() }?.setText(url)
securityGlyph?.text = securityGlyphFor(url)
if (hostOf(url) != previous?.let(::hostOf)) setTitleText(hostOf(url) ?: url)
// A different page: its pin state is unknown until the host answers through [setFavorite]. Show
// "Add" meanwhile — the toggle sends the explicit target state, so even a tap in that window can
// only ever add (idempotent), never silently remove an existing pin.
if (url != previous && onFavoriteToggle != null) applyFavorite(false)
}
/**
* Shows the page's own `<title>`, falling back to the current host when the page has none (WebView
* reports the URL itself as the title of an untitled document).
*/
fun updateTitle(pageTitle: String?) {
val real = pageTitle?.trim()?.takeIf { it.isNotEmpty() && it != currentUrl }
setTitleText(real ?: currentUrl?.let(::hostOf) ?: return)
}
/** Applies the registry's answer for [url]; ignored when the user has already moved to another page. */
fun setFavorite(
url: String,
favorite: Boolean,
) {
if (url == currentUrl) applyFavorite(favorite)
}
private fun applyFavorite(favorite: Boolean) {
isFavorite = favorite
favoriteLabel?.text = context.getString(if (favorite) R.string.browser_favorite_remove else R.string.browser_favorite_add)
}
private fun setTitleText(text: String) {
title = text
titleView?.text = text
grabber?.contentDescription = text
}
private fun hostOf(url: String): String? = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() }
private fun toggleFavorite() {
val url = currentUrl?.takeIf { it.isNotBlank() } ?: return
applyFavorite(!isFavorite)
collapse()
onFavoriteToggle?.invoke(url, isFavorite)
}
private fun securityGlyphFor(url: String): String =
when {
torOn == true -> "🧅" // 🧅 routed over Tor
url.startsWith("https://", ignoreCase = true) -> "🔒" // 🔒 secure
else -> "🌐" // 🌐 plain http
}
private fun torRow(): View {
// Steady, muted icon (the Switch carries the on/off state) — matches the Compose twin, where the
// lock icon is a constant onSurfaceVariant tint and the Switch is the state indicator.
val icon =
ImageView(context).apply {
setImageResource(R.drawable.ic_tor)
setColorFilter(dimmed)
layoutParams = LayoutParams(dp(22), dp(22))
}
val label =
TextView(context).apply {
text = context.getString(if (torOn == true) R.string.napplet_net_tor_label else R.string.napplet_net_open_label)
setTextColor(onSurface)
textSize = 15f
setPadding(dp(14), 0, 0, 0)
// Weight 1 so the label fills and shoves the Switch to the end, like the Compose row.
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
}
// Display-only: the whole row is the touch target (parity with the Compose row, whose Switch and
// row both route to the same onToggle), so the Switch itself doesn't take clicks.
val toggle =
Switch(context).apply {
isChecked = torOn == true
isClickable = false
isFocusable = false
}
torLabel = label
torSwitch = toggle
return LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(8), dp(10), dp(8), dp(10))
isClickable = true
setOnClickListener {
if (onNetworkTap != null) {
collapse()
onNetworkTap.invoke()
} else {
toggleTor()
}
}
addView(icon)
addView(label)
addView(toggle)
}
}
private fun toggleTor() {
val next = !(torOn ?: return)
torOn = next
torSwitch?.isChecked = next
torLabel?.text = context.getString(if (next) R.string.napplet_net_tor_label else R.string.napplet_net_open_label)
securityGlyph?.text = securityGlyphFor(currentUrl.orEmpty())
onToggleTor(next)
}
private fun toggleConsole() {
consoleShowing = !consoleShowing
consoleSwitch?.isChecked = consoleShowing
// Collapse the top sheet on toggle, like the Compose twin, so the bottom console isn't hidden behind it.
collapse()
onConsole?.invoke(consoleShowing)
}
private fun actionRow(
glyph: String,
label: String,
onClick: () -> Unit,
): View =
private fun iconRow(): View =
LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
// Same vertical rhythm as the Tor row and the Compose twin's rows.
setPadding(0, dp(2), 0, dp(4))
BrowserChrome.iconRow(state).forEach { action ->
val enabled = BrowserChrome.isEnabled(state, action)
val tint = if (action == Action.FAVORITE && isFavorite) accent else onSurface
val button =
glyphButton(
BrowserChromeLabels.symbolFor(action)!!,
context.getString(BrowserChromeLabels.labelFor(action, isFavorite = isFavorite)),
tint,
) {
collapse()
onRowAction(action)
}
if (action == Action.FAVORITE && isFavorite) button.typeface = BrowserGlyphs.filledTypeface(context)
button.isEnabled = enabled
button.alpha = if (enabled) 1f else 0.35f
button.layoutParams = LayoutParams(0, dp(48), 1f)
addView(button)
}
}
private fun row(action: Action): View {
val label =
when (action) {
Action.BACK_TO_APP -> context.getString(CommonsR.string.browser_action_back_to_app, BrowserChrome.displayHost(state.startUrl))
else -> context.getString(BrowserChromeLabels.labelFor(action, isFavorite = isFavorite, torOn = state.torOn == true))
}
val consoleLabel = if (action == Action.CONSOLE && consoleCount > 0) context.getString(CommonsR.string.browser_console_title, consoleCount) else label
return LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(8), dp(10), dp(8), dp(10))
isClickable = true
setOnClickListener { onClick() }
addView(iconView(BrowserChromeLabels.symbolFor(action), BrowserChromeLabels.drawableFor(action), dimmed, 22))
addView(
TextView(context).apply {
text = glyph
setTextColor(dimmed)
textSize = 18f
width = dp(28)
gravity = Gravity.CENTER
},
)
addView(
TextView(context).apply {
text = label
text = consoleLabel
setTextColor(onSurface)
textSize = 15f
setPadding(dp(8), 0, 0, 0)
setPadding(dp(14), 0, 0, 0)
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
},
)
if (action == Action.TEXT_SIZE) {
addView(textSizeStepper())
} else {
isClickable = true
setOnClickListener {
if (!BrowserChromeLabels.keepsSheetOpen(action)) collapse()
onRowAction(action)
}
if (BrowserChromeLabels.isToggle(action)) {
// Display-only: the whole row is the touch target, as in the Compose twin.
addView(
Switch(context).apply {
isChecked =
when (action) {
Action.TOR -> state.torOn == true
Action.DESKTOP_SITE -> desktopSite
else -> consoleShowing
}
isClickable = false
isFocusable = false
},
)
}
}
}
}
private fun textSizeStepper(): View =
LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
addView(
glyphButton(MaterialSymbols.Remove, context.getString(CommonsR.string.browser_action_text_smaller), onSurface) {
textZoom = BrowserChrome.stepTextZoom(textZoom, larger = false)
listener.onTextZoom(textZoom)
refresh()
},
)
addView(
TextView(context).apply {
text = context.getString(CommonsR.string.browser_action_text_size_value, textZoom)
setTextColor(onSurface)
textSize = 14f
gravity = Gravity.CENTER
minWidth = dp(48)
},
)
addView(
glyphButton(MaterialSymbols.Add, context.getString(CommonsR.string.browser_action_text_larger), onSurface) {
textZoom = BrowserChrome.stepTextZoom(textZoom, larger = true)
listener.onTextZoom(textZoom)
refresh()
},
)
}
private fun onRowAction(action: Action) {
when (action) {
Action.EDIT_ADDRESS -> {
editingAddress = true
refresh()
}
Action.FAVORITE -> {
isFavorite = !isFavorite
listener.onAction(action)
}
Action.DESKTOP_SITE -> {
desktopSite = !desktopSite
listener.onAction(action)
}
Action.CONSOLE -> {
consoleShowing = !consoleShowing
listener.onAction(action)
}
else -> listener.onAction(action)
}
}
/** The favorite state the user is asking for (read by the host right after a FAVORITE action). */
fun wantsFavorite(): Boolean = isFavorite
private fun sectionLabel(text: String): View =
TextView(context).apply {
this.text = text
setTextColor(dimmed)
textSize = 12f
isAllCaps = true
letterSpacing = 0.06f
setPadding(dp(8), dp(10), dp(8), dp(2))
}
private fun iconView(
symbol: MaterialSymbol?,
drawable: Int?,
tint: Int,
sizeDp: Int,
): View =
if (drawable != null) {
ImageView(context).apply {
setImageResource(drawable)
setColorFilter(tint)
layoutParams = LayoutParams(dp(sizeDp), dp(sizeDp))
}
} else {
glyphText(symbol?.glyph.orEmpty(), tint, sizeDp).apply { layoutParams = LayoutParams(dp(sizeDp + 4), LayoutParams.WRAP_CONTENT) }
}
private fun glyphText(
glyph: String,
tint: Int,
sizeDp: Int,
): TextView =
TextView(context).apply {
text = glyph
typeface = glyphs
setTextColor(tint)
setTextSize(TypedValue.COMPLEX_UNIT_DIP, sizeDp.toFloat())
gravity = Gravity.CENTER
includeFontPadding = false
}
private fun glyphButton(
symbol: MaterialSymbol,
description: String,
tint: Int,
onClick: () -> Unit,
): TextView =
glyphText(symbol.glyph, tint, 22).apply {
contentDescription = description
tooltipText = description
minWidth = dp(44)
minHeight = dp(44)
isClickable = true
isFocusable = true
background = selectableBackground()
// Mirrored glyphs (back/forward) flip for right-to-left layouts, as Compose's autoMirror does.
if (symbol.autoMirror && resources.configuration.layoutDirection == LAYOUT_DIRECTION_RTL) scaleX = -1f
setOnClickListener { onClick() }
}
private fun selectableBackground() =
TypedValue().let { tv ->
context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, tv, true)
ContextCompat.getDrawable(context, tv.resourceId)
}
private fun divider(): View =
View(context).apply {
setBackgroundColor(dimmed and 0x33FFFFFF.toInt())
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(1))
setBackgroundColor(dimmed and 0x33FFFFFF)
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(1)).apply { setMargins(0, dp(2), 0, dp(2)) }
}
private fun hideKeyboard(view: View) {
context.getSystemService(InputMethodManager::class.java)?.hideSoftInputFromWindow(view.windowToken, 0)
}
/** The grabber: a small rounded bar centered at the top edge; tap toggles, vertical drag opens/closes. */
@SuppressLint("ClickableViewAccessibility")
private fun buildGrabber(): View {
@@ -489,12 +552,7 @@ class NappletControlSheet(
return LinearLayout(context).apply {
orientation = VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
// Wrap the grabber (a vertical LinearLayout defaults its children to MATCH_PARENT width, which
// would stretch this chip's background across the whole screen) and center it under the parent.
layoutParams =
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
gravity = Gravity.CENTER_HORIZONTAL
}
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { gravity = Gravity.CENTER_HORIZONTAL }
setPadding(dp(16), dp(7), dp(16), dp(7))
background =
GradientDrawable().apply {
@@ -541,16 +599,24 @@ class NappletControlSheet(
}
}
private fun expand() {
val isExpanded: Boolean get() = expanded
fun expand() {
if (expanded) return
expanded = true
render()
panel.visibility = View.VISIBLE
}
private fun collapse() {
fun collapse() {
if (!expanded) return
expanded = false
if (editingAddress) {
editingAddress = false
hideKeyboard(panel)
}
panel.visibility = View.GONE
panel.removeAllViews()
}
private fun withAlpha(
@@ -565,4 +631,17 @@ class NappletControlSheet(
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
/** A [ScrollView] that stops growing at [maxHeightPx], so a long menu scrolls instead of covering the page. */
private class MaxHeightScrollView(
context: Context,
private val maxHeightPx: Int,
) : ScrollView(context) {
override fun onMeasure(
widthMeasureSpec: Int,
heightMeasureSpec: Int,
) {
super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(maxHeightPx, MeasureSpec.AT_MOST))
}
}
}
@@ -39,6 +39,7 @@ import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.webkit.ConsoleMessage
import android.webkit.RenderProcessGoneDetail
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
@@ -62,6 +63,7 @@ import androidx.webkit.ProxyController
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
@@ -195,6 +197,11 @@ class NappletHostActivity : ComponentActivity() {
// Bottom pull-up developer console: the page's console.log/warn/error plus any resource load errors.
private var consolePanel: NappletConsolePanel? = null
private var controlSheet: NappletControlSheet? = null
private var findBar: BrowserFindBar? = null
private var consoleShowing = false
// Set when the renderer died and the WebView was destroyed, so teardown doesn't touch it again.
private var webViewGone = false
// Set once the WebView has begun loading the shell, so a retry doesn't reload it.
private var started = false
@@ -204,7 +211,9 @@ class NappletHostActivity : ComponentActivity() {
private val backCallback =
object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (this@NappletHostActivity::webView.isInitialized && webView.canGoBack()) {
if (findBar?.isShowing == true) {
findBar?.hide()
} else if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) {
webView.goBack()
} else {
isEnabled = false
@@ -215,7 +224,8 @@ class NappletHostActivity : ComponentActivity() {
/** Keep the in-WebView back gesture enabled exactly while the applet has history to pop. */
private fun syncBackState() {
if (this::webView.isInitialized) backCallback.isEnabled = webView.canGoBack()
val canGoBack = this::webView.isInitialized && !webViewGone && webView.canGoBack()
backCallback.isEnabled = canGoBack || findBar?.isShowing == true
}
// True between onResume and onPause. Sent to the broker (foreground hold) on connect too, in case
@@ -329,6 +339,11 @@ class NappletHostActivity : ComponentActivity() {
Gravity.BOTTOM,
),
)
addView(
BrowserFindBar(this@NappletHostActivity, { if (this@NappletHostActivity::webView.isInitialized && !webViewGone) webView else null }) { syncBackState() }
.also { findBar = it },
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM),
)
// Added last so the thin loading bar paints above the content (and over the grabber's top
// edge); it's GONE except while loading, so it never obscures the trusted chrome.
addView(topProgressBar)
@@ -378,7 +393,7 @@ class NappletHostActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
if (this::webView.isInitialized) {
if (this::webView.isInitialized && !webViewGone) {
webView.onResume()
}
// Launching this :napplet-process surface backgrounded the main process; tell the broker to
@@ -392,7 +407,7 @@ class NappletHostActivity : ComponentActivity() {
// Foreground-only: stop the applet's JS/timers in the background so it cannot fire a
// sign/decrypt/pay request whose consent prompt would surface over (and be confused with)
// Amethyst's own UI. Requests only happen while the user is looking at this napplet.
if (this::webView.isInitialized) {
if (this::webView.isInitialized && !webViewGone) {
// webView.onPause() pauses THIS WebView's JS/DOM (the security goal — a backgrounded napplet can't
// fire a sign/decrypt/pay request). Do NOT call pauseTimers(): it's process-global and freezes
// EVERY WebView in `:napplet`, including the embedded browser/napplet surfaces, which never resume.
@@ -465,7 +480,7 @@ class NappletHostActivity : ComponentActivity() {
// A picker still up when the applet is torn down would otherwise leave its callback unanswered.
pendingFileChooser.cancel()
fileChooserLauncher.teardown()
if (this::webView.isInitialized) {
if (this::webView.isInitialized && !webViewGone) {
// Detach before destroy(): destroying an attached WebView corrupts the shared multiprocess
// renderer/network state and breaks the other (embedded) WebViews in this `:napplet` process
// (dead DNS, empty DOM reads, dead selection paint, broken IME). See NappletBrowserActivity.
@@ -644,6 +659,26 @@ class NappletHostActivity : ComponentActivity() {
logConsoleError(request, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty()))
}
/**
* The renderer died. Every WebView in `:napplet` shares one renderer and an unhandled crash kills
* the whole process (every embedded tab too), so drop only this WebView and offer to start over.
*/
override fun onRenderProcessGone(
view: WebView,
detail: RenderProcessGoneDetail,
): Boolean {
Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}); offering a restart" }
webViewGone = true
findBar?.hide()
(view.parent as? ViewGroup)?.removeView(view)
view.destroy()
loadingView?.let { contentFrame.removeView(it) }
loadingView = null
contentFrame.addView(buildErrorView { recreate() })
syncBackState()
return true
}
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
@@ -910,18 +945,56 @@ class NappletHostActivity : ComponentActivity() {
private fun buildControlSheet(): View =
NappletControlSheet(
context = this,
initialState =
BrowserChrome.State(
surface = if (profile == HostProfile.WEBSITE) BrowserChrome.Surface.NSITE else BrowserChrome.Surface.NAPPLET,
presentation = BrowserChrome.Presentation.FULL_SCREEN,
url = "",
startUrl = "",
// Website-mode nSites can re-route over Tor; switching rebuilds the session, so the row
// taps through to a full relaunch rather than toggling inline.
torOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null,
canFavorite = false,
hasAccessInfo = true,
),
title = barTitle(),
isSandbox = true,
onReload = { if (this::webView.isInitialized) webView.reload() },
// Website-mode nSites can re-route over Tor; switching rebuilds the session, so the row taps
// through to a full relaunch rather than toggling inline.
torInitiallyOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null,
onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ setNetworkMode(!useTor) }) else null,
onInfo = { showAccessDialog() },
onPermissions = { openPermissions() },
onConsole = { show -> consolePanel?.setShowing(show) },
listener =
object : NappletControlSheet.Listener {
override fun onAction(action: BrowserChrome.Action) {
val wv = if (this@NappletHostActivity::webView.isInitialized && !webViewGone) webView else null
when (action) {
BrowserChrome.Action.RELOAD -> wv?.reload()
BrowserChrome.Action.STOP -> wv?.stopLoading()
BrowserChrome.Action.FIND_IN_PAGE -> {
setConsoleShowing(false)
findBar?.show()
syncBackState()
}
BrowserChrome.Action.TOR -> setNetworkMode(!useTor)
BrowserChrome.Action.ACCESS_INFO -> showAccessDialog()
BrowserChrome.Action.SITE_SETTINGS -> openPermissions()
BrowserChrome.Action.CONSOLE -> setConsoleShowing(!consoleShowing)
else -> Unit
}
}
override fun onTextZoom(percent: Int) {
if (this@NappletHostActivity::webView.isInitialized && !webViewGone) BrowserWebTools.setTextZoom(webView, percent)
}
override fun onOriginTap() = showAccessDialog()
override fun onClose() = finish()
},
).also { controlSheet = it }
private fun setConsoleShowing(showing: Boolean) {
consoleShowing = showing
if (showing) findBar?.hide()
consolePanel?.setShowing(showing)
controlSheet?.setConsoleShowing(showing)
}
/**
* Ask the broker to open this napplet's editable permission screen. The sandbox can't state its own
* coordinate, so we send only the launch token; the broker resolves it to the trusted coordinate and
@@ -37,8 +37,10 @@ import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import android.view.View
import android.view.ViewGroup
import android.webkit.JsPromptResult
import android.webkit.JsResult
import android.webkit.RenderProcessGoneDetail
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
@@ -47,6 +49,7 @@ import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
@@ -113,6 +116,9 @@ class NappletHostService : Service() {
// createHostWebView — @Volatile gives the happens-before so the worker never sees a stale null.
@Volatile var contentServer: NappletContentServer? = null
var webView: WebView? = null
// The session's root view; a WebView lost to a renderer crash is rebuilt inside it on retry.
var container: FrameLayout? = null
var bridgeReplyProxy: JavaScriptReplyProxy? = null
var fireSeq = 0
@@ -185,7 +191,17 @@ class NappletHostService : Service() {
replyWithAdapter(tab)
}
NappletEmbedContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
NappletEmbedContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload()
NappletEmbedContract.MSG_RELOAD -> {
val tab = tabFor(msg) ?: return true
val container = tab.container
// After a renderer crash the tab has no WebView: the retry builds a fresh one.
if (tab.webView == null && container != null) {
val wv = createHostWebView(container.context, tab.sessionId, container)
container.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
} else {
tab.webView?.reload()
}
}
// onPause()/onResume() are per-WebView (pause/resume THIS surface's JS/DOM). Do NOT call
// pauseTimers()/resumeTimers(): they are process-global and would freeze/thaw every WebView in
// `:napplet` (the browser embed + other napplets), whose lifecycles are independent of this one.
@@ -311,10 +327,14 @@ class NappletHostService : Service() {
fun createHostWebView(
context: Context,
sessionId: String,
container: FrameLayout,
): WebView {
// The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather
// than build a WebView that no tab tracks (it would leak).
val tab = tabs[sessionId] ?: error("No napplet tab for session $sessionId")
tab.container = container
// A rebuild after a renderer crash: release the previous content server first.
tab.contentServer?.close()
val wv = WebView(nightThemedContext(context, tab.themeType))
// FIRST touch after construction: setProfile throws once the WebView has loaded content (or its
// profile has otherwise been used), so the storage partition must be chosen before the
@@ -357,6 +377,7 @@ class NappletHostService : Service() {
tab.contentServer = null
tab.webView?.destroy()
tab.webView = null
tab.container = null
}
@Suppress("SetJavaScriptEnabled")
@@ -533,6 +554,27 @@ class NappletHostService : Service() {
pushLoadState(tab, isLoading = false)
}
/**
* The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the
* whole process — every other tab included. Drop just this tab's WebView and report the load as
* failed; the tab's retry (MSG_RELOAD) rebuilds it in the same surface.
*/
override fun onRenderProcessGone(
view: WebView,
detail: RenderProcessGoneDetail,
): Boolean {
Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}) for an embedded napplet/nsite" }
(view.parent as? ViewGroup)?.removeView(view)
view.destroy()
if (tab.webView === view) {
tab.webView = null
tab.bridgeReplyProxy = null
tab.loadFailed = true
pushLoadState(tab, isLoading = false)
}
return true
}
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
@@ -29,7 +29,6 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
@@ -62,25 +61,29 @@ class NappletHostUiAdapter(
// WebView creation must run on the main thread; openSession is called on a binder thread.
mainHandler.post {
runCatching {
val webView = service.createHostWebView(context, sessionId)
// The session's view is a container around the WebView, so a WebView lost to a renderer
// crash can be replaced by a fresh one in the same surface.
val container = FrameLayout(context)
val webView = service.createHostWebView(context, sessionId, container)
container.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
// FrameLayout.LayoutParams (a MarginLayoutParams) — the SurfaceControlViewHost container
// measures children with measureChildWithMargins, which casts to MarginLayoutParams.
webView.layoutParams = FrameLayout.LayoutParams(initialWidth, initialHeight)
HostSession(sessionId, webView, service)
container.layoutParams = FrameLayout.LayoutParams(initialWidth, initialHeight)
HostSession(sessionId, container, service)
}.onSuccess { session -> clientExecutor.execute { client.onSessionOpened(session) } }
.onFailure { t -> clientExecutor.execute { client.onSessionError(t) } }
}
}
}
/** A single embedded napplet/nsite session: the WebView is the rendered view; close tears it down. */
/** A single embedded napplet/nsite session: the WebView's container is the rendered view; close tears it down. */
@RequiresApi(Build.VERSION_CODES.R)
private class HostSession(
private val sessionId: String,
private val webView: WebView,
private val container: FrameLayout,
private val service: NappletHostService,
) : SandboxedUiAdapter.Session {
override val view: View get() = webView
override val view: View get() = container
override val signalOptions: Set<String> = emptySet()
@@ -92,8 +95,8 @@ private class HostSession(
width: Int,
height: Int,
) {
webView.layoutParams = FrameLayout.LayoutParams(width, height)
webView.requestLayout()
container.layoutParams = FrameLayout.LayoutParams(width, height)
container.requestLayout()
}
override fun notifyZOrderChanged(isZOrderOnTop: Boolean) {
@@ -134,6 +134,29 @@ object NappletIpc {
*/
const val MSG_WEB_FAVORITE_STATE = 15
/**
* Host → broker (browser mode): the remembered camera / microphone / location answers for
* [KEY_BROWSER_ORIGIN]. Carries [KEY_REQUEST_ID] (a long) for correlation; the broker answers `replyTo`
* with [MSG_SITE_PERMISSIONS], one `perm.<key>` string per permission holding its
* `BrowserSitePermission.Decision` name.
*/
const val MSG_QUERY_SITE_PERMISSIONS = 16
/** Broker → host: the reply to [MSG_QUERY_SITE_PERMISSIONS]. */
const val MSG_SITE_PERMISSIONS = 17
/**
* Host → broker (browser mode): remember the user's answer ([KEY_SITE_DECISION]) for one permission
* ([KEY_SITE_PERMISSION], a `BrowserSitePermission.key`) on [KEY_BROWSER_ORIGIN]. Fire-and-forget.
*/
const val MSG_SET_SITE_PERMISSION = 18
/**
* Host → broker (browser mode): pin a launcher shortcut to [KEY_FAVORITE_URL] labelled
* [KEY_FAVORITE_LABEL]. The shortcut opens the page full screen in Amethyst's browser. Fire-and-forget.
*/
const val MSG_ADD_TO_HOME_SCREEN = 19
const val KEY_REQUEST_ID = "requestId"
const val KEY_PAYLOAD = "payload"
@@ -161,6 +184,15 @@ object NappletIpc {
/** Boolean: whether [KEY_FAVORITE_URL] is (or should become) a favorite. */
const val KEY_FAVORITE_IS_FAVORITE = "favoriteIsFavorite"
/** A `BrowserSitePermission.key` (`camera`, `microphone`, `location`). */
const val KEY_SITE_PERMISSION = "sitePermission"
/** A `BrowserSitePermission.Decision` name (`ASK`, `ALLOW`, `BLOCK`). */
const val KEY_SITE_DECISION = "siteDecision"
/** Prefix of the per-permission decision entries in a [MSG_SITE_PERMISSIONS] reply. */
const val KEY_SITE_PERMISSION_PREFIX = "perm."
/** Boolean: this sandbox surface is now foreground (true) or backgrounded (false). */
const val KEY_FOREGROUND = "foreground"
@@ -37,11 +37,4 @@
<string name="napplet_console_load_error">Failed to load (%1$d): %2$s</string>
<string name="napplet_console_http_error">HTTP %1$d %2$s</string>
<!-- Page-originated JavaScript dialogs (alert / confirm / prompt / beforeunload) in the browser -->
<string name="napplet_js_dialog_title">%1$s says</string>
<string name="napplet_js_dialog_title_generic">This page says</string>
<string name="napplet_js_dialog_block">Block dialogs from this page</string>
<string name="napplet_js_leave_title">Leave site?</string>
<string name="napplet_js_leave_message">Changes you made may not be saved.</string>
<string name="napplet_js_leave">Leave</string>
</resources>