mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
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:
+157
@@ -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 }
|
||||
}
|
||||
}
|
||||
+207
@@ -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() }
|
||||
}
|
||||
+175
@@ -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()
|
||||
}
|
||||
+7
-6
@@ -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) }
|
||||
}
|
||||
+376
@@ -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
|
||||
}
|
||||
+805
-178
File diff suppressed because it is too large
Load Diff
+85
@@ -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"
|
||||
|
||||
+411
-53
@@ -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 =
|
||||
|
||||
+12
-9
@@ -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) {
|
||||
|
||||
+457
-378
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+87
-14
@@ -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
|
||||
|
||||
+43
-1
@@ -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,
|
||||
|
||||
+12
-9
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user