mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +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.
|
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
|
**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,
|
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
|
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
|
# 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
|
`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/`. They are rendered
|
||||||
offscreen by `BrowserPillRenderTest` (commonsUI jvmTest), which writes PNGs to
|
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
|
Follows `2026-09-26-browser-pwa-parity.md`. That plan settled *what* the browser does. This one is about
|
||||||
how it looks and feels.
|
how it looks and feels.
|
||||||
@@ -222,15 +222,35 @@ A sheet with:
|
|||||||
| Spacing | 4dp grid, 16dp sheet padding, 8dp between tiles |
|
| Spacing | 4dp grid, 16dp sheet padding, 8dp between tiles |
|
||||||
| Type | title `titleMedium`, origin `titleSmall`, tiles `labelMedium`, supporting `bodySmall` |
|
| Type | title `titleMedium`, origin `titleSmall`, tiles `labelMedium`, supporting `bodySmall` |
|
||||||
|
|
||||||
## 5. Next steps
|
## 5. What shipped
|
||||||
|
|
||||||
1. Wire the prototypes in: `TopControlSheet` → `BrowserPill`, `EmbeddedFindBar` → `FindInPagePill`,
|
Every browser surface now draws the components in `commonsUI/…/browser/ui/pill/`; the hand-built chrome
|
||||||
`BottomConsoleSheet` → `ConsoleSheet`, and the embedded dialogs → `PermissionPromptCard` /
|
is gone.
|
||||||
`PageDialogCard` / `PageInfoSheet`.
|
|
||||||
2. Host the same composables in the full-screen window through `ComposeView`, and delete
|
- **Embedded tabs** (`EmbeddedTabLayer`): `EmbeddedTabChrome` now carries a `BrowserPillUi` and one
|
||||||
`NappletControlSheet`, `BrowserFindBar`, `NappletConsolePanel` and `BrowserJsDialogs`.
|
`onEvent(BrowserPillEvent)` callback. The layer draws `BrowserPill`, `FindInPagePill` and
|
||||||
3. Move the pill's labels from the `commons` Android resources to the Compose catalogue (the
|
`ConsoleSheet`, and handles find and the console itself. The address editor gets suggestions from
|
||||||
prototypes already use `Res.string.browser_pill_*`).
|
favorites and history (`OmniboxSuggestions`), and "Paste and go" checks only the clip's type.
|
||||||
4. Add "Only this time" to the permission flow (the registry already treats "no answer" as
|
- **Embedded page dialogs** (`WebAppScreen`): `PageDialogCard`, `PermissionPromptCard` and
|
||||||
not-remembered).
|
`PageInfoSheet` in Compose `Dialog`s. `MSG_PAGE_INFO` now sends the certificate fields
|
||||||
5. Confirm before clearing site data in both surfaces.
|
(`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
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
|
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). */
|
/** A JS dialog an embedded page opened, waiting for the user (the page's script is paused meanwhile). */
|
||||||
data class EmbeddedJsDialog(
|
data class EmbeddedJsDialog(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val type: Type,
|
val type: PageDialogType,
|
||||||
val url: String?,
|
val url: String?,
|
||||||
val message: String,
|
val message: String,
|
||||||
val defaultValue: String,
|
val defaultValue: String,
|
||||||
/** Offer "Block dialogs from this page" (from the page's second dialog on, as Chrome does). */
|
/** Offer "Block dialogs from this page" (from the page's second dialog on, as Chrome does). */
|
||||||
val offerBlock: Boolean,
|
val offerBlock: Boolean,
|
||||||
) {
|
)
|
||||||
enum class Type { ALERT, CONFIRM, PROMPT, BEFORE_UNLOAD }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A camera / microphone / location request from an embedded page, waiting for an answer. */
|
/** A camera / microphone / location request from an embedded page, waiting for an answer. */
|
||||||
data class EmbeddedPermissionRequest(
|
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.client.view.SandboxedSdkView
|
||||||
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
|
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
|
||||||
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
|
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.NappletWebViewProfiles
|
||||||
import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator
|
import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator
|
||||||
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
|
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
|
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.EmbeddedImeBridge
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
|
||||||
@@ -102,7 +104,7 @@ class EmbeddedWebAppController(
|
|||||||
override var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? = null
|
override var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? = null
|
||||||
|
|
||||||
/** JavaScript console output received from the embedded WebView, capped at [MAX_CONSOLE_LOGS] entries. */
|
/** 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()
|
override fun clearConsoleLogs() = consoleLogs.clear()
|
||||||
|
|
||||||
@@ -126,8 +128,8 @@ class EmbeddedWebAppController(
|
|||||||
/** The camera / microphone / location request the page is waiting on, if any. */
|
/** The camera / microphone / location request the page is waiting on, if any. */
|
||||||
val pendingPermission = mutableStateOf<EmbeddedPermissionRequest?>(null)
|
val pendingPermission = mutableStateOf<EmbeddedPermissionRequest?>(null)
|
||||||
|
|
||||||
/** Page-info text for the page on screen, once requested. */
|
/** The certificate of the page on screen, once page info asked for it (null for none, or not yet). */
|
||||||
val pageInfo = mutableStateOf<String?>(null)
|
val pageCertificate = mutableStateOf<CertificateInfo?>(null)
|
||||||
|
|
||||||
/** A main-frame load is in flight (the pill's reload button becomes stop). */
|
/** A main-frame load is in flight (the pill's reload button becomes stop). */
|
||||||
val isLoading = mutableStateOf(false)
|
val isLoading = mutableStateOf(false)
|
||||||
@@ -284,7 +286,7 @@ class EmbeddedWebAppController(
|
|||||||
val source = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_SOURCE).orEmpty()
|
val source = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_SOURCE).orEmpty()
|
||||||
val line = msg.data?.getInt(NappletBrowserContract.KEY_CONSOLE_LINE, 0) ?: 0
|
val line = msg.data?.getInt(NappletBrowserContract.KEY_CONSOLE_LINE, 0) ?: 0
|
||||||
if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(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 -> {
|
NappletBrowserContract.MSG_FILE_CHOOSER_REQUEST -> {
|
||||||
val data = msg.data ?: return true
|
val data = msg.data ?: return true
|
||||||
@@ -309,7 +311,17 @@ class EmbeddedWebAppController(
|
|||||||
val data = msg.data ?: return true
|
val data = msg.data ?: return true
|
||||||
_findResult.value = FindResult(data.getInt(NappletBrowserContract.KEY_FIND_ACTIVE), data.getInt(NappletBrowserContract.KEY_FIND_TOTAL))
|
_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 -> {
|
NappletBrowserContract.MSG_JS_DIALOG -> {
|
||||||
val data = msg.data ?: return true
|
val data = msg.data ?: return true
|
||||||
val id = data.getLong(NappletBrowserContract.KEY_DIALOG_ID)
|
val id = data.getLong(NappletBrowserContract.KEY_DIALOG_ID)
|
||||||
@@ -323,10 +335,10 @@ class EmbeddedWebAppController(
|
|||||||
id = id,
|
id = id,
|
||||||
type =
|
type =
|
||||||
when (data.getString(NappletBrowserContract.KEY_DIALOG_TYPE)) {
|
when (data.getString(NappletBrowserContract.KEY_DIALOG_TYPE)) {
|
||||||
"confirm" -> EmbeddedJsDialog.Type.CONFIRM
|
"confirm" -> PageDialogType.CONFIRM
|
||||||
"prompt" -> EmbeddedJsDialog.Type.PROMPT
|
"prompt" -> PageDialogType.PROMPT
|
||||||
"beforeunload" -> EmbeddedJsDialog.Type.BEFORE_UNLOAD
|
"beforeunload" -> PageDialogType.BEFORE_UNLOAD
|
||||||
else -> EmbeddedJsDialog.Type.ALERT
|
else -> PageDialogType.ALERT
|
||||||
},
|
},
|
||||||
url = data.getString(NappletBrowserContract.KEY_URL),
|
url = data.getString(NappletBrowserContract.KEY_URL),
|
||||||
message = data.getString(NappletBrowserContract.KEY_DIALOG_MESSAGE).orEmpty(),
|
message = data.getString(NappletBrowserContract.KEY_DIALOG_MESSAGE).orEmpty(),
|
||||||
@@ -439,7 +451,7 @@ class EmbeddedWebAppController(
|
|||||||
fun clearSiteData() = send(NappletBrowserContract.MSG_CLEAR_SITE_DATA) {}
|
fun clearSiteData() = send(NappletBrowserContract.MSG_CLEAR_SITE_DATA) {}
|
||||||
|
|
||||||
fun requestPageInfo() {
|
fun requestPageInfo() {
|
||||||
pageInfo.value = null
|
pageCertificate.value = null
|
||||||
send(NappletBrowserContract.MSG_PAGE_INFO_REQUEST) {}
|
send(NappletBrowserContract.MSG_PAGE_INFO_REQUEST) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,3 +525,13 @@ class EmbeddedWebAppController(
|
|||||||
private const val MAX_CONSOLE_LOGS = 200
|
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
|
||||||
|
}
|
||||||
|
|||||||
+148
-74
@@ -30,7 +30,9 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
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.boundsInWindow
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.BrowserChrome
|
||||||
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
|
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
|
||||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
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.favorites.FavoriteApp
|
||||||
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||||
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
|
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.resources.browser_unsupported
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
|
||||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
||||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||||
import com.vitorpamplona.amethyst.favorites.WebShortcuts
|
import com.vitorpamplona.amethyst.favorites.WebShortcuts
|
||||||
@@ -171,71 +184,101 @@ private fun EmbeddedWebAppTab(
|
|||||||
browserOrigin(currentUrl)?.let { origin -> nav.nav(Route.ConnectedAppDetail("browser:$origin")) }
|
browserOrigin(currentUrl)?.let { origin -> nav.nav(Route.ConnectedAppDetail("browser:$origin")) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onAction(action: BrowserChrome.Action) {
|
||||||
|
when (action) {
|
||||||
|
BrowserChrome.Action.BACK -> controller.back()
|
||||||
|
BrowserChrome.Action.FORWARD -> controller.forward()
|
||||||
|
BrowserChrome.Action.RELOAD -> controller.reload()
|
||||||
|
BrowserChrome.Action.STOP -> controller.stop()
|
||||||
|
BrowserChrome.Action.FAVORITE -> toggleFavorite()
|
||||||
|
BrowserChrome.Action.SHARE -> BrowserWebTools.share(context, pageTitle, null, currentUrl)
|
||||||
|
BrowserChrome.Action.BACK_TO_APP -> controller.backToScope(url)
|
||||||
|
BrowserChrome.Action.COPY_LINK -> BrowserWebTools.copyToClipboard(context, currentUrl)
|
||||||
|
BrowserChrome.Action.DESKTOP_SITE -> {
|
||||||
|
desktopSite = !desktopSite
|
||||||
|
controller.setDesktopSite(desktopSite)
|
||||||
|
}
|
||||||
|
BrowserChrome.Action.ADD_TO_HOME_SCREEN -> WebShortcuts.requestPin(context, currentUrl, pageTitle ?: hostLabel(currentUrl))
|
||||||
|
BrowserChrome.Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(context, currentUrl)
|
||||||
|
// The page the user is looking at, not the one the tab was pinned with.
|
||||||
|
BrowserChrome.Action.OPEN_FULL_SCREEN -> FavoriteAppLauncher.launchUrl(context, currentUrl)
|
||||||
|
BrowserChrome.Action.TOR -> {
|
||||||
|
torOn = !torOn
|
||||||
|
controller.setTor(torOn)
|
||||||
|
WebAppNetworkRegistry.set(currentUrl, torOn)
|
||||||
|
}
|
||||||
|
BrowserChrome.Action.SITE_SETTINGS -> openSiteSettings()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onNavigate(text: String) {
|
||||||
|
val resolved = OmniboxInput.resolve(text) ?: return
|
||||||
|
// .onion only resolves over Tor.
|
||||||
|
if (resolved.forceTor && proxyAvailable && !torOn) {
|
||||||
|
torOn = true
|
||||||
|
controller.setTor(true)
|
||||||
|
}
|
||||||
|
controller.navigate(resolved.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites first, then history: what the address editor offers for what the user has typed.
|
||||||
|
val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle()
|
||||||
|
val candidates =
|
||||||
|
remember(apps, history) {
|
||||||
|
buildList {
|
||||||
|
apps.forEach { if (it is FavoriteApp.WebApp) add(OmniboxSuggestions.Candidate(it.url, it.label, isFavorite = true)) }
|
||||||
|
history.forEach {
|
||||||
|
add(OmniboxSuggestions.Candidate(it.url, it.title.ifBlank { it.host }, isFavorite = false, visitCount = it.visitCount, lastVisitedAt = it.lastVisitedAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val siteDecisions by WebSitePermissionRegistry.decisions.collectAsStateWithLifecycle()
|
||||||
|
val sitePermissions = remember(siteDecisions, currentUrl) { browserOrigin(currentUrl)?.let { siteDecisions[it] }.orEmpty() }
|
||||||
|
|
||||||
// Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame.
|
// Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame.
|
||||||
val chrome =
|
val chrome =
|
||||||
remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, proxyAvailable, isFavorite, desktopSite, textZoom, controller) {
|
remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, proxyAvailable, isFavorite, desktopSite, textZoom, sitePermissions, candidates, controller) {
|
||||||
EmbeddedTabChrome(
|
EmbeddedTabChrome(
|
||||||
title = pageTitle ?: hostLabel(currentUrl),
|
ui =
|
||||||
state =
|
BrowserPillUi(
|
||||||
BrowserChrome.State(
|
title = pageTitle ?: hostLabel(currentUrl),
|
||||||
surface = BrowserChrome.Surface.WEB,
|
chrome =
|
||||||
presentation = BrowserChrome.Presentation.EMBEDDED,
|
BrowserChrome.State(
|
||||||
url = currentUrl,
|
surface = BrowserChrome.Surface.WEB,
|
||||||
startUrl = url,
|
presentation = BrowserChrome.Presentation.EMBEDDED,
|
||||||
canGoBack = canGoBack,
|
url = currentUrl,
|
||||||
canGoForward = canGoForward,
|
startUrl = url,
|
||||||
isLoading = isLoading,
|
canGoBack = canGoBack,
|
||||||
torOn = if (proxyAvailable) torOn else null,
|
canGoForward = canGoForward,
|
||||||
hasSiteSettings = browserOrigin(currentUrl) != null,
|
isLoading = isLoading,
|
||||||
|
torOn = if (proxyAvailable) torOn else null,
|
||||||
|
hasSiteSettings = browserOrigin(currentUrl) != null,
|
||||||
|
),
|
||||||
|
isFavorite = isFavorite,
|
||||||
|
desktopSite = desktopSite,
|
||||||
|
textZoom = textZoom,
|
||||||
|
sitePermissions = sitePermissions,
|
||||||
),
|
),
|
||||||
isFavorite = isFavorite,
|
onEvent = { event ->
|
||||||
desktopSite = desktopSite,
|
when (event) {
|
||||||
textZoom = textZoom,
|
is BrowserPillEvent.Action -> onAction(event.action)
|
||||||
onAction = { action ->
|
is BrowserPillEvent.Navigate -> onNavigate(event.input)
|
||||||
when (action) {
|
is BrowserPillEvent.TextZoom -> {
|
||||||
BrowserChrome.Action.BACK -> controller.back()
|
textZoom = event.percent
|
||||||
BrowserChrome.Action.FORWARD -> controller.forward()
|
controller.setTextZoom(event.percent)
|
||||||
BrowserChrome.Action.RELOAD -> controller.reload()
|
|
||||||
BrowserChrome.Action.STOP -> controller.stop()
|
|
||||||
BrowserChrome.Action.FAVORITE -> toggleFavorite()
|
|
||||||
BrowserChrome.Action.SHARE -> BrowserWebTools.share(context, pageTitle, null, currentUrl)
|
|
||||||
BrowserChrome.Action.BACK_TO_APP -> controller.backToScope(url)
|
|
||||||
BrowserChrome.Action.COPY_LINK -> BrowserWebTools.copyToClipboard(context, currentUrl)
|
|
||||||
BrowserChrome.Action.DESKTOP_SITE -> {
|
|
||||||
desktopSite = !desktopSite
|
|
||||||
controller.setDesktopSite(desktopSite)
|
|
||||||
}
|
}
|
||||||
BrowserChrome.Action.ADD_TO_HOME_SCREEN -> WebShortcuts.requestPin(context, currentUrl, pageTitle ?: hostLabel(currentUrl))
|
BrowserPillEvent.CopyOrigin -> BrowserWebTools.copyToClipboard(context, currentUrl)
|
||||||
BrowserChrome.Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(context, currentUrl)
|
BrowserPillEvent.PageInfo -> {
|
||||||
// The page the user is looking at, not the one the tab was pinned with.
|
controller.requestPageInfo()
|
||||||
BrowserChrome.Action.OPEN_FULL_SCREEN -> FavoriteAppLauncher.launchUrl(context, currentUrl)
|
showPageInfo = true
|
||||||
BrowserChrome.Action.TOR -> {
|
|
||||||
torOn = !torOn
|
|
||||||
controller.setTor(torOn)
|
|
||||||
WebAppNetworkRegistry.set(currentUrl, torOn)
|
|
||||||
}
|
}
|
||||||
BrowserChrome.Action.SITE_SETTINGS -> openSiteSettings()
|
BrowserPillEvent.Close -> Unit
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNavigate = { text ->
|
suggestionsFor = { typed ->
|
||||||
val resolved = OmniboxInput.resolve(text)
|
OmniboxSuggestions.rank(typed, candidates, limit = 5).map { AddressSuggestion(it.label, it.url, it.isFavorite) }
|
||||||
if (resolved != null) {
|
|
||||||
// .onion only resolves over Tor.
|
|
||||||
if (resolved.forceTor && proxyAvailable && !torOn) {
|
|
||||||
torOn = true
|
|
||||||
controller.setTor(true)
|
|
||||||
}
|
|
||||||
controller.navigate(resolved.url)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTextZoom = { percent ->
|
|
||||||
textZoom = percent
|
|
||||||
controller.setTextZoom(percent)
|
|
||||||
},
|
|
||||||
onOriginTap = {
|
|
||||||
controller.requestPageInfo()
|
|
||||||
showPageInfo = true
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -259,7 +302,7 @@ private fun EmbeddedWebAppTab(
|
|||||||
// A fullscreen video inside the tab: back leaves fullscreen first, as in Chrome.
|
// A fullscreen video inside the tab: back leaves fullscreen first, as in Chrome.
|
||||||
BackHandler(enabled = isFullscreen) { controller.exitFullscreen() }
|
BackHandler(enabled = isFullscreen) { controller.exitFullscreen() }
|
||||||
|
|
||||||
EmbeddedPageUi(controller, currentUrl, showPageInfo, onPageInfoDismiss = { showPageInfo = false }, onSiteSettings = ::openSiteSettings)
|
EmbeddedPageUi(controller, chrome.ui, showPageInfo, onPageInfoDismiss = { showPageInfo = false })
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
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
|
* 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
|
* 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)
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmbeddedPageUi(
|
private fun EmbeddedPageUi(
|
||||||
controller: EmbeddedWebAppController,
|
controller: EmbeddedWebAppController,
|
||||||
currentUrl: String,
|
ui: BrowserPillUi,
|
||||||
showPageInfo: Boolean,
|
showPageInfo: Boolean,
|
||||||
onPageInfoDismiss: () -> Unit,
|
onPageInfoDismiss: () -> Unit,
|
||||||
onSiteSettings: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val dialog by controller.pendingDialog
|
val dialog by controller.pendingDialog
|
||||||
dialog?.let { d ->
|
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.
|
// Android's runtime permission, asked only for what the user allowed the site to use.
|
||||||
@@ -330,26 +383,47 @@ private fun EmbeddedPageUi(
|
|||||||
if (ask.isEmpty()) {
|
if (ask.isEmpty()) {
|
||||||
LaunchedEffect(request.id) { grant(request.id, allowed) }
|
LaunchedEffect(request.id) { grant(request.id, allowed) }
|
||||||
} else {
|
} else {
|
||||||
EmbeddedPermissionPrompt(request.origin, ask) { allow ->
|
// allow: grant now; remember: store the answer for the site ("Only this time" and a dismissal don't).
|
||||||
// null = dismissed without an answer: deny this once, remember nothing.
|
fun answer(
|
||||||
if (allow != null) {
|
allow: Boolean,
|
||||||
|
remember: Boolean,
|
||||||
|
) {
|
||||||
|
if (remember) {
|
||||||
val decision = if (allow) BrowserSitePermission.Decision.ALLOW else BrowserSitePermission.Decision.BLOCK
|
val decision = if (allow) BrowserSitePermission.Decision.ALLOW else BrowserSitePermission.Decision.BLOCK
|
||||||
ask.forEach { WebSitePermissionRegistry.set(request.origin, it, decision) }
|
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) {
|
if (showPageInfo) {
|
||||||
val info by controller.pageInfo
|
val certificate by controller.pageCertificate
|
||||||
EmbeddedPageInfoDialog(
|
val origin = browserOrigin(ui.chrome.url)
|
||||||
host = hostLabel(currentUrl),
|
Dialog(onDismissRequest = onPageInfoDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||||
info = info,
|
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||||
onPermissions = if (browserOrigin(currentUrl) != null) onSiteSettings else null,
|
PageInfoSheet(
|
||||||
onClearData = { controller.clearSiteData() },
|
ui = ui,
|
||||||
onDismiss = onPageInfoDismiss,
|
certificate = certificate,
|
||||||
)
|
onPermissionChange = { permission, decision -> origin?.let { WebSitePermissionRegistry.set(it, permission, decision) } },
|
||||||
|
onClearSiteData = {
|
||||||
|
onPageInfoDismiss()
|
||||||
|
controller.clearSiteData()
|
||||||
|
},
|
||||||
|
modifier = Modifier.widthIn(max = 560.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-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
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
|
||||||
|
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
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.
|
* 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
|
* The [consoleLogs] list is Compose snapshot state so the console sheet recomposes as messages arrive.
|
||||||
* arrive. Implemented by [com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController].
|
* Implemented by [com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController].
|
||||||
*/
|
*/
|
||||||
interface ConsoleBridge {
|
interface ConsoleBridge {
|
||||||
val consoleLogs: SnapshotStateList<ConsoleLogEntry>
|
val consoleLogs: SnapshotStateList<ConsoleLine>
|
||||||
|
|
||||||
fun clearConsoleLogs()
|
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
|
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
|
* What a running app surface shows in its top pull-down pill, as plain data so [EmbeddedTabLayer] can draw
|
||||||
* [EmbeddedTabLayer] can draw the sheet over the (z-below) surface for the active tab. Deliberately not a
|
* the shared [com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill] over the (z-below) surface
|
||||||
* corner pill: that's where a site usually puts the user's own avatar/menu, so the handle lives at the
|
* for the active tab. Deliberately not a corner pill: that's where a site usually puts the user's own
|
||||||
* top-center instead.
|
* 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
|
* *Which* actions show comes from [BrowserPillUi.chrome] through
|
||||||
* full-screen browser's native sheet uses — so the two can't drift. Everything the user picks arrives in
|
* [com.vitorpamplona.amethyst.commons.browser.BrowserChrome] — the same layout the full-screen windows
|
||||||
* [onAction]; the console and find-in-page rows are handled by the layer itself.
|
* 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(
|
data class EmbeddedTabChrome(
|
||||||
val title: String,
|
val ui: BrowserPillUi,
|
||||||
val state: BrowserChrome.State,
|
val onEvent: (BrowserPillEvent) -> Unit,
|
||||||
val isFavorite: Boolean = false,
|
/** Address-editor suggestions for what the user has typed. */
|
||||||
val desktopSite: Boolean = false,
|
val suggestionsFor: (String) -> List<AddressSuggestion> = { emptyList() },
|
||||||
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 = {},
|
|
||||||
)
|
)
|
||||||
|
|||||||
+102
-72
@@ -83,7 +83,14 @@ import androidx.compose.ui.unit.IntSize
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
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.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.coroutines.delay
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
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
|
* 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
|
* 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
|
* [EmbeddedSurfaceTouchHolder] so a scroll gesture isn't stolen by a host-side ancestor (the
|
||||||
* cross-process WebView can't defend its own gesture).
|
* 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
|
// The active tab's top pull-down pill, drawn AFTER the surfaces so it sits on top of the (z-below)
|
||||||
// (z-below) surface, anchored to the top of the active tab's reserved bounds. Its expanded state
|
// surface, anchored to the top of the active tab's reserved bounds. Its expanded state is owned here
|
||||||
// is owned here (reset per tab) so we can draw a full-area dismiss scrim behind the open sheet —
|
// (reset per tab) so we can draw a full-area dismiss scrim behind the open pill — while collapsed,
|
||||||
// while collapsed, only the small grabber is interactive and page taps pass through.
|
// only the small grabber is interactive and page taps pass through.
|
||||||
val chrome = EmbeddedTabHost.activeChrome
|
val chrome = EmbeddedTabHost.activeChrome
|
||||||
val consoleBridge = activeController as? ConsoleBridge
|
val consoleBridge = activeController as? ConsoleBridge
|
||||||
val consoleCount = consoleBridge?.consoleLogs?.size ?: 0
|
|
||||||
val findBridge = activeController as? FindBridge
|
val findBridge = activeController as? FindBridge
|
||||||
|
|
||||||
if (chrome != null && bounds.width > 0f && bounds.height > 0f) {
|
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 consoleShowing by remember(activeId) { mutableStateOf(false) }
|
||||||
var consoleExpanded by remember(activeId) { mutableStateOf(false) }
|
|
||||||
var findShowing 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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
) { sheetExpanded = false },
|
) { pillExpanded = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
with(density) {
|
val consoleLogs = consoleBridge?.consoleLogs
|
||||||
TopControlSheet(
|
val ui =
|
||||||
chrome = chrome,
|
chrome.ui.copy(
|
||||||
expanded = sheetExpanded,
|
chrome = chrome.ui.chrome.copy(hasFind = chrome.ui.chrome.hasFind && findBridge != null),
|
||||||
onExpandedChange = { sheetExpanded = it },
|
|
||||||
consoleCount = consoleCount,
|
|
||||||
consoleShowing = consoleShowing,
|
consoleShowing = consoleShowing,
|
||||||
onConsole =
|
consoleErrors = consoleLogs?.count { it.level == ConsoleLine.Level.ERROR } ?: 0,
|
||||||
if (consoleBridge != null) {
|
)
|
||||||
{
|
|
||||||
if (consoleShowing) {
|
Box(tabModifier) {
|
||||||
consoleShowing = false
|
BrowserPill(
|
||||||
} else {
|
ui = ui,
|
||||||
// One bottom panel at a time: the console replaces the find bar.
|
expanded = pillExpanded,
|
||||||
if (findShowing) findBridge?.find("")
|
onExpandedChange = { pillExpanded = it },
|
||||||
findShowing = false
|
onEvent = { event ->
|
||||||
consoleShowing = true
|
val action = (event as? BrowserPillEvent.Action)?.action
|
||||||
consoleExpanded = true
|
when {
|
||||||
}
|
action == BrowserChrome.Action.FIND_IN_PAGE && findBridge != null -> {
|
||||||
}
|
// One bottom panel at a time: find replaces the console.
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onFind =
|
|
||||||
if (findBridge != null) {
|
|
||||||
{
|
|
||||||
consoleShowing = false
|
consoleShowing = false
|
||||||
findShowing = true
|
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 {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
modifier =
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
Modifier
|
|
||||||
.absoluteOffset(
|
|
||||||
(bounds.left - layerOrigin.x).toDp(),
|
|
||||||
(bounds.top - layerOrigin.y).toDp(),
|
|
||||||
).width(bounds.width.toDp()),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
if (findShowing && findBridge != null) {
|
||||||
BackHandler {
|
BackHandler { closeFind() }
|
||||||
findBridge.find("")
|
val result by findBridge.findResult
|
||||||
findShowing = false
|
FindInPagePill(
|
||||||
}
|
query = findQuery,
|
||||||
with(density) {
|
onQueryChange = {
|
||||||
EmbeddedFindBar(
|
findQuery = it
|
||||||
bridge = findBridge,
|
findBridge.find(it)
|
||||||
onClose = { findShowing = false },
|
},
|
||||||
modifier =
|
active = result?.active ?: 0,
|
||||||
Modifier
|
total = result?.total,
|
||||||
.absoluteOffset(
|
onNext = findBridge::findNext,
|
||||||
(bounds.left - layerOrigin.x).toDp(),
|
onClose = ::closeFind,
|
||||||
(bounds.top - layerOrigin.y).toDp(),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
).size(bounds.width.toDp(), bounds.height.toDp()),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Bottom console panel: opened via the "Console" row in the top pull-down sheet.
|
// The developer console: opened from the pill's console row.
|
||||||
if (consoleShowing && consoleBridge != null) {
|
if (consoleShowing && consoleLogs != null) {
|
||||||
with(density) {
|
ConsoleSheet(
|
||||||
BottomConsoleSheet(
|
lines = consoleLogs,
|
||||||
logs = consoleBridge.consoleLogs,
|
onCopy = { lines -> BrowserWebTools.copyText(context, "console", lines.joinToString("\n", transform = ::formatConsoleLine)) },
|
||||||
expanded = consoleExpanded,
|
|
||||||
onExpandedChange = { consoleExpanded = it },
|
|
||||||
onClear = { consoleBridge.clearConsoleLogs() },
|
onClear = { consoleBridge.clearConsoleLogs() },
|
||||||
modifier =
|
onCopyLine = { BrowserWebTools.copyText(context, "console", formatConsoleLine(it)) },
|
||||||
Modifier
|
onClose = { consoleShowing = false },
|
||||||
.absoluteOffset(
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
(bounds.left - layerOrigin.x).toDp(),
|
|
||||||
(bounds.top - layerOrigin.y).toDp(),
|
|
||||||
).size(bounds.width.toDp(), bounds.height.toDp()),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1001,3 +1018,16 @@ private fun SelectionToolbarItem(
|
|||||||
}.padding(horizontal = 12.dp, vertical = 10.dp),
|
}.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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-15
@@ -55,6 +55,8 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
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.favorites.FavoriteApp
|
||||||
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||||
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
|
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
|
||||||
@@ -174,21 +176,24 @@ private fun EmbeddedNostrAppTab(
|
|||||||
val chrome =
|
val chrome =
|
||||||
remember(title, coordinate, isFavorite, controller) {
|
remember(title, coordinate, isFavorite, controller) {
|
||||||
EmbeddedTabChrome(
|
EmbeddedTabChrome(
|
||||||
title = title.ifBlank { coordinate },
|
ui =
|
||||||
state =
|
BrowserPillUi(
|
||||||
BrowserChrome.State(
|
title = title.ifBlank { coordinate },
|
||||||
surface = BrowserChrome.Surface.NAPPLET,
|
chrome =
|
||||||
presentation = BrowserChrome.Presentation.EMBEDDED,
|
BrowserChrome.State(
|
||||||
url = "",
|
surface = BrowserChrome.Surface.NAPPLET,
|
||||||
startUrl = "",
|
presentation = BrowserChrome.Presentation.EMBEDDED,
|
||||||
hasAccessInfo = true,
|
url = "",
|
||||||
// The embedded nsite/napplet host has no find or text-size plumbing (yet).
|
startUrl = "",
|
||||||
hasFind = false,
|
hasAccessInfo = true,
|
||||||
hasTextSize = false,
|
// The embedded nsite/napplet host has no find or text-size plumbing (yet).
|
||||||
|
hasFind = false,
|
||||||
|
hasTextSize = false,
|
||||||
|
),
|
||||||
|
isFavorite = isFavorite,
|
||||||
),
|
),
|
||||||
isFavorite = isFavorite,
|
onEvent = { event ->
|
||||||
onAction = { action ->
|
when ((event as? BrowserPillEvent.Action)?.action) {
|
||||||
when (action) {
|
|
||||||
BrowserChrome.Action.RELOAD -> controller.reload()
|
BrowserChrome.Action.RELOAD -> controller.reload()
|
||||||
BrowserChrome.Action.OPEN_FULL_SCREEN ->
|
BrowserChrome.Action.OPEN_FULL_SCREEN ->
|
||||||
FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()), appStillLoadingStr)
|
FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()), appStillLoadingStr)
|
||||||
@@ -205,7 +210,6 @@ private fun EmbeddedNostrAppTab(
|
|||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOriginTap = { showAccess = true },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Publish the top-sheet controls to the tab layer (drawn over the z-below surface). In a SideEffect
|
// 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>
|
<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. -->
|
<!-- 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>
|
<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) -->
|
<!-- Browser actions -->
|
||||||
<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>
|
|
||||||
<string name="browser_link_copied">Link copied</string>
|
<string name="browser_link_copied">Link copied</string>
|
||||||
<string name="browser_no_other_browser">No other browser installed</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 -->
|
<!-- Downloads and sharing -->
|
||||||
<string name="browser_download_started">Downloading %1$s…</string>
|
<string name="browser_download_started">Downloading %1$s…</string>
|
||||||
<string name="browser_download_saved">Saved %1$s to Downloads</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_download_failed">Couldn\'t download %1$s</string>
|
||||||
<string name="browser_share_chooser">Share</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>
|
<string name="browser_site_data_cleared">Site data cleared</string>
|
||||||
|
|
||||||
<!-- Site permissions (camera / microphone / location) -->
|
<!-- 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_camera">Use your camera</string>
|
||||||
<string name="browser_permission_microphone">Use your microphone</string>
|
<string name="browser_permission_microphone">Use your microphone</string>
|
||||||
<string name="browser_permission_location">Know your approximate location</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_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_ask">Ask</string>
|
||||||
<string name="browser_permission_allowed">Allowed</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
|
* 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
|
* 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
|
* (when the clipboard holds a URL) and the omnibox suggestions for what's typed ([suggestionsFor]), each
|
||||||
* without leaving. [onCancel] folds it back into the origin field.
|
* with a ↖ that fills its address in without leaving. [onCancel] folds it back into the origin field.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AddressEditor(
|
fun AddressEditor(
|
||||||
initialUrl: String,
|
initialUrl: String,
|
||||||
security: BrowserChrome.Security,
|
security: BrowserChrome.Security,
|
||||||
suggestions: List<AddressSuggestion>,
|
suggestionsFor: (String) -> List<AddressSuggestion>,
|
||||||
clipboardUrl: String?,
|
clipboardUrl: String?,
|
||||||
onGo: (String) -> Unit,
|
onGo: (String) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
autoFocus: Boolean = true,
|
autoFocus: Boolean = true,
|
||||||
|
onPasteAndGo: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
var field by remember { mutableStateOf(TextFieldValue(initialUrl, TextRange(0, initialUrl.length))) }
|
var field by remember { mutableStateOf(TextFieldValue(initialUrl, TextRange(0, initialUrl.length))) }
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
if (autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
|
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) {
|
fun go(text: String = field.text) {
|
||||||
text.trim().takeIf { it.isNotEmpty() }?.let(onGo)
|
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(
|
AssistChip(
|
||||||
onClick = { go(clipboardUrl) },
|
onClick = { if (clipboardUrl != null) go(clipboardUrl) else onPasteAndGo?.invoke() },
|
||||||
label = { Text(stringRes(Res.string.browser_pill_paste_go) + " · " + BrowserChrome.displayHost(clipboardUrl), maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
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)) },
|
leadingIcon = { Icon(MaterialSymbols.ContentPasteGo, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize)) },
|
||||||
modifier = Modifier.padding(start = 48.dp),
|
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,
|
onEvent: (BrowserPillEvent) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showClose: Boolean = false,
|
showClose: Boolean = false,
|
||||||
suggestions: List<AddressSuggestion> = emptyList(),
|
suggestionsFor: (String) -> List<AddressSuggestion> = { emptyList() },
|
||||||
clipboardUrl: String? = null,
|
clipboardUrl: String? = null,
|
||||||
initiallyEditing: Boolean = false,
|
initiallyEditing: Boolean = false,
|
||||||
initiallyTextSizeOpen: Boolean = false,
|
initiallyTextSizeOpen: Boolean = false,
|
||||||
|
onPasteAndGo: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -128,10 +129,11 @@ fun BrowserPill(
|
|||||||
onEvent(event)
|
onEvent(event)
|
||||||
},
|
},
|
||||||
showClose = showClose,
|
showClose = showClose,
|
||||||
suggestions = suggestions,
|
suggestionsFor = suggestionsFor,
|
||||||
clipboardUrl = clipboardUrl,
|
clipboardUrl = clipboardUrl,
|
||||||
initiallyEditing = initiallyEditing,
|
initiallyEditing = initiallyEditing,
|
||||||
initiallyTextSizeOpen = initiallyTextSizeOpen,
|
initiallyTextSizeOpen = initiallyTextSizeOpen,
|
||||||
|
onPasteAndGo = onPasteAndGo,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PillHandle(ui, expanded, onExpandedChange)
|
PillHandle(ui, expanded, onExpandedChange)
|
||||||
@@ -213,10 +215,11 @@ fun BrowserPillSheet(
|
|||||||
onEvent: (BrowserPillEvent) -> Unit,
|
onEvent: (BrowserPillEvent) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showClose: Boolean = false,
|
showClose: Boolean = false,
|
||||||
suggestions: List<AddressSuggestion> = emptyList(),
|
suggestionsFor: (String) -> List<AddressSuggestion> = { emptyList() },
|
||||||
clipboardUrl: String? = null,
|
clipboardUrl: String? = null,
|
||||||
initiallyEditing: Boolean = false,
|
initiallyEditing: Boolean = false,
|
||||||
initiallyTextSizeOpen: Boolean = false,
|
initiallyTextSizeOpen: Boolean = false,
|
||||||
|
onPasteAndGo: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
var editing by rememberSaveable { mutableStateOf(initiallyEditing) }
|
var editing by rememberSaveable { mutableStateOf(initiallyEditing) }
|
||||||
var textSizeOpen by rememberSaveable { mutableStateOf(initiallyTextSizeOpen) }
|
var textSizeOpen by rememberSaveable { mutableStateOf(initiallyTextSizeOpen) }
|
||||||
@@ -244,10 +247,11 @@ fun BrowserPillSheet(
|
|||||||
AddressEditor(
|
AddressEditor(
|
||||||
initialUrl = ui.chrome.url,
|
initialUrl = ui.chrome.url,
|
||||||
security = ui.security,
|
security = ui.security,
|
||||||
suggestions = suggestions,
|
suggestionsFor = suggestionsFor,
|
||||||
clipboardUrl = clipboardUrl,
|
clipboardUrl = clipboardUrl,
|
||||||
onGo = { onEvent(BrowserPillEvent.Navigate(it)) },
|
onGo = { onEvent(BrowserPillEvent.Navigate(it)) },
|
||||||
onCancel = { editing = false },
|
onCancel = { editing = false },
|
||||||
|
onPasteAndGo = onPasteAndGo,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
PillHeader(ui, showClose, onClose = { onEvent(BrowserPillEvent.Close) })
|
PillHeader(ui, showClose, onClose = { onEvent(BrowserPillEvent.Close) })
|
||||||
@@ -255,6 +259,7 @@ fun BrowserPillSheet(
|
|||||||
ui = ui,
|
ui = ui,
|
||||||
onEdit = if (Action.EDIT_ADDRESS in page) ({ editing = true }) else null,
|
onEdit = if (Action.EDIT_ADDRESS in page) ({ editing = true }) else null,
|
||||||
onLongPress = if (!ui.chrome.isSandbox) ({ onEvent(BrowserPillEvent.CopyOrigin) }) 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) {
|
if (Action.BACK_TO_APP in page) {
|
||||||
OutOfScopeBanner(homeHost = BrowserChrome.displayHost(ui.chrome.startUrl)) {
|
OutOfScopeBanner(homeHost = BrowserChrome.displayHost(ui.chrome.startUrl)) {
|
||||||
|
|||||||
+3
@@ -67,6 +67,9 @@ sealed interface BrowserPillEvent {
|
|||||||
/** The origin field was long-pressed (copy link). */
|
/** The origin field was long-pressed (copy link). */
|
||||||
data object CopyOrigin : BrowserPillEvent
|
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
|
data object Close : BrowserPillEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -148,7 +148,7 @@ fun BrowserPillAddressEditorPreview() {
|
|||||||
expanded = true,
|
expanded = true,
|
||||||
onExpandedChange = {},
|
onExpandedChange = {},
|
||||||
onEvent = {},
|
onEvent = {},
|
||||||
suggestions = BrowserPillSamples.suggestions,
|
suggestionsFor = { BrowserPillSamples.suggestions },
|
||||||
clipboardUrl = "https://njump.me/npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
clipboardUrl = "https://njump.me/npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
||||||
initiallyEditing = true,
|
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.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
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
|
||||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_all
|
import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_all
|
||||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_clear
|
import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_clear
|
||||||
@@ -91,6 +92,7 @@ fun ConsoleSheet(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
maxHeight: Dp = 320.dp,
|
maxHeight: Dp = 320.dp,
|
||||||
initialFilter: ConsoleFilter = ConsoleFilter.ALL,
|
initialFilter: ConsoleFilter = ConsoleFilter.ALL,
|
||||||
|
onClose: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
var filter by remember { mutableStateOf(initialFilter) }
|
var filter by remember { mutableStateOf(initialFilter) }
|
||||||
val errors = lines.count { it.level == ConsoleLine.Level.ERROR }
|
val errors = lines.count { it.level == ConsoleLine.Level.ERROR }
|
||||||
@@ -127,6 +129,11 @@ fun ConsoleSheet(
|
|||||||
IconButton(onClick = onClear, enabled = lines.isNotEmpty()) {
|
IconButton(onClick = onClear, enabled = lines.isNotEmpty()) {
|
||||||
Icon(MaterialSymbols.Delete, contentDescription = stringRes(Res.string.browser_pill_console_clear), modifier = Modifier.size(20.dp))
|
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(
|
Row(
|
||||||
Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp),
|
Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp),
|
||||||
|
|||||||
+10
-3
@@ -123,6 +123,7 @@ fun OriginField(
|
|||||||
onEdit: (() -> Unit)?,
|
onEdit: (() -> Unit)?,
|
||||||
onLongPress: (() -> Unit)?,
|
onLongPress: (() -> Unit)?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onSecurityTap: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val security = ui.security
|
val security = ui.security
|
||||||
Row(
|
Row(
|
||||||
@@ -136,11 +137,17 @@ fun OriginField(
|
|||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
onClick = { onEdit?.invoke() },
|
onClick = { onEdit?.invoke() },
|
||||||
onLongClick = onLongPress,
|
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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
SecurityIcon(security, size = 18.dp)
|
// Chrome's page-info entry point: the connection badge itself.
|
||||||
Spacer(Modifier.width(10.dp))
|
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)) {
|
Column(Modifier.weight(1f).padding(vertical = 6.dp)) {
|
||||||
Text(
|
Text(
|
||||||
if (ui.chrome.isSandbox) stringRes(securityLabel(security)) else ui.host,
|
if (ui.chrome.isSandbox) stringRes(securityLabel(security)) else ui.host,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.androidLibrary)
|
alias(libs.plugins.androidLibrary)
|
||||||
|
// The full-screen browser / napplet windows draw the shared Compose browser chrome (commonsUI).
|
||||||
|
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -43,6 +45,11 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.activity)
|
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.androidx.webkit)
|
||||||
implementation(libs.okhttp)
|
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.app.Activity
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
|
import android.content.ClipDescription
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.net.http.SslCertificate
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
@@ -40,6 +40,7 @@ import androidx.core.net.toUri
|
|||||||
import androidx.webkit.WebViewCompat
|
import androidx.webkit.WebViewCompat
|
||||||
import androidx.webkit.WebViewFeature
|
import androidx.webkit.WebViewFeature
|
||||||
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import java.net.URISyntaxException
|
import java.net.URISyntaxException
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
@@ -266,37 +267,48 @@ object BrowserWebTools {
|
|||||||
|
|
||||||
// ---- page info ----
|
// ---- page info ----
|
||||||
|
|
||||||
/** The paragraphs of the page-info sheet for the page in [webView]. */
|
/** The certificate of the page in [webView], for page info; null for a page without one. */
|
||||||
fun pageInfo(
|
fun certificateInfo(webView: WebView): CertificateInfo? =
|
||||||
context: Context,
|
webView.certificate?.let { cert ->
|
||||||
webView: WebView,
|
CertificateInfo(
|
||||||
torOn: Boolean?,
|
issuedTo = cert.issuedTo?.cName?.takeIf { it.isNotBlank() } ?: cert.issuedTo?.oName.orEmpty(),
|
||||||
): String {
|
issuedBy = cert.issuedBy?.oName?.takeIf { it.isNotBlank() } ?: cert.issuedBy?.cName.orEmpty(),
|
||||||
val url = webView.url.orEmpty()
|
validUntil = cert.validNotAfterDate?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(it) }.orEmpty(),
|
||||||
val lines = mutableListOf<String>()
|
|
||||||
lines +=
|
|
||||||
context.getString(
|
|
||||||
if (url.startsWith("https://", ignoreCase = true)) CommonsR.string.browser_page_info_https else CommonsR.string.browser_page_info_http,
|
|
||||||
)
|
)
|
||||||
if (torOn != null) {
|
|
||||||
lines += context.getString(if (torOn) CommonsR.string.browser_page_info_tor else CommonsR.string.browser_page_info_open_web)
|
|
||||||
}
|
}
|
||||||
webView.certificate?.let { lines += certificateLine(context, it) }
|
|
||||||
return lines.joinToString("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun certificateLine(
|
|
||||||
context: Context,
|
|
||||||
cert: SslCertificate,
|
|
||||||
): String {
|
|
||||||
val to = cert.issuedTo?.cName?.takeIf { it.isNotBlank() } ?: cert.issuedTo?.oName.orEmpty()
|
|
||||||
val by = cert.issuedBy?.oName?.takeIf { it.isNotBlank() } ?: cert.issuedBy?.cName.orEmpty()
|
|
||||||
val until = cert.validNotAfterDate?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(it) }.orEmpty()
|
|
||||||
return context.getString(CommonsR.string.browser_page_info_certificate, to, by, until)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- copy / share / other browser ----
|
// ---- 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(
|
fun copyToClipboard(
|
||||||
context: Context,
|
context: Context,
|
||||||
text: String,
|
text: String,
|
||||||
|
|||||||
+230
-162
@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.napplethost
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.ActivityManager
|
import android.app.ActivityManager
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.drawable.Icon
|
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
|
||||||
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision
|
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision
|
||||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
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.napplet.NappletWebContract
|
||||||
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
|
||||||
import com.vitorpamplona.amethyst.commons.util.stringOrNull
|
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
|
* 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
|
* 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;
|
* 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;
|
* 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.
|
* Web Share; and recovery from a renderer crash.
|
||||||
@@ -130,10 +134,9 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
private var loadingView: View? = null
|
private var loadingView: View? = null
|
||||||
private var crashView: View? = null
|
private var crashView: View? = null
|
||||||
private var resumed = false
|
private var resumed = false
|
||||||
private var controlSheet: NappletControlSheet? = null
|
|
||||||
private var consolePanel: NappletConsolePanel? = null
|
// The pill, find, console and page dialogs — the shared Compose chrome (see BrowserChromeHost).
|
||||||
private var findBar: BrowserFindBar? = null
|
private var chrome: BrowserChromeHost? = null
|
||||||
private var consoleShowing = false
|
|
||||||
|
|
||||||
// A thin determinate progress bar pinned to the top edge (browser-style), driven by the chrome
|
// A thin determinate progress bar pinned to the top edge (browser-style), driven by the chrome
|
||||||
// client's onProgressChanged; hidden at 100%.
|
// client's onProgressChanged; hidden at 100%.
|
||||||
@@ -161,8 +164,10 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
private var customView: View? = null
|
private var customView: View? = null
|
||||||
private var customViewCallback: WebChromeClient.CustomViewCallback? = null
|
private var customViewCallback: WebChromeClient.CustomViewCallback? = null
|
||||||
|
|
||||||
// Page-originated alert/confirm/prompt/beforeunload, labelled with the page's origin.
|
// Page-originated alert/confirm/prompt/beforeunload: from the second dialog on a page, the user may block
|
||||||
private val jsDialogs by lazy { BrowserJsDialogs(this) }
|
// 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">`) ----
|
// ---- HTML file input (`<input type="file">`) ----
|
||||||
// Registered as a field so it is in place before onCreate returns, which is what
|
// 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) ----
|
// ---- site permissions (camera / microphone / location) ----
|
||||||
private val sitePermissionQueries = mutableMapOf<Long, (Map<BrowserSitePermission, Decision>) -> Unit>()
|
private val sitePermissionQueries = mutableMapOf<Long, (Map<BrowserSitePermission, Decision>) -> Unit>()
|
||||||
private var sitePermissionSeq = 0L
|
private var sitePermissionSeq = 0L
|
||||||
private var permissionPrompt: AlertDialog? = null
|
|
||||||
private var pendingRuntimeGrant: ((Map<String, Boolean>) -> Unit)? = null
|
private var pendingRuntimeGrant: ((Map<String, Boolean>) -> Unit)? = null
|
||||||
private val runtimePermissionLauncher =
|
private val runtimePermissionLauncher =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||||
@@ -230,7 +234,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
val wv = webView
|
val wv = webView
|
||||||
when {
|
when {
|
||||||
customView != null -> exitFullscreen()
|
customView != null -> exitFullscreen()
|
||||||
findBar?.isShowing == true -> findBar?.hide()
|
chrome?.handleBack() == true -> Unit
|
||||||
wv != null && wv.canGoBack() -> wv.goBack()
|
wv != null && wv.canGoBack() -> wv.goBack()
|
||||||
else -> {
|
else -> {
|
||||||
isEnabled = false
|
isEnabled = false
|
||||||
@@ -242,7 +246,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun syncBackState() {
|
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 =
|
private val brokerConnection =
|
||||||
@@ -304,18 +308,15 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE)
|
bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE)
|
||||||
onBackPressedDispatcher.addCallback(this, backCallback)
|
onBackPressedDispatcher.addCallback(this, backCallback)
|
||||||
|
|
||||||
val findBar = BrowserFindBar(this, { webView }) { syncBackState() }.also { this.findBar = it }
|
|
||||||
val root =
|
val root =
|
||||||
FrameLayout(this).apply {
|
FrameLayout(this).apply {
|
||||||
setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
|
setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
|
||||||
addView(contentFrame, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
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
|
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)
|
setContentView(root)
|
||||||
// Pad by the system bars + cutout AND the IME: on an edge-to-edge window (enforced for targetSdk
|
// 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
|
// 35+ on Android 15+) windowSoftInputMode=adjustResize no longer shrinks the window, so without
|
||||||
@@ -355,6 +356,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
BrowserWebTools.applyBrowserSettings(wv)
|
BrowserWebTools.applyBrowserSettings(wv)
|
||||||
wv.webViewClient = BrowserClient()
|
wv.webViewClient = BrowserClient()
|
||||||
wv.webChromeClient = BrowserChromeClient()
|
wv.webChromeClient = BrowserChromeClient()
|
||||||
|
wv.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) }
|
||||||
wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
|
wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
|
||||||
BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), if (useTor) proxyPort else -1)
|
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()
|
pendingFileChooser.cancel()
|
||||||
fileChooserLauncher.teardown()
|
fileChooserLauncher.teardown()
|
||||||
// A dialog still up would leak its window and leave the page's JS blocked on an unanswered result.
|
// A dialog still up would leak its window and leave the page's JS blocked on an unanswered result.
|
||||||
jsDialogs.dismiss()
|
chrome?.dialog?.answer?.invoke(false, null, false)
|
||||||
permissionPrompt?.dismiss()
|
chrome?.permissionPrompt?.answer?.invoke(false, false)
|
||||||
customViewCallback?.onCustomViewHidden()
|
customViewCallback?.onCustomViewHidden()
|
||||||
destroyWebView()
|
destroyWebView()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
@@ -466,7 +468,19 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
runCatching { broker.send(msg) }
|
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. */
|
/** Captures favicon, title and console output, and hosts every page-initiated UI. */
|
||||||
private inner class BrowserChromeClient : WebChromeClient() {
|
private inner class BrowserChromeClient : WebChromeClient() {
|
||||||
@@ -493,7 +507,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
title: String?,
|
title: String?,
|
||||||
) {
|
) {
|
||||||
pageTitle = title?.trim()?.takeIf { it.isNotEmpty() && it != view.url }
|
pageTitle = title?.trim()?.takeIf { it.isNotEmpty() && it != view.url }
|
||||||
controlSheet?.updateTitle(title)
|
updateUi { copy(title = pageTitle ?: BrowserChrome.displayHost(chrome.url)) }
|
||||||
updateTaskDescription()
|
updateTaskDescription()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,34 +526,30 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
|
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
|
||||||
val panel = consolePanel ?: return false
|
val host = chrome ?: return false
|
||||||
panel.appendLog(
|
host.appendConsole(
|
||||||
consoleMessage.messageLevel(),
|
ConsoleLine(BrowserChromeHost.levelOf(consoleMessage.messageLevel()), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()),
|
||||||
consoleMessage.message(),
|
|
||||||
consoleMessage.sourceId(),
|
|
||||||
consoleMessage.lineNumber(),
|
|
||||||
)
|
)
|
||||||
controlSheet?.updateConsoleCount(panel.entryCount)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// The framework's own JS dialogs only appear when the WebView's context IS an Activity
|
// 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
|
// (`JsDialogHelper.canShowAlertDialog`), and this one is built from [nightThemedContext] — a
|
||||||
// configuration context, not the Activity — so without these overrides every alert() was silently
|
// 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(
|
override fun onJsAlert(
|
||||||
view: WebView,
|
view: WebView,
|
||||||
url: String?,
|
url: String?,
|
||||||
message: String?,
|
message: String?,
|
||||||
result: JsResult,
|
result: JsResult,
|
||||||
): Boolean = jsDialogs.alert(url, message, result)
|
): Boolean = showJsDialog(PageDialogType.ALERT, url, message, null, result)
|
||||||
|
|
||||||
override fun onJsConfirm(
|
override fun onJsConfirm(
|
||||||
view: WebView,
|
view: WebView,
|
||||||
url: String?,
|
url: String?,
|
||||||
message: String?,
|
message: String?,
|
||||||
result: JsResult,
|
result: JsResult,
|
||||||
): Boolean = jsDialogs.confirm(url, message, result)
|
): Boolean = showJsDialog(PageDialogType.CONFIRM, url, message, null, result)
|
||||||
|
|
||||||
override fun onJsPrompt(
|
override fun onJsPrompt(
|
||||||
view: WebView,
|
view: WebView,
|
||||||
@@ -547,14 +557,14 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
message: String?,
|
message: String?,
|
||||||
defaultValue: String?,
|
defaultValue: String?,
|
||||||
result: JsPromptResult,
|
result: JsPromptResult,
|
||||||
): Boolean = jsDialogs.prompt(url, message, defaultValue, result)
|
): Boolean = showJsDialog(PageDialogType.PROMPT, url, message, defaultValue, result)
|
||||||
|
|
||||||
override fun onJsBeforeUnload(
|
override fun onJsBeforeUnload(
|
||||||
view: WebView,
|
view: WebView,
|
||||||
url: String?,
|
url: String?,
|
||||||
message: String?,
|
message: String?,
|
||||||
result: JsResult,
|
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
|
* 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 onPermissionRequest(request: PermissionRequest) = handlePermissionRequest(request)
|
||||||
|
|
||||||
override fun onPermissionRequestCanceled(request: PermissionRequest) {
|
override fun onPermissionRequestCanceled(request: PermissionRequest) {
|
||||||
permissionPrompt?.dismiss()
|
chrome?.permissionPrompt = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onGeolocationPermissionsShowPrompt(
|
override fun onGeolocationPermissionsShowPrompt(
|
||||||
@@ -598,7 +608,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onGeolocationPermissionsHidePrompt() {
|
override fun onGeolocationPermissionsHidePrompt() {
|
||||||
permissionPrompt?.dismiss()
|
chrome?.permissionPrompt = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onShowCustomView(
|
override fun onShowCustomView(
|
||||||
@@ -659,8 +669,9 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
applyThemeColor(null)
|
applyThemeColor(null)
|
||||||
}
|
}
|
||||||
// Chrome scopes "block this page's dialogs" to the page: a new main-frame load lifts it.
|
// Chrome scopes "block this page's dialogs" to the page: a new main-frame load lifts it.
|
||||||
jsDialogs.onMainFrameNavigation()
|
jsDialogsOnPage = 0
|
||||||
controlSheet?.setLoading(true)
|
jsDialogsBlocked = false
|
||||||
|
updateChromeState { copy(isLoading = true) }
|
||||||
showUrl(url)
|
showUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,7 +718,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
url: String,
|
url: String,
|
||||||
) {
|
) {
|
||||||
syncNavigation(view)
|
syncNavigation(view)
|
||||||
controlSheet?.setLoading(false)
|
updateChromeState { copy(isLoading = false) }
|
||||||
showUrl(url)
|
showUrl(url)
|
||||||
// Record only a clean http(s) main-frame load — never a typed-but-failed address.
|
// Record only a clean http(s) main-frame load — never a typed-but-failed address.
|
||||||
if (!mainFrameLoadFailed && (url.startsWith("https://") || url.startsWith("http://"))) {
|
if (!mainFrameLoadFailed && (url.startsWith("https://") || url.startsWith("http://"))) {
|
||||||
@@ -741,7 +752,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private fun syncNavigation(view: WebView) {
|
private fun syncNavigation(view: WebView) {
|
||||||
syncBackState()
|
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.
|
* it is pinned — the registry lives in the main process, so the star can't know on its own.
|
||||||
*/
|
*/
|
||||||
private fun showUrl(url: String) {
|
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
|
if (url == lastFavoriteQueryUrl) return
|
||||||
lastFavoriteQueryUrl = url
|
lastFavoriteQueryUrl = url
|
||||||
val msg =
|
val msg =
|
||||||
@@ -835,7 +855,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
if (resolved.forceTor && proxyPort > 0 && !useTor) {
|
if (resolved.forceTor && proxyPort > 0 && !useTor) {
|
||||||
useTor = true
|
useTor = true
|
||||||
applyWebViewProxy(proxyPort)
|
applyWebViewProxy(proxyPort)
|
||||||
controlSheet?.setTor(true)
|
updateChromeState { copy(torOn = true) }
|
||||||
}
|
}
|
||||||
webView?.loadUrl(resolved.url)
|
webView?.loadUrl(resolved.url)
|
||||||
}
|
}
|
||||||
@@ -944,7 +964,8 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
NappletIpc.MSG_WEB_FAVORITE_STATE -> {
|
NappletIpc.MSG_WEB_FAVORITE_STATE -> {
|
||||||
val url = data.getString(NappletIpc.KEY_FAVORITE_URL) ?: return true
|
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 -> {
|
NappletIpc.MSG_SITE_PERMISSIONS -> {
|
||||||
val callback = sitePermissionQueries.remove(data.getLong(NappletIpc.KEY_REQUEST_ID)) ?: return true
|
val callback = sitePermissionQueries.remove(data.getLong(NappletIpc.KEY_REQUEST_ID)) ?: return true
|
||||||
@@ -1004,10 +1025,10 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
if (ask.isEmpty()) {
|
if (ask.isEmpty()) {
|
||||||
ensureRuntimePermissions(allowed, done)
|
ensureRuntimePermissions(allowed, done)
|
||||||
} else {
|
} else {
|
||||||
showPermissionPrompt(origin, ask) { allow ->
|
showPermissionPrompt(origin, ask) { remembered, grantNow ->
|
||||||
// null = dismissed without an answer: deny this once, remember nothing.
|
// remembered: true/false = Allow while visiting / Don't allow; null = only this time or dismissed.
|
||||||
if (allow != null) ask.forEach { rememberSitePermission(origin, it, if (allow) Decision.ALLOW else Decision.BLOCK) }
|
if (remembered != null) ask.forEach { rememberSitePermission(origin, it, if (remembered) Decision.ALLOW else Decision.BLOCK) }
|
||||||
ensureRuntimePermissions(if (allow == true) allowed + ask else allowed, done)
|
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
|
* The permission prompt ([com.vitorpamplona.amethyst.commons.browser.ui.pill.PermissionPromptCard]):
|
||||||
* (allow), false (block), or null when the prompt went away unanswered.
|
* [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(
|
private fun showPermissionPrompt(
|
||||||
origin: String,
|
origin: String,
|
||||||
permissions: List<BrowserSitePermission>,
|
permissions: List<BrowserSitePermission>,
|
||||||
answer: (Boolean?) -> Unit,
|
answer: (allow: Boolean?, grantNow: Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (isFinishing || isDestroyed || permissionPrompt != null) {
|
val host = chrome
|
||||||
answer(null)
|
if (host == null || isFinishing || isDestroyed || host.permissionPrompt != null) {
|
||||||
|
answer(null, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var answered = false
|
host.permissionPrompt =
|
||||||
val lines =
|
BrowserChromeHost.PendingPermission(
|
||||||
permissions.joinToString("\n") {
|
host = BrowserChrome.displayHost(origin),
|
||||||
"• " +
|
security = BrowserChrome.security(host.ui.chrome),
|
||||||
getString(
|
permissions = permissions.toSet(),
|
||||||
when (it) {
|
) { allow, remember ->
|
||||||
BrowserSitePermission.CAMERA -> CommonsR.string.browser_permission_camera
|
when {
|
||||||
BrowserSitePermission.MICROPHONE -> CommonsR.string.browser_permission_microphone
|
allow && remember -> answer(true, true)
|
||||||
BrowserSitePermission.LOCATION -> CommonsR.string.browser_permission_location
|
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. */
|
/** Requests Android's runtime permission for each allowed site permission that lacks it. */
|
||||||
@@ -1283,7 +1288,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
useTor = newUseTor
|
useTor = newUseTor
|
||||||
applyWebViewProxy(if (useTor) proxyPort else -1)
|
applyWebViewProxy(if (useTor) proxyPort else -1)
|
||||||
webView?.reload()
|
webView?.reload()
|
||||||
controlSheet?.setTor(useTor)
|
updateChromeState { copy(torOn = useTor) }
|
||||||
// Key the persisted choice on the host actually displayed (which may differ from startUrl after
|
// 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.
|
// in-page navigation), so the preference sticks to the right site.
|
||||||
val host = runCatching { currentUrl().toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return
|
val host = runCatching { currentUrl().toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return
|
||||||
@@ -1305,92 +1310,162 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
private fun barTitle(): String = title.ifBlank { runCatching { startUrl.toUri().host }.getOrNull() ?: getString(CommonsR.string.napplet_untitled) }
|
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
|
* The window's chrome: the pull-down pill at the top (out of the corner where a site shows its own avatar),
|
||||||
* own avatar) that expands to the Chrome-PWA-style menu laid out by [BrowserChrome].
|
* find and the console at the bottom, and the page's dialogs — the shared Compose components.
|
||||||
*/
|
*/
|
||||||
private fun buildControlSheet(): View =
|
private fun buildChrome(): BrowserChromeHost =
|
||||||
NappletControlSheet(
|
BrowserChromeHost(
|
||||||
context = this,
|
activity = this,
|
||||||
initialState =
|
dark = isDarkTheme(),
|
||||||
BrowserChrome.State(
|
initial =
|
||||||
surface = BrowserChrome.Surface.WEB,
|
BrowserPillUi(
|
||||||
presentation = BrowserChrome.Presentation.FULL_SCREEN,
|
title = barTitle(),
|
||||||
url = startUrl,
|
chrome =
|
||||||
startUrl = startUrl,
|
BrowserChrome.State(
|
||||||
torOn = if (proxyPort > 0) useTor else null,
|
surface = BrowserChrome.Surface.WEB,
|
||||||
|
presentation = BrowserChrome.Presentation.FULL_SCREEN,
|
||||||
|
url = startUrl,
|
||||||
|
startUrl = startUrl,
|
||||||
|
torOn = if (proxyPort > 0) useTor else null,
|
||||||
|
),
|
||||||
|
isFavorite = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
|
||||||
),
|
),
|
||||||
title = barTitle(),
|
listener = chromeListener,
|
||||||
listener = sheetListener,
|
)
|
||||||
isFavoriteInitially = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
|
|
||||||
).also { controlSheet = it }
|
|
||||||
|
|
||||||
private val sheetListener =
|
private fun isDarkTheme(): Boolean =
|
||||||
object : NappletControlSheet.Listener {
|
when (themeType) {
|
||||||
override fun onAction(action: Action) {
|
"DARK" -> true
|
||||||
val wv = webView
|
"LIGHT" -> false
|
||||||
when (action) {
|
else -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
Action.BACK -> wv?.goBack()
|
}
|
||||||
Action.FORWARD -> wv?.goForward()
|
|
||||||
Action.RELOAD -> wv?.reload()
|
private val chromeListener =
|
||||||
Action.STOP -> wv?.stopLoading()
|
object : BrowserChromeHost.Listener {
|
||||||
Action.FAVORITE -> sendFavoriteToggle(currentUrl(), controlSheet?.wantsFavorite() ?: true)
|
override fun onPillEvent(event: BrowserPillEvent) {
|
||||||
Action.SHARE -> BrowserWebTools.share(this@NappletBrowserActivity, pageTitle, null, currentUrl())
|
when (event) {
|
||||||
Action.BACK_TO_APP -> wv?.let { BrowserWebTools.backToScope(it, startUrl) }
|
is BrowserPillEvent.Action -> onAction(event.action)
|
||||||
Action.COPY_LINK -> BrowserWebTools.copyToClipboard(this@NappletBrowserActivity, currentUrl())
|
is BrowserPillEvent.Navigate -> loadAddress(event.input)
|
||||||
Action.EDIT_ADDRESS -> Unit
|
is BrowserPillEvent.TextZoom -> {
|
||||||
Action.FIND_IN_PAGE -> {
|
webView?.let { BrowserWebTools.setTextZoom(it, event.percent) }
|
||||||
setConsoleShowing(false)
|
updateUi { copy(textZoom = event.percent) }
|
||||||
findBar?.show()
|
|
||||||
syncBackState()
|
|
||||||
}
|
}
|
||||||
Action.TEXT_SIZE -> Unit
|
BrowserPillEvent.CopyOrigin -> BrowserWebTools.copyToClipboard(this@NappletBrowserActivity, currentUrl())
|
||||||
Action.DESKTOP_SITE ->
|
BrowserPillEvent.PageInfo -> showPageInfo()
|
||||||
wv?.let {
|
BrowserPillEvent.Close -> finish()
|
||||||
val desktop = !BrowserWebTools.isDesktopMode(it)
|
|
||||||
BrowserWebTools.setDesktopMode(it, desktop)
|
|
||||||
controlSheet?.setDesktopSite(desktop)
|
|
||||||
}
|
|
||||||
Action.ADD_TO_HOME_SCREEN -> addToHomeScreen()
|
|
||||||
Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(this@NappletBrowserActivity, currentUrl())
|
|
||||||
Action.OPEN_FULL_SCREEN -> Unit
|
|
||||||
Action.TOR -> setNetworkMode(!useTor)
|
|
||||||
Action.ACCESS_INFO -> Unit
|
|
||||||
Action.SITE_SETTINGS -> openPermissions()
|
|
||||||
Action.CONSOLE -> setConsoleShowing(!consoleShowing)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNavigate(text: String) = loadAddress(text)
|
override fun onFind(query: String) {
|
||||||
|
val wv = webView ?: return
|
||||||
override fun onTextZoom(percent: Int) {
|
if (query.isEmpty()) wv.clearMatches() else wv.findAllAsync(query)
|
||||||
webView?.let { BrowserWebTools.setTextZoom(it, percent) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOriginTap() = showPageInfo()
|
override fun onFindNext(forward: Boolean) {
|
||||||
|
webView?.findNext(forward)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onClose() = finish()
|
override fun onFindClosed() {
|
||||||
|
webView?.clearMatches()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPermissionChange(
|
||||||
|
permission: BrowserSitePermission,
|
||||||
|
decision: Decision,
|
||||||
|
) {
|
||||||
|
BrowserChrome.originOf(currentUrl())?.let { rememberSitePermission(it, permission, decision) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClearSiteData() {
|
||||||
|
webView?.let { BrowserWebTools.clearSiteData(this@NappletBrowserActivity, it, currentUrl()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPanelsChanged() = syncBackState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setConsoleShowing(showing: Boolean) {
|
private fun onAction(action: Action) {
|
||||||
consoleShowing = showing
|
val wv = webView
|
||||||
if (showing) findBar?.hide()
|
when (action) {
|
||||||
consolePanel?.setShowing(showing)
|
Action.BACK -> wv?.goBack()
|
||||||
controlSheet?.setConsoleShowing(showing)
|
Action.FORWARD -> wv?.goForward()
|
||||||
|
Action.RELOAD -> wv?.reload()
|
||||||
|
Action.STOP -> wv?.stopLoading()
|
||||||
|
// The star flips the shown state; the toggle sends that target explicitly.
|
||||||
|
Action.FAVORITE -> sendFavoriteToggle(currentUrl(), chrome?.ui?.isFavorite != true)
|
||||||
|
Action.SHARE -> BrowserWebTools.share(this, pageTitle, null, currentUrl())
|
||||||
|
Action.BACK_TO_APP -> wv?.let { BrowserWebTools.backToScope(it, startUrl) }
|
||||||
|
Action.COPY_LINK -> BrowserWebTools.copyToClipboard(this, currentUrl())
|
||||||
|
Action.DESKTOP_SITE ->
|
||||||
|
wv?.let {
|
||||||
|
val desktop = !BrowserWebTools.isDesktopMode(it)
|
||||||
|
BrowserWebTools.setDesktopMode(it, desktop)
|
||||||
|
updateUi { copy(desktopSite = desktop) }
|
||||||
|
}
|
||||||
|
Action.ADD_TO_HOME_SCREEN -> addToHomeScreen()
|
||||||
|
Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(this, currentUrl())
|
||||||
|
Action.TOR -> setNetworkMode(!useTor)
|
||||||
|
Action.SITE_SETTINGS -> openPermissions()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Chrome's page-info sheet: connection, Tor, certificate, then site settings and clearing its data. */
|
/**
|
||||||
|
* Shows a page's JS dialog in the chrome, titled with the page's host. Answers at once when dialogs are
|
||||||
|
* blocked for this page, another is already up, or the window is going away.
|
||||||
|
*/
|
||||||
|
private fun showJsDialog(
|
||||||
|
type: PageDialogType,
|
||||||
|
url: String?,
|
||||||
|
message: String?,
|
||||||
|
defaultValue: String?,
|
||||||
|
result: JsResult,
|
||||||
|
): Boolean {
|
||||||
|
val host = chrome
|
||||||
|
if (jsDialogsBlocked) {
|
||||||
|
// A blocked page may no longer hold the user on it: leaving is allowed, everything else cancels.
|
||||||
|
if (type == PageDialogType.BEFORE_UNLOAD) result.confirm() else result.cancel()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (host == null || host.dialog != null || isFinishing || isDestroyed) {
|
||||||
|
result.cancel()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
jsDialogsOnPage++
|
||||||
|
host.dialog =
|
||||||
|
BrowserChromeHost.PendingDialog(
|
||||||
|
type = type,
|
||||||
|
host = url?.let(BrowserChrome::originOf)?.let(BrowserChrome::displayHost),
|
||||||
|
security = BrowserChrome.security(host.ui.chrome),
|
||||||
|
message = message.orEmpty(),
|
||||||
|
defaultValue = defaultValue.orEmpty(),
|
||||||
|
offerBlock = jsDialogsOnPage > 1,
|
||||||
|
) { confirmed, text, block ->
|
||||||
|
if (block) jsDialogsBlocked = true
|
||||||
|
when {
|
||||||
|
!confirmed -> result.cancel()
|
||||||
|
result is JsPromptResult -> result.confirm(text.orEmpty())
|
||||||
|
else -> result.confirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome's page info: connection, route and certificate, then this site's camera / microphone / location
|
||||||
|
* answers (asked of the broker first, so the sheet shows the truth) and its data.
|
||||||
|
*/
|
||||||
private fun showPageInfo() {
|
private fun showPageInfo() {
|
||||||
val wv = webView ?: return
|
val wv = webView ?: return
|
||||||
val url = currentUrl()
|
val origin = BrowserChrome.originOf(currentUrl())
|
||||||
AlertDialog
|
val certificate = BrowserWebTools.certificateInfo(wv)
|
||||||
.Builder(this)
|
if (origin == null) {
|
||||||
.setTitle(BrowserChrome.displayHost(url))
|
chrome?.showPageInfo(certificate)
|
||||||
.setMessage(BrowserWebTools.pageInfo(this, wv, if (proxyPort > 0) useTor else null))
|
return
|
||||||
.setPositiveButton(android.R.string.ok, null)
|
}
|
||||||
.setNeutralButton(CommonsR.string.browser_page_info_permissions) { _, _ -> openPermissions() }
|
querySitePermissions(origin) { decisions ->
|
||||||
.setNegativeButton(CommonsR.string.browser_page_info_clear_data) { _, _ ->
|
updateUi { copy(sitePermissions = decisions.filterValues { it != Decision.ASK }) }
|
||||||
webView?.let { BrowserWebTools.clearSiteData(this, it, url) }
|
chrome?.showPageInfo(certificate)
|
||||||
}.show()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Asks the main process to pin a launcher shortcut that reopens this page in Amethyst's browser. */
|
/** 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)
|
queueToBroker(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildConsolePanel(): View =
|
|
||||||
NappletConsolePanel(this).also {
|
|
||||||
it.onClearCallback = { controlSheet?.updateConsoleCount(0) }
|
|
||||||
consolePanel = it
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildLoadingView(): View =
|
private fun buildLoadingView(): View =
|
||||||
LinearLayout(this).apply {
|
LinearLayout(this).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
@@ -1521,10 +1590,11 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
private fun updateLoadProgress(progress: Int) {
|
private fun updateLoadProgress(progress: Int) {
|
||||||
if (progress >= 100) {
|
if (progress >= 100) {
|
||||||
topProgressBar.visibility = View.GONE
|
topProgressBar.visibility = View.GONE
|
||||||
controlSheet?.setLoading(false)
|
updateUi { copy(loadProgress = null, chrome = chrome.copy(isLoading = false)) }
|
||||||
} else {
|
} else {
|
||||||
topProgressBar.progress = progress
|
topProgressBar.progress = progress
|
||||||
topProgressBar.visibility = View.VISIBLE
|
topProgressBar.visibility = View.VISIBLE
|
||||||
|
updateUi { copy(loadProgress = progress / 100f) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1533,9 +1603,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
request: WebResourceRequest,
|
request: WebResourceRequest,
|
||||||
message: String,
|
message: String,
|
||||||
) {
|
) {
|
||||||
val panel = consolePanel ?: return
|
chrome?.appendConsole(ConsoleLine(ConsoleLine.Level.ERROR, message, request.url?.toString().orEmpty(), 0))
|
||||||
panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0)
|
|
||||||
controlSheet?.updateConsoleCount(panel.entryCount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveThemeColor(attr: Int): Int {
|
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. */
|
/** Clear the current site's cookies and storage in this account's profile, then reload. */
|
||||||
const val MSG_CLEAR_SITE_DATA = 24
|
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
|
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
|
const val MSG_PAGE_INFO = 26
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,7 +191,9 @@ object NappletBrowserContract {
|
|||||||
const val KEY_FIND_TOTAL = "findTotal"
|
const val KEY_FIND_TOTAL = "findTotal"
|
||||||
const val KEY_ENABLED = "enabled"
|
const val KEY_ENABLED = "enabled"
|
||||||
const val KEY_TEXT_ZOOM = "textZoom"
|
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_ID = "dialogId"
|
||||||
const val KEY_DIALOG_TYPE = "dialogType"
|
const val KEY_DIALOG_TYPE = "dialogType"
|
||||||
const val KEY_DIALOG_MESSAGE = "dialogMessage"
|
const val KEY_DIALOG_MESSAGE = "dialogMessage"
|
||||||
|
|||||||
+6
-1
@@ -262,8 +262,13 @@ class NappletBrowserService : Service() {
|
|||||||
NappletBrowserContract.MSG_PAGE_INFO_REQUEST -> {
|
NappletBrowserContract.MSG_PAGE_INFO_REQUEST -> {
|
||||||
val tab = tabFor(msg) ?: return true
|
val tab = tabFor(msg) ?: return true
|
||||||
val wv = tab.webView ?: return true
|
val wv = tab.webView ?: return true
|
||||||
|
val certificate = BrowserWebTools.certificateInfo(wv)
|
||||||
sendToClient(tab, NappletBrowserContract.MSG_PAGE_INFO) {
|
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 -> {
|
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+93
-92
@@ -26,6 +26,7 @@ import android.content.ComponentName
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
@@ -64,6 +65,10 @@ import androidx.webkit.WebMessageCompat
|
|||||||
import androidx.webkit.WebViewCompat
|
import androidx.webkit.WebViewCompat
|
||||||
import androidx.webkit.WebViewFeature
|
import androidx.webkit.WebViewFeature
|
||||||
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
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.NappletWebContract
|
||||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||||
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
|
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
|
||||||
@@ -194,11 +199,8 @@ class NappletHostActivity : ComponentActivity() {
|
|||||||
// WebChromeClient's onProgressChanged; hidden at 100%.
|
// WebChromeClient's onProgressChanged; hidden at 100%.
|
||||||
private val topProgressBar by lazy { buildTopProgressBar() }
|
private val topProgressBar by lazy { buildTopProgressBar() }
|
||||||
|
|
||||||
// Bottom pull-up developer console: the page's console.log/warn/error plus any resource load errors.
|
// The trusted pull-down pill, find and the developer console — the shared Compose chrome.
|
||||||
private var consolePanel: NappletConsolePanel? = null
|
private var chrome: BrowserChromeHost? = null
|
||||||
private var controlSheet: NappletControlSheet? = null
|
|
||||||
private var findBar: BrowserFindBar? = null
|
|
||||||
private var consoleShowing = false
|
|
||||||
|
|
||||||
// Set when the renderer died and the WebView was destroyed, so teardown doesn't touch it again.
|
// Set when the renderer died and the WebView was destroyed, so teardown doesn't touch it again.
|
||||||
private var webViewGone = false
|
private var webViewGone = false
|
||||||
@@ -211,8 +213,8 @@ class NappletHostActivity : ComponentActivity() {
|
|||||||
private val backCallback =
|
private val backCallback =
|
||||||
object : OnBackPressedCallback(false) {
|
object : OnBackPressedCallback(false) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
if (findBar?.isShowing == true) {
|
if (chrome?.handleBack() == true) {
|
||||||
findBar?.hide()
|
Unit
|
||||||
} else if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) {
|
} else if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) {
|
||||||
webView.goBack()
|
webView.goBack()
|
||||||
} else {
|
} else {
|
||||||
@@ -225,7 +227,7 @@ class NappletHostActivity : ComponentActivity() {
|
|||||||
/** Keep the in-WebView back gesture enabled exactly while the applet has history to pop. */
|
/** Keep the in-WebView back gesture enabled exactly while the applet has history to pop. */
|
||||||
private fun syncBackState() {
|
private fun syncBackState() {
|
||||||
val canGoBack = this::webView.isInitialized && !webViewGone && webView.canGoBack()
|
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
|
// 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.
|
// profile has otherwise been used), so the storage partition must be chosen before anything else.
|
||||||
NappletWebViewProfile.apply(this, webView, webViewProfile)
|
NappletWebViewProfile.apply(this, webView, webViewProfile)
|
||||||
hardenWebView(webView)
|
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
|
// 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).
|
// mounts. This activity has a themed context, so it resolves the color locally (no IPC needed).
|
||||||
webView.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
|
webView.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
|
||||||
@@ -321,33 +324,11 @@ class NappletHostActivity : ComponentActivity() {
|
|||||||
val root =
|
val root =
|
||||||
FrameLayout(this).apply {
|
FrameLayout(this).apply {
|
||||||
addView(contentFrame, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
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)
|
setContentView(root)
|
||||||
// Activities are edge-to-edge by default on recent Android; pad by the system bar, display-cutout
|
// 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
|
// 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 {
|
): Boolean {
|
||||||
Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}); offering a restart" }
|
Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}); offering a restart" }
|
||||||
webViewGone = true
|
webViewGone = true
|
||||||
findBar?.hide()
|
chrome?.closeFind()
|
||||||
(view.parent as? ViewGroup)?.removeView(view)
|
(view.parent as? ViewGroup)?.removeView(view)
|
||||||
view.destroy()
|
view.destroy()
|
||||||
loadingView?.let { contentFrame.removeView(it) }
|
loadingView?.let { contentFrame.removeView(it) }
|
||||||
@@ -721,9 +702,10 @@ class NappletHostActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
|
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
|
||||||
val panel = consolePanel ?: return false
|
val host = chrome ?: return false
|
||||||
panel.appendLog(consoleMessage.messageLevel(), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber())
|
host.appendConsole(
|
||||||
controlSheet?.updateConsoleCount(panel.entryCount)
|
ConsoleLine(BrowserChromeHost.levelOf(consoleMessage.messageLevel()), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()),
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -751,9 +733,11 @@ class NappletHostActivity : ComponentActivity() {
|
|||||||
private fun updateLoadProgress(progress: Int) {
|
private fun updateLoadProgress(progress: Int) {
|
||||||
if (progress >= 100) {
|
if (progress >= 100) {
|
||||||
topProgressBar.visibility = View.GONE
|
topProgressBar.visibility = View.GONE
|
||||||
|
chrome?.let { it.ui = it.ui.copy(loadProgress = null, chrome = it.ui.chrome.copy(isLoading = false)) }
|
||||||
} else {
|
} else {
|
||||||
topProgressBar.progress = progress
|
topProgressBar.progress = progress
|
||||||
topProgressBar.visibility = View.VISIBLE
|
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,
|
request: WebResourceRequest,
|
||||||
message: String,
|
message: String,
|
||||||
) {
|
) {
|
||||||
val panel = consolePanel ?: return
|
chrome?.appendConsole(ConsoleLine(ConsoleLine.Level.ERROR, message, request.url?.toString().orEmpty(), 0))
|
||||||
panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0)
|
|
||||||
controlSheet?.updateConsoleCount(panel.entryCount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- bridge: shell <-> native ----
|
// ---- bridge: shell <-> native ----
|
||||||
@@ -937,63 +919,88 @@ class NappletHostActivity : ComponentActivity() {
|
|||||||
private fun barTitle(): String = title.ifBlank { getString(CommonsR.string.napplet_untitled) }
|
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
|
* The trusted pull-down pill: a small grabber at the top edge (out of the corner where the app shows its
|
||||||
* shows its own avatar) that expands to the sandbox **shield**, the nSite network/Tor row (website
|
* own avatar) that expands to the sandbox **shield**, the nSite network/Tor row (website mode only, taps
|
||||||
* mode only, taps through to the confirm dialog), reload, and the "what it can access" sheet. The
|
* through to a relaunch), reload, find, text size and "what it can access". The applet can't draw over
|
||||||
* applet can't draw over it. Mirrors the embedded tabs' Compose `TopControlSheet`.
|
* it. The same Compose components as the embedded tabs and the web browser.
|
||||||
*/
|
*/
|
||||||
private fun buildControlSheet(): View =
|
private fun buildChrome(): BrowserChromeHost =
|
||||||
NappletControlSheet(
|
BrowserChromeHost(
|
||||||
context = this,
|
activity = this,
|
||||||
initialState =
|
dark = isDarkTheme(),
|
||||||
BrowserChrome.State(
|
initial =
|
||||||
surface = if (profile == HostProfile.WEBSITE) BrowserChrome.Surface.NSITE else BrowserChrome.Surface.NAPPLET,
|
BrowserPillUi(
|
||||||
presentation = BrowserChrome.Presentation.FULL_SCREEN,
|
title = barTitle(),
|
||||||
url = "",
|
chrome =
|
||||||
startUrl = "",
|
BrowserChrome.State(
|
||||||
// Website-mode nSites can re-route over Tor; switching rebuilds the session, so the row
|
surface = if (profile == HostProfile.WEBSITE) BrowserChrome.Surface.NSITE else BrowserChrome.Surface.NAPPLET,
|
||||||
// taps through to a full relaunch rather than toggling inline.
|
presentation = BrowserChrome.Presentation.FULL_SCREEN,
|
||||||
torOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null,
|
url = "",
|
||||||
canFavorite = false,
|
startUrl = "",
|
||||||
hasAccessInfo = true,
|
// 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 = chromeListener,
|
||||||
listener =
|
)
|
||||||
object : NappletControlSheet.Listener {
|
|
||||||
override fun onAction(action: BrowserChrome.Action) {
|
private fun isDarkTheme(): Boolean =
|
||||||
val wv = if (this@NappletHostActivity::webView.isInitialized && !webViewGone) webView else null
|
when (themeType) {
|
||||||
when (action) {
|
"DARK" -> true
|
||||||
BrowserChrome.Action.RELOAD -> wv?.reload()
|
"LIGHT" -> false
|
||||||
BrowserChrome.Action.STOP -> wv?.stopLoading()
|
else -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
BrowserChrome.Action.FIND_IN_PAGE -> {
|
}
|
||||||
setConsoleShowing(false)
|
|
||||||
findBar?.show()
|
private fun liveWebView(): WebView? = if (this::webView.isInitialized && !webViewGone) webView else null
|
||||||
syncBackState()
|
|
||||||
}
|
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.TOR -> setNetworkMode(!useTor)
|
||||||
BrowserChrome.Action.ACCESS_INFO -> showAccessDialog()
|
BrowserChrome.Action.ACCESS_INFO -> showAccessDialog()
|
||||||
BrowserChrome.Action.SITE_SETTINGS -> openPermissions()
|
BrowserChrome.Action.SITE_SETTINGS -> openPermissions()
|
||||||
BrowserChrome.Action.CONSOLE -> setConsoleShowing(!consoleShowing)
|
|
||||||
else -> Unit
|
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) {
|
override fun onFind(query: String) {
|
||||||
if (this@NappletHostActivity::webView.isInitialized && !webViewGone) BrowserWebTools.setTextZoom(webView, percent)
|
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()
|
override fun onFindClosed() {
|
||||||
},
|
liveWebView()?.clearMatches()
|
||||||
).also { controlSheet = it }
|
}
|
||||||
|
|
||||||
private fun setConsoleShowing(showing: Boolean) {
|
override fun onPermissionChange(
|
||||||
consoleShowing = showing
|
permission: BrowserSitePermission,
|
||||||
if (showing) findBar?.hide()
|
decision: BrowserSitePermission.Decision,
|
||||||
consolePanel?.setShowing(showing)
|
) = Unit
|
||||||
controlSheet?.setConsoleShowing(showing)
|
|
||||||
}
|
override fun onClearSiteData() = Unit
|
||||||
|
|
||||||
|
override fun onPanelsChanged() = syncBackState()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the broker to open this napplet's editable permission screen. The sandbox can't state its own
|
* Ask the broker to open this napplet's editable permission screen. The sandbox can't state its own
|
||||||
@@ -1008,12 +1015,6 @@ class NappletHostActivity : ComponentActivity() {
|
|||||||
if (brokerMessenger != null) sendToBroker(msg)
|
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
|
* 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
|
* [NappletWebChromeClient.onProgressChanged]: visible while the shell + verified blobs load and gone
|
||||||
|
|||||||
Reference in New Issue
Block a user