feat(browser): draw every browser surface with the Compose pill components

Embedded tabs, the full-screen web browser and the nsite/napplet windows now
share one set of Compose components (commonsUI browser/ui/pill):

- Embedded tabs: EmbeddedTabChrome carries a BrowserPillUi plus one event
  callback. The tab layer draws BrowserPill, FindInPagePill and ConsoleSheet.
  The address editor suggests favorites and history.
- Embedded page dialogs: PageDialogCard, PermissionPromptCard (now with "Only
  this time") and PageInfoSheet. Page info reports the certificate as fields,
  and site permissions can be edited in place.
- Full-screen windows: BrowserChromeHost hosts the same composables in
  ComposeViews over the WebView, replacing the hand-built View chrome.
- Clear site data asks for confirmation first.

Removed the old renderers: NappletControlSheet, BrowserFindBar,
NappletConsolePanel, BrowserJsDialogs, BrowserChromeLabels, TopControlSheet,
EmbeddedFindBar, BottomConsoleSheet and EmbeddedPageDialogs, plus the strings
only they used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ
This commit is contained in:
Claude
2026-09-26 18:36:00 +00:00
parent 7267d6c359
commit d52d54ac3f
33 changed files with 1212 additions and 3042 deletions
@@ -292,6 +292,10 @@ nsite/napplet hosts (sandbox profile permitting).
What shipped, where it lives, and what was deliberately left out.
> The two renderers described below (`TopControlSheet`, `NappletControlSheet` and their find, console and
> dialog views) were later replaced by one set of Compose components. See
> `2026-09-26-browser-ui-review.md` §5.
**Shared layout.** `commons/…/browser/BrowserChrome.kt` decides which actions the top pill shows, and in
what order, for every surface (web / nsite / napplet × embedded / full screen), plus the security badge,
the scope check, text-zoom steps, the desktop user agent and the theme-colour parser. It is covered by
+33 -13
View File
@@ -1,9 +1,9 @@
# Browser surfaces: UI review and redesign
Status: **review + Compose prototypes**. The prototypes live in
Status: **shipped** (see §5). The components live in
`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/`. They are rendered
offscreen by `BrowserPillRenderTest` (commonsUI jvmTest), which writes PNGs to
`commonsUI/build/browser-pill/`. None of it is wired into the app yet (see §5).
`commonsUI/build/browser-pill/`.
Follows `2026-09-26-browser-pwa-parity.md`. That plan settled *what* the browser does. This one is about
how it looks and feels.
@@ -222,15 +222,35 @@ A sheet with:
| Spacing | 4dp grid, 16dp sheet padding, 8dp between tiles |
| Type | title `titleMedium`, origin `titleSmall`, tiles `labelMedium`, supporting `bodySmall` |
## 5. Next steps
## 5. What shipped
1. Wire the prototypes in: `TopControlSheet` → `BrowserPill`, `EmbeddedFindBar` → `FindInPagePill`,
`BottomConsoleSheet` → `ConsoleSheet`, and the embedded dialogs → `PermissionPromptCard` /
`PageDialogCard` / `PageInfoSheet`.
2. Host the same composables in the full-screen window through `ComposeView`, and delete
`NappletControlSheet`, `BrowserFindBar`, `NappletConsolePanel` and `BrowserJsDialogs`.
3. Move the pill's labels from the `commons` Android resources to the Compose catalogue (the
prototypes already use `Res.string.browser_pill_*`).
4. Add "Only this time" to the permission flow (the registry already treats "no answer" as
not-remembered).
5. Confirm before clearing site data in both surfaces.
Every browser surface now draws the components in `commonsUI/…/browser/ui/pill/`; the hand-built chrome
is gone.
- **Embedded tabs** (`EmbeddedTabLayer`): `EmbeddedTabChrome` now carries a `BrowserPillUi` and one
`onEvent(BrowserPillEvent)` callback. The layer draws `BrowserPill`, `FindInPagePill` and
`ConsoleSheet`, and handles find and the console itself. The address editor gets suggestions from
favorites and history (`OmniboxSuggestions`), and "Paste and go" checks only the clip's type.
- **Embedded page dialogs** (`WebAppScreen`): `PageDialogCard`, `PermissionPromptCard` and
`PageInfoSheet` in Compose `Dialog`s. `MSG_PAGE_INFO` now sends the certificate fields
(`KEY_CERT_ISSUED_TO` / `_BY` / `_VALID_UNTIL`) instead of a paragraph of text. Permissions in page
info are edited in place, straight into `WebSitePermissionRegistry`.
- **Full-screen windows** (`NappletBrowserActivity`, `NappletHostActivity`): `BrowserChromeHost` hosts
the same composables in two `ComposeView`s over the page. The top view grows to fill the window only
while the pill is open, so it can catch taps outside the pill; the bottom view holds find or the
console. Dialogs, the permission prompt and page info are Compose `Dialog`s. The browser asks the
broker for the site's decisions before showing page info. `:nappletHost` now applies the Compose
compiler and links the same JetBrains Compose libraries (Apache-2.0) the app already ships.
- **Permissions:** Allow while visiting (remembered), Only this time (granted, not remembered), and
Don't allow (remembered). Dismissing the prompt denies the request once and remembers nothing.
- **Clear site data** asks for confirmation inline, in both surfaces.
- **Removed:** `NappletControlSheet`, `BrowserFindBar`, `NappletConsolePanel`, `BrowserJsDialogs`,
`BrowserChromeLabels`, `TopControlSheet`, `EmbeddedFindBar`, `BottomConsoleSheet`,
`EmbeddedPageDialogs`, `ConsoleLogEntry` and `BrowserWebTools.pageInfo`, plus the Android strings only
they used.
Still open:
- The sandboxed apps' "What it can access" is still a platform `AlertDialog`. It lists launch
capabilities, not browser state.
- The embedded nsite/napplet tab has no find or text-size plumbing yet (`hasFind` / `hasTextSize` are
off there).
@@ -1,163 +0,0 @@
/*
* 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.ui.screen.loggedIn.browser
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* An embedded page's `alert` / `confirm` / `prompt` / `beforeunload`, drawn by the main process over the tab
* (the provider's WebView has no window to draw one). Titled with the page's host ("example.com says") so a
* page can't pass its dialog off as Amethyst's own, and offering "Block dialogs from this page" from the
* page's second dialog on — the same rules as the full-screen browser.
*/
@Composable
fun EmbeddedJsDialogView(
dialog: EmbeddedJsDialog,
onAnswer: (confirmed: Boolean, text: String?, block: Boolean) -> Unit,
) {
var text by remember(dialog.id) { mutableStateOf(dialog.defaultValue) }
val leave = dialog.type == EmbeddedJsDialog.Type.BEFORE_UNLOAD
val host = dialog.url?.let { BrowserChrome.originOf(it) }?.let(BrowserChrome::displayHost)
AlertDialog(
onDismissRequest = { onAnswer(false, null, false) },
title = {
Text(
when {
leave -> stringRes(CommonsR.string.browser_js_leave_title)
host != null -> stringRes(CommonsR.string.browser_js_dialog_title, host)
else -> stringRes(CommonsR.string.browser_js_dialog_title_generic)
},
)
},
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
// The page's own beforeunload text is ignored, as in every current browser (it was abused).
Text(if (leave) stringRes(CommonsR.string.browser_js_leave_message) else dialog.message)
if (dialog.type == EmbeddedJsDialog.Type.PROMPT) {
Spacer(Modifier.height(12.dp))
OutlinedTextField(value = text, onValueChange = { text = it }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
},
confirmButton = {
TextButton(onClick = { onAnswer(true, if (dialog.type == EmbeddedJsDialog.Type.PROMPT) text else null, false) }) {
Text(if (leave) stringRes(CommonsR.string.browser_js_leave) else stringRes(android.R.string.ok))
}
},
dismissButton = {
Row {
if (dialog.offerBlock) {
// A blocked page may not keep the user on it, so blocking a beforeunload means leaving.
TextButton(onClick = { onAnswer(leave, null, true) }) { Text(stringRes(CommonsR.string.browser_js_dialog_block)) }
}
if (dialog.type != EmbeddedJsDialog.Type.ALERT) {
TextButton(onClick = { onAnswer(false, null, false) }) { Text(stringRes(android.R.string.cancel)) }
}
}
},
)
}
/**
* Chrome's permission bubble for an embedded page: "<host> wants to — use your camera — Block / Allow".
* Dismissing it denies this once without remembering anything.
*/
@Composable
fun EmbeddedPermissionPrompt(
origin: String,
permissions: Set<BrowserSitePermission>,
onAnswer: (allow: Boolean?) -> Unit,
) {
AlertDialog(
onDismissRequest = { onAnswer(null) },
title = { Text(stringRes(CommonsR.string.browser_permission_title, BrowserChrome.displayHost(origin))) },
text = {
Column {
permissions.forEach { permission ->
Text(
"• " +
stringRes(
when (permission) {
BrowserSitePermission.CAMERA -> CommonsR.string.browser_permission_camera
BrowserSitePermission.MICROPHONE -> CommonsR.string.browser_permission_microphone
BrowserSitePermission.LOCATION -> CommonsR.string.browser_permission_location
},
),
)
}
}
},
confirmButton = { TextButton(onClick = { onAnswer(true) }) { Text(stringRes(CommonsR.string.browser_permission_allow)) } },
dismissButton = { TextButton(onClick = { onAnswer(false) }) { Text(stringRes(CommonsR.string.browser_permission_block)) } },
)
}
/** Chrome's page-info sheet for an embedded page: connection, Tor, certificate; site settings; clear data. */
@Composable
fun EmbeddedPageInfoDialog(
host: String,
info: String?,
onPermissions: (() -> Unit)?,
onClearData: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(host) },
text = { Text(info ?: "…") },
confirmButton = { TextButton(onClick = onDismiss) { Text(stringRes(android.R.string.ok)) } },
dismissButton = {
Row {
TextButton(onClick = {
onClearData()
onDismiss()
}) { Text(stringRes(CommonsR.string.browser_page_info_clear_data)) }
if (onPermissions != null) {
TextButton(onClick = {
onPermissions()
onDismiss()
}) { Text(stringRes(CommonsR.string.browser_page_info_permissions)) }
}
}
},
)
}
@@ -21,19 +21,18 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType
/** A JS dialog an embedded page opened, waiting for the user (the page's script is paused meanwhile). */
data class EmbeddedJsDialog(
val id: Long,
val type: Type,
val type: PageDialogType,
val url: String?,
val message: String,
val defaultValue: String,
/** Offer "Block dialogs from this page" (from the page's second dialog on, as Chrome does). */
val offerBlock: Boolean,
) {
enum class Type { ALERT, CONFIRM, PROMPT, BEFORE_UNLOAD }
}
)
/** A camera / microphone / location request from an embedded page, waiting for an answer. */
data class EmbeddedPermissionRequest(
@@ -42,11 +42,13 @@ import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleLogEntry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
@@ -102,7 +104,7 @@ class EmbeddedWebAppController(
override var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? = null
/** JavaScript console output received from the embedded WebView, capped at [MAX_CONSOLE_LOGS] entries. */
override val consoleLogs = mutableStateListOf<ConsoleLogEntry>()
override val consoleLogs = mutableStateListOf<ConsoleLine>()
override fun clearConsoleLogs() = consoleLogs.clear()
@@ -126,8 +128,8 @@ class EmbeddedWebAppController(
/** The camera / microphone / location request the page is waiting on, if any. */
val pendingPermission = mutableStateOf<EmbeddedPermissionRequest?>(null)
/** Page-info text for the page on screen, once requested. */
val pageInfo = mutableStateOf<String?>(null)
/** The certificate of the page on screen, once page info asked for it (null for none, or not yet). */
val pageCertificate = mutableStateOf<CertificateInfo?>(null)
/** A main-frame load is in flight (the pill's reload button becomes stop). */
val isLoading = mutableStateOf(false)
@@ -284,7 +286,7 @@ class EmbeddedWebAppController(
val source = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_SOURCE).orEmpty()
val line = msg.data?.getInt(NappletBrowserContract.KEY_CONSOLE_LINE, 0) ?: 0
if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0)
consoleLogs.add(ConsoleLogEntry(level, message, source, line))
consoleLogs.add(ConsoleLine(consoleLevelOf(level), message, source, line))
}
NappletBrowserContract.MSG_FILE_CHOOSER_REQUEST -> {
val data = msg.data ?: return true
@@ -309,7 +311,17 @@ class EmbeddedWebAppController(
val data = msg.data ?: return true
_findResult.value = FindResult(data.getInt(NappletBrowserContract.KEY_FIND_ACTIVE), data.getInt(NappletBrowserContract.KEY_FIND_TOTAL))
}
NappletBrowserContract.MSG_PAGE_INFO -> pageInfo.value = msg.data?.getString(NappletBrowserContract.KEY_PAGE_INFO)
NappletBrowserContract.MSG_PAGE_INFO -> {
val data = msg.data ?: return true
pageCertificate.value =
data.getString(NappletBrowserContract.KEY_CERT_ISSUED_TO)?.let { issuedTo ->
CertificateInfo(
issuedTo = issuedTo,
issuedBy = data.getString(NappletBrowserContract.KEY_CERT_ISSUED_BY).orEmpty(),
validUntil = data.getString(NappletBrowserContract.KEY_CERT_VALID_UNTIL).orEmpty(),
)
}
}
NappletBrowserContract.MSG_JS_DIALOG -> {
val data = msg.data ?: return true
val id = data.getLong(NappletBrowserContract.KEY_DIALOG_ID)
@@ -323,10 +335,10 @@ class EmbeddedWebAppController(
id = id,
type =
when (data.getString(NappletBrowserContract.KEY_DIALOG_TYPE)) {
"confirm" -> EmbeddedJsDialog.Type.CONFIRM
"prompt" -> EmbeddedJsDialog.Type.PROMPT
"beforeunload" -> EmbeddedJsDialog.Type.BEFORE_UNLOAD
else -> EmbeddedJsDialog.Type.ALERT
"confirm" -> PageDialogType.CONFIRM
"prompt" -> PageDialogType.PROMPT
"beforeunload" -> PageDialogType.BEFORE_UNLOAD
else -> PageDialogType.ALERT
},
url = data.getString(NappletBrowserContract.KEY_URL),
message = data.getString(NappletBrowserContract.KEY_DIALOG_MESSAGE).orEmpty(),
@@ -439,7 +451,7 @@ class EmbeddedWebAppController(
fun clearSiteData() = send(NappletBrowserContract.MSG_CLEAR_SITE_DATA) {}
fun requestPageInfo() {
pageInfo.value = null
pageCertificate.value = null
send(NappletBrowserContract.MSG_PAGE_INFO_REQUEST) {}
}
@@ -513,3 +525,13 @@ class EmbeddedWebAppController(
private const val MAX_CONSOLE_LOGS = 200
}
}
/** Maps the provider's console level (WebView's `ConsoleMessage.MessageLevel` name) onto the chrome's. */
private fun consoleLevelOf(level: String): ConsoleLine.Level =
when (level) {
"ERROR" -> ConsoleLine.Level.ERROR
"WARNING" -> ConsoleLine.Level.WARNING
"DEBUG" -> ConsoleLine.Level.DEBUG
"TIP" -> ConsoleLine.Level.INFO
else -> ConsoleLine.Level.LOG
}
@@ -30,7 +30,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -49,6 +51,9 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -57,6 +62,13 @@ import com.vitorpamplona.amethyst.R
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.browser.OmniboxSuggestions
import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageInfoSheet
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PermissionPromptCard
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
@@ -64,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.browser_unsupported
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.favorites.WebShortcuts
@@ -171,71 +184,101 @@ private fun EmbeddedWebAppTab(
browserOrigin(currentUrl)?.let { origin -> nav.nav(Route.ConnectedAppDetail("browser:$origin")) }
}
fun onAction(action: BrowserChrome.Action) {
when (action) {
BrowserChrome.Action.BACK -> controller.back()
BrowserChrome.Action.FORWARD -> controller.forward()
BrowserChrome.Action.RELOAD -> controller.reload()
BrowserChrome.Action.STOP -> controller.stop()
BrowserChrome.Action.FAVORITE -> toggleFavorite()
BrowserChrome.Action.SHARE -> BrowserWebTools.share(context, pageTitle, null, currentUrl)
BrowserChrome.Action.BACK_TO_APP -> controller.backToScope(url)
BrowserChrome.Action.COPY_LINK -> BrowserWebTools.copyToClipboard(context, currentUrl)
BrowserChrome.Action.DESKTOP_SITE -> {
desktopSite = !desktopSite
controller.setDesktopSite(desktopSite)
}
BrowserChrome.Action.ADD_TO_HOME_SCREEN -> WebShortcuts.requestPin(context, currentUrl, pageTitle ?: hostLabel(currentUrl))
BrowserChrome.Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(context, currentUrl)
// The page the user is looking at, not the one the tab was pinned with.
BrowserChrome.Action.OPEN_FULL_SCREEN -> FavoriteAppLauncher.launchUrl(context, currentUrl)
BrowserChrome.Action.TOR -> {
torOn = !torOn
controller.setTor(torOn)
WebAppNetworkRegistry.set(currentUrl, torOn)
}
BrowserChrome.Action.SITE_SETTINGS -> openSiteSettings()
else -> Unit
}
}
fun onNavigate(text: String) {
val resolved = OmniboxInput.resolve(text) ?: return
// .onion only resolves over Tor.
if (resolved.forceTor && proxyAvailable && !torOn) {
torOn = true
controller.setTor(true)
}
controller.navigate(resolved.url)
}
// Favorites first, then history: what the address editor offers for what the user has typed.
val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle()
val candidates =
remember(apps, history) {
buildList {
apps.forEach { if (it is FavoriteApp.WebApp) add(OmniboxSuggestions.Candidate(it.url, it.label, isFavorite = true)) }
history.forEach {
add(OmniboxSuggestions.Candidate(it.url, it.title.ifBlank { it.host }, isFavorite = false, visitCount = it.visitCount, lastVisitedAt = it.lastVisitedAt))
}
}
}
val siteDecisions by WebSitePermissionRegistry.decisions.collectAsStateWithLifecycle()
val sitePermissions = remember(siteDecisions, currentUrl) { browserOrigin(currentUrl)?.let { siteDecisions[it] }.orEmpty() }
// Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame.
val chrome =
remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, proxyAvailable, isFavorite, desktopSite, textZoom, controller) {
remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, proxyAvailable, isFavorite, desktopSite, textZoom, sitePermissions, candidates, controller) {
EmbeddedTabChrome(
title = pageTitle ?: hostLabel(currentUrl),
state =
BrowserChrome.State(
surface = BrowserChrome.Surface.WEB,
presentation = BrowserChrome.Presentation.EMBEDDED,
url = currentUrl,
startUrl = url,
canGoBack = canGoBack,
canGoForward = canGoForward,
isLoading = isLoading,
torOn = if (proxyAvailable) torOn else null,
hasSiteSettings = browserOrigin(currentUrl) != null,
ui =
BrowserPillUi(
title = pageTitle ?: hostLabel(currentUrl),
chrome =
BrowserChrome.State(
surface = BrowserChrome.Surface.WEB,
presentation = BrowserChrome.Presentation.EMBEDDED,
url = currentUrl,
startUrl = url,
canGoBack = canGoBack,
canGoForward = canGoForward,
isLoading = isLoading,
torOn = if (proxyAvailable) torOn else null,
hasSiteSettings = browserOrigin(currentUrl) != null,
),
isFavorite = isFavorite,
desktopSite = desktopSite,
textZoom = textZoom,
sitePermissions = sitePermissions,
),
isFavorite = isFavorite,
desktopSite = desktopSite,
textZoom = textZoom,
onAction = { action ->
when (action) {
BrowserChrome.Action.BACK -> controller.back()
BrowserChrome.Action.FORWARD -> controller.forward()
BrowserChrome.Action.RELOAD -> controller.reload()
BrowserChrome.Action.STOP -> controller.stop()
BrowserChrome.Action.FAVORITE -> toggleFavorite()
BrowserChrome.Action.SHARE -> BrowserWebTools.share(context, pageTitle, null, currentUrl)
BrowserChrome.Action.BACK_TO_APP -> controller.backToScope(url)
BrowserChrome.Action.COPY_LINK -> BrowserWebTools.copyToClipboard(context, currentUrl)
BrowserChrome.Action.DESKTOP_SITE -> {
desktopSite = !desktopSite
controller.setDesktopSite(desktopSite)
onEvent = { event ->
when (event) {
is BrowserPillEvent.Action -> onAction(event.action)
is BrowserPillEvent.Navigate -> onNavigate(event.input)
is BrowserPillEvent.TextZoom -> {
textZoom = event.percent
controller.setTextZoom(event.percent)
}
BrowserChrome.Action.ADD_TO_HOME_SCREEN -> WebShortcuts.requestPin(context, currentUrl, pageTitle ?: hostLabel(currentUrl))
BrowserChrome.Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(context, currentUrl)
// The page the user is looking at, not the one the tab was pinned with.
BrowserChrome.Action.OPEN_FULL_SCREEN -> FavoriteAppLauncher.launchUrl(context, currentUrl)
BrowserChrome.Action.TOR -> {
torOn = !torOn
controller.setTor(torOn)
WebAppNetworkRegistry.set(currentUrl, torOn)
BrowserPillEvent.CopyOrigin -> BrowserWebTools.copyToClipboard(context, currentUrl)
BrowserPillEvent.PageInfo -> {
controller.requestPageInfo()
showPageInfo = true
}
BrowserChrome.Action.SITE_SETTINGS -> openSiteSettings()
else -> Unit
BrowserPillEvent.Close -> Unit
}
},
onNavigate = { text ->
val resolved = OmniboxInput.resolve(text)
if (resolved != null) {
// .onion only resolves over Tor.
if (resolved.forceTor && proxyAvailable && !torOn) {
torOn = true
controller.setTor(true)
}
controller.navigate(resolved.url)
}
},
onTextZoom = { percent ->
textZoom = percent
controller.setTextZoom(percent)
},
onOriginTap = {
controller.requestPageInfo()
showPageInfo = true
suggestionsFor = { typed ->
OmniboxSuggestions.rank(typed, candidates, limit = 5).map { AddressSuggestion(it.label, it.url, it.isFavorite) }
},
)
}
@@ -259,7 +302,7 @@ private fun EmbeddedWebAppTab(
// A fullscreen video inside the tab: back leaves fullscreen first, as in Chrome.
BackHandler(enabled = isFullscreen) { controller.exitFullscreen() }
EmbeddedPageUi(controller, currentUrl, showPageInfo, onPageInfoDismiss = { showPageInfo = false }, onSiteSettings = ::openSiteSettings)
EmbeddedPageUi(controller, chrome.ui, showPageInfo, onPageInfoDismiss = { showPageInfo = false })
Scaffold(
bottomBar = {
@@ -280,22 +323,32 @@ private fun EmbeddedWebAppTab(
/**
* Everything an embedded page asks the user for, drawn by the main process because the provider has no
* window: JS dialogs, camera / microphone / location prompts (remembered per origin in
* [WebSitePermissionRegistry], then Android's own runtime permission), and page info.
* [WebSitePermissionRegistry], then Android's own runtime permission), and page info — the shared
* [PageDialogCard], [PermissionPromptCard] and [PageInfoSheet].
*/
@RequiresApi(Build.VERSION_CODES.R)
@Composable
private fun EmbeddedPageUi(
controller: EmbeddedWebAppController,
currentUrl: String,
ui: BrowserPillUi,
showPageInfo: Boolean,
onPageInfoDismiss: () -> Unit,
onSiteSettings: () -> Unit,
) {
val context = LocalContext.current
val dialog by controller.pendingDialog
dialog?.let { d ->
EmbeddedJsDialogView(d) { confirmed, text, block -> controller.answerDialog(d.id, confirmed, text, block) }
Dialog(onDismissRequest = { controller.answerDialog(d.id, confirmed = false) }) {
PageDialogCard(
type = d.type,
host = d.url?.let(::browserOrigin)?.let(::hostLabel),
security = ui.security,
message = d.message,
defaultValue = d.defaultValue,
offerBlock = d.offerBlock,
onResult = { confirmed, text, block -> controller.answerDialog(d.id, confirmed, text, block) },
)
}
}
// Android's runtime permission, asked only for what the user allowed the site to use.
@@ -330,26 +383,47 @@ private fun EmbeddedPageUi(
if (ask.isEmpty()) {
LaunchedEffect(request.id) { grant(request.id, allowed) }
} else {
EmbeddedPermissionPrompt(request.origin, ask) { allow ->
// null = dismissed without an answer: deny this once, remember nothing.
if (allow != null) {
// allow: grant now; remember: store the answer for the site ("Only this time" and a dismissal don't).
fun answer(
allow: Boolean,
remember: Boolean,
) {
if (remember) {
val decision = if (allow) BrowserSitePermission.Decision.ALLOW else BrowserSitePermission.Decision.BLOCK
ask.forEach { WebSitePermissionRegistry.set(request.origin, it, decision) }
}
grant(request.id, if (allow == true) allowed + ask else allowed)
grant(request.id, if (allow) allowed + ask else allowed)
}
Dialog(onDismissRequest = { answer(allow = false, remember = false) }) {
PermissionPromptCard(
host = hostLabel(request.origin),
security = ui.security,
permissions = ask,
onAllow = { answer(allow = true, remember = true) },
onAllowOnce = { answer(allow = true, remember = false) },
onDeny = { answer(allow = false, remember = true) },
)
}
}
}
if (showPageInfo) {
val info by controller.pageInfo
EmbeddedPageInfoDialog(
host = hostLabel(currentUrl),
info = info,
onPermissions = if (browserOrigin(currentUrl) != null) onSiteSettings else null,
onClearData = { controller.clearSiteData() },
onDismiss = onPageInfoDismiss,
)
val certificate by controller.pageCertificate
val origin = browserOrigin(ui.chrome.url)
Dialog(onDismissRequest = onPageInfoDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
PageInfoSheet(
ui = ui,
certificate = certificate,
onPermissionChange = { permission, decision -> origin?.let { WebSitePermissionRegistry.set(it, permission, decision) } },
onClearSiteData = {
onPageInfoDismiss()
controller.clearSiteData()
},
modifier = Modifier.widthIn(max = 560.dp),
)
}
}
}
}
@@ -1,237 +0,0 @@
/*
* 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.ui.screen.loggedIn.embed
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* A bottom **pull-up sheet** for JavaScript console output. Collapsed it's a small grabber at the
* bottom edge of the embedded surface. Pull it up (or tap) to reveal a scrollable log of console
* messages captured from the page via [WebChromeClient.onConsoleMessage]. The page can't draw over
* it (the surface is z-ordered below this layer).
*
* Mirrors [TopControlSheet]'s pattern but anchored at the bottom using a [Box] with
* [Alignment.BottomCenter].
*/
@Composable
fun BottomConsoleSheet(
logs: List<ConsoleLogEntry>,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onClear: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier, contentAlignment = Alignment.BottomCenter) {
// Column anchored at BottomCenter. Grabber sits at the top of the column so it rides up
// with the panel as it expands — standard bottom-sheet handle behaviour.
Column(
modifier = Modifier.align(Alignment.BottomCenter),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Grabber — the only touch target when collapsed; stays at the top of the panel when open
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier
.clip(RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f))
.clickable { onExpandedChange(!expanded) }
.draggable(
orientation = Orientation.Vertical,
state =
rememberDraggableState { delta ->
if (delta < -1f) {
onExpandedChange(true)
} else if (delta > 1f) {
onExpandedChange(false)
}
},
).padding(horizontal = 16.dp, vertical = 7.dp),
) {
Spacer(
Modifier
.width(36.dp)
.height(5.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)),
)
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut(),
) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f),
contentColor = MaterialTheme.colorScheme.onSurface,
// No elevation. This panel docks flush against the app's bottom navigation bar, so a
// shadowElevation just casts a shadow onto that bar — a seam that breaks the flush,
// background-matched look. The tonalElevation had no visual effect here anyway (the color
// isn't exactly colorScheme.surface, so Material never applies the surfaceTint); the
// shadow was the only thing clashing with the nav bar. The grabber + divider still
// separate the panel from the page above it.
tonalElevation = 0.dp,
shadowElevation = 0.dp,
shape = RoundedCornerShape(bottomStart = 0.dp, bottomEnd = 0.dp),
) {
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.4f).dp
Column(Modifier.fillMaxWidth().heightIn(max = maxHeight)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringRes(CommonsR.string.browser_console_title, logs.size),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onClear) {
Text(
stringRes(CommonsR.string.browser_console_clear),
style = MaterialTheme.typography.labelSmall,
)
}
}
HorizontalDivider()
val scrollState = rememberScrollState()
LaunchedEffect(logs.size) { scrollState.scrollTo(Int.MAX_VALUE) }
Column(
Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
logs.forEach { entry ->
ConsoleLogRow(entry)
}
}
}
}
}
}
}
}
@Composable
private fun ConsoleLogRow(entry: ConsoleLogEntry) {
val levelColor = consoleLevelColor(entry.level)
val dimColor = MaterialTheme.colorScheme.onSurfaceVariant
val srcShort =
entry.source
.substringAfterLast("/")
.substringAfterLast("\\")
.let { if (it.isBlank()) entry.source.takeLast(20) else it }
Row(
Modifier.fillMaxWidth().padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
Text(
consoleLevelChar(entry.level),
color = levelColor,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
modifier = Modifier.width(14.dp),
)
Spacer(Modifier.width(4.dp))
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = levelColor)) {
append(entry.message)
}
if (srcShort.isNotBlank()) {
withStyle(SpanStyle(color = dimColor)) {
append(" $srcShort:${entry.lineNumber}")
}
}
},
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
modifier = Modifier.weight(1f),
overflow = TextOverflow.Visible,
)
}
}
@Composable
private fun consoleLevelColor(level: String): Color {
val warningAmber = if (isSystemInDarkTheme()) Color(0xFFFFB74D) else Color(0xFFE65100)
return when (level.uppercase()) {
"ERROR" -> MaterialTheme.colorScheme.error
"WARNING" -> warningAmber
"TIP" -> MaterialTheme.colorScheme.tertiary
"DEBUG" -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurface
}
}
private fun consoleLevelChar(level: String): String =
when (level.uppercase()) {
"ERROR" -> "E"
"WARNING" -> "W"
"TIP" -> "T"
"DEBUG" -> "D"
else -> "I"
}
@@ -21,21 +21,15 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import androidx.compose.runtime.snapshots.SnapshotStateList
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
/**
* A surface controller that exposes JavaScript console output captured from the embedded WebView.
* The [consoleLogs] list is Compose snapshot state so [BottomConsoleSheet] recomposes as messages
* arrive. Implemented by [com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController].
* The [consoleLogs] list is Compose snapshot state so the console sheet recomposes as messages arrive.
* Implemented by [com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController].
*/
interface ConsoleBridge {
val consoleLogs: SnapshotStateList<ConsoleLogEntry>
val consoleLogs: SnapshotStateList<ConsoleLine>
fun clearConsoleLogs()
}
data class ConsoleLogEntry(
val level: String,
val message: String,
val source: String,
val lineNumber: Int,
)
@@ -1,123 +0,0 @@
/*
* 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.ui.screen.loggedIn.embed
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* Find in page for the active embedded tab, docked at the bottom of its bounds (the top edge belongs to the
* pill's grabber). Mirrors the full-screen browser's native find bar: query, "n/m", previous, next, close.
*/
@Composable
fun EmbeddedFindBar(
bridge: FindBridge,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
var query by remember { mutableStateOf("") }
val result by bridge.findResult
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
fun close() {
bridge.find("")
onClose()
}
Box(modifier, contentAlignment = Alignment.BottomCenter) {
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 0.dp,
shadowElevation = 6.dp,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
) {
Row(Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) {
TextField(
value = query,
onValueChange = {
query = it
bridge.find(it)
},
modifier = Modifier.weight(1f).focusRequester(focus),
singleLine = true,
placeholder = { Text(stringRes(CommonsR.string.browser_find_hint)) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { bridge.findNext(true) }),
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
val counts = result
if (query.isNotEmpty() && counts != null) {
Text(
if (counts.total > 0) stringRes(CommonsR.string.browser_find_count, counts.active + 1, counts.total) else "0/0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
IconButton(onClick = { bridge.findNext(false) }) {
Icon(MaterialSymbols.KeyboardArrowUp, contentDescription = stringRes(CommonsR.string.browser_find_previous))
}
IconButton(onClick = { bridge.findNext(true) }) {
Icon(MaterialSymbols.KeyboardArrowDown, contentDescription = stringRes(CommonsR.string.browser_find_next))
}
IconButton(onClick = ::close) {
Icon(MaterialSymbols.Close, contentDescription = stringRes(CommonsR.string.browser_find_close))
}
}
}
}
}
@@ -20,28 +20,24 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
/**
* The controls a running app surface offers through its top pull-down pill — described as plain data so
* [EmbeddedTabLayer] can draw the sheet over the (z-below) surface for the active tab. Deliberately not a
* corner pill: that's where a site usually puts the user's own avatar/menu, so the handle lives at the
* top-center instead.
* What a running app surface shows in its top pull-down pill, as plain data so [EmbeddedTabLayer] can draw
* the shared [com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill] over the (z-below) surface
* for the active tab. Deliberately not a corner pill: that's where a site usually puts the user's own
* avatar/menu, so the handle lives at the top-center instead.
*
* *Which* actions show, and in what order, comes from [BrowserChrome] with [state] — the same layout the
* full-screen browser's native sheet uses — so the two can't drift. Everything the user picks arrives in
* [onAction]; the console and find-in-page rows are handled by the layer itself.
* *Which* actions show comes from [BrowserPillUi.chrome] through
* [com.vitorpamplona.amethyst.commons.browser.BrowserChrome] — the same layout the full-screen windows
* use. Everything the user picks arrives in [onEvent]; find in page and the console are handled by the
* layer itself, since it draws them.
*/
data class EmbeddedTabChrome(
val title: String,
val state: BrowserChrome.State,
val isFavorite: Boolean = false,
val desktopSite: Boolean = false,
val textZoom: Int = BrowserChrome.DEFAULT_TEXT_ZOOM,
val onAction: (BrowserChrome.Action) -> Unit,
/** The user typed an address into "Edit address" and pressed Go. */
val onNavigate: (String) -> Unit = {},
val onTextZoom: (Int) -> Unit = {},
/** The origin chip was tapped: page info (web) or the access summary (sandboxed apps). */
val onOriginTap: () -> Unit = {},
val ui: BrowserPillUi,
val onEvent: (BrowserPillEvent) -> Unit,
/** Address-editor suggestions for what the user has typed. */
val suggestionsFor: (String) -> List<AddressSuggestion> = { emptyList() },
)
@@ -83,7 +83,14 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.ui.EmbeddedLoadOverlay
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleSheet
import com.vitorpamplona.amethyst.commons.browser.ui.pill.FindInPagePill
import com.vitorpamplona.amethyst.napplethost.BrowserWebTools
import kotlinx.coroutines.delay
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@@ -126,7 +133,7 @@ private fun EmbeddedImeBridge.sendFieldOp(
*
* The surface is z-ordered *below* the client window (privacysandbox.ui locks it there), which still
* forwards touch input to the provider yet lets Compose draw over it — so the active tab's
* [TopControlSheet] is rendered on top of the surface here. Each surface is wrapped in an
* pill ([com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill]) is rendered on top of the surface here. Each surface is wrapped in an
* [EmbeddedSurfaceTouchHolder] so a scroll gesture isn't stolen by a host-side ancestor (the
* cross-process WebView can't defend its own gesture).
*
@@ -268,107 +275,117 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
}
}
// The active tab's top pull-down sheet, drawn AFTER the surfaces so it sits on top of the
// (z-below) surface, anchored to the top of the active tab's reserved bounds. Its expanded state
// is owned here (reset per tab) so we can draw a full-area dismiss scrim behind the open sheet —
// while collapsed, only the small grabber is interactive and page taps pass through.
// The active tab's top pull-down pill, drawn AFTER the surfaces so it sits on top of the (z-below)
// surface, anchored to the top of the active tab's reserved bounds. Its expanded state is owned here
// (reset per tab) so we can draw a full-area dismiss scrim behind the open pill — while collapsed,
// only the small grabber is interactive and page taps pass through.
val chrome = EmbeddedTabHost.activeChrome
val consoleBridge = activeController as? ConsoleBridge
val consoleCount = consoleBridge?.consoleLogs?.size ?: 0
val findBridge = activeController as? FindBridge
if (chrome != null && bounds.width > 0f && bounds.height > 0f) {
var sheetExpanded by remember(activeId) { mutableStateOf(false) }
var pillExpanded by remember(activeId) { mutableStateOf(false) }
var consoleShowing by remember(activeId) { mutableStateOf(false) }
var consoleExpanded by remember(activeId) { mutableStateOf(false) }
var findShowing by remember(activeId) { mutableStateOf(false) }
var findQuery by remember(activeId) { mutableStateOf("") }
val context = LocalContext.current
if (sheetExpanded) {
fun closeFind() {
if (findShowing) findBridge?.find("")
findShowing = false
findQuery = ""
}
val tabModifier =
with(density) {
Modifier
.absoluteOffset(
(bounds.left - layerOrigin.x).toDp(),
(bounds.top - layerOrigin.y).toDp(),
).size(bounds.width.toDp(), bounds.height.toDp())
}
if (pillExpanded) {
BackHandler { pillExpanded = false }
Box(
Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { sheetExpanded = false },
) { pillExpanded = false },
)
}
with(density) {
TopControlSheet(
chrome = chrome,
expanded = sheetExpanded,
onExpandedChange = { sheetExpanded = it },
consoleCount = consoleCount,
val consoleLogs = consoleBridge?.consoleLogs
val ui =
chrome.ui.copy(
chrome = chrome.ui.chrome.copy(hasFind = chrome.ui.chrome.hasFind && findBridge != null),
consoleShowing = consoleShowing,
onConsole =
if (consoleBridge != null) {
{
if (consoleShowing) {
consoleShowing = false
} else {
// One bottom panel at a time: the console replaces the find bar.
if (findShowing) findBridge?.find("")
findShowing = false
consoleShowing = true
consoleExpanded = true
}
}
} else {
null
},
onFind =
if (findBridge != null) {
{
consoleErrors = consoleLogs?.count { it.level == ConsoleLine.Level.ERROR } ?: 0,
)
Box(tabModifier) {
BrowserPill(
ui = ui,
expanded = pillExpanded,
onExpandedChange = { pillExpanded = it },
onEvent = { event ->
val action = (event as? BrowserPillEvent.Action)?.action
when {
action == BrowserChrome.Action.FIND_IN_PAGE && findBridge != null -> {
// One bottom panel at a time: find replaces the console.
consoleShowing = false
findShowing = true
}
action == BrowserChrome.Action.CONSOLE && consoleBridge != null -> {
if (!consoleShowing) closeFind()
consoleShowing = !consoleShowing
}
else -> chrome.onEvent(event)
}
},
showClose = false,
suggestionsFor = chrome.suggestionsFor,
onPasteAndGo =
if (BrowserWebTools.clipboardHasText(context)) {
{
pillExpanded = false
BrowserWebTools.clipboardText(context)?.let { chrome.onEvent(BrowserPillEvent.Navigate(it)) }
}
} else {
null
},
modifier =
Modifier
.absoluteOffset(
(bounds.left - layerOrigin.x).toDp(),
(bounds.top - layerOrigin.y).toDp(),
).width(bounds.width.toDp()),
modifier = Modifier.align(Alignment.TopCenter),
)
}
// Find in page: opened via the "Find in page" row in the top pull-down sheet.
if (findShowing && findBridge != null) {
BackHandler {
findBridge.find("")
findShowing = false
}
with(density) {
EmbeddedFindBar(
bridge = findBridge,
onClose = { findShowing = false },
modifier =
Modifier
.absoluteOffset(
(bounds.left - layerOrigin.x).toDp(),
(bounds.top - layerOrigin.y).toDp(),
).size(bounds.width.toDp(), bounds.height.toDp()),
// Find in page: opened from the pill's Find tile.
if (findShowing && findBridge != null) {
BackHandler { closeFind() }
val result by findBridge.findResult
FindInPagePill(
query = findQuery,
onQueryChange = {
findQuery = it
findBridge.find(it)
},
active = result?.active ?: 0,
total = result?.total,
onNext = findBridge::findNext,
onClose = ::closeFind,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
// Bottom console panel: opened via the "Console" row in the top pull-down sheet.
if (consoleShowing && consoleBridge != null) {
with(density) {
BottomConsoleSheet(
logs = consoleBridge.consoleLogs,
expanded = consoleExpanded,
onExpandedChange = { consoleExpanded = it },
// The developer console: opened from the pill's console row.
if (consoleShowing && consoleLogs != null) {
ConsoleSheet(
lines = consoleLogs,
onCopy = { lines -> BrowserWebTools.copyText(context, "console", lines.joinToString("\n", transform = ::formatConsoleLine)) },
onClear = { consoleBridge.clearConsoleLogs() },
modifier =
Modifier
.absoluteOffset(
(bounds.left - layerOrigin.x).toDp(),
(bounds.top - layerOrigin.y).toDp(),
).size(bounds.width.toDp(), bounds.height.toDp()),
onCopyLine = { BrowserWebTools.copyText(context, "console", formatConsoleLine(it)) },
onClose = { consoleShowing = false },
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
@@ -1001,3 +1018,16 @@ private fun SelectionToolbarItem(
}.padding(horizontal = 12.dp, vertical = 10.dp),
)
}
/** One console line as plain text, for copying. */
private fun formatConsoleLine(line: ConsoleLine): String =
buildString {
append(line.level.name).append(": ").append(line.message)
if (line.source.isNotBlank()) {
append(" (")
.append(line.source)
.append(':')
.append(line.line)
.append(')')
}
}
@@ -1,459 +0,0 @@
/*
* 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.ui.screen.loggedIn.embed
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.SectionKind
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.napplethost.BrowserChromeLabels
import com.vitorpamplona.amethyst.ui.stringRes
import androidx.compose.material3.Icon as Material3Icon
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* A top **pull-down pill** for a running app surface. 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. Pulled down (or tapped) it shows a
* Chrome-PWA-style app menu: the page title with its origin and connection badge (tap for page info), the
* icon row (back · forward · reload/stop · star · share), then the menu rows and the Privacy and Developer
* groups. The page can't draw over it (the surface is z-ordered below this layer).
*
* The layout comes from [BrowserChrome] and the icons/labels from [BrowserChromeLabels] — both shared with
* the full-screen browser's native sheet — so an action looks and sits the same in either.
*
* @param onConsole When non-null, CONSOLE toggles the log panel through it ([consoleCount] messages so far).
* @param onFind When non-null, FIND_IN_PAGE opens the find bar through it.
*/
@Composable
fun TopControlSheet(
chrome: EmbeddedTabChrome,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
consoleCount: Int = 0,
consoleShowing: Boolean = false,
onConsole: (() -> Unit)? = null,
onFind: (() -> Unit)? = null,
) {
val state = remember(chrome.state, onConsole) { if (onConsole == null) chrome.state.copy(hasConsole = false) else chrome.state }
var editingAddress by remember(expanded) { mutableStateOf(false) }
fun act(action: Action) {
when (action) {
Action.CONSOLE -> onConsole?.invoke()
Action.FIND_IN_PAGE -> onFind?.invoke()
else -> chrome.onAction(action)
}
}
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AnimatedVisibility(
visible = expanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
// No tonal elevation. Material 3 only recolors a Surface whose color is EXACTLY
// colorScheme.surface — it swaps in surfaceColorAtElevation(), which blends surfaceTint
// (= primary, Amethyst's purple) over the surface. On the light theme that near-white +
// purple mix reads as a pink/lilac cast instead of the plain background the sheet should
// have. Keep the drop shadow (shadowElevation) to lift the sheet off the page below it.
tonalElevation = 0.dp,
shadowElevation = 6.dp,
shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp),
) {
Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp)) {
SheetHeader(
chrome = chrome,
state = state,
editingAddress = editingAddress,
onOriginTap = {
onExpandedChange(false)
chrome.onOriginTap()
},
onCopy = { chrome.onAction(Action.COPY_LINK) },
onNavigate = { text ->
editingAddress = false
onExpandedChange(false)
chrome.onNavigate(text)
},
)
IconActionRow(state, chrome.isFavorite) { action ->
onExpandedChange(false)
act(action)
}
HorizontalDivider()
val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.55f).dp
Column(Modifier.heightIn(max = maxHeight).verticalScroll(rememberScrollState())) {
BrowserChrome.sections(state).forEachIndexed { index, section ->
if (index > 0) HorizontalDivider(Modifier.padding(vertical = 2.dp))
sectionTitle(section.kind)?.let {
Text(
it.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 2.dp),
)
}
section.actions.forEach { action ->
when {
action == Action.TEXT_SIZE -> TextSizeRow(chrome.textZoom, chrome.onTextZoom)
BrowserChromeLabels.isToggle(action) ->
SheetSwitchItem(
action = action,
label = rowLabel(action, state, chrome.isFavorite, consoleCount),
checked =
when (action) {
Action.TOR -> state.torOn == true
Action.DESKTOP_SITE -> chrome.desktopSite
else -> consoleShowing
},
) {
onExpandedChange(false)
act(action)
}
else ->
SheetItem(action, rowLabel(action, state, chrome.isFavorite, consoleCount)) {
if (action == Action.EDIT_ADDRESS) {
editingAddress = true
} else {
onExpandedChange(false)
act(action)
}
}
}
}
}
}
}
}
}
// The grabber: a small rounded bar centered at the top edge. It is the ONLY touch target the sheet
// draws — the rest of the top strip stays transparent so page taps pass straight through to the
// surface below. Pull down to open, up to close; tapping toggles.
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f))
.clickable { onExpandedChange(!expanded) }
.draggable(
orientation = Orientation.Vertical,
state =
rememberDraggableState { delta ->
if (delta > 1f) {
onExpandedChange(true)
} else if (delta < -1f) {
onExpandedChange(false)
}
},
).padding(horizontal = 16.dp, vertical = 7.dp),
) {
Spacer(
Modifier
.width(36.dp)
.height(5.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)),
)
}
}
}
@Composable
private fun sectionTitle(kind: SectionKind): String? =
when (kind) {
SectionKind.PAGE -> null
SectionKind.PRIVACY -> stringRes(CommonsR.string.browser_section_privacy)
SectionKind.DEVELOPER -> stringRes(CommonsR.string.browser_section_developer)
}
@Composable
private fun rowLabel(
action: Action,
state: BrowserChrome.State,
isFavorite: Boolean,
consoleCount: Int,
): String =
when {
action == Action.BACK_TO_APP -> stringRes(CommonsR.string.browser_action_back_to_app, BrowserChrome.displayHost(state.startUrl))
action == Action.CONSOLE && consoleCount > 0 -> stringRes(CommonsR.string.browser_console_title, consoleCount)
else -> stringRes(BrowserChromeLabels.labelFor(action, isFavorite = isFavorite, torOn = state.torOn == true))
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SheetHeader(
chrome: EmbeddedTabChrome,
state: BrowserChrome.State,
editingAddress: Boolean,
onOriginTap: () -> Unit,
onCopy: () -> Unit,
onNavigate: (String) -> Unit,
) {
val security = BrowserChrome.security(state)
Row(Modifier.padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
SymbolOrDrawable(
BrowserChromeLabels.securitySymbol(security),
BrowserChromeLabels.securityDrawable(security),
tint = if (state.isSandbox) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
size = 20,
)
Spacer(Modifier.width(12.dp))
if (editingAddress) {
AddressField(state.url, onNavigate, Modifier.weight(1f))
} else {
Column(
Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.combinedClickable(onClick = onOriginTap, onLongClick = if (state.isSandbox) null else onCopy),
) {
Text(chrome.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
val securityLabel = stringRes(BrowserChromeLabels.securityLabel(security))
Text(
if (state.isSandbox) securityLabel else BrowserChrome.displayHost(state.url) + " · " + securityLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** The rarely used editable address, swapped in for the origin chip by "Edit address". */
@Composable
private fun AddressField(
url: String,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var field by remember { mutableStateOf(TextFieldValue(url, TextRange(0, url.length))) }
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
TextField(
value = field,
onValueChange = { field = it },
modifier = modifier.focusRequester(focus),
singleLine = true,
placeholder = { Text(stringRes(CommonsR.string.browser_address_hint)) },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
keyboardActions =
KeyboardActions(onGo = {
field.text
.trim()
.takeIf { it.isNotEmpty() }
?.let(onNavigate)
}),
colors =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
}
@Composable
private fun IconActionRow(
state: BrowserChrome.State,
isFavorite: Boolean,
onAction: (Action) -> Unit,
) {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
BrowserChrome.iconRow(state).forEach { action ->
val enabled = BrowserChrome.isEnabled(state, action)
val favorite = action == Action.FAVORITE && isFavorite
IconButton(onClick = { onAction(action) }, enabled = enabled) {
Icon(
BrowserChromeLabels.symbolFor(action)!!,
contentDescription = stringRes(BrowserChromeLabels.labelFor(action, isFavorite = isFavorite)),
modifier = Modifier.size(24.dp),
tint =
when {
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
favorite -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
},
filled = favorite,
)
}
}
}
}
@Composable
private fun SymbolOrDrawable(
symbol: MaterialSymbol?,
drawable: Int?,
tint: Color,
size: Int,
) {
if (drawable != null) {
Material3Icon(painterResource(drawable), contentDescription = null, modifier = Modifier.size(size.dp), tint = tint)
} else if (symbol != null) {
Icon(symbol, contentDescription = null, modifier = Modifier.size(size.dp), tint = tint)
}
}
@Composable
private fun SheetItem(
action: Action,
label: String,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SymbolOrDrawable(BrowserChromeLabels.symbolFor(action), BrowserChromeLabels.drawableFor(action), MaterialTheme.colorScheme.onSurfaceVariant, 22)
Spacer(Modifier.width(14.dp))
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun SheetSwitchItem(
action: Action,
label: String,
checked: Boolean,
onToggle: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable(onClick = onToggle)
// Same row rhythm as [SheetItem] so every entry lines up; the Switch is taller but the
// padding (the inter-item spacing) is identical.
.padding(horizontal = 8.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SymbolOrDrawable(BrowserChromeLabels.symbolFor(action), BrowserChromeLabels.drawableFor(action), MaterialTheme.colorScheme.onSurfaceVariant, 22)
Spacer(Modifier.width(14.dp))
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = { onToggle() })
}
}
@Composable
private fun TextSizeRow(
textZoom: Int,
onTextZoom: (Int) -> Unit,
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(MaterialSymbols.FormatSize, contentDescription = null, modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(14.dp))
Text(stringRes(CommonsR.string.browser_action_text_size), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
IconButton(onClick = { onTextZoom(BrowserChrome.stepTextZoom(textZoom, larger = false)) }) {
Icon(MaterialSymbols.Remove, contentDescription = stringRes(CommonsR.string.browser_action_text_smaller))
}
Text(
stringRes(CommonsR.string.browser_action_text_size_value, textZoom),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.widthIn(min = 44.dp),
)
IconButton(onClick = { onTextZoom(BrowserChrome.stepTextZoom(textZoom, larger = true)) }) {
Icon(MaterialSymbols.Add, contentDescription = stringRes(CommonsR.string.browser_action_text_larger))
}
}
}
@@ -55,6 +55,8 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
@@ -174,21 +176,24 @@ private fun EmbeddedNostrAppTab(
val chrome =
remember(title, coordinate, isFavorite, controller) {
EmbeddedTabChrome(
title = title.ifBlank { coordinate },
state =
BrowserChrome.State(
surface = BrowserChrome.Surface.NAPPLET,
presentation = BrowserChrome.Presentation.EMBEDDED,
url = "",
startUrl = "",
hasAccessInfo = true,
// The embedded nsite/napplet host has no find or text-size plumbing (yet).
hasFind = false,
hasTextSize = false,
ui =
BrowserPillUi(
title = title.ifBlank { coordinate },
chrome =
BrowserChrome.State(
surface = BrowserChrome.Surface.NAPPLET,
presentation = BrowserChrome.Presentation.EMBEDDED,
url = "",
startUrl = "",
hasAccessInfo = true,
// The embedded nsite/napplet host has no find or text-size plumbing (yet).
hasFind = false,
hasTextSize = false,
),
isFavorite = isFavorite,
),
isFavorite = isFavorite,
onAction = { action ->
when (action) {
onEvent = { event ->
when ((event as? BrowserPillEvent.Action)?.action) {
BrowserChrome.Action.RELOAD -> controller.reload()
BrowserChrome.Action.OPEN_FULL_SCREEN ->
FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()), appStillLoadingStr)
@@ -205,7 +210,6 @@ private fun EmbeddedNostrAppTab(
else -> Unit
}
},
onOriginTap = { showAccess = true },
)
}
// Publish the top-sheet controls to the tab layer (drawn over the z-below surface). In a SideEffect
+1 -58
View File
@@ -9,79 +9,22 @@
<string name="browser_file_chooser_title">Choose a file</string>
<!-- Shown when the device has no app that can hand a file back to the page. -->
<string name="browser_file_chooser_unavailable">No app available to pick a file</string>
<!-- Browser top pill (shared by the embedded tab and the full-screen browser) -->
<string name="browser_action_back">Back</string>
<string name="browser_action_forward">Forward</string>
<string name="browser_action_reload">Reload</string>
<string name="browser_action_stop">Stop loading</string>
<string name="browser_action_favorite_add">Add to favorites</string>
<string name="browser_action_favorite_remove">Remove from favorites</string>
<string name="browser_action_share">Share…</string>
<string name="browser_action_back_to_app">Back to %1$s</string>
<string name="browser_action_copy_link">Copy link</string>
<string name="browser_action_edit_address">Edit address</string>
<string name="browser_action_find_in_page">Find in page</string>
<string name="browser_action_text_size">Text size</string>
<string name="browser_action_text_size_value">%1$d%%</string>
<string name="browser_action_text_smaller">Smaller text</string>
<string name="browser_action_text_larger">Larger text</string>
<string name="browser_action_desktop_site">Desktop site</string>
<string name="browser_action_add_to_home">Add to Home screen</string>
<string name="browser_action_open_in_browser_app">Open in another browser</string>
<string name="browser_action_open_full_screen">Open full screen</string>
<string name="browser_action_tor_on">Loads over Tor</string>
<string name="browser_action_tor_off">Loads over the open web</string>
<string name="browser_action_access_info">What it can access</string>
<string name="browser_action_site_settings">Site settings &amp; permissions</string>
<string name="browser_action_close">Close</string>
<string name="browser_section_privacy">Privacy</string>
<string name="browser_section_developer">Developer</string>
<string name="browser_security_tor">Onion-routed over Tor</string>
<string name="browser_security_https">Secure connection</string>
<string name="browser_security_http">Not secure</string>
<string name="browser_security_sandbox">Sandboxed app</string>
<!-- Browser actions -->
<string name="browser_link_copied">Link copied</string>
<string name="browser_no_other_browser">No other browser installed</string>
<!-- Find in page -->
<string name="browser_find_hint">Find in page</string>
<string name="browser_find_count">%1$d/%2$d</string>
<string name="browser_find_previous">Previous match</string>
<string name="browser_find_next">Next match</string>
<string name="browser_find_close">Close find in page</string>
<!-- Page-originated JavaScript dialogs (alert / confirm / prompt / beforeunload) -->
<string name="browser_js_dialog_title">%1$s says</string>
<string name="browser_js_dialog_title_generic">This page says</string>
<string name="browser_js_dialog_block">Block dialogs from this page</string>
<string name="browser_js_leave_title">Leave site?</string>
<string name="browser_js_leave_message">Changes you made may not be saved.</string>
<string name="browser_js_leave">Leave</string>
<!-- Downloads and sharing -->
<string name="browser_download_started">Downloading %1$s…</string>
<string name="browser_download_saved">Saved %1$s to Downloads</string>
<string name="browser_download_failed">Couldn\'t download %1$s</string>
<string name="browser_share_chooser">Share</string>
<!-- Page info -->
<string name="browser_page_info_title">Page info</string>
<string name="browser_page_info_https">Your connection to this site is encrypted.</string>
<string name="browser_page_info_http">Your connection to this site is not encrypted. Don\'t enter passwords or payment details.</string>
<string name="browser_page_info_tor">This site loads over Tor. The site can\'t see your IP address.</string>
<string name="browser_page_info_open_web">This site loads over the open web. The site can see your IP address.</string>
<string name="browser_page_info_certificate">Certificate issued to %1$s by %2$s, valid until %3$s.</string>
<string name="browser_page_info_permissions">Permissions</string>
<string name="browser_page_info_clear_data">Clear site data</string>
<string name="browser_site_data_cleared">Site data cleared</string>
<!-- Site permissions (camera / microphone / location) -->
<string name="browser_permission_title">%1$s wants to</string>
<string name="browser_permission_camera">Use your camera</string>
<string name="browser_permission_microphone">Use your microphone</string>
<string name="browser_permission_location">Know your approximate location</string>
<string name="browser_permission_allow">Allow</string>
<string name="browser_permission_block">Block</string>
<string name="browser_permission_system_denied">Amethyst doesn\'t have Android\'s permission for this. Allow it in system settings.</string>
<string name="browser_permission_ask">Ask</string>
<string name="browser_permission_allowed">Allowed</string>
@@ -76,24 +76,29 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
/**
* The address as a first-class input, opened from the origin field. A 56dp pill field with the URL
* pre-selected, a leading search/security icon, and trailing clear + a filled Go; below it Paste and go
* (when the clipboard holds a URL) and the omnibox suggestions, each with a ↖ that fills its address in
* without leaving. [onCancel] folds it back into the origin field.
* (when the clipboard holds a URL) and the omnibox suggestions for what's typed ([suggestionsFor]), each
* with a ↖ that fills its address in without leaving. [onCancel] folds it back into the origin field.
*/
@Composable
fun AddressEditor(
initialUrl: String,
security: BrowserChrome.Security,
suggestions: List<AddressSuggestion>,
suggestionsFor: (String) -> List<AddressSuggestion>,
clipboardUrl: String?,
onGo: (String) -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
autoFocus: Boolean = true,
onPasteAndGo: (() -> Unit)? = null,
) {
var field by remember { mutableStateOf(TextFieldValue(initialUrl, TextRange(0, initialUrl.length))) }
val focus = remember { FocusRequester() }
if (autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
// Ranked against what the user typed; the untouched page URL counts as nothing typed yet.
val typed = if (field.text == initialUrl) "" else field.text
val suggestions = remember(typed) { suggestionsFor(typed) }
fun go(text: String = field.text) {
text.trim().takeIf { it.isNotEmpty() }?.let(onGo)
}
@@ -153,10 +158,13 @@ fun AddressEditor(
}
}
if (clipboardUrl != null && clipboardUrl != field.text) {
// [clipboardUrl] when the host may read the clipboard up front; otherwise [onPasteAndGo] reads it only
// on tap (Android announces every clipboard read, so the offer can't peek at the contents).
if ((clipboardUrl != null && clipboardUrl != field.text) || onPasteAndGo != null) {
val pasteLabel = stringRes(Res.string.browser_pill_paste_go)
AssistChip(
onClick = { go(clipboardUrl) },
label = { Text(stringRes(Res.string.browser_pill_paste_go) + " · " + BrowserChrome.displayHost(clipboardUrl), maxLines = 1, overflow = TextOverflow.Ellipsis) },
onClick = { if (clipboardUrl != null) go(clipboardUrl) else onPasteAndGo?.invoke() },
label = { Text(if (clipboardUrl != null) pasteLabel + " · " + BrowserChrome.displayHost(clipboardUrl) else pasteLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) },
leadingIcon = { Icon(MaterialSymbols.ContentPasteGo, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize)) },
modifier = Modifier.padding(start = 48.dp),
)
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.browser.ui.pill
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.ui.theme.AmethystPreviewTheme
/**
* The Material theme for browser chrome drawn outside the main app's composition — the full-screen
* browser and nsite/napplet windows in the keyless `:napplet` process, which has no access to the user's
* theme preferences beyond light/dark. Uses Amethyst's default palette, typography and shapes.
*/
@Composable
fun BrowserChromeTheme(
dark: Boolean,
content: @Composable () -> Unit,
) = AmethystPreviewTheme(dark = dark, content = content)
@@ -109,10 +109,11 @@ fun BrowserPill(
onEvent: (BrowserPillEvent) -> Unit,
modifier: Modifier = Modifier,
showClose: Boolean = false,
suggestions: List<AddressSuggestion> = emptyList(),
suggestionsFor: (String) -> List<AddressSuggestion> = { emptyList() },
clipboardUrl: String? = null,
initiallyEditing: Boolean = false,
initiallyTextSizeOpen: Boolean = false,
onPasteAndGo: (() -> Unit)? = null,
) {
Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
AnimatedVisibility(
@@ -128,10 +129,11 @@ fun BrowserPill(
onEvent(event)
},
showClose = showClose,
suggestions = suggestions,
suggestionsFor = suggestionsFor,
clipboardUrl = clipboardUrl,
initiallyEditing = initiallyEditing,
initiallyTextSizeOpen = initiallyTextSizeOpen,
onPasteAndGo = onPasteAndGo,
)
}
PillHandle(ui, expanded, onExpandedChange)
@@ -213,10 +215,11 @@ fun BrowserPillSheet(
onEvent: (BrowserPillEvent) -> Unit,
modifier: Modifier = Modifier,
showClose: Boolean = false,
suggestions: List<AddressSuggestion> = emptyList(),
suggestionsFor: (String) -> List<AddressSuggestion> = { emptyList() },
clipboardUrl: String? = null,
initiallyEditing: Boolean = false,
initiallyTextSizeOpen: Boolean = false,
onPasteAndGo: (() -> Unit)? = null,
) {
var editing by rememberSaveable { mutableStateOf(initiallyEditing) }
var textSizeOpen by rememberSaveable { mutableStateOf(initiallyTextSizeOpen) }
@@ -244,10 +247,11 @@ fun BrowserPillSheet(
AddressEditor(
initialUrl = ui.chrome.url,
security = ui.security,
suggestions = suggestions,
suggestionsFor = suggestionsFor,
clipboardUrl = clipboardUrl,
onGo = { onEvent(BrowserPillEvent.Navigate(it)) },
onCancel = { editing = false },
onPasteAndGo = onPasteAndGo,
)
} else {
PillHeader(ui, showClose, onClose = { onEvent(BrowserPillEvent.Close) })
@@ -255,6 +259,7 @@ fun BrowserPillSheet(
ui = ui,
onEdit = if (Action.EDIT_ADDRESS in page) ({ editing = true }) else null,
onLongPress = if (!ui.chrome.isSandbox) ({ onEvent(BrowserPillEvent.CopyOrigin) }) else null,
onSecurityTap = if (!ui.chrome.isSandbox) ({ onEvent(BrowserPillEvent.PageInfo) }) else null,
)
if (Action.BACK_TO_APP in page) {
OutOfScopeBanner(homeHost = BrowserChrome.displayHost(ui.chrome.startUrl)) {
@@ -67,6 +67,9 @@ sealed interface BrowserPillEvent {
/** The origin field was long-pressed (copy link). */
data object CopyOrigin : BrowserPillEvent
/** The security badge at the start of the origin field was tapped: show page info. */
data object PageInfo : BrowserPillEvent
data object Close : BrowserPillEvent
}
@@ -148,7 +148,7 @@ fun BrowserPillAddressEditorPreview() {
expanded = true,
onExpandedChange = {},
onEvent = {},
suggestions = BrowserPillSamples.suggestions,
suggestionsFor = { BrowserPillSamples.suggestions },
clipboardUrl = "https://njump.me/npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
initiallyEditing = true,
)
@@ -65,6 +65,7 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.browser_pill_close
import com.vitorpamplona.amethyst.commons.resources.browser_pill_console
import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_all
import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_clear
@@ -91,6 +92,7 @@ fun ConsoleSheet(
modifier: Modifier = Modifier,
maxHeight: Dp = 320.dp,
initialFilter: ConsoleFilter = ConsoleFilter.ALL,
onClose: (() -> Unit)? = null,
) {
var filter by remember { mutableStateOf(initialFilter) }
val errors = lines.count { it.level == ConsoleLine.Level.ERROR }
@@ -127,6 +129,11 @@ fun ConsoleSheet(
IconButton(onClick = onClear, enabled = lines.isNotEmpty()) {
Icon(MaterialSymbols.Delete, contentDescription = stringRes(Res.string.browser_pill_console_clear), modifier = Modifier.size(20.dp))
}
if (onClose != null) {
IconButton(onClick = onClose) {
Icon(MaterialSymbols.Close, contentDescription = stringRes(Res.string.browser_pill_close), modifier = Modifier.size(20.dp))
}
}
}
Row(
Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp),
@@ -123,6 +123,7 @@ fun OriginField(
onEdit: (() -> Unit)?,
onLongPress: (() -> Unit)?,
modifier: Modifier = Modifier,
onSecurityTap: (() -> Unit)? = null,
) {
val security = ui.security
Row(
@@ -136,11 +137,17 @@ fun OriginField(
role = Role.Button,
onClick = { onEdit?.invoke() },
onLongClick = onLongPress,
).padding(start = 16.dp, end = 6.dp),
).padding(start = if (onSecurityTap != null) 4.dp else 16.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SecurityIcon(security, size = 18.dp)
Spacer(Modifier.width(10.dp))
// Chrome's page-info entry point: the connection badge itself.
if (onSecurityTap != null) {
IconButton(onClick = onSecurityTap) { SecurityIcon(security, size = 20.dp) }
Spacer(Modifier.width(2.dp))
} else {
SecurityIcon(security, size = 18.dp)
Spacer(Modifier.width(10.dp))
}
Column(Modifier.weight(1f).padding(vertical = 6.dp)) {
Text(
if (ui.chrome.isSandbox) stringRes(securityLabel(security)) else ui.host,
+7
View File
@@ -2,6 +2,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidLibrary)
// The full-screen browser / napplet windows draw the shared Compose browser chrome (commonsUI).
alias(libs.plugins.jetbrainsComposeCompiler)
}
android {
@@ -43,6 +45,11 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity)
implementation(libs.jetbrains.compose.ui)
implementation(libs.jetbrains.compose.foundation)
implementation(libs.jetbrains.compose.runtime)
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.components.resources)
implementation(libs.androidx.webkit)
implementation(libs.okhttp)
@@ -0,0 +1,388 @@
/*
* 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.view.Gravity
import android.view.ViewGroup
import android.webkit.ConsoleMessage
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserChromeTheme
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleSheet
import com.vitorpamplona.amethyst.commons.browser.ui.pill.FindInPagePill
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageInfoSheet
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PermissionPromptCard
/**
* The browser chrome of a full-screen `:napplet` window (the web browser and the nsite/napplet host), drawn
* with the shared Compose components from `commonsUI` — the same pill, find pill, console sheet and page
* cards the embedded tabs use, so both surfaces are pixel-identical.
*
* Owns the chrome's Compose state and two [ComposeView]s laid over the page: the pill at the top (grown to
* the full window while open, so a tap outside closes it) and find / console at the bottom. Page dialogs,
* permission prompts and page info open as Compose dialogs. The window only feeds state in ([ui], console
* lines, find results) and handles what comes out through [Listener].
*/
class BrowserChromeHost(
private val activity: ComponentActivity,
private val dark: Boolean,
initial: BrowserPillUi,
private val listener: Listener,
private val showClose: Boolean = true,
private val suggestionsFor: (String) -> List<AddressSuggestion> = { emptyList() },
) {
/** What the chrome asks its window to do. */
interface Listener {
/** Everything from the pill except find and the console, which the host runs itself. */
fun onPillEvent(event: BrowserPillEvent)
fun onFind(query: String) {}
fun onFindNext(forward: Boolean) {}
fun onFindClosed() {}
fun onPermissionChange(
permission: BrowserSitePermission,
decision: BrowserSitePermission.Decision,
) {}
fun onClearSiteData() {}
/** The pill, find or the console opened or closed (the window may need to route back). */
fun onPanelsChanged() {}
}
/** A page's JS dialog waiting for an answer. */
class PendingDialog(
val type: PageDialogType,
val host: String?,
val security: BrowserChrome.Security,
val message: String,
val defaultValue: String,
val offerBlock: Boolean,
val answer: (confirmed: Boolean, text: String?, block: Boolean) -> Unit,
)
/** A site permission request waiting for an answer: allow or not, and whether to remember the choice. */
class PendingPermission(
val host: String,
val security: BrowserChrome.Security,
val permissions: Set<BrowserSitePermission>,
val answer: (allow: Boolean, remember: Boolean) -> Unit,
)
var ui by mutableStateOf(initial)
var expanded by mutableStateOf(false)
private set
var findOpen by mutableStateOf(false)
private set
private var findQuery by mutableStateOf("")
private var findActive by mutableStateOf(0)
private var findTotal by mutableStateOf<Int?>(null)
var consoleShowing by mutableStateOf(false)
private set
val console = mutableStateListOf<ConsoleLine>()
var dialog by mutableStateOf<PendingDialog?>(null)
var permissionPrompt by mutableStateOf<PendingPermission?>(null)
private var pageInfoOpen by mutableStateOf(false)
private var certificate by mutableStateOf<CertificateInfo?>(null)
private var topView: ComposeView? = null
/** Lays the chrome over [root], above the page. */
fun attach(root: FrameLayout) {
val top =
ComposeView(activity).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { BrowserChromeTheme(dark) { TopChrome() } }
}
topView = top
root.addView(top, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP))
val bottom =
ComposeView(activity).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { BrowserChromeTheme(dark) { BottomChrome() } }
}
root.addView(bottom, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM))
}
fun showPill(open: Boolean) {
if (expanded == open) return
expanded = open
// Open, the pill's view spans the window so a tap anywhere outside the sheet closes it; closed, it
// shrinks back to the grabber so every other touch reaches the page.
topView?.let { view ->
view.layoutParams = (view.layoutParams as FrameLayout.LayoutParams).apply { height = if (open) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT }
}
listener.onPanelsChanged()
}
fun openFind() {
consoleShowing = false
findOpen = true
listener.onPanelsChanged()
}
fun closeFind() {
if (!findOpen) return
findOpen = false
findQuery = ""
findTotal = null
listener.onFindClosed()
listener.onPanelsChanged()
}
fun showConsole(show: Boolean) {
if (show) closeFind()
consoleShowing = show
listener.onPanelsChanged()
}
fun setFindResult(
active: Int,
total: Int,
) {
findActive = active
findTotal = total
}
fun appendConsole(line: ConsoleLine) {
if (console.size >= MAX_CONSOLE_LINES) console.removeAt(0)
console.add(line)
}
/** Page info for the page on screen, with its certificate when it has one. */
fun showPageInfo(certificate: CertificateInfo?) {
this.certificate = certificate
pageInfoOpen = true
}
/** Back closes, in order: the open pill, find, then nothing (the page's own history). */
fun handleBack(): Boolean =
when {
expanded -> {
showPill(false)
true
}
findOpen -> {
closeFind()
true
}
else -> false
}
val wantsBack: Boolean get() = expanded || findOpen
private fun uiWithConsole(): BrowserPillUi = ui.copy(consoleShowing = consoleShowing, consoleErrors = console.count { it.level == ConsoleLine.Level.ERROR })
@Composable
private fun TopChrome() {
Box(Modifier.fillMaxSize()) {
if (expanded) {
Box(
Modifier
.fillMaxSize()
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { showPill(false) },
)
}
BrowserPill(
ui = uiWithConsole(),
expanded = expanded,
onExpandedChange = ::showPill,
onEvent = { event ->
when {
event is BrowserPillEvent.Action && event.action == BrowserChrome.Action.FIND_IN_PAGE -> openFind()
event is BrowserPillEvent.Action && event.action == BrowserChrome.Action.CONSOLE -> showConsole(!consoleShowing)
else -> listener.onPillEvent(event)
}
},
showClose = showClose,
suggestionsFor = suggestionsFor,
onPasteAndGo = if (clipboardHasText()) ({ pasteAndGo() }) else null,
modifier = Modifier.align(Alignment.TopCenter),
)
}
PageDialogs()
}
@Composable
private fun BottomChrome() {
when {
findOpen ->
FindInPagePill(
query = findQuery,
onQueryChange = {
findQuery = it
if (it.isEmpty()) findTotal = null
listener.onFind(it)
},
active = findActive,
total = findTotal,
onNext = listener::onFindNext,
onClose = ::closeFind,
)
consoleShowing ->
ConsoleSheet(
lines = console,
onCopy = { lines -> copy(lines.joinToString("\n") { formatLine(it) }) },
onClear = { console.clear() },
onCopyLine = { copy(formatLine(it)) },
onClose = { showConsole(false) },
)
}
}
@Composable
private fun PageDialogs() {
dialog?.let { pending ->
Dialog(onDismissRequest = {
dialog = null
pending.answer(false, null, false)
}) {
PageDialogCard(
type = pending.type,
host = pending.host,
security = pending.security,
message = pending.message,
defaultValue = pending.defaultValue,
offerBlock = pending.offerBlock,
onResult = { confirmed, text, block ->
dialog = null
pending.answer(confirmed, text, block)
},
)
}
}
permissionPrompt?.let { pending ->
Dialog(onDismissRequest = {
permissionPrompt = null
pending.answer(false, false)
}) {
PermissionPromptCard(
host = pending.host,
security = pending.security,
permissions = pending.permissions,
onAllow = {
permissionPrompt = null
pending.answer(true, true)
},
onAllowOnce = {
permissionPrompt = null
pending.answer(true, false)
},
onDeny = {
permissionPrompt = null
pending.answer(false, true)
},
)
}
}
if (pageInfoOpen) {
Dialog(onDismissRequest = { pageInfoOpen = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
PageInfoSheet(
ui = ui,
certificate = certificate,
onPermissionChange = { permission, decision ->
ui = ui.copy(sitePermissions = ui.sitePermissions + (permission to decision))
listener.onPermissionChange(permission, decision)
},
onClearSiteData = {
pageInfoOpen = false
listener.onClearSiteData()
},
modifier = Modifier.widthIn(max = 560.dp),
)
}
}
}
}
private fun clipboardHasText(): Boolean = BrowserWebTools.clipboardHasText(activity)
private fun pasteAndGo() {
val text = BrowserWebTools.clipboardText(activity)
showPill(false)
if (text != null) listener.onPillEvent(BrowserPillEvent.Navigate(text))
}
private fun copy(text: String) = BrowserWebTools.copyText(activity, "console", text)
private fun formatLine(line: ConsoleLine): String =
buildString {
append(line.level.name).append(": ").append(line.message)
if (line.source.isNotBlank()) {
append(" (")
.append(line.source)
.append(':')
.append(line.line)
.append(')')
}
}
companion object {
private const val MAX_CONSOLE_LINES = 500
/** Maps WebView's console level onto the chrome's. */
fun levelOf(level: ConsoleMessage.MessageLevel): ConsoleLine.Level =
when (level) {
ConsoleMessage.MessageLevel.ERROR -> ConsoleLine.Level.ERROR
ConsoleMessage.MessageLevel.WARNING -> ConsoleLine.Level.WARNING
ConsoleMessage.MessageLevel.DEBUG -> ConsoleLine.Level.DEBUG
ConsoleMessage.MessageLevel.TIP -> ConsoleLine.Level.INFO
else -> ConsoleLine.Level.LOG
}
}
}
@@ -1,157 +0,0 @@
/*
* 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 }
}
}
@@ -1,197 +0,0 @@
/*
* 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()
}
@@ -1,182 +0,0 @@
/*
* 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.app.AlertDialog
import android.text.InputType
import android.webkit.JsPromptResult
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
* browser, the way Chrome does: titled with the page's origin ("example.com says") so a page can't pass
* its dialog off as Amethyst's own, and — from the second dialog on the same page — offering "Block
* dialogs from this page", so a script looping on alert() can't trap the user. The block lasts until the
* next main-frame navigation ([onMainFrameNavigation]).
*
* Every [JsResult] handed in is answered exactly once on every path (button, back, dismissal, a dialog
* already showing, the Activity going away): until it is, the page's JavaScript stays blocked.
*/
internal class BrowserJsDialogs(
private val activity: Activity,
) {
private var showing: AlertDialog? = null
private var shownOnThisPage = 0
private var blocked = false
/** A new main-frame load: the page that earned the block is gone. */
fun onMainFrameNavigation() {
shownOnThisPage = 0
blocked = false
}
/** Closes a dialog still up (the Activity is being destroyed); its result is cancelled. */
fun dismiss() {
showing?.dismiss()
showing = null
}
fun alert(
url: String?,
message: String?,
result: JsResult,
): Boolean =
show(result, onBlocked = { result.cancel() }) { answer ->
setTitle(titleFor(url))
setMessage(message.orEmpty())
setPositiveButton(android.R.string.ok) { _, _ -> answer { result.confirm() } }
}
fun confirm(
url: String?,
message: String?,
result: JsResult,
): Boolean =
show(result, onBlocked = { result.cancel() }) { answer ->
setTitle(titleFor(url))
setMessage(message.orEmpty())
setPositiveButton(android.R.string.ok) { _, _ -> answer { result.confirm() } }
setNegativeButton(android.R.string.cancel) { _, _ -> answer { result.cancel() } }
}
fun prompt(
url: String?,
message: String?,
defaultValue: String?,
result: JsPromptResult,
): Boolean {
val field =
EditText(activity).apply {
setText(defaultValue.orEmpty())
setSelectAllOnFocus(true)
isSingleLine = true
inputType = InputType.TYPE_CLASS_TEXT
}
val container =
FrameLayout(activity).apply {
val pad = (20 * activity.resources.displayMetrics.density).toInt()
setPadding(pad, 0, pad, 0)
addView(field)
}
return show(result, onBlocked = { result.cancel() }) { answer ->
setTitle(titleFor(url))
setMessage(message.orEmpty())
setView(container)
setPositiveButton(android.R.string.ok) { _, _ -> answer { result.confirm(field.text?.toString().orEmpty()) } }
setNegativeButton(android.R.string.cancel) { _, _ -> answer { result.cancel() } }
}
}
/**
* "Leave site?" — confirm lets the navigation proceed, cancel keeps the user on the page. The page's own
* message is ignored, as in every current browser (it was abused for scare text). A blocked page may
* no longer hold the user hostage, so blocked means "leave".
*/
fun beforeUnload(result: JsResult): Boolean =
show(result, onBlocked = { result.confirm() }) { answer ->
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() } }
}
/**
* Builds and shows one dialog. [build] wires the buttons through `answer`, which records that the
* result was settled so the dismiss listener doesn't cancel it a second time. Always returns true: the
* result is ours from here on.
*/
private fun show(
result: JsResult,
onBlocked: () -> Unit,
build: AlertDialog.Builder.(answer: (() -> Unit) -> Unit) -> Unit,
): Boolean {
if (blocked) {
onBlocked()
return true
}
// One at a time, and never onto a window that is going away.
if (showing != null || activity.isFinishing || activity.isDestroyed) {
result.cancel()
return true
}
var settled = false
val answer: (() -> Unit) -> Unit = { action ->
if (!settled) {
settled = true
action()
}
}
shownOnThisPage++
val builder = AlertDialog.Builder(activity).apply { build(answer) }
if (shownOnThisPage > 1) {
builder.setNeutralButton(CommonsR.string.browser_js_dialog_block) { _, _ ->
blocked = true
answer { onBlocked() }
}
}
val dialog =
builder
.setOnDismissListener {
showing = null
// Back, a tap outside, or teardown: no button answered, so the page gets a cancel.
answer { result.cancel() }
}.create()
showing = dialog
dialog.show()
return true
}
private fun titleFor(url: String?): String {
val uri = url?.let { runCatching { it.toUri() }.getOrNull() }
val scheme = uri?.scheme?.lowercase()
val host = uri?.host?.takeIf { it.isNotBlank() }
return if (host != null && (scheme == "http" || scheme == "https")) {
activity.getString(CommonsR.string.browser_js_dialog_title, host)
} else {
activity.getString(CommonsR.string.browser_js_dialog_title_generic)
}
}
}
@@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.napplethost
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipDescription
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
@@ -40,6 +40,7 @@ import androidx.core.net.toUri
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo
import com.vitorpamplona.quartz.utils.Log
import java.net.URISyntaxException
import java.text.DateFormat
@@ -266,37 +267,48 @@ object BrowserWebTools {
// ---- 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,
/** The certificate of the page in [webView], for page info; null for a page without one. */
fun certificateInfo(webView: WebView): CertificateInfo? =
webView.certificate?.let { cert ->
CertificateInfo(
issuedTo = cert.issuedTo?.cName?.takeIf { it.isNotBlank() } ?: cert.issuedTo?.oName.orEmpty(),
issuedBy = cert.issuedBy?.oName?.takeIf { it.isNotBlank() } ?: cert.issuedBy?.cName.orEmpty(),
validUntil = cert.validNotAfterDate?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(it) }.orEmpty(),
)
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 ----
/** Copies [text] with no confirmation of our own (Android 13+ shows one). */
fun copyText(
context: Context,
label: String,
text: String,
) {
context.getSystemService(ClipboardManager::class.java)?.setPrimaryClip(ClipData.newPlainText(label, text))
}
/**
* Whether "Paste and go" can be offered. Checks the clip's type only, never its contents, so Android
* doesn't toast a clipboard read every time the pill opens.
*/
fun clipboardHasText(context: Context): Boolean {
val description = context.getSystemService(ClipboardManager::class.java)?.primaryClipDescription ?: return false
return description.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML)
}
/** The clipboard's text, trimmed; read only when the user asks to paste. */
fun clipboardText(context: Context): String? =
context
.getSystemService(ClipboardManager::class.java)
?.primaryClip
?.takeIf { it.itemCount > 0 }
?.getItemAt(0)
?.coerceToText(context)
?.toString()
?.trim()
?.takeIf { it.isNotEmpty() }
fun copyToClipboard(
context: Context,
text: String,
@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.napplethost
import android.Manifest
import android.app.ActivityManager
import android.app.AlertDialog
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.Icon
@@ -85,6 +85,10 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
@@ -108,7 +112,7 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR
*
* PWA behaviours, beyond the page itself: its own task in Recents titled, iconed and coloured after the
* site ([updateTaskDescription]); system bars tinted with the page's `theme-color`; the top pill
* ([NappletControlSheet], laid out by [BrowserChrome]); JS dialogs; new windows (`_blank` / `window.open`)
* ([BrowserChromeHost], laid out by [BrowserChrome]); JS dialogs; new windows (`_blank` / `window.open`)
* as new browser windows with `opener` intact ([BrowserPopups]); downloads; HTML fullscreen video;
* camera / microphone / location behind a per-site prompt; find in page; long-press link and image menus;
* Web Share; and recovery from a renderer crash.
@@ -130,10 +134,9 @@ class NappletBrowserActivity : ComponentActivity() {
private var loadingView: View? = null
private var crashView: View? = null
private var resumed = false
private var controlSheet: NappletControlSheet? = null
private var consolePanel: NappletConsolePanel? = null
private var findBar: BrowserFindBar? = null
private var consoleShowing = false
// The pill, find, console and page dialogs — the shared Compose chrome (see BrowserChromeHost).
private var chrome: BrowserChromeHost? = null
// A thin determinate progress bar pinned to the top edge (browser-style), driven by the chrome
// client's onProgressChanged; hidden at 100%.
@@ -161,8 +164,10 @@ class NappletBrowserActivity : ComponentActivity() {
private var customView: View? = null
private var customViewCallback: WebChromeClient.CustomViewCallback? = null
// Page-originated alert/confirm/prompt/beforeunload, labelled with the page's origin.
private val jsDialogs by lazy { BrowserJsDialogs(this) }
// Page-originated alert/confirm/prompt/beforeunload: from the second dialog on a page, the user may block
// the rest until the next main-frame navigation (Chrome's rule), so a looping alert() can't trap them.
private var jsDialogsOnPage = 0
private var jsDialogsBlocked = false
// ---- HTML file input (`<input type="file">`) ----
// Registered as a field so it is in place before onCreate returns, which is what
@@ -175,7 +180,6 @@ class NappletBrowserActivity : ComponentActivity() {
// ---- site permissions (camera / microphone / location) ----
private val sitePermissionQueries = mutableMapOf<Long, (Map<BrowserSitePermission, Decision>) -> Unit>()
private var sitePermissionSeq = 0L
private var permissionPrompt: AlertDialog? = null
private var pendingRuntimeGrant: ((Map<String, Boolean>) -> Unit)? = null
private val runtimePermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
@@ -230,7 +234,7 @@ class NappletBrowserActivity : ComponentActivity() {
val wv = webView
when {
customView != null -> exitFullscreen()
findBar?.isShowing == true -> findBar?.hide()
chrome?.handleBack() == true -> Unit
wv != null && wv.canGoBack() -> wv.goBack()
else -> {
isEnabled = false
@@ -242,7 +246,7 @@ class NappletBrowserActivity : ComponentActivity() {
}
private fun syncBackState() {
backCallback.isEnabled = customView != null || findBar?.isShowing == true || webView?.canGoBack() == true
backCallback.isEnabled = customView != null || chrome?.wantsBack == true || webView?.canGoBack() == true
}
private val brokerConnection =
@@ -304,18 +308,15 @@ class NappletBrowserActivity : ComponentActivity() {
bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE)
onBackPressedDispatcher.addCallback(this, backCallback)
val findBar = BrowserFindBar(this, { webView }) { syncBackState() }.also { this.findBar = it }
val root =
FrameLayout(this).apply {
setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
addView(contentFrame, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
addView(buildControlSheet(), FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.TOP))
addView(buildConsolePanel(), FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM))
addView(findBar, 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).
addView(topProgressBar)
}
this.root = root
chrome = buildChrome().also { it.attach(root) }
// Added last so the thin loading bar paints above the content (and over the grabber's top edge).
root.addView(topProgressBar)
setContentView(root)
// Pad by the system bars + cutout AND the IME: on an edge-to-edge window (enforced for targetSdk
// 35+ on Android 15+) windowSoftInputMode=adjustResize no longer shrinks the window, so without
@@ -355,6 +356,7 @@ class NappletBrowserActivity : ComponentActivity() {
BrowserWebTools.applyBrowserSettings(wv)
wv.webViewClient = BrowserClient()
wv.webChromeClient = BrowserChromeClient()
wv.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) }
wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), if (useTor) proxyPort else -1)
}
@@ -415,8 +417,8 @@ class NappletBrowserActivity : ComponentActivity() {
pendingFileChooser.cancel()
fileChooserLauncher.teardown()
// A dialog still up would leak its window and leave the page's JS blocked on an unanswered result.
jsDialogs.dismiss()
permissionPrompt?.dismiss()
chrome?.dialog?.answer?.invoke(false, null, false)
chrome?.permissionPrompt?.answer?.invoke(false, false)
customViewCallback?.onCustomViewHidden()
destroyWebView()
super.onDestroy()
@@ -466,7 +468,19 @@ class NappletBrowserActivity : ComponentActivity() {
runCatching { broker.send(msg) }
}
private fun currentUrl(): String = webView?.url?.takeIf { it.isNotBlank() } ?: controlSheet?.state?.url?.takeIf { it.isNotBlank() } ?: startUrl
private fun currentUrl(): String =
webView?.url?.takeIf { it.isNotBlank() } ?: chrome
?.ui
?.chrome
?.url
?.takeIf { it.isNotBlank() } ?: startUrl
/** Updates what the chrome shows. */
private inline fun updateUi(block: BrowserPillUi.() -> BrowserPillUi) {
chrome?.let { it.ui = it.ui.block() }
}
private inline fun updateChromeState(crossinline block: BrowserChrome.State.() -> BrowserChrome.State) = updateUi { copy(chrome = chrome.block()) }
/** Captures favicon, title and console output, and hosts every page-initiated UI. */
private inner class BrowserChromeClient : WebChromeClient() {
@@ -493,7 +507,7 @@ class NappletBrowserActivity : ComponentActivity() {
title: String?,
) {
pageTitle = title?.trim()?.takeIf { it.isNotEmpty() && it != view.url }
controlSheet?.updateTitle(title)
updateUi { copy(title = pageTitle ?: BrowserChrome.displayHost(chrome.url)) }
updateTaskDescription()
}
@@ -512,34 +526,30 @@ class NappletBrowserActivity : ComponentActivity() {
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
val panel = consolePanel ?: return false
panel.appendLog(
consoleMessage.messageLevel(),
consoleMessage.message(),
consoleMessage.sourceId(),
consoleMessage.lineNumber(),
val host = chrome ?: return false
host.appendConsole(
ConsoleLine(BrowserChromeHost.levelOf(consoleMessage.messageLevel()), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()),
)
controlSheet?.updateConsoleCount(panel.entryCount)
return true
}
// The framework's own JS dialogs only appear when the WebView's context IS an Activity
// (`JsDialogHelper.canShowAlertDialog`), and this one is built from [nightThemedContext] — a
// configuration context, not the Activity — so without these overrides every alert() was silently
// dismissed, confirm() always answered false and prompt() null. [jsDialogs] shows them itself.
// dismissed, confirm() always answered false and prompt() null. The chrome shows them itself.
override fun onJsAlert(
view: WebView,
url: String?,
message: String?,
result: JsResult,
): Boolean = jsDialogs.alert(url, message, result)
): Boolean = showJsDialog(PageDialogType.ALERT, url, message, null, result)
override fun onJsConfirm(
view: WebView,
url: String?,
message: String?,
result: JsResult,
): Boolean = jsDialogs.confirm(url, message, result)
): Boolean = showJsDialog(PageDialogType.CONFIRM, url, message, null, result)
override fun onJsPrompt(
view: WebView,
@@ -547,14 +557,14 @@ class NappletBrowserActivity : ComponentActivity() {
message: String?,
defaultValue: String?,
result: JsPromptResult,
): Boolean = jsDialogs.prompt(url, message, defaultValue, result)
): Boolean = showJsDialog(PageDialogType.PROMPT, url, message, defaultValue, result)
override fun onJsBeforeUnload(
view: WebView,
url: String?,
message: String?,
result: JsResult,
): Boolean = jsDialogs.beforeUnload(result)
): Boolean = showJsDialog(PageDialogType.BEFORE_UNLOAD, url, message, null, result)
/**
* A `_blank` link or a user-initiated `window.open()`: open it as a new browser window, like a new
@@ -583,7 +593,7 @@ class NappletBrowserActivity : ComponentActivity() {
override fun onPermissionRequest(request: PermissionRequest) = handlePermissionRequest(request)
override fun onPermissionRequestCanceled(request: PermissionRequest) {
permissionPrompt?.dismiss()
chrome?.permissionPrompt = null
}
override fun onGeolocationPermissionsShowPrompt(
@@ -598,7 +608,7 @@ class NappletBrowserActivity : ComponentActivity() {
}
override fun onGeolocationPermissionsHidePrompt() {
permissionPrompt?.dismiss()
chrome?.permissionPrompt = null
}
override fun onShowCustomView(
@@ -659,8 +669,9 @@ class NappletBrowserActivity : ComponentActivity() {
applyThemeColor(null)
}
// Chrome scopes "block this page's dialogs" to the page: a new main-frame load lifts it.
jsDialogs.onMainFrameNavigation()
controlSheet?.setLoading(true)
jsDialogsOnPage = 0
jsDialogsBlocked = false
updateChromeState { copy(isLoading = true) }
showUrl(url)
}
@@ -707,7 +718,7 @@ class NappletBrowserActivity : ComponentActivity() {
url: String,
) {
syncNavigation(view)
controlSheet?.setLoading(false)
updateChromeState { copy(isLoading = false) }
showUrl(url)
// Record only a clean http(s) main-frame load — never a typed-but-failed address.
if (!mainFrameLoadFailed && (url.startsWith("https://") || url.startsWith("http://"))) {
@@ -741,7 +752,7 @@ class NappletBrowserActivity : ComponentActivity() {
private fun syncNavigation(view: WebView) {
syncBackState()
controlSheet?.setNavigation(view.canGoBack(), view.canGoForward())
updateChromeState { copy(canGoBack = view.canGoBack(), canGoForward = view.canGoForward()) }
}
/**
@@ -749,7 +760,16 @@ class NappletBrowserActivity : ComponentActivity() {
* it is pinned — the registry lives in the main process, so the star can't know on its own.
*/
private fun showUrl(url: String) {
controlSheet?.updateUrl(url)
updateUi {
if (url == chrome.url) {
this
} else {
// Another site: its host names it until its title arrives; its pin state is unknown until the
// broker answers (the star toggle sends an explicit target, so a tap meanwhile can only add).
val newSite = BrowserChrome.displayHost(url) != BrowserChrome.displayHost(chrome.url)
copy(chrome = chrome.copy(url = url), title = if (newSite) BrowserChrome.displayHost(url) else title, isFavorite = false)
}
}
if (url == lastFavoriteQueryUrl) return
lastFavoriteQueryUrl = url
val msg =
@@ -835,7 +855,7 @@ class NappletBrowserActivity : ComponentActivity() {
if (resolved.forceTor && proxyPort > 0 && !useTor) {
useTor = true
applyWebViewProxy(proxyPort)
controlSheet?.setTor(true)
updateChromeState { copy(torOn = true) }
}
webView?.loadUrl(resolved.url)
}
@@ -944,7 +964,8 @@ class NappletBrowserActivity : ComponentActivity() {
}
NappletIpc.MSG_WEB_FAVORITE_STATE -> {
val url = data.getString(NappletIpc.KEY_FAVORITE_URL) ?: return true
controlSheet?.setFavorite(url, data.getBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, false))
val favorite = data.getBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, false)
updateUi { if (url == chrome.url) copy(isFavorite = favorite) else this }
}
NappletIpc.MSG_SITE_PERMISSIONS -> {
val callback = sitePermissionQueries.remove(data.getLong(NappletIpc.KEY_REQUEST_ID)) ?: return true
@@ -1004,10 +1025,10 @@ class NappletBrowserActivity : ComponentActivity() {
if (ask.isEmpty()) {
ensureRuntimePermissions(allowed, done)
} else {
showPermissionPrompt(origin, ask) { allow ->
// null = dismissed without an answer: deny this once, remember nothing.
if (allow != null) ask.forEach { rememberSitePermission(origin, it, if (allow) Decision.ALLOW else Decision.BLOCK) }
ensureRuntimePermissions(if (allow == true) allowed + ask else allowed, done)
showPermissionPrompt(origin, ask) { remembered, grantNow ->
// remembered: true/false = Allow while visiting / Don't allow; null = only this time or dismissed.
if (remembered != null) ask.forEach { rememberSitePermission(origin, it, if (remembered) Decision.ALLOW else Decision.BLOCK) }
ensureRuntimePermissions(if (grantNow) allowed + ask else allowed, done)
}
}
}
@@ -1049,49 +1070,33 @@ class NappletBrowserActivity : ComponentActivity() {
}
/**
* Chrome's permission bubble: "<host> wants to — Use your camera — Block / Allow". [answer] gets true
* (allow), false (block), or null when the prompt went away unanswered.
* The permission prompt ([com.vitorpamplona.amethyst.commons.browser.ui.pill.PermissionPromptCard]):
* [answer] gets true (allow, remembered), false (block, remembered), or null (allow only this time, or
* dismissed — nothing remembered; a dismissal also denies).
*/
private fun showPermissionPrompt(
origin: String,
permissions: List<BrowserSitePermission>,
answer: (Boolean?) -> Unit,
answer: (allow: Boolean?, grantNow: Boolean) -> Unit,
) {
if (isFinishing || isDestroyed || permissionPrompt != null) {
answer(null)
val host = chrome
if (host == null || isFinishing || isDestroyed || host.permissionPrompt != null) {
answer(null, false)
return
}
var answered = false
val lines =
permissions.joinToString("\n") {
"• " +
getString(
when (it) {
BrowserSitePermission.CAMERA -> CommonsR.string.browser_permission_camera
BrowserSitePermission.MICROPHONE -> CommonsR.string.browser_permission_microphone
BrowserSitePermission.LOCATION -> CommonsR.string.browser_permission_location
},
)
host.permissionPrompt =
BrowserChromeHost.PendingPermission(
host = BrowserChrome.displayHost(origin),
security = BrowserChrome.security(host.ui.chrome),
permissions = permissions.toSet(),
) { allow, remember ->
when {
allow && remember -> answer(true, true)
allow -> answer(null, true)
remember -> answer(false, false)
else -> answer(null, false)
}
}
permissionPrompt =
AlertDialog
.Builder(this)
.setTitle(getString(CommonsR.string.browser_permission_title, BrowserChrome.displayHost(origin)))
.setMessage(lines)
.setPositiveButton(CommonsR.string.browser_permission_allow) { _, _ ->
answered = true
answer(true)
}.setNegativeButton(CommonsR.string.browser_permission_block) { _, _ ->
answered = true
answer(false)
}.setOnDismissListener {
permissionPrompt = null
// Dismissed without choosing (back, the page cancelling): deny this time, remember nothing.
if (!answered) {
answered = true
answer(null)
}
}.show()
}
/** Requests Android's runtime permission for each allowed site permission that lacks it. */
@@ -1283,7 +1288,7 @@ class NappletBrowserActivity : ComponentActivity() {
useTor = newUseTor
applyWebViewProxy(if (useTor) proxyPort else -1)
webView?.reload()
controlSheet?.setTor(useTor)
updateChromeState { copy(torOn = useTor) }
// Key the persisted choice on the host actually displayed (which may differ from startUrl after
// in-page navigation), so the preference sticks to the right site.
val host = runCatching { currentUrl().toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return
@@ -1305,92 +1310,162 @@ class NappletBrowserActivity : ComponentActivity() {
private fun barTitle(): String = title.ifBlank { runCatching { startUrl.toUri().host }.getOrNull() ?: getString(CommonsR.string.napplet_untitled) }
/**
* The top pull-down pill: a small grabber at the top edge (out of the corner where a site shows its
* own avatar) that expands to the Chrome-PWA-style menu laid out by [BrowserChrome].
* The window's chrome: the pull-down pill at the top (out of the corner where a site shows its own avatar),
* find and the console at the bottom, and the page's dialogs — the shared Compose components.
*/
private fun buildControlSheet(): View =
NappletControlSheet(
context = this,
initialState =
BrowserChrome.State(
surface = BrowserChrome.Surface.WEB,
presentation = BrowserChrome.Presentation.FULL_SCREEN,
url = startUrl,
startUrl = startUrl,
torOn = if (proxyPort > 0) useTor else null,
private fun buildChrome(): BrowserChromeHost =
BrowserChromeHost(
activity = this,
dark = isDarkTheme(),
initial =
BrowserPillUi(
title = barTitle(),
chrome =
BrowserChrome.State(
surface = BrowserChrome.Surface.WEB,
presentation = BrowserChrome.Presentation.FULL_SCREEN,
url = startUrl,
startUrl = startUrl,
torOn = if (proxyPort > 0) useTor else null,
),
isFavorite = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
),
title = barTitle(),
listener = sheetListener,
isFavoriteInitially = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
).also { controlSheet = it }
listener = chromeListener,
)
private val sheetListener =
object : NappletControlSheet.Listener {
override fun onAction(action: Action) {
val wv = webView
when (action) {
Action.BACK -> wv?.goBack()
Action.FORWARD -> wv?.goForward()
Action.RELOAD -> wv?.reload()
Action.STOP -> wv?.stopLoading()
Action.FAVORITE -> sendFavoriteToggle(currentUrl(), controlSheet?.wantsFavorite() ?: true)
Action.SHARE -> BrowserWebTools.share(this@NappletBrowserActivity, pageTitle, null, currentUrl())
Action.BACK_TO_APP -> wv?.let { BrowserWebTools.backToScope(it, startUrl) }
Action.COPY_LINK -> BrowserWebTools.copyToClipboard(this@NappletBrowserActivity, currentUrl())
Action.EDIT_ADDRESS -> Unit
Action.FIND_IN_PAGE -> {
setConsoleShowing(false)
findBar?.show()
syncBackState()
private fun isDarkTheme(): Boolean =
when (themeType) {
"DARK" -> true
"LIGHT" -> false
else -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
}
private val chromeListener =
object : BrowserChromeHost.Listener {
override fun onPillEvent(event: BrowserPillEvent) {
when (event) {
is BrowserPillEvent.Action -> onAction(event.action)
is BrowserPillEvent.Navigate -> loadAddress(event.input)
is BrowserPillEvent.TextZoom -> {
webView?.let { BrowserWebTools.setTextZoom(it, event.percent) }
updateUi { copy(textZoom = event.percent) }
}
Action.TEXT_SIZE -> Unit
Action.DESKTOP_SITE ->
wv?.let {
val desktop = !BrowserWebTools.isDesktopMode(it)
BrowserWebTools.setDesktopMode(it, desktop)
controlSheet?.setDesktopSite(desktop)
}
Action.ADD_TO_HOME_SCREEN -> addToHomeScreen()
Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(this@NappletBrowserActivity, currentUrl())
Action.OPEN_FULL_SCREEN -> Unit
Action.TOR -> setNetworkMode(!useTor)
Action.ACCESS_INFO -> Unit
Action.SITE_SETTINGS -> openPermissions()
Action.CONSOLE -> setConsoleShowing(!consoleShowing)
BrowserPillEvent.CopyOrigin -> BrowserWebTools.copyToClipboard(this@NappletBrowserActivity, currentUrl())
BrowserPillEvent.PageInfo -> showPageInfo()
BrowserPillEvent.Close -> finish()
}
}
override fun onNavigate(text: String) = loadAddress(text)
override fun onTextZoom(percent: Int) {
webView?.let { BrowserWebTools.setTextZoom(it, percent) }
override fun onFind(query: String) {
val wv = webView ?: return
if (query.isEmpty()) wv.clearMatches() else wv.findAllAsync(query)
}
override fun onOriginTap() = showPageInfo()
override fun onFindNext(forward: Boolean) {
webView?.findNext(forward)
}
override fun onClose() = finish()
override fun onFindClosed() {
webView?.clearMatches()
}
override fun onPermissionChange(
permission: BrowserSitePermission,
decision: Decision,
) {
BrowserChrome.originOf(currentUrl())?.let { rememberSitePermission(it, permission, decision) }
}
override fun onClearSiteData() {
webView?.let { BrowserWebTools.clearSiteData(this@NappletBrowserActivity, it, currentUrl()) }
}
override fun onPanelsChanged() = syncBackState()
}
private fun setConsoleShowing(showing: Boolean) {
consoleShowing = showing
if (showing) findBar?.hide()
consolePanel?.setShowing(showing)
controlSheet?.setConsoleShowing(showing)
private fun onAction(action: Action) {
val wv = webView
when (action) {
Action.BACK -> wv?.goBack()
Action.FORWARD -> wv?.goForward()
Action.RELOAD -> wv?.reload()
Action.STOP -> wv?.stopLoading()
// The star flips the shown state; the toggle sends that target explicitly.
Action.FAVORITE -> sendFavoriteToggle(currentUrl(), chrome?.ui?.isFavorite != true)
Action.SHARE -> BrowserWebTools.share(this, pageTitle, null, currentUrl())
Action.BACK_TO_APP -> wv?.let { BrowserWebTools.backToScope(it, startUrl) }
Action.COPY_LINK -> BrowserWebTools.copyToClipboard(this, currentUrl())
Action.DESKTOP_SITE ->
wv?.let {
val desktop = !BrowserWebTools.isDesktopMode(it)
BrowserWebTools.setDesktopMode(it, desktop)
updateUi { copy(desktopSite = desktop) }
}
Action.ADD_TO_HOME_SCREEN -> addToHomeScreen()
Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(this, currentUrl())
Action.TOR -> setNetworkMode(!useTor)
Action.SITE_SETTINGS -> openPermissions()
else -> Unit
}
}
/** Chrome's page-info sheet: connection, Tor, certificate, then site settings and clearing its data. */
/**
* Shows a page's JS dialog in the chrome, titled with the page's host. Answers at once when dialogs are
* blocked for this page, another is already up, or the window is going away.
*/
private fun showJsDialog(
type: PageDialogType,
url: String?,
message: String?,
defaultValue: String?,
result: JsResult,
): Boolean {
val host = chrome
if (jsDialogsBlocked) {
// A blocked page may no longer hold the user on it: leaving is allowed, everything else cancels.
if (type == PageDialogType.BEFORE_UNLOAD) result.confirm() else result.cancel()
return true
}
if (host == null || host.dialog != null || isFinishing || isDestroyed) {
result.cancel()
return true
}
jsDialogsOnPage++
host.dialog =
BrowserChromeHost.PendingDialog(
type = type,
host = url?.let(BrowserChrome::originOf)?.let(BrowserChrome::displayHost),
security = BrowserChrome.security(host.ui.chrome),
message = message.orEmpty(),
defaultValue = defaultValue.orEmpty(),
offerBlock = jsDialogsOnPage > 1,
) { confirmed, text, block ->
if (block) jsDialogsBlocked = true
when {
!confirmed -> result.cancel()
result is JsPromptResult -> result.confirm(text.orEmpty())
else -> result.confirm()
}
}
return true
}
/**
* Chrome's page info: connection, route and certificate, then this site's camera / microphone / location
* answers (asked of the broker first, so the sheet shows the truth) and its data.
*/
private fun showPageInfo() {
val wv = webView ?: return
val url = currentUrl()
AlertDialog
.Builder(this)
.setTitle(BrowserChrome.displayHost(url))
.setMessage(BrowserWebTools.pageInfo(this, wv, if (proxyPort > 0) useTor else null))
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(CommonsR.string.browser_page_info_permissions) { _, _ -> openPermissions() }
.setNegativeButton(CommonsR.string.browser_page_info_clear_data) { _, _ ->
webView?.let { BrowserWebTools.clearSiteData(this, it, url) }
}.show()
val origin = BrowserChrome.originOf(currentUrl())
val certificate = BrowserWebTools.certificateInfo(wv)
if (origin == null) {
chrome?.showPageInfo(certificate)
return
}
querySitePermissions(origin) { decisions ->
updateUi { copy(sitePermissions = decisions.filterValues { it != Decision.ASK }) }
chrome?.showPageInfo(certificate)
}
}
/** Asks the main process to pin a launcher shortcut that reopens this page in Amethyst's browser. */
@@ -1446,12 +1521,6 @@ class NappletBrowserActivity : ComponentActivity() {
queueToBroker(msg)
}
private fun buildConsolePanel(): View =
NappletConsolePanel(this).also {
it.onClearCallback = { controlSheet?.updateConsoleCount(0) }
consolePanel = it
}
private fun buildLoadingView(): View =
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
@@ -1521,10 +1590,11 @@ class NappletBrowserActivity : ComponentActivity() {
private fun updateLoadProgress(progress: Int) {
if (progress >= 100) {
topProgressBar.visibility = View.GONE
controlSheet?.setLoading(false)
updateUi { copy(loadProgress = null, chrome = chrome.copy(isLoading = false)) }
} else {
topProgressBar.progress = progress
topProgressBar.visibility = View.VISIBLE
updateUi { copy(loadProgress = progress / 100f) }
}
}
@@ -1533,9 +1603,7 @@ class NappletBrowserActivity : ComponentActivity() {
request: WebResourceRequest,
message: String,
) {
val panel = consolePanel ?: return
panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0)
controlSheet?.updateConsoleCount(panel.entryCount)
chrome?.appendConsole(ConsoleLine(ConsoleLine.Level.ERROR, message, request.url?.toString().orEmpty(), 0))
}
private fun resolveThemeColor(attr: Int): Int {
@@ -145,10 +145,13 @@ object NappletBrowserContract {
/** 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]. */
/** Ask for the page's certificate for page info; answered with [MSG_PAGE_INFO]. */
const val MSG_PAGE_INFO_REQUEST = 25
/** Provider → client: [KEY_PAGE_INFO] for the page on screen. */
/**
* Provider → client: the certificate of the page on screen — [KEY_CERT_ISSUED_TO], [KEY_CERT_ISSUED_BY]
* and [KEY_CERT_VALID_UNTIL], all absent for a page without one (plain HTTP).
*/
const val MSG_PAGE_INFO = 26
/**
@@ -188,7 +191,9 @@ object NappletBrowserContract {
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_CERT_ISSUED_TO = "certIssuedTo"
const val KEY_CERT_ISSUED_BY = "certIssuedBy"
const val KEY_CERT_VALID_UNTIL = "certValidUntil"
const val KEY_DIALOG_ID = "dialogId"
const val KEY_DIALOG_TYPE = "dialogType"
const val KEY_DIALOG_MESSAGE = "dialogMessage"
@@ -262,8 +262,13 @@ class NappletBrowserService : Service() {
NappletBrowserContract.MSG_PAGE_INFO_REQUEST -> {
val tab = tabFor(msg) ?: return true
val wv = tab.webView ?: return true
val certificate = BrowserWebTools.certificateInfo(wv)
sendToClient(tab, NappletBrowserContract.MSG_PAGE_INFO) {
putString(NappletBrowserContract.KEY_PAGE_INFO, BrowserWebTools.pageInfo(this@NappletBrowserService, wv, if (tab.proxyPort > 0) tab.useTor else null))
certificate?.let {
putString(NappletBrowserContract.KEY_CERT_ISSUED_TO, it.issuedTo)
putString(NappletBrowserContract.KEY_CERT_ISSUED_BY, it.issuedBy)
putString(NappletBrowserContract.KEY_CERT_VALID_UNTIL, it.validUntil)
}
}
}
NappletBrowserContract.MSG_JS_DIALOG_RESULT -> {
@@ -1,302 +0,0 @@
/*
* 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.Typeface
import android.graphics.drawable.GradientDrawable
import android.util.TypedValue
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.webkit.ConsoleMessage
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* The full-screen browser's **bottom pull-up sheet** for JavaScript console output. The whole sheet is
* hidden until the user flips the Console **toggle** in [NappletControlSheet] ([setShowing]); turned on,
* it reveals a small grabber at the bottom edge (symmetric to that sheet's top grabber) already pulled
* up. Pull it down/up (or tap) to collapse/expand the scrollable log of
* [console.log / warn / error / debug] messages captured from the page via
* `WebChromeClient.onConsoleMessage`. Capped at [MAX_ENTRIES] entries (oldest dropped on overflow).
* Built in plain Views like [NappletControlSheet] — no Compose/Material.
*
* Its grabber + panel are elevated above [NappletControlSheet]'s panel so that, when both are open at
* once (e.g. in landscape), this bottom sheet draws on top of the top pull-down sheet — mirroring the
* Compose layer, where `BottomConsoleSheet` is composed after `TopControlSheet`.
*/
@SuppressLint("UseSwitchCompatOrMaterialCode")
class NappletConsolePanel(
context: Context,
) : LinearLayout(context) {
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 var expanded = false
private var showing = false
private val panel: LinearLayout
private lateinit var logContainer: LinearLayout
private lateinit var scrollView: ScrollView
init {
orientation = VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
// Hidden until the Console toggle turns it on; matches the Compose `BottomConsoleSheet`, which is
// only composed while the toggle is on.
visibility = View.GONE
panel = buildPanel().also { addView(it) }
addView(buildGrabber())
}
private fun buildPanel(): LinearLayout =
LinearLayout(context).apply {
orientation = VERTICAL
visibility = View.GONE
// Above NappletControlSheet's panel (6dp) so an open console draws over an open top sheet.
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(surface)
}
setPadding(dp(8), dp(10), dp(8), dp(6))
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(220))
val headerRow =
LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(8), 0, dp(4), dp(4))
addView(
TextView(context).apply {
text = context.getString(CommonsR.string.browser_console_title_short)
setTextColor(dimmed)
textSize = 12f
layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)
},
)
addView(
TextView(context).apply {
text = context.getString(CommonsR.string.browser_console_clear)
setTextColor(dimmed)
textSize = 12f
setPadding(dp(12), dp(6), dp(12), dp(6))
isClickable = true
setOnClickListener { clearLogs() }
},
)
}
val container =
LinearLayout(context).apply {
orientation = VERTICAL
}
logContainer = container
val sv =
ScrollView(context).apply {
addView(container, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)
}
scrollView = sv
addView(headerRow)
addView(sv)
}
/** Number of log entries currently stored (used to update the control sheet count label). */
var entryCount: Int = 0
private set
/**
* Shows or hides the entire sheet (grabber + log), driven by the control sheet's Console **toggle**:
* off hides everything, on reveals the sheet already pulled up — mirroring the Compose
* `BottomConsoleSheet`, which is only composed while the toggle is on and opens expanded.
*/
fun setShowing(show: Boolean) {
if (show == showing) return
showing = show
if (show) {
visibility = View.VISIBLE
expand()
} else {
collapse()
visibility = View.GONE
}
}
fun appendLog(
level: ConsoleMessage.MessageLevel,
message: String,
source: String,
lineNumber: Int,
) {
if (entryCount >= MAX_ENTRIES && logContainer.childCount > 0) {
logContainer.removeViewAt(0)
} else {
entryCount++
}
val levelChar =
when (level) {
ConsoleMessage.MessageLevel.ERROR -> "E"
ConsoleMessage.MessageLevel.WARNING -> "W"
ConsoleMessage.MessageLevel.TIP -> "T"
ConsoleMessage.MessageLevel.DEBUG -> "D"
else -> "I"
}
val levelColor =
when (level) {
ConsoleMessage.MessageLevel.ERROR -> Color.RED
ConsoleMessage.MessageLevel.WARNING -> Color.rgb(255, 152, 0)
ConsoleMessage.MessageLevel.TIP -> Color.CYAN
ConsoleMessage.MessageLevel.DEBUG -> dimmed
else -> onSurface
}
val srcShort =
source
.substringAfterLast("/")
.substringAfterLast("\\")
.let { if (it.isBlank()) source.takeLast(20) else it }
val annotation = if (srcShort.isNotBlank()) " ($srcShort:$lineNumber)" else ""
val entry =
TextView(context).apply {
text = "$levelChar $message$annotation"
setTextColor(levelColor)
textSize = 11f
typeface = Typeface.MONOSPACE
setPadding(dp(4), dp(2), dp(4), dp(2))
}
logContainer.addView(entry)
scrollView.post { scrollView.fullScroll(View.FOCUS_DOWN) }
}
private fun clearLogs() {
logContainer.removeAllViews()
entryCount = 0
// Return a callback so the activity can update the control sheet count after clearing.
onClearCallback?.invoke()
}
var onClearCallback: (() -> Unit)? = null
/** The grabber: a small rounded bar centered at the bottom edge. Tap toggles, vertical drag opens/closes. */
@SuppressLint("ClickableViewAccessibility")
private fun buildGrabber(): View {
val bar =
View(context).apply {
background =
GradientDrawable().apply {
cornerRadius = dp(3).toFloat()
setColor(dimmed and 0x99FFFFFF.toInt())
}
layoutParams = LayoutParams(dp(36), dp(5))
}
return LinearLayout(context).apply {
orientation = VERTICAL
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))
// Above NappletControlSheet's panel (6dp) so the grabber stays on top of an open top sheet.
elevation = dp(8).toFloat()
background =
GradientDrawable().apply {
cornerRadii = floatArrayOf(dp(12).toFloat(), dp(12).toFloat(), dp(12).toFloat(), dp(12).toFloat(), 0f, 0f, 0f, 0f)
setColor(withAlpha(surface, 0.6f))
}
isClickable = true
contentDescription = context.getString(CommonsR.string.browser_console_title_short)
addView(bar)
var downY = 0f
var dragged = false
setOnTouchListener { _, ev ->
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downY = ev.rawY
dragged = false
true
}
MotionEvent.ACTION_MOVE -> {
val dy = ev.rawY - downY
if (dy < -dp(8)) {
expand()
dragged = true
} else if (dy > dp(8)) {
collapse()
dragged = true
}
true
}
MotionEvent.ACTION_UP -> {
if (!dragged) {
if (expanded) collapse() else expand()
}
true
}
else -> false
}
}
}
}
private fun expand() {
if (expanded) return
expanded = true
panel.visibility = View.VISIBLE
}
private fun collapse() {
if (!expanded) return
expanded = false
panel.visibility = View.GONE
}
private fun withAlpha(
color: Int,
alpha: Float,
): Int = (color and 0x00FFFFFF) or ((alpha * 255).toInt() shl 24)
private fun resolveThemeColor(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()
private companion object {
private const val MAX_ENTRIES = 200
}
}
@@ -1,647 +0,0 @@
/*
* 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.Typeface
import android.graphics.drawable.GradientDrawable
import android.text.InputType
import android.text.TextUtils
import android.util.TypedValue
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 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 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:
*
* - 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", "ViewConstructor")
class NappletControlSheet(
context: Context,
initialState: BrowserChrome.State,
title: String,
private val listener: Listener,
isFavoriteInitially: Boolean = false,
/** 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 val panel: LinearLayout
private val grabber: View
init {
orientation = VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
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)
}
// ---- state updates from the host ----
/**
* 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 header(): View =
LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
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 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(state.url)
setTextColor(onSurface)
setHintTextColor(dimmed)
hint = context.getString(CommonsR.string.browser_address_hint)
contentDescription = context.getString(CommonsR.string.browser_address_hint)
textSize = 15f
isSingleLine = true
setSelectAllOnFocus(true)
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).apply { marginStart = dp(8) }
setOnEditorActionListener { v, actionId, _ ->
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
}
}
field.post {
field.requestFocus()
context.getSystemService(InputMethodManager::class.java)?.showSoftInput(field, 0)
}
return field
}
private fun iconRow(): View =
LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
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))
addView(iconView(BrowserChromeLabels.symbolFor(action), BrowserChromeLabels.drawableFor(action), dimmed, 22))
addView(
TextView(context).apply {
text = consoleLabel
setTextColor(onSurface)
textSize = 15f
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)
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 {
val bar =
View(context).apply {
background =
GradientDrawable().apply {
cornerRadius = dp(3).toFloat()
setColor(dimmed and 0x99FFFFFF.toInt())
}
layoutParams = LayoutParams(dp(36), dp(5))
}
return LinearLayout(context).apply {
orientation = VERTICAL
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 {
cornerRadii = floatArrayOf(0f, 0f, 0f, 0f, dp(12).toFloat(), dp(12).toFloat(), dp(12).toFloat(), dp(12).toFloat())
setColor(withAlpha(surface, 0.6f))
}
isClickable = true
contentDescription = title
addView(bar)
var downY = 0f
var dragged = false
setOnTouchListener { _, ev ->
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downY = ev.rawY
dragged = false
true
}
MotionEvent.ACTION_MOVE -> {
val dy = ev.rawY - downY
if (dy > dp(8)) {
expand()
dragged = true
} else if (dy < -dp(8)) {
collapse()
dragged = true
}
true
}
MotionEvent.ACTION_UP -> {
if (!dragged) {
if (expanded) {
collapse()
} else {
expand()
}
}
true
}
else -> false
}
}
}
}
val isExpanded: Boolean get() = expanded
fun expand() {
if (expanded) return
expanded = true
render()
panel.visibility = View.VISIBLE
}
fun collapse() {
if (!expanded) return
expanded = false
if (editingAddress) {
editingAddress = false
hideKeyboard(panel)
}
panel.visibility = View.GONE
panel.removeAllViews()
}
private fun withAlpha(
color: Int,
alpha: Float,
): Int = (color and 0x00FFFFFF) or ((alpha * 255).toInt() shl 24)
private fun resolveThemeColor(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()
/** 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))
}
}
}
@@ -26,6 +26,7 @@ import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.os.Handler
@@ -64,6 +65,10 @@ 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.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
@@ -194,11 +199,8 @@ class NappletHostActivity : ComponentActivity() {
// WebChromeClient's onProgressChanged; hidden at 100%.
private val topProgressBar by lazy { buildTopProgressBar() }
// 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
// The trusted pull-down pill, find and the developer console — the shared Compose chrome.
private var chrome: BrowserChromeHost? = null
// Set when the renderer died and the WebView was destroyed, so teardown doesn't touch it again.
private var webViewGone = false
@@ -211,8 +213,8 @@ class NappletHostActivity : ComponentActivity() {
private val backCallback =
object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (findBar?.isShowing == true) {
findBar?.hide()
if (chrome?.handleBack() == true) {
Unit
} else if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) {
webView.goBack()
} else {
@@ -225,7 +227,7 @@ class NappletHostActivity : ComponentActivity() {
/** Keep the in-WebView back gesture enabled exactly while the applet has history to pop. */
private fun syncBackState() {
val canGoBack = this::webView.isInitialized && !webViewGone && webView.canGoBack()
backCallback.isEnabled = canGoBack || findBar?.isShowing == true
backCallback.isEnabled = canGoBack || chrome?.wantsBack == true
}
// True between onResume and onPause. Sent to the broker (foreground hold) on connect too, in case
@@ -294,6 +296,7 @@ class NappletHostActivity : ComponentActivity() {
// profile has otherwise been used), so the storage partition must be chosen before anything else.
NappletWebViewProfile.apply(this, webView, webViewProfile)
hardenWebView(webView)
webView.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) }
// Theme the WebView's pre-paint background to the app's so it doesn't flash white when the shell
// mounts. This activity has a themed context, so it resolves the color locally (no IPC needed).
webView.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
@@ -321,33 +324,11 @@ class NappletHostActivity : ComponentActivity() {
val root =
FrameLayout(this).apply {
addView(contentFrame, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
addView(
buildControlSheet(),
FrameLayout
.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.TOP,
),
)
addView(
buildConsolePanel(),
FrameLayout
.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
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)
}
chrome = buildChrome().also { it.attach(root) }
// 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.
root.addView(topProgressBar)
setContentView(root)
// Activities are edge-to-edge by default on recent Android; pad by the system bar, display-cutout
// and IME insets so neither the chrome nor the applet draws under the system bars or the soft
@@ -669,7 +650,7 @@ class NappletHostActivity : ComponentActivity() {
): Boolean {
Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}); offering a restart" }
webViewGone = true
findBar?.hide()
chrome?.closeFind()
(view.parent as? ViewGroup)?.removeView(view)
view.destroy()
loadingView?.let { contentFrame.removeView(it) }
@@ -721,9 +702,10 @@ class NappletHostActivity : ComponentActivity() {
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
val panel = consolePanel ?: return false
panel.appendLog(consoleMessage.messageLevel(), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber())
controlSheet?.updateConsoleCount(panel.entryCount)
val host = chrome ?: return false
host.appendConsole(
ConsoleLine(BrowserChromeHost.levelOf(consoleMessage.messageLevel()), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()),
)
return true
}
}
@@ -751,9 +733,11 @@ class NappletHostActivity : ComponentActivity() {
private fun updateLoadProgress(progress: Int) {
if (progress >= 100) {
topProgressBar.visibility = View.GONE
chrome?.let { it.ui = it.ui.copy(loadProgress = null, chrome = it.ui.chrome.copy(isLoading = false)) }
} else {
topProgressBar.progress = progress
topProgressBar.visibility = View.VISIBLE
chrome?.let { it.ui = it.ui.copy(loadProgress = progress / 100f, chrome = it.ui.chrome.copy(isLoading = true)) }
}
}
@@ -762,9 +746,7 @@ class NappletHostActivity : ComponentActivity() {
request: WebResourceRequest,
message: String,
) {
val panel = consolePanel ?: return
panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0)
controlSheet?.updateConsoleCount(panel.entryCount)
chrome?.appendConsole(ConsoleLine(ConsoleLine.Level.ERROR, message, request.url?.toString().orEmpty(), 0))
}
// ---- bridge: shell <-> native ----
@@ -937,63 +919,88 @@ class NappletHostActivity : ComponentActivity() {
private fun barTitle(): String = title.ifBlank { getString(CommonsR.string.napplet_untitled) }
/**
* The trusted top pull-down sheet: a small grabber at the top edge (out of the corner where the app
* shows its own avatar) that expands to the sandbox **shield**, the nSite network/Tor row (website
* mode only, taps through to the confirm dialog), reload, and the "what it can access" sheet. The
* applet can't draw over it. Mirrors the embedded tabs' Compose `TopControlSheet`.
* The trusted pull-down pill: a small grabber at the top edge (out of the corner where the app shows its
* own avatar) that expands to the sandbox **shield**, the nSite network/Tor row (website mode only, taps
* through to a relaunch), reload, find, text size and "what it can access". The applet can't draw over
* it. The same Compose components as the embedded tabs and the web browser.
*/
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,
private fun buildChrome(): BrowserChromeHost =
BrowserChromeHost(
activity = this,
dark = isDarkTheme(),
initial =
BrowserPillUi(
title = barTitle(),
chrome =
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(),
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()
}
listener = chromeListener,
)
private fun isDarkTheme(): Boolean =
when (themeType) {
"DARK" -> true
"LIGHT" -> false
else -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
}
private fun liveWebView(): WebView? = if (this::webView.isInitialized && !webViewGone) webView else null
private val chromeListener =
object : BrowserChromeHost.Listener {
override fun onPillEvent(event: BrowserPillEvent) {
when (event) {
is BrowserPillEvent.Action ->
when (event.action) {
BrowserChrome.Action.RELOAD -> liveWebView()?.reload()
BrowserChrome.Action.STOP -> liveWebView()?.stopLoading()
BrowserChrome.Action.TOR -> setNetworkMode(!useTor)
BrowserChrome.Action.ACCESS_INFO -> showAccessDialog()
BrowserChrome.Action.SITE_SETTINGS -> openPermissions()
BrowserChrome.Action.CONSOLE -> setConsoleShowing(!consoleShowing)
else -> Unit
}
is BrowserPillEvent.TextZoom -> {
liveWebView()?.let { BrowserWebTools.setTextZoom(it, event.percent) }
chrome?.let { it.ui = it.ui.copy(textZoom = event.percent) }
}
BrowserPillEvent.PageInfo -> showAccessDialog()
BrowserPillEvent.Close -> finish()
else -> Unit
}
}
override fun onTextZoom(percent: Int) {
if (this@NappletHostActivity::webView.isInitialized && !webViewGone) BrowserWebTools.setTextZoom(webView, percent)
}
override fun onFind(query: String) {
val wv = liveWebView() ?: return
if (query.isEmpty()) wv.clearMatches() else wv.findAllAsync(query)
}
override fun onOriginTap() = showAccessDialog()
override fun onFindNext(forward: Boolean) {
liveWebView()?.findNext(forward)
}
override fun onClose() = finish()
},
).also { controlSheet = it }
override fun onFindClosed() {
liveWebView()?.clearMatches()
}
private fun setConsoleShowing(showing: Boolean) {
consoleShowing = showing
if (showing) findBar?.hide()
consolePanel?.setShowing(showing)
controlSheet?.setConsoleShowing(showing)
}
override fun onPermissionChange(
permission: BrowserSitePermission,
decision: BrowserSitePermission.Decision,
) = Unit
override fun onClearSiteData() = Unit
override fun onPanelsChanged() = syncBackState()
}
/**
* Ask the broker to open this napplet's editable permission screen. The sandbox can't state its own
@@ -1008,12 +1015,6 @@ class NappletHostActivity : ComponentActivity() {
if (brokerMessenger != null) sendToBroker(msg)
}
private fun buildConsolePanel(): View =
NappletConsolePanel(this).also {
it.onClearCallback = { controlSheet?.updateConsoleCount(0) }
consolePanel = it
}
/**
* A thin determinate progress bar pinned to the top edge, like a browser's. Driven by
* [NappletWebChromeClient.onProgressChanged]: visible while the shell + verified blobs load and gone