mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Merge pull request #4312 from vitorpamplona/claude/bold-planck-9walk3
Add search engine selection and improve browser omnibox UX
This commit is contained in:
@@ -115,6 +115,7 @@ object FavoriteAppLauncher {
|
|||||||
// Opaque per-account storage partition, so a web app can't carry one npub's session
|
// Opaque per-account storage partition, so a web app can't carry one npub's session
|
||||||
// into another. Derived here (the sandbox never sees the pubkey).
|
// into another. Derived here (the sandbox never sees the pubkey).
|
||||||
webViewProfile = NappletWebViewProfiles.current(),
|
webViewProfile = NappletWebViewProfiles.current(),
|
||||||
|
searchEngine = Amethyst.instance.uiPrefs.value.searchEngine.value,
|
||||||
).apply {
|
).apply {
|
||||||
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.NIP47Setup
|
|||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.NowPlayingSettingsScreen
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.NowPlayingSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.ProfileUiSettingsScreen
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.ProfileUiSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.ReactionsSettingsScreen
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.ReactionsSettingsScreen
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SearchEngineSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SecurityFiltersScreen
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SecurityFiltersScreen
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SpammingUsersScreen
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.SpammingUsersScreen
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.UpdateZapAmountScreen
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.UpdateZapAmountScreen
|
||||||
@@ -654,6 +655,7 @@ fun BuildNavigation(
|
|||||||
composableFromEnd<Route.DrawerSettings> { DrawerSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.DrawerSettings> { DrawerSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.SearchEngineSettings> { SearchEngineSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.NowPlayingSettings> {
|
composableFromEnd<Route.NowPlayingSettings> {
|
||||||
|
|||||||
+3
@@ -80,6 +80,8 @@ class EmbeddedWebAppController(
|
|||||||
private val initialUseTor: Boolean,
|
private val initialUseTor: Boolean,
|
||||||
private val backgroundColor: Int,
|
private val backgroundColor: Int,
|
||||||
private val themeType: String = "SYSTEM",
|
private val themeType: String = "SYSTEM",
|
||||||
|
// Read on every create, so a session made after the user changes engines picks the new one up.
|
||||||
|
private val searchEngine: () -> String? = { null },
|
||||||
) : EmbeddedSurfaceController,
|
) : EmbeddedSurfaceController,
|
||||||
EmbeddedImeBridge,
|
EmbeddedImeBridge,
|
||||||
EmbeddedMagnifierProbe,
|
EmbeddedMagnifierProbe,
|
||||||
@@ -505,6 +507,7 @@ class EmbeddedWebAppController(
|
|||||||
putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor)
|
putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor)
|
||||||
putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor)
|
putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor)
|
||||||
putString(NappletBrowserContract.KEY_THEME, themeType)
|
putString(NappletBrowserContract.KEY_THEME, themeType)
|
||||||
|
putString(NappletBrowserContract.KEY_SEARCH_ENGINE, searchEngine())
|
||||||
// Opaque per-account storage partition, so an embedded site can't carry one
|
// Opaque per-account storage partition, so an embedded site can't carry one
|
||||||
// npub's session into another. Derived here (the sandbox never sees the pubkey).
|
// npub's session into another. Derived here (the sandbox never sees the pubkey).
|
||||||
putString(NappletBrowserContract.KEY_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
|
putString(NappletBrowserContract.KEY_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
|
||||||
|
|||||||
+3
-1
@@ -63,6 +63,7 @@ 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.OmniboxSuggestions
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.SearchEngines
|
||||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion
|
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.BrowserPillEvent
|
||||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
|
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
|
||||||
@@ -220,7 +221,8 @@ private fun EmbeddedWebAppTab(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onNavigate(text: String) {
|
fun onNavigate(text: String) {
|
||||||
val resolved = OmniboxInput.resolve(text) ?: return
|
val engine = SearchEngines.byId(Amethyst.instance.uiPrefs.value.searchEngine.value)
|
||||||
|
val resolved = OmniboxInput.resolve(text, engine.queryPrefix) ?: return
|
||||||
// .onion only resolves over Tor.
|
// .onion only resolves over Tor.
|
||||||
if (resolved.forceTor && proxyAvailable && !torOn) {
|
if (resolved.forceTor && proxyAvailable && !torOn) {
|
||||||
torOn = true
|
torOn = true
|
||||||
|
|||||||
+8
-1
@@ -73,7 +73,14 @@ object EmbeddedTabFactory {
|
|||||||
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
|
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EmbeddedWebAppController(context.applicationContext, ::currentTorPort, initialUseTor, backgroundColor, theme).also {
|
EmbeddedWebAppController(
|
||||||
|
context.applicationContext,
|
||||||
|
::currentTorPort,
|
||||||
|
initialUseTor,
|
||||||
|
backgroundColor,
|
||||||
|
theme,
|
||||||
|
searchEngine = { Amethyst.instance.uiPrefs.value.searchEngine.value },
|
||||||
|
).also {
|
||||||
EmbeddedTabHost.takePageSnapshot(webAppId(url))?.let(it::restore)
|
EmbeddedTabHost.takePageSnapshot(webAppId(url))?.let(it::restore)
|
||||||
it.bind(url)
|
it.bind(url)
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-9
@@ -27,9 +27,9 @@ package com.vitorpamplona.amethyst.commons.browser
|
|||||||
*
|
*
|
||||||
* The rules, in order:
|
* The rules, in order:
|
||||||
* - blank → null (nothing to open)
|
* - blank → null (nothing to open)
|
||||||
* - already has a scheme (`foo://…`) → used verbatim
|
* - already has a scheme (`foo://…`) and no spaces → used verbatim
|
||||||
* - looks like a host/URL (no spaces, has a dot, or is `localhost`) → `https://` prepended
|
* - looks like a host/URL (no spaces, has a dot, or is `localhost`) → `https://` prepended
|
||||||
* - anything else → a search on [searchPrefix] (DuckDuckGo by default), URL-encoded
|
* - anything else — including prose that merely contains a link → a search on [searchPrefix] (DuckDuckGo by default), URL-encoded
|
||||||
*
|
*
|
||||||
* [Resolved.forceTor] is set for `.onion` addresses, which only resolve over Tor; the caller ORs it with
|
* [Resolved.forceTor] is set for `.onion` addresses, which only resolve over Tor; the caller ORs it with
|
||||||
* the user's per-site choice. Pure and platform-agnostic (no `android.net.Uri`) so it lives in commons
|
* the user's per-site choice. Pure and platform-agnostic (no `android.net.Uri`) so it lives in commons
|
||||||
@@ -42,6 +42,8 @@ object OmniboxInput {
|
|||||||
data class Resolved(
|
data class Resolved(
|
||||||
val url: String,
|
val url: String,
|
||||||
val forceTor: Boolean,
|
val forceTor: Boolean,
|
||||||
|
/** True when [url] is a search for the text rather than the address the user typed. */
|
||||||
|
val isSearch: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun resolve(
|
fun resolve(
|
||||||
@@ -50,14 +52,22 @@ object OmniboxInput {
|
|||||||
): Resolved? {
|
): Resolved? {
|
||||||
val text = raw.trim()
|
val text = raw.trim()
|
||||||
if (text.isEmpty()) return null
|
if (text.isEmpty()) return null
|
||||||
if (text.contains("://")) return Resolved(text, isOnion(text))
|
if (!isAddress(text)) return Resolved(searchPrefix + encodeQuery(text), forceTor = false, isSearch = true)
|
||||||
if (looksLikeHost(text)) {
|
val url = if (hasScheme(text)) text else "https://$text"
|
||||||
val url = "https://$text"
|
return Resolved(url, isOnion(url))
|
||||||
return Resolved(url, isOnion(url))
|
|
||||||
}
|
|
||||||
return Resolved(searchPrefix + encodeQuery(text), forceTor = false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [raw] would be opened as an address rather than searched for — the one rule [resolve]
|
||||||
|
* follows, exposed so the address bar's icon and hint can say what Go will do before it happens.
|
||||||
|
*/
|
||||||
|
fun isAddress(raw: String): Boolean {
|
||||||
|
val text = raw.trim()
|
||||||
|
return hasScheme(text) || looksLikeHost(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasScheme(text: String): Boolean = text.contains("://") && text.none { it.isWhitespace() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when [text] (with no scheme) reads as a hostname/URL rather than a search query: no spaces, and
|
* True when [text] (with no scheme) reads as a hostname/URL rather than a search query: no spaces, and
|
||||||
* either `localhost` or a dotted host (so `example.com`, `1.2.3.4`, `localhost:8080` are hosts but
|
* either `localhost` or a dotted host (so `example.com`, `1.2.3.4`, `localhost:8080` are hosts but
|
||||||
@@ -93,7 +103,8 @@ object OmniboxInput {
|
|||||||
.substringBefore('#')
|
.substringBefore('#')
|
||||||
.substringBefore(':')
|
.substringBefore(':')
|
||||||
|
|
||||||
private fun isOnion(url: String): Boolean = hostOf(url)?.endsWith(".onion", ignoreCase = true) == true
|
/** Whether [url]'s host is a Tor onion service, which only resolves over Tor. */
|
||||||
|
fun isOnion(url: String): Boolean = hostOf(url)?.endsWith(".onion", ignoreCase = true) == true
|
||||||
|
|
||||||
private const val UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
|
private const val UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
|
||||||
private val HEX = "0123456789ABCDEF".toCharArray()
|
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||||
|
|||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a page's main frame failed to load, grouped by what the person can do about it. The WebView's own
|
||||||
|
* error page only prints the network error code, and over Tor that code is a SOCKS failure whatever the
|
||||||
|
* real cause was, so "net::ERR_SOCKS_CONNECTION_FAILED" is all someone sees for a misspelled address.
|
||||||
|
*/
|
||||||
|
enum class PageLoadFailure {
|
||||||
|
/** The name does not exist (DNS said so). */
|
||||||
|
NOT_FOUND,
|
||||||
|
|
||||||
|
/** The site did not answer: refused, reset, or (through a proxy) unreachable for a reason we can't see. */
|
||||||
|
UNREACHABLE,
|
||||||
|
|
||||||
|
/** The local proxy itself refused the connection: Tor is not running or not ready yet. */
|
||||||
|
PROXY_DOWN,
|
||||||
|
|
||||||
|
TIMED_OUT,
|
||||||
|
OFFLINE,
|
||||||
|
|
||||||
|
/** Certificate or TLS failure. */
|
||||||
|
INSECURE,
|
||||||
|
REDIRECT_LOOP,
|
||||||
|
|
||||||
|
/** A `.onion` address that went out without Tor; it can only resolve over Tor. */
|
||||||
|
ONION_NEEDS_TOR,
|
||||||
|
|
||||||
|
/** Plain http to a host the platform only allows over https. */
|
||||||
|
CLEARTEXT_BLOCKED,
|
||||||
|
OTHER,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// WebViewClient.ERROR_* values, so this stays free of android.webkit.
|
||||||
|
private const val ERROR_HOST_LOOKUP = -2
|
||||||
|
private const val ERROR_PROXY_AUTHENTICATION = -5
|
||||||
|
private const val ERROR_CONNECT = -6
|
||||||
|
private const val ERROR_TIMEOUT = -8
|
||||||
|
private const val ERROR_REDIRECT_LOOP = -9
|
||||||
|
private const val ERROR_FAILED_SSL_HANDSHAKE = -11
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies a main-frame error from its Chromium [description] ("net::ERR_NAME_NOT_RESOLVED"),
|
||||||
|
* falling back to the WebView [errorCode]. Returns null for a navigation that was cancelled rather
|
||||||
|
* than failed (ERR_ABORTED: a stop, a download, a redirect to another app), which needs no error page.
|
||||||
|
*/
|
||||||
|
fun classify(
|
||||||
|
errorCode: Int,
|
||||||
|
description: String?,
|
||||||
|
): PageLoadFailure? {
|
||||||
|
val code =
|
||||||
|
description
|
||||||
|
?.trim()
|
||||||
|
?.removePrefix("net::")
|
||||||
|
?.uppercase()
|
||||||
|
.orEmpty()
|
||||||
|
return when {
|
||||||
|
code == "ERR_ABORTED" -> null
|
||||||
|
code == "ERR_NAME_NOT_RESOLVED" || code == "ERR_NAME_RESOLUTION_FAILED" -> NOT_FOUND
|
||||||
|
code == "ERR_PROXY_CONNECTION_FAILED" -> PROXY_DOWN
|
||||||
|
code.startsWith("ERR_SOCKS_") || code == "ERR_TUNNEL_CONNECTION_FAILED" -> UNREACHABLE
|
||||||
|
code == "ERR_INTERNET_DISCONNECTED" || code == "ERR_NETWORK_CHANGED" -> OFFLINE
|
||||||
|
code == "ERR_TIMED_OUT" || code == "ERR_CONNECTION_TIMED_OUT" -> TIMED_OUT
|
||||||
|
code == "ERR_TOO_MANY_REDIRECTS" -> REDIRECT_LOOP
|
||||||
|
code == "ERR_CLEARTEXT_NOT_PERMITTED" -> CLEARTEXT_BLOCKED
|
||||||
|
code.startsWith("ERR_CERT_") || code.startsWith("ERR_SSL_") || code == "ERR_BAD_SSL_CLIENT_AUTH_CERT" -> INSECURE
|
||||||
|
code.startsWith("ERR_CONNECTION_") || code == "ERR_EMPTY_RESPONSE" || code == "ERR_ADDRESS_UNREACHABLE" -> UNREACHABLE
|
||||||
|
else -> fromErrorCode(errorCode) ?: OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [classify] for a page at [url]: an onion address that failed while the page was not on Tor is
|
||||||
|
* reported as [ONION_NEEDS_TOR] (its "name not resolved" would otherwise read as "doesn't exist").
|
||||||
|
*/
|
||||||
|
fun forPage(
|
||||||
|
errorCode: Int,
|
||||||
|
description: String?,
|
||||||
|
url: String,
|
||||||
|
viaTor: Boolean,
|
||||||
|
): PageLoadFailure? {
|
||||||
|
val failure = classify(errorCode, description) ?: return null
|
||||||
|
return if (!viaTor && OmniboxInput.isOnion(url)) ONION_NEEDS_TOR else failure
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether retrying the same page on the open web might help: Tor reached nothing, or too slowly. */
|
||||||
|
fun mayBeTorBlocked(failure: PageLoadFailure): Boolean = failure == UNREACHABLE || failure == TIMED_OUT
|
||||||
|
|
||||||
|
private fun fromErrorCode(errorCode: Int): PageLoadFailure? =
|
||||||
|
when (errorCode) {
|
||||||
|
ERROR_HOST_LOOKUP -> NOT_FOUND
|
||||||
|
ERROR_PROXY_AUTHENTICATION -> PROXY_DOWN
|
||||||
|
ERROR_CONNECT -> UNREACHABLE
|
||||||
|
ERROR_TIMEOUT -> TIMED_OUT
|
||||||
|
ERROR_REDIRECT_LOOP -> REDIRECT_LOOP
|
||||||
|
ERROR_FAILED_SSL_HANDSHAKE -> INSECURE
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search engines the browser omnibox can send a query to, chosen in Settings → Search engine and
|
||||||
|
* stored as [SearchEngine.id] in the app-wide UI settings. [OmniboxInput.resolve] takes the chosen
|
||||||
|
* engine's [SearchEngine.queryPrefix]; the query is URL-encoded onto it.
|
||||||
|
*
|
||||||
|
* Names are brand names, so they are not translated.
|
||||||
|
*/
|
||||||
|
object SearchEngines {
|
||||||
|
data class SearchEngine(
|
||||||
|
/** Stable key persisted in settings and passed to the browser process. Never rename one. */
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val queryPrefix: String,
|
||||||
|
) {
|
||||||
|
/** The engine's host, shown under its name (e.g. `duckduckgo.com`). */
|
||||||
|
val host: String get() = OmniboxInput.hostOf(queryPrefix)?.removePrefix("www.") ?: queryPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
val DUCKDUCKGO = SearchEngine("duckduckgo", "DuckDuckGo", OmniboxInput.DEFAULT_SEARCH_PREFIX)
|
||||||
|
|
||||||
|
/** Privacy-respecting engines first, then the mainstream ones. */
|
||||||
|
val list: List<SearchEngine> =
|
||||||
|
listOf(
|
||||||
|
DUCKDUCKGO,
|
||||||
|
SearchEngine("brave", "Brave Search", "https://search.brave.com/search?q="),
|
||||||
|
SearchEngine("startpage", "Startpage", "https://www.startpage.com/sp/search?query="),
|
||||||
|
SearchEngine("mojeek", "Mojeek", "https://www.mojeek.com/search?q="),
|
||||||
|
SearchEngine("qwant", "Qwant", "https://www.qwant.com/?q="),
|
||||||
|
SearchEngine("ecosia", "Ecosia", "https://www.ecosia.org/search?q="),
|
||||||
|
SearchEngine("kagi", "Kagi", "https://kagi.com/search?q="),
|
||||||
|
SearchEngine("google", "Google", "https://www.google.com/search?q="),
|
||||||
|
SearchEngine("bing", "Bing", "https://www.bing.com/search?q="),
|
||||||
|
)
|
||||||
|
|
||||||
|
val DEFAULT: SearchEngine = DUCKDUCKGO
|
||||||
|
|
||||||
|
/** The engine stored under [id], or [DEFAULT] when it is null or no longer offered. */
|
||||||
|
fun byId(id: String?): SearchEngine = list.firstOrNull { it.id == id } ?: DEFAULT
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.model
|
package com.vitorpamplona.amethyst.commons.model
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.SearchEngines
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
@@ -64,6 +65,8 @@ data class UiSettings(
|
|||||||
// be bank or Venmo handles carrying legal names, and this puts them one tap
|
// be bank or Venmo handles carrying legal names, and this puts them one tap
|
||||||
// from every note in the feed.
|
// from every note in the feed.
|
||||||
val showPayToZapChip: Boolean = true,
|
val showPayToZapChip: Boolean = true,
|
||||||
|
// The browser omnibox's search engine, by [SearchEngines] id. Unknown ids fall back to the default.
|
||||||
|
val searchEngine: String = SearchEngines.DEFAULT.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class ThemeType(
|
enum class ThemeType(
|
||||||
|
|||||||
+10
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.model
|
package com.vitorpamplona.amethyst.commons.model
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.SearchEngines
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -57,6 +58,7 @@ class UiSettingsFlow(
|
|||||||
val composeSignature: MutableStateFlow<String> = MutableStateFlow(""),
|
val composeSignature: MutableStateFlow<String> = MutableStateFlow(""),
|
||||||
val showOnchainWallet: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
val showOnchainWallet: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||||
val showPayToZapChip: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
val showPayToZapChip: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||||
|
val searchEngine: MutableStateFlow<String> = MutableStateFlow(SearchEngines.DEFAULT.id),
|
||||||
) {
|
) {
|
||||||
val listOfFlows: List<Flow<Any?>> =
|
val listOfFlows: List<Flow<Any?>> =
|
||||||
listOf<Flow<Any?>>(
|
listOf<Flow<Any?>>(
|
||||||
@@ -90,6 +92,7 @@ class UiSettingsFlow(
|
|||||||
composeSignature,
|
composeSignature,
|
||||||
showOnchainWallet,
|
showOnchainWallet,
|
||||||
showPayToZapChip,
|
showPayToZapChip,
|
||||||
|
searchEngine,
|
||||||
)
|
)
|
||||||
|
|
||||||
// emits at every change in any of the propertyes.
|
// emits at every change in any of the propertyes.
|
||||||
@@ -127,6 +130,7 @@ class UiSettingsFlow(
|
|||||||
flows[27] as String,
|
flows[27] as String,
|
||||||
flows[28] as Boolean,
|
flows[28] as Boolean,
|
||||||
flows[29] as Boolean,
|
flows[29] as Boolean,
|
||||||
|
flows[30] as String,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +166,7 @@ class UiSettingsFlow(
|
|||||||
composeSignature.value,
|
composeSignature.value,
|
||||||
showOnchainWallet.value,
|
showOnchainWallet.value,
|
||||||
showPayToZapChip.value,
|
showPayToZapChip.value,
|
||||||
|
searchEngine.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun update(torSettings: UiSettings): Boolean {
|
fun update(torSettings: UiSettings): Boolean {
|
||||||
@@ -287,6 +292,10 @@ class UiSettingsFlow(
|
|||||||
showPayToZapChip.tryEmit(torSettings.showPayToZapChip)
|
showPayToZapChip.tryEmit(torSettings.showPayToZapChip)
|
||||||
any = true
|
any = true
|
||||||
}
|
}
|
||||||
|
if (searchEngine.value != torSettings.searchEngine) {
|
||||||
|
searchEngine.tryEmit(torSettings.searchEngine)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
|
||||||
return any
|
return any
|
||||||
}
|
}
|
||||||
@@ -342,6 +351,7 @@ class UiSettingsFlow(
|
|||||||
MutableStateFlow(uiSettings.composeSignature),
|
MutableStateFlow(uiSettings.composeSignature),
|
||||||
MutableStateFlow(uiSettings.showOnchainWallet),
|
MutableStateFlow(uiSettings.showOnchainWallet),
|
||||||
MutableStateFlow(uiSettings.showPayToZapChip),
|
MutableStateFlow(uiSettings.showPayToZapChip),
|
||||||
|
MutableStateFlow(uiSettings.searchEngine),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -581,6 +581,8 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object ProfileUiSettings : Route()
|
@Serializable object ProfileUiSettings : Route()
|
||||||
|
|
||||||
|
@Serializable object SearchEngineSettings : Route()
|
||||||
|
|
||||||
@Serializable object VideoPlayerSettings : Route()
|
@Serializable object VideoPlayerSettings : Route()
|
||||||
|
|
||||||
@Serializable object CallSettings : Route()
|
@Serializable object CallSettings : Route()
|
||||||
|
|||||||
+4
@@ -27,6 +27,7 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.SearchEngines
|
||||||
import com.vitorpamplona.amethyst.commons.model.AccentColorType
|
import com.vitorpamplona.amethyst.commons.model.AccentColorType
|
||||||
import com.vitorpamplona.amethyst.commons.model.BooleanType
|
import com.vitorpamplona.amethyst.commons.model.BooleanType
|
||||||
import com.vitorpamplona.amethyst.commons.model.ConnectivityType
|
import com.vitorpamplona.amethyst.commons.model.ConnectivityType
|
||||||
@@ -129,6 +130,7 @@ class UiSettingsStore(
|
|||||||
val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature")
|
val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature")
|
||||||
val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet")
|
val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet")
|
||||||
val UI_SHOW_PAYTO_ZAP_CHIP = booleanPreferencesKey("ui.show_payto_zap_chip")
|
val UI_SHOW_PAYTO_ZAP_CHIP = booleanPreferencesKey("ui.show_payto_zap_chip")
|
||||||
|
val UI_SEARCH_ENGINE = stringPreferencesKey("ui.search_engine")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every setting's default matches what the old `getBoolean(key, default)`
|
* Every setting's default matches what the old `getBoolean(key, default)`
|
||||||
@@ -173,6 +175,7 @@ class UiSettingsStore(
|
|||||||
composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "",
|
composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "",
|
||||||
showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true,
|
showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true,
|
||||||
showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: true,
|
showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: true,
|
||||||
|
searchEngine = preferences[UI_SEARCH_ENGINE] ?: SearchEngines.DEFAULT.id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +218,7 @@ class UiSettingsStore(
|
|||||||
preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature
|
preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature
|
||||||
preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet
|
preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet
|
||||||
preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip
|
preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip
|
||||||
|
preferences[UI_SEARCH_ENGINE] = sharedSettings.searchEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+6
@@ -68,6 +68,12 @@ class NappletProxyClaims {
|
|||||||
return route()
|
return route()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a surface other than [owner] wants Tor — in which case the process stays on Tor even if
|
||||||
|
* [owner] switches to the open web, so offering [owner] an open-web retry would change nothing.
|
||||||
|
*/
|
||||||
|
fun torWantedByOthers(owner: Any): Boolean = claims.any { (other, port) -> other !== owner && port > 0 }
|
||||||
|
|
||||||
fun route(): Route = Route(claims.values.lastOrNull { it > 0 } ?: NO_PROXY)
|
fun route(): Route = Route(claims.values.lastOrNull { it > 0 } ?: NO_PROXY)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+21
@@ -57,6 +57,27 @@ class OmniboxInputTest {
|
|||||||
assertEquals("https://duckduckgo.com/?q=cats", OmniboxInput.resolve("cats")?.url)
|
assertEquals("https://duckduckgo.com/?q=cats", OmniboxInput.resolve("cats")?.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun textWithSpacesIsASearchEvenWithAScheme() {
|
||||||
|
// Pasted prose that merely contains a link must not be loaded as a (broken) URL.
|
||||||
|
val resolved = OmniboxInput.resolve("check this out https://example.com")!!
|
||||||
|
assertEquals("https://duckduckgo.com/?q=check%20this%20out%20https%3A%2F%2Fexample.com", resolved.url)
|
||||||
|
assertTrue(resolved.isSearch)
|
||||||
|
assertTrue(!OmniboxInput.isAddress("check this out https://example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isAddressMatchesResolve() {
|
||||||
|
listOf("example.com", "http://example.com", "localhost:8080", "nostr://npub1abc").forEach {
|
||||||
|
assertTrue(OmniboxInput.isAddress(it), it)
|
||||||
|
assertTrue(!OmniboxInput.resolve(it)!!.isSearch, it)
|
||||||
|
}
|
||||||
|
listOf("cats", "how to tie a knot").forEach {
|
||||||
|
assertTrue(!OmniboxInput.isAddress(it), it)
|
||||||
|
assertTrue(OmniboxInput.resolve(it)!!.isSearch, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun searchPrefixIsConfigurable() {
|
fun searchPrefixIsConfigurable() {
|
||||||
assertEquals("https://search.example/?s=cats", OmniboxInput.resolve("cats", "https://search.example/?s=")?.url)
|
assertEquals("https://search.example/?s=cats", OmniboxInput.resolve("cats", "https://search.example/?s=")?.url)
|
||||||
|
|||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class PageLoadFailureTest {
|
||||||
|
@Test
|
||||||
|
fun torSocksFailureIsUnreachableNotAProxyProblem() {
|
||||||
|
// What the WebView reports over Tor for a host that does not exist.
|
||||||
|
assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.classify(-1, "net::ERR_SOCKS_CONNECTION_FAILED"))
|
||||||
|
assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.classify(-6, "net::ERR_SOCKS_CONNECTION_HOST_UNREACHABLE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun localProxyRefusalMeansTorIsDown() {
|
||||||
|
assertEquals(PageLoadFailure.PROXY_DOWN, PageLoadFailure.classify(-6, "net::ERR_PROXY_CONNECTION_FAILED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dnsMissIsNotFound() {
|
||||||
|
assertEquals(PageLoadFailure.NOT_FOUND, PageLoadFailure.classify(-2, "net::ERR_NAME_NOT_RESOLVED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancelledNavigationShowsNoErrorPage() {
|
||||||
|
assertNull(PageLoadFailure.classify(-1, "net::ERR_ABORTED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun commonCodesMapToTheirGroup() {
|
||||||
|
assertEquals(PageLoadFailure.OFFLINE, PageLoadFailure.classify(-1, "net::ERR_INTERNET_DISCONNECTED"))
|
||||||
|
assertEquals(PageLoadFailure.TIMED_OUT, PageLoadFailure.classify(-8, "net::ERR_CONNECTION_TIMED_OUT"))
|
||||||
|
assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.classify(-6, "net::ERR_CONNECTION_REFUSED"))
|
||||||
|
assertEquals(PageLoadFailure.INSECURE, PageLoadFailure.classify(-11, "net::ERR_CERT_AUTHORITY_INVALID"))
|
||||||
|
assertEquals(PageLoadFailure.REDIRECT_LOOP, PageLoadFailure.classify(-9, "net::ERR_TOO_MANY_REDIRECTS"))
|
||||||
|
assertEquals(PageLoadFailure.CLEARTEXT_BLOCKED, PageLoadFailure.classify(-1, "net::ERR_CLEARTEXT_NOT_PERMITTED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onionOffTorNeedsTorInsteadOfNotFound() {
|
||||||
|
val onion = "http://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/"
|
||||||
|
assertEquals(PageLoadFailure.ONION_NEEDS_TOR, PageLoadFailure.forPage(-2, "net::ERR_NAME_NOT_RESOLVED", onion, viaTor = false))
|
||||||
|
// Over Tor an onion failure is an ordinary reachability failure.
|
||||||
|
assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.forPage(-1, "net::ERR_SOCKS_CONNECTION_FAILED", onion, viaTor = true))
|
||||||
|
assertEquals(PageLoadFailure.NOT_FOUND, PageLoadFailure.forPage(-2, "net::ERR_NAME_NOT_RESOLVED", "https://example.com/", viaTor = false))
|
||||||
|
assertNull(PageLoadFailure.forPage(-1, "net::ERR_ABORTED", onion, viaTor = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onlyReachabilityFailuresSuggestTryingWithoutTor() {
|
||||||
|
assertEquals(true, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.UNREACHABLE))
|
||||||
|
assertEquals(true, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.TIMED_OUT))
|
||||||
|
assertEquals(false, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.NOT_FOUND))
|
||||||
|
assertEquals(false, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.INSECURE))
|
||||||
|
assertEquals(false, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.PROXY_DOWN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownDescriptionFallsBackToTheWebViewCode() {
|
||||||
|
assertEquals(PageLoadFailure.NOT_FOUND, PageLoadFailure.classify(-2, null))
|
||||||
|
assertEquals(PageLoadFailure.TIMED_OUT, PageLoadFailure.classify(-8, "net::ERR_SOMETHING_NEW"))
|
||||||
|
assertEquals(PageLoadFailure.OTHER, PageLoadFailure.classify(-1, "net::ERR_SOMETHING_NEW"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SearchEnginesTest {
|
||||||
|
@Test
|
||||||
|
fun unknownOrMissingIdFallsBackToDefault() {
|
||||||
|
assertEquals(SearchEngines.DEFAULT, SearchEngines.byId(null))
|
||||||
|
assertEquals(SearchEngines.DEFAULT, SearchEngines.byId("altavista"))
|
||||||
|
assertEquals("duckduckgo", SearchEngines.DEFAULT.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun idsAreUniqueAndPrefixesAreHttps() {
|
||||||
|
assertEquals(
|
||||||
|
SearchEngines.list.size,
|
||||||
|
SearchEngines.list
|
||||||
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
|
.size,
|
||||||
|
)
|
||||||
|
SearchEngines.list.forEach { assertTrue(it.queryPrefix.startsWith("https://"), it.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchUsesTheChosenEngine() {
|
||||||
|
val brave = SearchEngines.byId("brave")
|
||||||
|
assertEquals("https://search.brave.com/search?q=cats%20and%20dogs", OmniboxInput.resolve("cats and dogs", brave.queryPrefix)?.url)
|
||||||
|
// Addresses are never sent to the engine.
|
||||||
|
assertEquals("https://example.com", OmniboxInput.resolve("example.com", brave.queryPrefix)?.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hostDropsWww() {
|
||||||
|
assertEquals("google.com", SearchEngines.byId("google").host)
|
||||||
|
assertEquals("duckduckgo.com", SearchEngines.DEFAULT.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -31,6 +31,17 @@ class NappletProxyClaimsTest {
|
|||||||
private val torTab = Any()
|
private val torTab = Any()
|
||||||
private val openTab = Any()
|
private val openTab = Any()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun torWantedByOthersIgnoresTheAskingSurface() {
|
||||||
|
claims.claim(torTab, 9050)
|
||||||
|
assertEquals(false, claims.torWantedByOthers(torTab))
|
||||||
|
assertEquals(true, claims.torWantedByOthers(openTab))
|
||||||
|
claims.claim(openTab, NappletProxyClaims.NO_PROXY)
|
||||||
|
assertEquals(false, claims.torWantedByOthers(torTab))
|
||||||
|
claims.release(torTab)
|
||||||
|
assertEquals(false, claims.torWantedByOthers(openTab))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun noClaimsMeansNoProxy() {
|
fun noClaimsMeansNoProxy() {
|
||||||
assertEquals(DIRECT, claims.route())
|
assertEquals(DIRECT, claims.route())
|
||||||
|
|||||||
+9
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.ui.components.util
|
package com.vitorpamplona.amethyst.commons.ui.components.util
|
||||||
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
|
import android.content.ClipDescription
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.Clipboard
|
import androidx.compose.ui.platform.Clipboard
|
||||||
|
|
||||||
@@ -34,3 +35,11 @@ actual suspend fun Clipboard.getText(): String? =
|
|||||||
?.getItemAt(0)
|
?.getItemAt(0)
|
||||||
?.text
|
?.text
|
||||||
?.toString()
|
?.toString()
|
||||||
|
|
||||||
|
// The description is metadata: reading it doesn't trigger the "pasted from your clipboard" toast that
|
||||||
|
// getPrimaryClip does. Only the types whose items carry the text getText reads: a text/uri-list clip
|
||||||
|
// (ClipData.newUri) has none, so offering to paste it would do nothing.
|
||||||
|
actual suspend fun Clipboard.hasText(): Boolean =
|
||||||
|
nativeClipboard.primaryClipDescription?.let {
|
||||||
|
it.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || it.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML)
|
||||||
|
} == true
|
||||||
|
|||||||
@@ -987,6 +987,30 @@
|
|||||||
<string name="browser_reload">Reload</string>
|
<string name="browser_reload">Reload</string>
|
||||||
<string name="browser_unsupported">The in-app browser needs Android 11 or newer.</string>
|
<string name="browser_unsupported">The in-app browser needs Android 11 or newer.</string>
|
||||||
<string name="embedded_tab_load_failed">Couldn't load this app.</string>
|
<string name="embedded_tab_load_failed">Couldn't load this app.</string>
|
||||||
|
<string name="page_error_not_found_title">This site can't be found</string>
|
||||||
|
<string name="page_error_not_found_body">%1$s doesn't exist. Check the address for typos.</string>
|
||||||
|
<string name="page_error_unreachable_title">This site can't be reached</string>
|
||||||
|
<string name="page_error_unreachable_body">%1$s didn't respond. It may be down, or the address may not exist.</string>
|
||||||
|
<string name="page_error_unreachable_tor_body">Tor couldn't reach %1$s. The site may be down, or the address may not exist.</string>
|
||||||
|
<string name="page_error_proxy_down_title">Tor isn't connected</string>
|
||||||
|
<string name="page_error_proxy_down_body">This site goes through Tor, and Tor isn't ready yet. Try again in a moment.</string>
|
||||||
|
<string name="page_error_timed_out_title">This site took too long to respond</string>
|
||||||
|
<string name="page_error_timed_out_body">%1$s didn't answer in time.</string>
|
||||||
|
<string name="page_error_offline_title">You're offline</string>
|
||||||
|
<string name="page_error_offline_body">Check your connection and try again.</string>
|
||||||
|
<string name="page_error_insecure_title">This connection isn't private</string>
|
||||||
|
<string name="page_error_insecure_body">%1$s didn't present a valid certificate, so the page wasn't loaded.</string>
|
||||||
|
<string name="page_error_redirect_loop_title">This page isn't working</string>
|
||||||
|
<string name="page_error_redirect_loop_body">%1$s redirected too many times.</string>
|
||||||
|
<string name="page_error_onion_title">This site needs Tor</string>
|
||||||
|
<string name="page_error_onion_body">%1$s is an onion site, which only opens over Tor.</string>
|
||||||
|
<string name="page_error_open_with_tor">Open with Tor</string>
|
||||||
|
<string name="page_error_try_without_tor">Try without Tor</string>
|
||||||
|
<string name="page_error_try_without_tor_note">Some sites block Tor. Without it, %1$s sees your IP address, and this site stays off Tor until you turn Tor back on.</string>
|
||||||
|
<string name="page_error_cleartext_title">This site isn't secure</string>
|
||||||
|
<string name="page_error_cleartext_body">%1$s only offers an unencrypted connection, which Android blocks for this address.</string>
|
||||||
|
<string name="page_error_other_title">This page couldn't load</string>
|
||||||
|
<string name="page_error_other_body">Something went wrong loading %1$s.</string>
|
||||||
<string name="browser_go">Open</string>
|
<string name="browser_go">Open</string>
|
||||||
<string name="browser_clear">Clear</string>
|
<string name="browser_clear">Clear</string>
|
||||||
<string name="browser_discover_nsites">Sites from people you follow</string>
|
<string name="browser_discover_nsites">Sites from people you follow</string>
|
||||||
@@ -6363,6 +6387,11 @@
|
|||||||
<string name="cordn_voice_record">Record a voice note</string>
|
<string name="cordn_voice_record">Record a voice note</string>
|
||||||
<string name="cordn_voice_stop">Stop and send</string>
|
<string name="cordn_voice_stop">Stop and send</string>
|
||||||
<string name="browser_address_hint">Search or enter address</string>
|
<string name="browser_address_hint">Search or enter address</string>
|
||||||
|
<string name="browser_omnibox_go_to">Go to %1$s</string>
|
||||||
|
<string name="browser_omnibox_search_for">Search for “%1$s”</string>
|
||||||
|
<string name="search_engine_settings">Search engine</string>
|
||||||
|
<string name="search_engine_settings_description">What the browser's address bar searches with when what you type isn't a web address.</string>
|
||||||
|
<string name="search_engine_search_keywords" translatable="false">search engine, browser, address bar, omnibox, duckduckgo, google</string>
|
||||||
<string name="napplet_untitled">Untitled nApplet</string>
|
<string name="napplet_untitled">Untitled nApplet</string>
|
||||||
<string name="browser_permission_section">Site permissions</string>
|
<string name="browser_permission_section">Site permissions</string>
|
||||||
<string name="browser_permission_camera">Use your camera</string>
|
<string name="browser_permission_camera">Use your camera</string>
|
||||||
|
|||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.PageLoadFailure
|
||||||
|
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.commons.resources.Res
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_cleartext_body
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_cleartext_title
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_insecure_body
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_insecure_title
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_not_found_body
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_not_found_title
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_offline_body
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_offline_title
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_onion_body
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_onion_title
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_open_with_tor
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_other_body
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_other_title
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_proxy_down_body
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_proxy_down_title
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_redirect_loop_body
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_redirect_loop_title
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_timed_out_body
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_timed_out_title
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_try_without_tor
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_try_without_tor_note
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_unreachable_body
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_unreachable_title
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.page_error_unreachable_tor_body
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.try_again
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser's own page for a main frame that failed to load, in place of the WebView's built-in one
|
||||||
|
* (an Android robot over raw "net::ERR_…" text, unreadable in dark mode and with no way to retry).
|
||||||
|
* Names the cause in words, keeps the raw [detail] code small underneath for anyone debugging, and
|
||||||
|
* offers [onRetry], which turns into a spinner while [retrying]. [viaTor] rewords the reachability case: over Tor every failure to reach the site
|
||||||
|
* looks the same, so it can't promise the address exists. [onSwitchTor], when given, adds the one route
|
||||||
|
* change that could help: "Open with Tor" for an onion address, else "Try without Tor" with a note on
|
||||||
|
* what that gives away — the caller offers it only where switching would actually change the route.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PageLoadError(
|
||||||
|
failure: PageLoadFailure,
|
||||||
|
host: String,
|
||||||
|
detail: String?,
|
||||||
|
viaTor: Boolean,
|
||||||
|
retrying: Boolean,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onSwitchTor: (() -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val copy = failure.errorCopy(viaTor)
|
||||||
|
// A dead name over Tor fails again within a millisecond, which would make Try again look like it did
|
||||||
|
// nothing. Hold the spinner briefly so every tap visibly retries.
|
||||||
|
var justTapped by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(justTapped) {
|
||||||
|
if (justTapped) {
|
||||||
|
delay(MIN_RETRY_SPINNER_MS)
|
||||||
|
justTapped = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Column(Modifier.widthIn(max = 420.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
symbol = copy.symbol,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
text = stringRes(copy.title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringRes(copy.body, host),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (!detail.isNullOrBlank()) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = detail,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Box(Modifier.height(48.dp), contentAlignment = Alignment.Center) {
|
||||||
|
if (retrying || justTapped) {
|
||||||
|
CircularProgressIndicator(Modifier.size(32.dp), strokeWidth = 3.dp)
|
||||||
|
} else {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
justTapped = true
|
||||||
|
onRetry()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringRes(Res.string.try_again))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onSwitchTor != null && !retrying && !justTapped) {
|
||||||
|
val toTor = failure == PageLoadFailure.ONION_NEEDS_TOR
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
justTapped = true
|
||||||
|
onSwitchTor()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringRes(if (toTor) Res.string.page_error_open_with_tor else Res.string.page_error_try_without_tor))
|
||||||
|
}
|
||||||
|
if (!toTor) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringRes(Res.string.page_error_try_without_tor_note, host),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MIN_RETRY_SPINNER_MS = 700L
|
||||||
|
|
||||||
|
private class PageErrorCopy(
|
||||||
|
val symbol: MaterialSymbol,
|
||||||
|
val title: StringResource,
|
||||||
|
val body: StringResource,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun PageLoadFailure.errorCopy(viaTor: Boolean): PageErrorCopy =
|
||||||
|
when (this) {
|
||||||
|
PageLoadFailure.NOT_FOUND -> PageErrorCopy(MaterialSymbols.PublicOff, Res.string.page_error_not_found_title, Res.string.page_error_not_found_body)
|
||||||
|
PageLoadFailure.UNREACHABLE ->
|
||||||
|
PageErrorCopy(
|
||||||
|
MaterialSymbols.PublicOff,
|
||||||
|
Res.string.page_error_unreachable_title,
|
||||||
|
if (viaTor) Res.string.page_error_unreachable_tor_body else Res.string.page_error_unreachable_body,
|
||||||
|
)
|
||||||
|
PageLoadFailure.PROXY_DOWN -> PageErrorCopy(MaterialSymbols.SyncProblem, Res.string.page_error_proxy_down_title, Res.string.page_error_proxy_down_body)
|
||||||
|
PageLoadFailure.TIMED_OUT -> PageErrorCopy(MaterialSymbols.Timer, Res.string.page_error_timed_out_title, Res.string.page_error_timed_out_body)
|
||||||
|
PageLoadFailure.OFFLINE -> PageErrorCopy(MaterialSymbols.PublicOff, Res.string.page_error_offline_title, Res.string.page_error_offline_body)
|
||||||
|
PageLoadFailure.INSECURE -> PageErrorCopy(MaterialSymbols.NoEncryption, Res.string.page_error_insecure_title, Res.string.page_error_insecure_body)
|
||||||
|
PageLoadFailure.REDIRECT_LOOP -> PageErrorCopy(MaterialSymbols.Warning, Res.string.page_error_redirect_loop_title, Res.string.page_error_redirect_loop_body)
|
||||||
|
PageLoadFailure.ONION_NEEDS_TOR -> PageErrorCopy(MaterialSymbols.Lock, Res.string.page_error_onion_title, Res.string.page_error_onion_body)
|
||||||
|
PageLoadFailure.CLEARTEXT_BLOCKED -> PageErrorCopy(MaterialSymbols.NoEncryption, Res.string.page_error_cleartext_title, Res.string.page_error_cleartext_body)
|
||||||
|
PageLoadFailure.OTHER -> PageErrorCopy(MaterialSymbols.PublicOff, Res.string.page_error_other_title, Res.string.page_error_other_body)
|
||||||
|
}
|
||||||
+8
-2
@@ -63,6 +63,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||||
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
|
||||||
@@ -119,11 +120,16 @@ fun AddressEditor(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// The field shows what it will do: the page's badge while it still holds the page's URL,
|
// The field shows what it will do: the page's badge while it still holds the page's URL,
|
||||||
// a search glyph once the user types something else.
|
// then a globe or a search glyph for what Go will do with what they typed.
|
||||||
if (field.text == initialUrl) {
|
if (field.text == initialUrl) {
|
||||||
SecurityIcon(security, size = 20.dp)
|
SecurityIcon(security, size = 20.dp)
|
||||||
} else {
|
} else {
|
||||||
Icon(MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(
|
||||||
|
if (OmniboxInput.isAddress(field.text)) MaterialSymbols.Language else MaterialSymbols.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
|
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
|
||||||
|
|||||||
+6
@@ -27,3 +27,9 @@ expect suspend fun Clipboard.setText(text: String)
|
|||||||
|
|
||||||
/** The clipboard's current plain text, or null when it holds none. */
|
/** The clipboard's current plain text, or null when it holds none. */
|
||||||
expect suspend fun Clipboard.getText(): String?
|
expect suspend fun Clipboard.getText(): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the clipboard holds text, answered from its metadata without reading the contents — so offering
|
||||||
|
* "Paste and go" neither makes Android announce a paste nor iOS prompt for one. Only the tap should read.
|
||||||
|
*/
|
||||||
|
expect suspend fun Clipboard.hasText(): Boolean
|
||||||
|
|||||||
+267
-45
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.browser
|
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.browser
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.WindowInsets
|
|||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -43,27 +45,42 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan
|
|||||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
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
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.text.TextRange
|
import androidx.compose.ui.text.TextRange
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
@@ -78,6 +95,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryEntry
|
|||||||
import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients
|
import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients
|
||||||
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.OmniboxSuggestions
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.SearchEngines
|
||||||
import com.vitorpamplona.amethyst.commons.browser.SuggestedWebApp
|
import com.vitorpamplona.amethyst.commons.browser.SuggestedWebApp
|
||||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||||
@@ -96,6 +114,10 @@ import com.vitorpamplona.amethyst.commons.resources.browser_discover_napplets
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.browser_discover_nsites
|
import com.vitorpamplona.amethyst.commons.resources.browser_discover_nsites
|
||||||
import com.vitorpamplona.amethyst.commons.resources.browser_favorites
|
import com.vitorpamplona.amethyst.commons.resources.browser_favorites
|
||||||
import com.vitorpamplona.amethyst.commons.resources.browser_go
|
import com.vitorpamplona.amethyst.commons.resources.browser_go
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.browser_omnibox_go_to
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.browser_omnibox_search_for
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.browser_pill_fill_in
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.browser_pill_paste_go
|
||||||
import com.vitorpamplona.amethyst.commons.resources.browser_recent_options
|
import com.vitorpamplona.amethyst.commons.resources.browser_recent_options
|
||||||
import com.vitorpamplona.amethyst.commons.resources.browser_recent_remove
|
import com.vitorpamplona.amethyst.commons.resources.browser_recent_remove
|
||||||
import com.vitorpamplona.amethyst.commons.resources.browser_suggested
|
import com.vitorpamplona.amethyst.commons.resources.browser_suggested
|
||||||
@@ -103,6 +125,8 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_app_add
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.favorite_app_recent
|
import com.vitorpamplona.amethyst.commons.resources.favorite_app_recent
|
||||||
import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove
|
import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove
|
||||||
import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading
|
import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.util.getText
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.util.hasText
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
|
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
|
||||||
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
|
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
|
||||||
@@ -121,6 +145,7 @@ import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
|||||||
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.StringResource
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
|
||||||
@@ -168,8 +193,30 @@ private fun BrowserLauncher(
|
|||||||
// showing "isn't loaded yet" until the user happens to visit the nsite/napplet feed.
|
// showing "isn't loaded yet" until the user happens to visit the nsite/napplet feed.
|
||||||
PreloadFavoriteNostrApps(apps, accountViewModel)
|
PreloadFavoriteNostrApps(apps, accountViewModel)
|
||||||
|
|
||||||
|
val searchEngineId by accountViewModel.settings.uiSettingsFlow.searchEngine
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
val searchEngine = SearchEngines.byId(searchEngineId)
|
||||||
|
|
||||||
var field by remember { mutableStateOf(TextFieldValue("")) }
|
var field by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
|
|
||||||
|
// The selection an inline completion produced. While the field's selection is still exactly this, the
|
||||||
|
// highlighted suffix is ghost text the user didn't type; any other selection (select-all, a shift-select)
|
||||||
|
// is the user's own.
|
||||||
|
var ghost by remember { mutableStateOf<TextRange?>(null) }
|
||||||
|
val ghostShowing = ghost != null && field.selection == ghost
|
||||||
|
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Checked from the clipboard's metadata whenever the field gains focus or the window comes back (the
|
||||||
|
// user may have left to copy a link), so "Paste and go" is only offered when there is something to
|
||||||
|
// paste; the contents are read only when the user taps it.
|
||||||
|
val windowFocused = LocalWindowInfo.current.isWindowFocused
|
||||||
|
var clipboardHasText by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(focused, windowFocused) { clipboardHasText = focused && windowFocused && clipboard.hasText() }
|
||||||
|
|
||||||
// Favorites + visit history + the hardcoded Discover apps, flattened into the neutral candidate shape
|
// Favorites + visit history + the hardcoded Discover apps, flattened into the neutral candidate shape
|
||||||
// the ranker consumes — so typing the omnibox finds a suggested app even before its first visit. The
|
// the ranker consumes — so typing the omnibox finds a suggested app even before its first visit. The
|
||||||
// ranker dedupes by host, and favorites/history outscore a plain default, so a default that the user
|
// ranker dedupes by host, and favorites/history outscore a plain default, so a default that the user
|
||||||
@@ -225,9 +272,8 @@ private fun BrowserLauncher(
|
|||||||
value = withContext(Dispatchers.Default) { nappletNotes.toDiscoverApps(nappletFollows::matchAuthor, favoriteCoordinates) }
|
value = withContext(Dispatchers.Default) { nappletNotes.toDiscoverApps(nappletFollows::matchAuthor, favoriteCoordinates) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the
|
// What the user actually typed, excluding the ghost-completion suffix while one is showing.
|
||||||
// caret when collapsed, or the start of the highlighted suffix when a completion is showing).
|
val typed = ghost?.takeIf { ghostShowing }?.let { field.text.take(it.start) } ?: field.text
|
||||||
val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length))
|
|
||||||
// One ranking per typed text: an appended character is ranked in onValueChange (for the inline
|
// One ranking per typed text: an appended character is ranked in onValueChange (for the inline
|
||||||
// completion) and then again for this list on the recomposition that follows — keep the last one.
|
// completion) and then again for this list on the recomposition that follows — keep the last one.
|
||||||
val lastRanking = remember(candidates) { arrayOfNulls<Pair<String, List<OmniboxSuggestions.Suggestion>>>(1) }
|
val lastRanking = remember(candidates) { arrayOfNulls<Pair<String, List<OmniboxSuggestions.Suggestion>>>(1) }
|
||||||
@@ -239,8 +285,13 @@ private fun BrowserLauncher(
|
|||||||
val suggestions = remember(typed, candidates) { ranked(typed) }
|
val suggestions = remember(typed, candidates) { ranked(typed) }
|
||||||
|
|
||||||
fun open(text: String) {
|
fun open(text: String) {
|
||||||
val target = OmniboxInput.resolve(text) ?: return
|
val target = OmniboxInput.resolve(text, searchEngine.queryPrefix) ?: return
|
||||||
appLauncher.launchUrl(target.url, target.forceTor)
|
appLauncher.launchUrl(target.url, target.forceTor)
|
||||||
|
// The site opens in its own window: coming back should land on a fresh launcher, not on the
|
||||||
|
// half-typed address and its suggestions.
|
||||||
|
field = TextFieldValue("")
|
||||||
|
ghost = null
|
||||||
|
focusManager.clearFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pin/unpin a plain web URL by its favorite id. Shared by the suggestion list and the Recent rows.
|
// Pin/unpin a plain web URL by its favorite id. Shared by the suggestion list and the Recent rows.
|
||||||
@@ -259,23 +310,27 @@ private fun BrowserLauncher(
|
|||||||
// Inline autocomplete: when the user appends a character, offer the top host as selected ghost text so
|
// Inline autocomplete: when the user appends a character, offer the top host as selected ghost text so
|
||||||
// the next keystroke replaces it. On deletion or mid-string edits, leave the value untouched.
|
// the next keystroke replaces it. On deletion or mid-string edits, leave the value untouched.
|
||||||
fun onValueChange(new: TextFieldValue) {
|
fun onValueChange(new: TextFieldValue) {
|
||||||
val prevTyped = field.text.take(field.selection.min.coerceIn(0, field.text.length))
|
// What is left once the selection (a ghost suffix, or anything the user selected) is typed over.
|
||||||
|
val kept = field.text.removeRange(field.selection.min, field.selection.max)
|
||||||
val newText = new.text
|
val newText = new.text
|
||||||
val appended =
|
val appended =
|
||||||
new.selection.collapsed &&
|
new.selection.collapsed &&
|
||||||
new.selection.start == newText.length &&
|
new.selection.start == newText.length &&
|
||||||
newText.length > prevTyped.length &&
|
newText.length > kept.length &&
|
||||||
newText.startsWith(prevTyped)
|
newText.startsWith(kept)
|
||||||
if (appended) {
|
if (appended) {
|
||||||
// The completion has always looked at the top 8 (rank's default limit).
|
// The completion has always looked at the top 8 (rank's default limit).
|
||||||
val completion = OmniboxSuggestions.completion(newText, ranked(newText).take(8))
|
val completion = OmniboxSuggestions.completion(newText, ranked(newText).take(8))
|
||||||
if (completion != null) {
|
if (completion != null) {
|
||||||
// Keep the user's own casing for the typed prefix; append only the remaining suffix.
|
// Keep the user's own casing for the typed prefix; append only the remaining suffix.
|
||||||
val full = newText + completion.substring(newText.length)
|
val full = newText + completion.substring(newText.length)
|
||||||
field = TextFieldValue(full, TextRange(newText.length, full.length))
|
val suffix = TextRange(newText.length, full.length)
|
||||||
|
field = TextFieldValue(full, suffix)
|
||||||
|
ghost = suffix
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (new.text != field.text) ghost = null
|
||||||
field = new
|
field = new
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,9 +339,25 @@ private fun BrowserLauncher(
|
|||||||
OmniBar(
|
OmniBar(
|
||||||
nav = nav,
|
nav = nav,
|
||||||
field = field,
|
field = field,
|
||||||
|
focused = focused,
|
||||||
|
onFocusChange = { focused = it },
|
||||||
onValueChange = ::onValueChange,
|
onValueChange = ::onValueChange,
|
||||||
onClear = { field = TextFieldValue("") },
|
hasCompletion = ghostShowing,
|
||||||
|
onAcceptCompletion = {
|
||||||
|
field = field.copy(selection = TextRange(field.text.length))
|
||||||
|
ghost = null
|
||||||
|
},
|
||||||
|
onClear = {
|
||||||
|
field = TextFieldValue("")
|
||||||
|
ghost = null
|
||||||
|
},
|
||||||
onOpen = { open(field.text) },
|
onOpen = { open(field.text) },
|
||||||
|
onPasteAndGo =
|
||||||
|
if (focused && field.text.isEmpty() && clipboardHasText) {
|
||||||
|
{ scope.launch { clipboard.getText()?.let(::open) } }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
@@ -298,12 +369,16 @@ private fun BrowserLauncher(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
when {
|
when {
|
||||||
typed.isNotBlank() && suggestions.isNotEmpty() ->
|
typed.isNotBlank() ->
|
||||||
SuggestionGrid(
|
SuggestionGrid(
|
||||||
|
entered = field.text,
|
||||||
|
searchEngine = searchEngine,
|
||||||
suggestions = suggestions,
|
suggestions = suggestions,
|
||||||
iconKeys = iconKeys,
|
iconKeys = iconKeys,
|
||||||
historyUrls = historyUrls,
|
historyUrls = historyUrls,
|
||||||
|
onOpenEntered = { open(field.text) },
|
||||||
onOpen = { open(it.url) },
|
onOpen = { open(it.url) },
|
||||||
|
onFillIn = { field = TextFieldValue(it, TextRange(it.length)) },
|
||||||
onToggleFavorite = { toggleFavorite(it.url, it.label) },
|
onToggleFavorite = { toggleFavorite(it.url, it.label) },
|
||||||
onRemoveFromHistory = { appServices.browserHistory.remove(it) },
|
onRemoveFromHistory = { appServices.browserHistory.remove(it) },
|
||||||
modifier = contentModifier,
|
modifier = contentModifier,
|
||||||
@@ -333,14 +408,31 @@ private fun BrowserLauncher(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launcher's address field: a rounded pill with the same anatomy as the in-site
|
||||||
|
* [AddressEditor][com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressEditor] —
|
||||||
|
* a leading glyph that says what Go will do (search vs. open an address), the field, clear, and a filled Go
|
||||||
|
* once there is something to go to. Focused and empty, that trailing slot is a paste-and-go button
|
||||||
|
* instead, when the clipboard holds text. Tab (or →) accepts the inline completion; Escape leaves the field.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun OmniBar(
|
private fun OmniBar(
|
||||||
nav: INav,
|
nav: INav,
|
||||||
field: TextFieldValue,
|
field: TextFieldValue,
|
||||||
|
focused: Boolean,
|
||||||
|
onFocusChange: (Boolean) -> Unit,
|
||||||
onValueChange: (TextFieldValue) -> Unit,
|
onValueChange: (TextFieldValue) -> Unit,
|
||||||
|
hasCompletion: Boolean,
|
||||||
|
onAcceptCompletion: () -> Unit,
|
||||||
onClear: () -> Unit,
|
onClear: () -> Unit,
|
||||||
onOpen: () -> Unit,
|
onOpen: () -> Unit,
|
||||||
|
onPasteAndGo: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val colors = MaterialTheme.colorScheme
|
||||||
|
val isAddress = OmniboxInput.isAddress(field.text)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -349,7 +441,7 @@ private fun OmniBar(
|
|||||||
// apply the top system insets itself — systemBars rather than just statusBars, so the
|
// apply the top system insets itself — systemBars rather than just statusBars, so the
|
||||||
// caption bar of a desktop window (Waydroid/DeX freeform) is respected too.
|
// caption bar of a desktop window (Waydroid/DeX freeform) is respected too.
|
||||||
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top))
|
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top))
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
.padding(start = if (nav.canPop()) 4.dp else 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// When reached from the drawer (pushed onto the back stack) rather than as a bottom-bar tab, show
|
// When reached from the drawer (pushed onto the back stack) rather than as a bottom-bar tab, show
|
||||||
@@ -357,52 +449,176 @@ private fun OmniBar(
|
|||||||
if (nav.canPop()) {
|
if (nav.canPop()) {
|
||||||
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
|
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
|
||||||
}
|
}
|
||||||
TextField(
|
Row(
|
||||||
value = field,
|
Modifier
|
||||||
onValueChange = onValueChange,
|
.weight(1f)
|
||||||
modifier = Modifier.weight(1f),
|
.height(52.dp)
|
||||||
singleLine = true,
|
.clip(CircleShape)
|
||||||
placeholder = { Text(stringRes(Res.string.browser_address_hint)) },
|
.background(if (focused) colors.surfaceContainerHighest else colors.surfaceContainerHigh)
|
||||||
keyboardOptions =
|
.border(if (focused) 2.dp else 1.dp, if (focused) colors.primary else colors.outlineVariant, CircleShape)
|
||||||
KeyboardOptions(
|
// The whole pill is the target, not just the text line inside it.
|
||||||
capitalization = KeyboardCapitalization.None,
|
.clickable(interactionSource = null, indication = null) { focusRequester.requestFocus() }
|
||||||
autoCorrectEnabled = false,
|
.padding(start = 16.dp, end = 4.dp),
|
||||||
keyboardType = KeyboardType.Uri,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
imeAction = ImeAction.Go,
|
) {
|
||||||
),
|
Icon(
|
||||||
keyboardActions = KeyboardActions(onGo = { onOpen() }),
|
if (isAddress) MaterialSymbols.Language else MaterialSymbols.Search,
|
||||||
trailingIcon = {
|
contentDescription = null,
|
||||||
if (field.text.isNotEmpty()) {
|
modifier = Modifier.size(20.dp),
|
||||||
IconButton(onClick = onClear) {
|
tint = if (focused) colors.primary else colors.onSurfaceVariant,
|
||||||
Icon(MaterialSymbols.Clear, contentDescription = stringRes(Res.string.browser_clear))
|
)
|
||||||
}
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
|
||||||
|
BasicTextField(
|
||||||
|
value = field,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(color = colors.onSurface),
|
||||||
|
cursorBrush = SolidColor(colors.primary),
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Go,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onGo = { onOpen() }),
|
||||||
|
// The placeholder lives inside the field's decoration so it is part of the field's
|
||||||
|
// semantics: a screen reader announces it as the field's hint, as Material's TextField does.
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
|
if (field.text.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringRes(Res.string.browser_address_hint),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = colors.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onFocusChanged { onFocusChange(it.isFocused) }
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
|
when (event.key) {
|
||||||
|
// Tab takes the ghost completion instead of moving focus away.
|
||||||
|
Key.Tab ->
|
||||||
|
if (hasCompletion) {
|
||||||
|
onAcceptCompletion()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Key.Escape -> {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (field.text.isNotEmpty()) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
onClear()
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}) {
|
||||||
|
Icon(MaterialSymbols.Cancel, contentDescription = stringRes(Res.string.browser_clear), modifier = Modifier.size(20.dp), tint = colors.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field.text.isNotBlank()) {
|
||||||
|
FilledIconButton(onClick = onOpen, modifier = Modifier.size(44.dp)) {
|
||||||
|
Icon(MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = stringRes(Res.string.browser_go), modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
} else if (onPasteAndGo != null) {
|
||||||
|
IconButton(onClick = onPasteAndGo) {
|
||||||
|
Icon(MaterialSymbols.ContentPasteGo, contentDescription = stringRes(Res.string.browser_pill_paste_go), modifier = Modifier.size(20.dp), tint = colors.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
colors =
|
|
||||||
TextFieldDefaults.colors(
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (field.text.isNotBlank()) {
|
|
||||||
IconButton(onClick = onOpen) {
|
|
||||||
Icon(MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = stringRes(Res.string.browser_go))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The typed-state body: ranked suggestions split into a highlighted Favorites group, then Recent (visited
|
* The first row while typing: exactly what Go / Enter will do with the [entered] text — open it as an
|
||||||
* sites), then Discover (the hardcoded web apps the user hasn't pinned or visited yet). Every row carries
|
* address, or search for it — so the user never has to guess which one they're about to get.
|
||||||
* the same 3-dot menu as the idle Recent cards (pin/unpin; plus remove-from-history for visited sites).
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun EnteredRow(
|
||||||
|
entered: String,
|
||||||
|
searchEngine: SearchEngines.SearchEngine,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val target = OmniboxInput.resolve(entered, searchEngine.queryPrefix) ?: return
|
||||||
|
val isAddress = !target.isSearch
|
||||||
|
val title =
|
||||||
|
if (isAddress) {
|
||||||
|
stringRes(Res.string.browser_omnibox_go_to, target.url.removePrefix("https://"))
|
||||||
|
} else {
|
||||||
|
stringRes(Res.string.browser_omnibox_search_for, entered.trim())
|
||||||
|
}
|
||||||
|
val subtitle = if (isAddress) null else searchEngine.name
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (isAddress) MaterialSymbols.Language else MaterialSymbols.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
MaterialSymbols.AutoMirrored.ArrowForward,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp).size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The typed-state body: what Go will do with the [entered] text, then ranked suggestions split into a
|
||||||
|
* highlighted Favorites group, then Recent (visited sites), then Discover (the hardcoded web apps the user
|
||||||
|
* hasn't pinned or visited yet). Every row carries a ↖ that puts its address in the field to keep typing
|
||||||
|
* from, and the same 3-dot menu as the idle Recent cards (pin/unpin; plus remove-from-history for visited
|
||||||
|
* sites).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionGrid(
|
private fun SuggestionGrid(
|
||||||
|
entered: String,
|
||||||
|
searchEngine: SearchEngines.SearchEngine,
|
||||||
suggestions: List<OmniboxSuggestions.Suggestion>,
|
suggestions: List<OmniboxSuggestions.Suggestion>,
|
||||||
iconKeys: Set<String>,
|
iconKeys: Set<String>,
|
||||||
historyUrls: Set<String>,
|
historyUrls: Set<String>,
|
||||||
|
onOpenEntered: () -> Unit,
|
||||||
onOpen: (OmniboxSuggestions.Suggestion) -> Unit,
|
onOpen: (OmniboxSuggestions.Suggestion) -> Unit,
|
||||||
|
onFillIn: (String) -> Unit,
|
||||||
onToggleFavorite: (OmniboxSuggestions.Suggestion) -> Unit,
|
onToggleFavorite: (OmniboxSuggestions.Suggestion) -> Unit,
|
||||||
onRemoveFromHistory: (String) -> Unit,
|
onRemoveFromHistory: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -426,6 +642,7 @@ private fun SuggestionGrid(
|
|||||||
highlighted = highlighted,
|
highlighted = highlighted,
|
||||||
removableFromHistory = suggestion.url in historyUrls,
|
removableFromHistory = suggestion.url in historyUrls,
|
||||||
onClick = { onOpen(suggestion) },
|
onClick = { onOpen(suggestion) },
|
||||||
|
onFillIn = { onFillIn(suggestion.url.removePrefix("https://")) },
|
||||||
onToggleFavorite = { onToggleFavorite(suggestion) },
|
onToggleFavorite = { onToggleFavorite(suggestion) },
|
||||||
onRemoveFromHistory = { onRemoveFromHistory(suggestion.url) },
|
onRemoveFromHistory = { onRemoveFromHistory(suggestion.url) },
|
||||||
)
|
)
|
||||||
@@ -433,6 +650,7 @@ private fun SuggestionGrid(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LazyVerticalGrid(columns = GridCells.Fixed(1), modifier = modifier) {
|
LazyVerticalGrid(columns = GridCells.Fixed(1), modifier = modifier) {
|
||||||
|
item(key = "entered") { EnteredRow(entered, searchEngine, onOpenEntered) }
|
||||||
section("f", Res.string.browser_favorites, favorites, highlighted = true)
|
section("f", Res.string.browser_favorites, favorites, highlighted = true)
|
||||||
section("o", Res.string.favorite_app_recent, recent, highlighted = false)
|
section("o", Res.string.favorite_app_recent, recent, highlighted = false)
|
||||||
section("s", Res.string.browser_suggested, discover, highlighted = false)
|
section("s", Res.string.browser_suggested, discover, highlighted = false)
|
||||||
@@ -446,6 +664,7 @@ private fun SuggestionRow(
|
|||||||
highlighted: Boolean,
|
highlighted: Boolean,
|
||||||
removableFromHistory: Boolean,
|
removableFromHistory: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onFillIn: () -> Unit,
|
||||||
onToggleFavorite: () -> Unit,
|
onToggleFavorite: () -> Unit,
|
||||||
onRemoveFromHistory: () -> Unit,
|
onRemoveFromHistory: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -479,6 +698,9 @@ private fun SuggestionRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = onFillIn) {
|
||||||
|
Icon(MaterialSymbols.NorthWest, contentDescription = stringRes(Res.string.browser_pill_fill_in), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
Box {
|
Box {
|
||||||
IconButton(onClick = { menuOpen = true }) {
|
IconButton(onClick = { menuOpen = true }) {
|
||||||
Icon(MaterialSymbols.MoreVert, contentDescription = stringRes(Res.string.browser_recent_options))
|
Icon(MaterialSymbols.MoreVert, contentDescription = stringRes(Res.string.browser_recent_options))
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* 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.ui.screen.loggedIn.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.SearchEngines
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.search_engine_settings
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.search_engine_settings_description
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
|
|
||||||
|
/** Picks the engine the browser's address bar searches with when what was typed isn't an address. */
|
||||||
|
@Composable
|
||||||
|
fun SearchEngineSettingsScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopBarWithBackButton(stringRes(Res.string.search_engine_settings), nav)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
SearchEngineSettingsContent(accountViewModel.settings.uiSettingsFlow, Modifier.padding(padding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchEngineSettingsContent(
|
||||||
|
ui: UiSettingsFlow,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val selectedId by ui.searchEngine.collectAsStateWithLifecycle()
|
||||||
|
val selected = SearchEngines.byId(selectedId)
|
||||||
|
|
||||||
|
LazyColumn(modifier = modifier.fillMaxWidth()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringRes(Res.string.search_engine_settings_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(SearchEngines.list, key = { it.id }) { engine ->
|
||||||
|
SearchEngineRow(
|
||||||
|
engine = engine,
|
||||||
|
selected = engine == selected,
|
||||||
|
onClick = { ui.searchEngine.tryEmit(engine.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(16.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchEngineRow(
|
||||||
|
engine: SearchEngines.SearchEngine,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(selected = selected, onClick = onClick)
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(engine.name, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(engine.host, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -96,6 +96,8 @@ import com.vitorpamplona.amethyst.commons.resources.resource_usage_search_keywor
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.resource_usage_title
|
import com.vitorpamplona.amethyst.commons.resources.resource_usage_title
|
||||||
import com.vitorpamplona.amethyst.commons.resources.route_home
|
import com.vitorpamplona.amethyst.commons.resources.route_home
|
||||||
import com.vitorpamplona.amethyst.commons.resources.route_import_follows
|
import com.vitorpamplona.amethyst.commons.resources.route_import_follows
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.search_engine_search_keywords
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.search_engine_settings
|
||||||
import com.vitorpamplona.amethyst.commons.resources.security_filters
|
import com.vitorpamplona.amethyst.commons.resources.security_filters
|
||||||
import com.vitorpamplona.amethyst.commons.resources.security_filters_search_keywords
|
import com.vitorpamplona.amethyst.commons.resources.security_filters_search_keywords
|
||||||
import com.vitorpamplona.amethyst.commons.resources.translations
|
import com.vitorpamplona.amethyst.commons.resources.translations
|
||||||
@@ -198,6 +200,7 @@ fun buildSettingsCatalog(
|
|||||||
symEntry(Res.string.notification_settings, MaterialSymbols.Notifications, Res.string.notification_settings_search_keywords, Route.NotificationSettings),
|
symEntry(Res.string.notification_settings, MaterialSymbols.Notifications, Res.string.notification_settings_search_keywords, Route.NotificationSettings),
|
||||||
symEntry(Res.string.compose_settings, MaterialSymbols.Edit, Res.string.compose_search_keywords, Route.ComposeSettings),
|
symEntry(Res.string.compose_settings, MaterialSymbols.Edit, Res.string.compose_search_keywords, Route.ComposeSettings),
|
||||||
symEntry(Res.string.profile_ui_settings, MaterialSymbols.AccountCircle, Res.string.profile_ui_search_keywords, Route.ProfileUiSettings),
|
symEntry(Res.string.profile_ui_settings, MaterialSymbols.AccountCircle, Res.string.profile_ui_search_keywords, Route.ProfileUiSettings),
|
||||||
|
symEntry(Res.string.search_engine_settings, MaterialSymbols.TravelExplore, Res.string.search_engine_search_keywords, Route.SearchEngineSettings),
|
||||||
symEntry(Res.string.calendar_reminder_settings_title, MaterialSymbols.CalendarMonth, Res.string.calendar_reminder_search_keywords, Route.CalendarReminderSettings),
|
symEntry(Res.string.calendar_reminder_settings_title, MaterialSymbols.CalendarMonth, Res.string.calendar_reminder_search_keywords, Route.CalendarReminderSettings),
|
||||||
symEntry(Res.string.ots_explorer_settings, MaterialSymbols.Search, Res.string.ots_explorer_search_keywords, Route.OtsSettings),
|
symEntry(Res.string.ots_explorer_settings, MaterialSymbols.Search, Res.string.ots_explorer_search_keywords, Route.OtsSettings),
|
||||||
symEntry(Res.string.namecoin_settings, MaterialSymbols.Security, Res.string.namecoin_search_keywords, Route.NamecoinSettings),
|
symEntry(Res.string.namecoin_settings, MaterialSymbols.Security, Res.string.namecoin_search_keywords, Route.NamecoinSettings),
|
||||||
|
|||||||
+2
@@ -34,3 +34,5 @@ actual suspend fun Clipboard.getText(): String? {
|
|||||||
val pasteboard = UIPasteboard.generalPasteboard
|
val pasteboard = UIPasteboard.generalPasteboard
|
||||||
return if (pasteboard.hasStrings) pasteboard.string else null
|
return if (pasteboard.hasStrings) pasteboard.string else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual suspend fun Clipboard.hasText(): Boolean = UIPasteboard.generalPasteboard.hasStrings
|
||||||
|
|||||||
+14
@@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.commons.ui.components.util
|
|||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.Clipboard
|
import androidx.compose.ui.platform.Clipboard
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.awt.Toolkit
|
||||||
import java.awt.datatransfer.DataFlavor
|
import java.awt.datatransfer.DataFlavor
|
||||||
import java.awt.datatransfer.StringSelection
|
import java.awt.datatransfer.StringSelection
|
||||||
import java.awt.datatransfer.Transferable
|
import java.awt.datatransfer.Transferable
|
||||||
@@ -42,3 +45,14 @@ actual suspend fun Clipboard.getText(): String? {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Off the UI thread: on X11 the flavor query is a round trip to whichever app owns the clipboard.
|
||||||
|
actual suspend fun Clipboard.hasText(): Boolean =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Toolkit.getDefaultToolkit().systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Headless, or another app holds the clipboard open.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ object BrowserPopups {
|
|||||||
val useTor: Boolean,
|
val useTor: Boolean,
|
||||||
val themeType: String,
|
val themeType: String,
|
||||||
val webViewProfile: String?,
|
val webViewProfile: String?,
|
||||||
|
val searchEngine: String?,
|
||||||
) {
|
) {
|
||||||
private var target: BridgeTarget? = null
|
private var target: BridgeTarget? = null
|
||||||
private val queued = mutableListOf<() -> Unit>()
|
private val queued = mutableListOf<() -> Unit>()
|
||||||
@@ -110,6 +111,7 @@ object BrowserPopups {
|
|||||||
useTor: Boolean,
|
useTor: Boolean,
|
||||||
themeType: String,
|
themeType: String,
|
||||||
webViewProfile: String?,
|
webViewProfile: String?,
|
||||||
|
searchEngine: String?,
|
||||||
): Pair<String, WebView> {
|
): Pair<String, WebView> {
|
||||||
val app = context.applicationContext
|
val app = context.applicationContext
|
||||||
val wrapper = MutableContextWrapper(nightThemedContext(app, themeType))
|
val wrapper = MutableContextWrapper(nightThemedContext(app, themeType))
|
||||||
@@ -118,7 +120,7 @@ object BrowserPopups {
|
|||||||
// the popup must see the same logged-in session anyway.
|
// the popup must see the same logged-in session anyway.
|
||||||
NappletWebViewProfile.apply(app, webView, webViewProfile)
|
NappletWebViewProfile.apply(app, webView, webViewProfile)
|
||||||
BrowserWebTools.applyBrowserSettings(webView)
|
BrowserWebTools.applyBrowserSettings(webView)
|
||||||
val entry = Pending(webView, wrapper, proxyPort, useTor, themeType, webViewProfile)
|
val entry = Pending(webView, wrapper, proxyPort, useTor, themeType, webViewProfile, searchEngine)
|
||||||
WebViewCompat.addWebMessageListener(webView, NappletWebContract.BRIDGE_NAME, setOf("*")) { view, message, origin, isMainFrame, reply ->
|
WebViewCompat.addWebMessageListener(webView, NappletWebContract.BRIDGE_NAME, setOf("*")) { view, message, origin, isMainFrame, reply ->
|
||||||
entry.dispatch(view, message, origin, isMainFrame, reply)
|
entry.dispatch(view, message, origin, isMainFrame, reply)
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-7
@@ -67,6 +67,11 @@ import android.widget.Toast
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.ColorUtils
|
import androidx.core.graphics.ColorUtils
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
@@ -84,6 +89,10 @@ 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.DownloadCooldown
|
import com.vitorpamplona.amethyst.commons.browser.DownloadCooldown
|
||||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.PageLoadFailure
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.SearchEngines
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.ui.PageLoadError
|
||||||
|
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserChromeTheme
|
||||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
|
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.BrowserPillUi
|
||||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
|
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
|
||||||
@@ -129,6 +138,9 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
private var themeType: String = "SYSTEM"
|
private var themeType: String = "SYSTEM"
|
||||||
private var webViewProfile: String? = null
|
private var webViewProfile: String? = null
|
||||||
|
|
||||||
|
// SearchEngines id from the main process (the sandbox can't read the user's settings itself).
|
||||||
|
private var searchEngine: String? = null
|
||||||
|
|
||||||
// Key for this window's foreground lease with the broker; stable for the Activity's life.
|
// Key for this window's foreground lease with the broker; stable for the Activity's life.
|
||||||
private var leaseKey: String = ""
|
private var leaseKey: String = ""
|
||||||
|
|
||||||
@@ -136,6 +148,21 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
private var root: FrameLayout? = null
|
private var root: FrameLayout? = null
|
||||||
private var loadingView: View? = null
|
private var loadingView: View? = null
|
||||||
private var crashView: View? = null
|
private var crashView: View? = null
|
||||||
|
|
||||||
|
/** Our own page over the WebView's built-in one after a main-frame load failure; see [showLoadError]. */
|
||||||
|
private var loadErrorView: View? = null
|
||||||
|
private var loadError by mutableStateOf<LoadErrorState?>(null)
|
||||||
|
|
||||||
|
private data class LoadErrorState(
|
||||||
|
val failure: PageLoadFailure,
|
||||||
|
val host: String,
|
||||||
|
val detail: String?,
|
||||||
|
val viaTor: Boolean,
|
||||||
|
/** Whether flipping this page's Tor choice could change the outcome; see [canSwitchTor]. */
|
||||||
|
val canSwitchTor: Boolean,
|
||||||
|
val retrying: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
private var resumed = false
|
private var resumed = false
|
||||||
|
|
||||||
// The pill, find, console and page dialogs — the shared Compose chrome (see BrowserChromeHost).
|
// The pill, find, console and page dialogs — the shared Compose chrome (see BrowserChromeHost).
|
||||||
@@ -296,6 +323,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
useTor = popup.useTor
|
useTor = popup.useTor
|
||||||
themeType = popup.themeType
|
themeType = popup.themeType
|
||||||
webViewProfile = popup.webViewProfile
|
webViewProfile = popup.webViewProfile
|
||||||
|
searchEngine = popup.searchEngine
|
||||||
leaseKey = "popup:$popupToken"
|
leaseKey = "popup:$popupToken"
|
||||||
} else {
|
} else {
|
||||||
startUrl = intent.getStringExtra(EXTRA_URL)?.takeIf { it.isNotBlank() } ?: run {
|
startUrl = intent.getStringExtra(EXTRA_URL)?.takeIf { it.isNotBlank() } ?: run {
|
||||||
@@ -306,6 +334,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
useTor = intent.getBooleanExtra(EXTRA_USE_TOR, true)
|
useTor = intent.getBooleanExtra(EXTRA_USE_TOR, true)
|
||||||
themeType = intent.getStringExtra(EXTRA_THEME).orEmpty().ifBlank { "SYSTEM" }
|
themeType = intent.getStringExtra(EXTRA_THEME).orEmpty().ifBlank { "SYSTEM" }
|
||||||
webViewProfile = intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE)
|
webViewProfile = intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE)
|
||||||
|
searchEngine = intent.getStringExtra(EXTRA_SEARCH_ENGINE)
|
||||||
leaseKey = startUrl
|
leaseKey = startUrl
|
||||||
}
|
}
|
||||||
title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
|
title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
|
||||||
@@ -353,9 +382,11 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
val wv = buildWebView(popup)
|
val wv = buildWebView(popup)
|
||||||
contentFrame.addView(wv, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
contentFrame.addView(wv, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||||
if (popup == null) {
|
if (popup == null) {
|
||||||
loadingView = buildLoadingView().also { contentFrame.addView(it) }
|
if (!guardOnion(wv, startUrl, isMainFrame = true)) {
|
||||||
// Wait for this page's route to be in effect before the first request leaves.
|
loadingView = buildLoadingView().also { contentFrame.addView(it) }
|
||||||
claimRoute { if (webView === wv) wv.loadUrl(startUrl) }
|
// Wait for this page's route to be in effect before the first request leaves.
|
||||||
|
claimRoute { if (webView === wv) wv.loadUrl(startUrl) }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
wv.url?.let { if (it.isNotBlank() && it != "about:blank") startUrl = it }
|
wv.url?.let { if (it.isNotBlank() && it != "about:blank") startUrl = it }
|
||||||
}
|
}
|
||||||
@@ -651,7 +682,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
): Boolean {
|
): Boolean {
|
||||||
if (!isUserGesture) return false
|
if (!isUserGesture) return false
|
||||||
val transport = resultMsg.obj as? WebView.WebViewTransport ?: return false
|
val transport = resultMsg.obj as? WebView.WebViewTransport ?: return false
|
||||||
val (token, child) = BrowserPopups.create(this@NappletBrowserActivity, shimJs, proxyPort, useTor, themeType, webViewProfile)
|
val (token, child) = BrowserPopups.create(this@NappletBrowserActivity, shimJs, proxyPort, useTor, themeType, webViewProfile, searchEngine)
|
||||||
transport.webView = child
|
transport.webView = child
|
||||||
resultMsg.sendToTarget()
|
resultMsg.sendToTarget()
|
||||||
startActivity(popupIntent(this@NappletBrowserActivity, token))
|
startActivity(popupIntent(this@NappletBrowserActivity, token))
|
||||||
@@ -719,7 +750,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
): Boolean {
|
): Boolean {
|
||||||
val uri = request.url
|
val uri = request.url
|
||||||
val scheme = uri.scheme?.lowercase()
|
val scheme = uri.scheme?.lowercase()
|
||||||
if (scheme == "http" || scheme == "https") return false
|
if (scheme == "http" || scheme == "https") return guardOnion(view, uri.toString(), request.isForMainFrame)
|
||||||
return BrowserWebTools.openExternal(this@NappletBrowserActivity, uri, request.hasGesture()) { view.loadUrl(it) }
|
return BrowserWebTools.openExternal(this@NappletBrowserActivity, uri, request.hasGesture()) { view.loadUrl(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,6 +762,8 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
// A fresh main-frame navigation: arm history gating and show the new address.
|
// A fresh main-frame navigation: arm history gating and show the new address.
|
||||||
pendingMainFrameUrl = url
|
pendingMainFrameUrl = url
|
||||||
mainFrameLoadFailed = false
|
mainFrameLoadFailed = false
|
||||||
|
// Keep the error page up while the next attempt loads: under it is the WebView's own error page.
|
||||||
|
loadError?.let { loadError = it.copy(retrying = true) }
|
||||||
// The page is being replaced: nothing it asked for (replies, subscription pushes) may reach the next
|
// The page is being replaced: nothing it asked for (replies, subscription pushes) may reach the next
|
||||||
// one, even a next one that never talks to the bridge.
|
// one, even a next one that never talks to the bridge.
|
||||||
if (bridge.onNavigation()) releasePage()
|
if (bridge.onNavigation()) releasePage()
|
||||||
@@ -758,7 +791,14 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
) {
|
) {
|
||||||
// A main-frame failure (DNS miss on a misspelled host, no connection, …) disqualifies this
|
// A main-frame failure (DNS miss on a misspelled host, no connection, …) disqualifies this
|
||||||
// navigation from history. Sub-resource errors are irrelevant to whether the page opened.
|
// navigation from history. Sub-resource errors are irrelevant to whether the page opened.
|
||||||
if (request.isForMainFrame) mainFrameLoadFailed = true
|
if (request.isForMainFrame) {
|
||||||
|
mainFrameLoadFailed = true
|
||||||
|
val description = error.description?.toString()
|
||||||
|
val failedUrl = request.url?.toString() ?: view.url.orEmpty()
|
||||||
|
PageLoadFailure.forPage(error.errorCode, description, failedUrl, routedOverTor)?.let { failure ->
|
||||||
|
showLoadError(failedUrl, failure, description)
|
||||||
|
}
|
||||||
|
}
|
||||||
logConsoleError(request, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty()))
|
logConsoleError(request, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,6 +817,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
// The page has painted its first frame — drop the loading screen.
|
// The page has painted its first frame — drop the loading screen.
|
||||||
loadingView?.let { contentFrame.removeView(it) }
|
loadingView?.let { contentFrame.removeView(it) }
|
||||||
loadingView = null
|
loadingView = null
|
||||||
|
if (!mainFrameLoadFailed) hideLoadError()
|
||||||
showUrl(url)
|
showUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,6 +837,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
syncNavigation(view)
|
syncNavigation(view)
|
||||||
updateChromeState { copy(isLoading = false) }
|
updateChromeState { copy(isLoading = false) }
|
||||||
showUrl(url)
|
showUrl(url)
|
||||||
|
if (!mainFrameLoadFailed) hideLoadError()
|
||||||
// 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://"))) {
|
||||||
recordHistory(url, view.title)
|
recordHistory(url, view.title)
|
||||||
@@ -944,7 +986,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
|
|
||||||
/** Loads a user-typed address from "Edit address", forcing Tor for `.onion` when available. */
|
/** Loads a user-typed address from "Edit address", forcing Tor for `.onion` when available. */
|
||||||
private fun loadAddress(text: String) {
|
private fun loadAddress(text: String) {
|
||||||
val resolved = OmniboxInput.resolve(text) ?: return
|
val resolved = OmniboxInput.resolve(text, SearchEngines.byId(searchEngine).queryPrefix) ?: return
|
||||||
if (resolved.forceTor && !useTor) {
|
if (resolved.forceTor && !useTor) {
|
||||||
useTor = true
|
useTor = true
|
||||||
updateChromeState { copy(torOn = true, torForced = false) }
|
updateChromeState { copy(torOn = true, torForced = false) }
|
||||||
@@ -1798,6 +1840,85 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
addView(ProgressBar(this@NappletBrowserActivity))
|
addView(ProgressBar(this@NappletBrowserActivity))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the WebView's built-in error page (an Android robot over "net::ERR_…" text) with one that says
|
||||||
|
* what went wrong in words and offers a retry. It sits in [contentFrame] under the pill, so the address
|
||||||
|
* can still be edited. It stays up through the next attempt (showing it is retrying) and goes away only
|
||||||
|
* once a page really paints, so a retry never flashes the built-in page underneath.
|
||||||
|
*/
|
||||||
|
private fun showLoadError(
|
||||||
|
url: String,
|
||||||
|
failure: PageLoadFailure,
|
||||||
|
detail: String?,
|
||||||
|
) {
|
||||||
|
loadError = LoadErrorState(failure, BrowserChrome.displayHost(url).ifBlank { url }, detail, routedOverTor, canSwitchTor(failure, url))
|
||||||
|
if (loadErrorView != null) return
|
||||||
|
val dark = isDarkTheme()
|
||||||
|
loadErrorView =
|
||||||
|
ComposeView(this)
|
||||||
|
.apply {
|
||||||
|
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||||
|
setContent {
|
||||||
|
val state = loadError ?: return@setContent
|
||||||
|
BrowserChromeTheme(dark) {
|
||||||
|
PageLoadError(
|
||||||
|
failure = state.failure,
|
||||||
|
host = state.host,
|
||||||
|
detail = state.detail,
|
||||||
|
viaTor = state.viaTor,
|
||||||
|
retrying = state.retrying,
|
||||||
|
onRetry = { webView?.reload() },
|
||||||
|
onSwitchTor = if (state.canSwitchTor) ({ setNetworkMode(!useTor) }) else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.also { contentFrame.addView(it, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the error page may offer to flip this page's Tor choice. An onion off Tor can be opened with
|
||||||
|
* Tor when Tor is running. Otherwise only a reachability failure while this page asked for Tor (some
|
||||||
|
* sites drop Tor exits), never for an onion, and only when no other open surface also needs Tor — the
|
||||||
|
* route is shared and Tor wins, so the open-web retry would go through Tor again anyway.
|
||||||
|
*/
|
||||||
|
private fun canSwitchTor(
|
||||||
|
failure: PageLoadFailure,
|
||||||
|
url: String,
|
||||||
|
): Boolean =
|
||||||
|
when {
|
||||||
|
proxyPort <= 0 -> false
|
||||||
|
failure == PageLoadFailure.ONION_NEEDS_TOR -> !useTor
|
||||||
|
else -> useTor && PageLoadFailure.mayBeTorBlocked(failure) && !OmniboxInput.isOnion(url) && !WebViewProxyPolicy.torWantedByOthers(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a `.onion` link from leaving on the open web, where its name would be looked up by the regular
|
||||||
|
* resolver and fail as "not found": a main-frame navigation to an onion on a page with Tor off switches
|
||||||
|
* this page to Tor first (as a typed onion address does), or, with no Tor to switch to, shows why it
|
||||||
|
* can't open. Returns whether the navigation was taken over.
|
||||||
|
*/
|
||||||
|
private fun guardOnion(
|
||||||
|
view: WebView,
|
||||||
|
url: String,
|
||||||
|
isMainFrame: Boolean,
|
||||||
|
): Boolean {
|
||||||
|
if (!isMainFrame || useTor || !OmniboxInput.isOnion(url)) return false
|
||||||
|
if (proxyPort > 0) {
|
||||||
|
useTor = true
|
||||||
|
updateChromeState { copy(torOn = true, torForced = false) }
|
||||||
|
claimRoute { if (webView === view) view.loadUrl(url) }
|
||||||
|
} else {
|
||||||
|
showLoadError(url, PageLoadFailure.ONION_NEEDS_TOR, null)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideLoadError() {
|
||||||
|
loadError = null
|
||||||
|
loadErrorView?.let { contentFrame.removeView(it) }
|
||||||
|
loadErrorView = null
|
||||||
|
}
|
||||||
|
|
||||||
/** Chrome's "Aw, Snap!": the page's renderer died; offer to load [url] again in a fresh WebView. */
|
/** Chrome's "Aw, Snap!": the page's renderer died; offer to load [url] again in a fresh WebView. */
|
||||||
private fun showCrashView(url: String) {
|
private fun showCrashView(url: String) {
|
||||||
crashView?.let { contentFrame.removeView(it) }
|
crashView?.let { contentFrame.removeView(it) }
|
||||||
@@ -1884,6 +2005,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
private const val EXTRA_THEME = "theme"
|
private const val EXTRA_THEME = "theme"
|
||||||
private const val EXTRA_IS_FAVORITE = "isFavorite"
|
private const val EXTRA_IS_FAVORITE = "isFavorite"
|
||||||
private const val EXTRA_POPUP_TOKEN = "popupToken"
|
private const val EXTRA_POPUP_TOKEN = "popupToken"
|
||||||
|
private const val EXTRA_SEARCH_ENGINE = "searchEngine"
|
||||||
|
|
||||||
/** `TaskDescription.Builder.setIcon(Icon)` exists from this SDK on. */
|
/** `TaskDescription.Builder.setIcon(Icon)` exists from this SDK on. */
|
||||||
private const val ICON_BUILDER_SDK = 37
|
private const val ICON_BUILDER_SDK = 37
|
||||||
@@ -1897,6 +2019,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
theme: String = "SYSTEM",
|
theme: String = "SYSTEM",
|
||||||
isFavorite: Boolean = false,
|
isFavorite: Boolean = false,
|
||||||
webViewProfile: String? = null,
|
webViewProfile: String? = null,
|
||||||
|
searchEngine: String? = null,
|
||||||
): Intent =
|
): Intent =
|
||||||
Intent()
|
Intent()
|
||||||
.setClassName(context, ACTIVITY_CLASS)
|
.setClassName(context, ACTIVITY_CLASS)
|
||||||
@@ -1909,6 +2032,7 @@ class NappletBrowserActivity : ComponentActivity() {
|
|||||||
// Opaque per-account storage partition; shares the host contract's key so there is one
|
// Opaque per-account storage partition; shares the host contract's key so there is one
|
||||||
// name for the concept across every WebView creation site.
|
// name for the concept across every WebView creation site.
|
||||||
.putExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE, webViewProfile)
|
.putExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE, webViewProfile)
|
||||||
|
.putExtra(EXTRA_SEARCH_ENGINE, searchEngine)
|
||||||
// Distinct task identity per URL for documentLaunchMode=intoExisting.
|
// Distinct task identity per URL for documentLaunchMode=intoExisting.
|
||||||
.setData(url.toUri())
|
.setData(url.toUri())
|
||||||
|
|
||||||
|
|||||||
+6
@@ -338,6 +338,12 @@ object NappletBrowserContract {
|
|||||||
*/
|
*/
|
||||||
const val KEY_THEME = "theme"
|
const val KEY_THEME = "theme"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's omnibox search engine, as a [com.vitorpamplona.amethyst.commons.browser.SearchEngines]
|
||||||
|
* id, so an address typed into a popup window this tab opens searches with the same engine.
|
||||||
|
*/
|
||||||
|
const val KEY_SEARCH_ENGINE = "searchEngine"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opaque per-account WebView storage-profile name (a truncated SHA-256 of the account pubkey,
|
* Opaque per-account WebView storage-profile name (a truncated SHA-256 of the account pubkey,
|
||||||
* minted in the main process). Partitions cookies/localStorage/IndexedDB/service workers per
|
* minted in the main process). Partitions cookies/localStorage/IndexedDB/service workers per
|
||||||
|
|||||||
+4
-1
@@ -108,6 +108,8 @@ class NappletBrowserService : Service() {
|
|||||||
val themeType: String,
|
val themeType: String,
|
||||||
// Opaque per-account WebView storage-profile name (see NappletWebViewProfile).
|
// Opaque per-account WebView storage-profile name (see NappletWebViewProfile).
|
||||||
val webViewProfile: String?,
|
val webViewProfile: String?,
|
||||||
|
// SearchEngines id the main process chose; handed to popups, whose address bar resolves here.
|
||||||
|
val searchEngine: String?,
|
||||||
) {
|
) {
|
||||||
var webView: WebView? = null
|
var webView: WebView? = null
|
||||||
|
|
||||||
@@ -272,6 +274,7 @@ class NappletBrowserService : Service() {
|
|||||||
bgColor = data.getInt(NappletBrowserContract.KEY_BG_COLOR, Color.WHITE),
|
bgColor = data.getInt(NappletBrowserContract.KEY_BG_COLOR, Color.WHITE),
|
||||||
themeType = data.getString(NappletBrowserContract.KEY_THEME).orEmpty().ifBlank { "SYSTEM" },
|
themeType = data.getString(NappletBrowserContract.KEY_THEME).orEmpty().ifBlank { "SYSTEM" },
|
||||||
webViewProfile = data.getString(NappletBrowserContract.KEY_WEBVIEW_PROFILE),
|
webViewProfile = data.getString(NappletBrowserContract.KEY_WEBVIEW_PROFILE),
|
||||||
|
searchEngine = data.getString(NappletBrowserContract.KEY_SEARCH_ENGINE),
|
||||||
)
|
)
|
||||||
tabs[sessionId] = tab
|
tabs[sessionId] = tab
|
||||||
// Bind the broker once; a re-sent MSG_CREATE_SESSION (e.g. client reconnect) must not
|
// Bind the broker once; a re-sent MSG_CREATE_SESSION (e.g. client reconnect) must not
|
||||||
@@ -835,7 +838,7 @@ class NappletBrowserService : Service() {
|
|||||||
val tab = tab ?: return false
|
val tab = tab ?: return false
|
||||||
if (!isUserGesture) return false
|
if (!isUserGesture) return false
|
||||||
val transport = resultMsg.obj as? WebView.WebViewTransport ?: return false
|
val transport = resultMsg.obj as? WebView.WebViewTransport ?: return false
|
||||||
val (token, child) = BrowserPopups.create(this@NappletBrowserService, shimJs, tab.proxyPort, tab.useTor, tab.themeType, tab.webViewProfile)
|
val (token, child) = BrowserPopups.create(this@NappletBrowserService, shimJs, tab.proxyPort, tab.useTor, tab.themeType, tab.webViewProfile, tab.searchEngine)
|
||||||
transport.webView = child
|
transport.webView = child
|
||||||
resultMsg.sendToTarget()
|
resultMsg.sendToTarget()
|
||||||
runCatching { startActivity(NappletBrowserActivity.popupIntent(this@NappletBrowserService, token)) }
|
runCatching { startActivity(NappletBrowserActivity.popupIntent(this@NappletBrowserService, token)) }
|
||||||
|
|||||||
+3
@@ -97,6 +97,9 @@ object WebViewProxyPolicy {
|
|||||||
sync(null)
|
sync(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether another live surface needs Tor, which keeps the shared route on Tor whatever [owner] picks. */
|
||||||
|
fun torWantedByOthers(owner: Any): Boolean = claims.torWantedByOthers(owner)
|
||||||
|
|
||||||
/** Tells [listener] (now, and on every change) whether the process currently routes through Tor. */
|
/** Tells [listener] (now, and on every change) whether the process currently routes through Tor. */
|
||||||
fun observeRoute(
|
fun observeRoute(
|
||||||
owner: Any,
|
owner: Any,
|
||||||
|
|||||||
Reference in New Issue
Block a user