mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
-163
@@ -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)) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+3
-4
@@ -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(
|
||||
|
||||
+33
-11
@@ -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
|
||||
}
|
||||
|
||||
+121
-47
@@ -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,27 +184,7 @@ private fun EmbeddedWebAppTab(
|
||||
browserOrigin(currentUrl)?.let { origin -> nav.nav(Route.ConnectedAppDetail("browser:$origin")) }
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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,
|
||||
),
|
||||
isFavorite = isFavorite,
|
||||
desktopSite = desktopSite,
|
||||
textZoom = textZoom,
|
||||
onAction = { action ->
|
||||
fun onAction(action: BrowserChrome.Action) {
|
||||
when (action) {
|
||||
BrowserChrome.Action.BACK -> controller.back()
|
||||
BrowserChrome.Action.FORWARD -> controller.forward()
|
||||
@@ -217,10 +210,10 @@ private fun EmbeddedWebAppTab(
|
||||
BrowserChrome.Action.SITE_SETTINGS -> openSiteSettings()
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
onNavigate = { text ->
|
||||
val resolved = OmniboxInput.resolve(text)
|
||||
if (resolved != null) {
|
||||
}
|
||||
|
||||
fun onNavigate(text: String) {
|
||||
val resolved = OmniboxInput.resolve(text) ?: return
|
||||
// .onion only resolves over Tor.
|
||||
if (resolved.forceTor && proxyAvailable && !torOn) {
|
||||
torOn = true
|
||||
@@ -228,14 +221,64 @@ private fun EmbeddedWebAppTab(
|
||||
}
|
||||
controller.navigate(resolved.url)
|
||||
}
|
||||
},
|
||||
onTextZoom = { percent ->
|
||||
textZoom = percent
|
||||
controller.setTextZoom(percent)
|
||||
},
|
||||
onOriginTap = {
|
||||
|
||||
// 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, sitePermissions, candidates, controller) {
|
||||
EmbeddedTabChrome(
|
||||
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,
|
||||
),
|
||||
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)
|
||||
}
|
||||
BrowserPillEvent.CopyOrigin -> BrowserWebTools.copyToClipboard(context, currentUrl)
|
||||
BrowserPillEvent.PageInfo -> {
|
||||
controller.requestPageInfo()
|
||||
showPageInfo = true
|
||||
}
|
||||
BrowserPillEvent.Close -> Unit
|
||||
}
|
||||
},
|
||||
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,27 +383,48 @@ 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidPermissionFor(permission: BrowserSitePermission): String =
|
||||
|
||||
-237
@@ -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"
|
||||
}
|
||||
+4
-10
@@ -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,
|
||||
)
|
||||
|
||||
-123
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-19
@@ -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() },
|
||||
)
|
||||
|
||||
+101
-71
@@ -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.
|
||||
// Find in page: opened from the pill's Find tile.
|
||||
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()),
|
||||
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(')')
|
||||
}
|
||||
}
|
||||
|
||||
-459
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -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,8 +176,10 @@ private fun EmbeddedNostrAppTab(
|
||||
val chrome =
|
||||
remember(title, coordinate, isFavorite, controller) {
|
||||
EmbeddedTabChrome(
|
||||
ui =
|
||||
BrowserPillUi(
|
||||
title = title.ifBlank { coordinate },
|
||||
state =
|
||||
chrome =
|
||||
BrowserChrome.State(
|
||||
surface = BrowserChrome.Surface.NAPPLET,
|
||||
presentation = BrowserChrome.Presentation.EMBEDDED,
|
||||
@@ -187,8 +191,9 @@ private fun EmbeddedNostrAppTab(
|
||||
hasTextSize = false,
|
||||
),
|
||||
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
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
+14
-6
@@ -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),
|
||||
)
|
||||
|
||||
+35
@@ -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)
|
||||
+9
-4
@@ -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)) {
|
||||
|
||||
+3
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ fun BrowserPillAddressEditorPreview() {
|
||||
expanded = true,
|
||||
onExpandedChange = {},
|
||||
onEvent = {},
|
||||
suggestions = BrowserPillSamples.suggestions,
|
||||
suggestionsFor = { BrowserPillSamples.suggestions },
|
||||
clipboardUrl = "https://njump.me/npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
||||
initiallyEditing = true,
|
||||
)
|
||||
|
||||
+7
@@ -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),
|
||||
|
||||
+8
-1
@@ -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,
|
||||
) {
|
||||
// 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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+388
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-157
@@ -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()
|
||||
}
|
||||
-182
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-27
@@ -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,
|
||||
|
||||
+214
-146
@@ -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,13 +1310,17 @@ 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 =
|
||||
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,
|
||||
@@ -1319,78 +1328,144 @@ class NappletBrowserActivity : ComponentActivity() {
|
||||
startUrl = startUrl,
|
||||
torOn = if (proxyPort > 0) useTor else null,
|
||||
),
|
||||
title = barTitle(),
|
||||
listener = sheetListener,
|
||||
isFavoriteInitially = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
|
||||
).also { controlSheet = it }
|
||||
isFavorite = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
|
||||
),
|
||||
listener = chromeListener,
|
||||
)
|
||||
|
||||
private val sheetListener =
|
||||
object : NappletControlSheet.Listener {
|
||||
override fun onAction(action: Action) {
|
||||
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) }
|
||||
}
|
||||
BrowserPillEvent.CopyOrigin -> BrowserWebTools.copyToClipboard(this@NappletBrowserActivity, currentUrl())
|
||||
BrowserPillEvent.PageInfo -> showPageInfo()
|
||||
BrowserPillEvent.Close -> finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFind(query: String) {
|
||||
val wv = webView ?: return
|
||||
if (query.isEmpty()) wv.clearMatches() else wv.findAllAsync(query)
|
||||
}
|
||||
|
||||
override fun onFindNext(forward: Boolean) {
|
||||
webView?.findNext(forward)
|
||||
}
|
||||
|
||||
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 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())
|
||||
// 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@NappletBrowserActivity, currentUrl())
|
||||
Action.EDIT_ADDRESS -> Unit
|
||||
Action.FIND_IN_PAGE -> {
|
||||
setConsoleShowing(false)
|
||||
findBar?.show()
|
||||
syncBackState()
|
||||
}
|
||||
Action.TEXT_SIZE -> Unit
|
||||
Action.COPY_LINK -> BrowserWebTools.copyToClipboard(this, currentUrl())
|
||||
Action.DESKTOP_SITE ->
|
||||
wv?.let {
|
||||
val desktop = !BrowserWebTools.isDesktopMode(it)
|
||||
BrowserWebTools.setDesktopMode(it, desktop)
|
||||
controlSheet?.setDesktopSite(desktop)
|
||||
updateUi { copy(desktopSite = desktop) }
|
||||
}
|
||||
Action.ADD_TO_HOME_SCREEN -> addToHomeScreen()
|
||||
Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(this@NappletBrowserActivity, currentUrl())
|
||||
Action.OPEN_FULL_SCREEN -> Unit
|
||||
Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(this, currentUrl())
|
||||
Action.TOR -> setNetworkMode(!useTor)
|
||||
Action.ACCESS_INFO -> Unit
|
||||
Action.SITE_SETTINGS -> openPermissions()
|
||||
Action.CONSOLE -> setConsoleShowing(!consoleShowing)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNavigate(text: String) = loadAddress(text)
|
||||
|
||||
override fun onTextZoom(percent: Int) {
|
||||
webView?.let { BrowserWebTools.setTextZoom(it, percent) }
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
override fun onOriginTap() = showPageInfo()
|
||||
|
||||
override fun onClose() = finish()
|
||||
}
|
||||
|
||||
private fun setConsoleShowing(showing: Boolean) {
|
||||
consoleShowing = showing
|
||||
if (showing) findBar?.hide()
|
||||
consolePanel?.setShowing(showing)
|
||||
controlSheet?.setConsoleShowing(showing)
|
||||
}
|
||||
|
||||
/** Chrome's page-info sheet: connection, Tor, certificate, then site settings and clearing its data. */
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
+8
-3
@@ -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"
|
||||
|
||||
+6
-1
@@ -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 -> {
|
||||
|
||||
-302
@@ -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
|
||||
}
|
||||
}
|
||||
-647
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-81
@@ -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,62 +919,87 @@ 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 =
|
||||
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.
|
||||
// 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()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user