Merge pull request #4312 from vitorpamplona/claude/bold-planck-9walk3

Add search engine selection and improve browser omnibox UX
This commit is contained in:
Vitor Pamplona
2026-10-02 11:58:09 -04:00
committed by GitHub
32 changed files with 1238 additions and 67 deletions
@@ -115,6 +115,7 @@ object FavoriteAppLauncher {
// 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).
webViewProfile = NappletWebViewProfiles.current(),
searchEngine = Amethyst.instance.uiPrefs.value.searchEngine.value,
).apply {
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.ProfileUiSettingsScreen
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.SpammingUsersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.UpdateZapAmountScreen
@@ -654,6 +655,7 @@ fun BuildNavigation(
composableFromEnd<Route.DrawerSettings> { DrawerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.SearchEngineSettings> { SearchEngineSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.NowPlayingSettings> {
@@ -80,6 +80,8 @@ class EmbeddedWebAppController(
private val initialUseTor: Boolean,
private val backgroundColor: Int,
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,
EmbeddedImeBridge,
EmbeddedMagnifierProbe,
@@ -505,6 +507,7 @@ class EmbeddedWebAppController(
putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor)
putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor)
putString(NappletBrowserContract.KEY_THEME, themeType)
putString(NappletBrowserContract.KEY_SEARCH_ENGINE, searchEngine())
// 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).
putString(NappletBrowserContract.KEY_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions
import com.vitorpamplona.amethyst.commons.browser.SearchEngines
import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
@@ -220,7 +221,8 @@ private fun EmbeddedWebAppTab(
}
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.
if (resolved.forceTor && proxyAvailable && !torOn) {
torOn = true
@@ -73,7 +73,14 @@ object EmbeddedTabFactory {
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)
it.bind(url)
}
@@ -27,9 +27,9 @@ package com.vitorpamplona.amethyst.commons.browser
*
* The rules, in order:
* - 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
* - 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
* 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(
val url: String,
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(
@@ -50,14 +52,22 @@ object OmniboxInput {
): Resolved? {
val text = raw.trim()
if (text.isEmpty()) return null
if (text.contains("://")) return Resolved(text, isOnion(text))
if (looksLikeHost(text)) {
val url = "https://$text"
if (!isAddress(text)) return Resolved(searchPrefix + encodeQuery(text), forceTor = false, isSearch = true)
val url = if (hasScheme(text)) text else "https://$text"
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
* 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(':')
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 val HEX = "0123456789ABCDEF".toCharArray()
@@ -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
}
}
}
@@ -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
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.browser.SearchEngines
import kotlinx.serialization.Serializable
@Stable
@@ -64,6 +65,8 @@ data class UiSettings(
// be bank or Venmo handles carrying legal names, and this puts them one tap
// from every note in the feed.
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(
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.browser.SearchEngines
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -57,6 +58,7 @@ class UiSettingsFlow(
val composeSignature: MutableStateFlow<String> = MutableStateFlow(""),
val showOnchainWallet: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showPayToZapChip: MutableStateFlow<Boolean> = MutableStateFlow(true),
val searchEngine: MutableStateFlow<String> = MutableStateFlow(SearchEngines.DEFAULT.id),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -90,6 +92,7 @@ class UiSettingsFlow(
composeSignature,
showOnchainWallet,
showPayToZapChip,
searchEngine,
)
// emits at every change in any of the propertyes.
@@ -127,6 +130,7 @@ class UiSettingsFlow(
flows[27] as String,
flows[28] as Boolean,
flows[29] as Boolean,
flows[30] as String,
)
}
@@ -162,6 +166,7 @@ class UiSettingsFlow(
composeSignature.value,
showOnchainWallet.value,
showPayToZapChip.value,
searchEngine.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -287,6 +292,10 @@ class UiSettingsFlow(
showPayToZapChip.tryEmit(torSettings.showPayToZapChip)
any = true
}
if (searchEngine.value != torSettings.searchEngine) {
searchEngine.tryEmit(torSettings.searchEngine)
any = true
}
return any
}
@@ -342,6 +351,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.composeSignature),
MutableStateFlow(uiSettings.showOnchainWallet),
MutableStateFlow(uiSettings.showPayToZapChip),
MutableStateFlow(uiSettings.searchEngine),
)
}
}
@@ -581,6 +581,8 @@ sealed class Route {
@Serializable object ProfileUiSettings : Route()
@Serializable object SearchEngineSettings : Route()
@Serializable object VideoPlayerSettings : Route()
@Serializable object CallSettings : Route()
@@ -27,6 +27,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
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.BooleanType
import com.vitorpamplona.amethyst.commons.model.ConnectivityType
@@ -129,6 +130,7 @@ class UiSettingsStore(
val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature")
val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet")
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)`
@@ -173,6 +175,7 @@ class UiSettingsStore(
composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "",
showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: 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_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet
preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip
preferences[UI_SEARCH_ENGINE] = sharedSettings.searchEngine
}
/**
@@ -68,6 +68,12 @@ class NappletProxyClaims {
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)
companion object {
@@ -57,6 +57,27 @@ class OmniboxInputTest {
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
fun searchPrefixIsConfigurable() {
assertEquals("https://search.example/?s=cats", OmniboxInput.resolve("cats", "https://search.example/?s=")?.url)
@@ -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"))
}
}
@@ -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)
}
}
@@ -31,6 +31,17 @@ class NappletProxyClaimsTest {
private val torTab = 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
fun noClaimsMeansNoProxy() {
assertEquals(DIRECT, claims.route())
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.ui.components.util
import android.content.ClipData
import android.content.ClipDescription
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.Clipboard
@@ -34,3 +35,11 @@ actual suspend fun Clipboard.getText(): String? =
?.getItemAt(0)
?.text
?.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_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="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_clear">Clear</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_stop">Stop and send</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="browser_permission_section">Site permissions</string>
<string name="browser_permission_camera">Use your camera</string>
@@ -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)
}
@@ -63,6 +63,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -119,11 +120,16 @@ fun AddressEditor(
verticalAlignment = Alignment.CenterVertically,
) {
// 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) {
SecurityIcon(security, size = 20.dp)
} 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))
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
@@ -27,3 +27,9 @@ expect suspend fun Clipboard.setText(text: String)
/** The clipboard's current plain text, or null when it holds none. */
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
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.browser
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
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.font.FontWeight
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.OmniboxInput
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.favorites.FavoriteApp
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_favorites
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_remove
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_remove
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.note.ArrowBackIcon
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.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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.
PreloadFavoriteNostrApps(apps, accountViewModel)
val searchEngineId by accountViewModel.settings.uiSettingsFlow.searchEngine
.collectAsStateWithLifecycle()
val searchEngine = SearchEngines.byId(searchEngineId)
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
// 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
@@ -225,9 +272,8 @@ private fun BrowserLauncher(
value = withContext(Dispatchers.Default) { nappletNotes.toDiscoverApps(nappletFollows::matchAuthor, favoriteCoordinates) }
}
// What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the
// caret when collapsed, or the start of the highlighted suffix when a completion is showing).
val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length))
// What the user actually typed, excluding the ghost-completion suffix while one is showing.
val typed = ghost?.takeIf { ghostShowing }?.let { field.text.take(it.start) } ?: field.text
// 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.
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) }
fun open(text: String) {
val target = OmniboxInput.resolve(text) ?: return
val target = OmniboxInput.resolve(text, searchEngine.queryPrefix) ?: return
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.
@@ -259,23 +310,27 @@ private fun BrowserLauncher(
// 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.
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 appended =
new.selection.collapsed &&
new.selection.start == newText.length &&
newText.length > prevTyped.length &&
newText.startsWith(prevTyped)
newText.length > kept.length &&
newText.startsWith(kept)
if (appended) {
// The completion has always looked at the top 8 (rank's default limit).
val completion = OmniboxSuggestions.completion(newText, ranked(newText).take(8))
if (completion != null) {
// Keep the user's own casing for the typed prefix; append only the remaining suffix.
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
}
}
if (new.text != field.text) ghost = null
field = new
}
@@ -284,9 +339,25 @@ private fun BrowserLauncher(
OmniBar(
nav = nav,
field = field,
focused = focused,
onFocusChange = { focused = it },
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) },
onPasteAndGo =
if (focused && field.text.isEmpty() && clipboardHasText) {
{ scope.launch { clipboard.getText()?.let(::open) } }
} else {
null
},
)
},
bottomBar = {
@@ -298,12 +369,16 @@ private fun BrowserLauncher(
.fillMaxSize()
.padding(padding)
when {
typed.isNotBlank() && suggestions.isNotEmpty() ->
typed.isNotBlank() ->
SuggestionGrid(
entered = field.text,
searchEngine = searchEngine,
suggestions = suggestions,
iconKeys = iconKeys,
historyUrls = historyUrls,
onOpenEntered = { open(field.text) },
onOpen = { open(it.url) },
onFillIn = { field = TextFieldValue(it, TextRange(it.length)) },
onToggleFavorite = { toggleFavorite(it.url, it.label) },
onRemoveFromHistory = { appServices.browserHistory.remove(it) },
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
private fun OmniBar(
nav: INav,
field: TextFieldValue,
focused: Boolean,
onFocusChange: (Boolean) -> Unit,
onValueChange: (TextFieldValue) -> Unit,
hasCompletion: Boolean,
onAcceptCompletion: () -> Unit,
onClear: () -> Unit,
onOpen: () -> Unit,
onPasteAndGo: (() -> Unit)?,
) {
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val colors = MaterialTheme.colorScheme
val isAddress = OmniboxInput.isAddress(field.text)
Row(
modifier =
Modifier
@@ -349,7 +441,7 @@ private fun OmniBar(
// 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.
.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,
) {
// When reached from the drawer (pushed onto the back stack) rather than as a bottom-bar tab, show
@@ -357,12 +449,32 @@ private fun OmniBar(
if (nav.canPop()) {
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
}
TextField(
Row(
Modifier
.weight(1f)
.height(52.dp)
.clip(CircleShape)
.background(if (focused) colors.surfaceContainerHighest else colors.surfaceContainerHigh)
.border(if (focused) 2.dp else 1.dp, if (focused) colors.primary else colors.outlineVariant, CircleShape)
// The whole pill is the target, not just the text line inside it.
.clickable(interactionSource = null, indication = null) { focusRequester.requestFocus() }
.padding(start = 16.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (isAddress) MaterialSymbols.Language else MaterialSymbols.Search,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (focused) colors.primary else colors.onSurfaceVariant,
)
Spacer(Modifier.width(12.dp))
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
BasicTextField(
value = field,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
singleLine = true,
placeholder = { Text(stringRes(Res.string.browser_address_hint)) },
textStyle = MaterialTheme.typography.bodyLarge.copy(color = colors.onSurface),
cursorBrush = SolidColor(colors.primary),
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
@@ -371,38 +483,142 @@ private fun OmniBar(
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { onOpen() }),
trailingIcon = {
if (field.text.isNotEmpty()) {
IconButton(onClick = onClear) {
Icon(MaterialSymbols.Clear, contentDescription = stringRes(Res.string.browser_clear))
// 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
}
},
colors =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
}
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()) {
IconButton(onClick = onOpen) {
Icon(MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = stringRes(Res.string.browser_go))
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)
}
}
}
}
}
/**
* The typed-state body: 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
* the same 3-dot menu as the idle Recent cards (pin/unpin; plus remove-from-history for visited sites).
* The first row while typing: exactly what Go / Enter will do with the [entered] text — open it as an
* address, or search for it — so the user never has to guess which one they're about to get.
*/
@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
private fun SuggestionGrid(
entered: String,
searchEngine: SearchEngines.SearchEngine,
suggestions: List<OmniboxSuggestions.Suggestion>,
iconKeys: Set<String>,
historyUrls: Set<String>,
onOpenEntered: () -> Unit,
onOpen: (OmniboxSuggestions.Suggestion) -> Unit,
onFillIn: (String) -> Unit,
onToggleFavorite: (OmniboxSuggestions.Suggestion) -> Unit,
onRemoveFromHistory: (String) -> Unit,
modifier: Modifier = Modifier,
@@ -426,6 +642,7 @@ private fun SuggestionGrid(
highlighted = highlighted,
removableFromHistory = suggestion.url in historyUrls,
onClick = { onOpen(suggestion) },
onFillIn = { onFillIn(suggestion.url.removePrefix("https://")) },
onToggleFavorite = { onToggleFavorite(suggestion) },
onRemoveFromHistory = { onRemoveFromHistory(suggestion.url) },
)
@@ -433,6 +650,7 @@ private fun SuggestionGrid(
}
LazyVerticalGrid(columns = GridCells.Fixed(1), modifier = modifier) {
item(key = "entered") { EnteredRow(entered, searchEngine, onOpenEntered) }
section("f", Res.string.browser_favorites, favorites, highlighted = true)
section("o", Res.string.favorite_app_recent, recent, highlighted = false)
section("s", Res.string.browser_suggested, discover, highlighted = false)
@@ -446,6 +664,7 @@ private fun SuggestionRow(
highlighted: Boolean,
removableFromHistory: Boolean,
onClick: () -> Unit,
onFillIn: () -> Unit,
onToggleFavorite: () -> 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 {
IconButton(onClick = { menuOpen = true }) {
Icon(MaterialSymbols.MoreVert, contentDescription = stringRes(Res.string.browser_recent_options))
@@ -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)
}
}
}
@@ -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.route_home
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_search_keywords
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.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.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.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),
@@ -34,3 +34,5 @@ actual suspend fun Clipboard.getText(): String? {
val pasteboard = UIPasteboard.generalPasteboard
return if (pasteboard.hasStrings) pasteboard.string else null
}
actual suspend fun Clipboard.hasText(): Boolean = UIPasteboard.generalPasteboard.hasStrings
@@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.commons.ui.components.util
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.platform.ClipEntry
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.StringSelection
import java.awt.datatransfer.Transferable
@@ -42,3 +45,14 @@ actual suspend fun Clipboard.getText(): String? {
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 themeType: String,
val webViewProfile: String?,
val searchEngine: String?,
) {
private var target: BridgeTarget? = null
private val queued = mutableListOf<() -> Unit>()
@@ -110,6 +111,7 @@ object BrowserPopups {
useTor: Boolean,
themeType: String,
webViewProfile: String?,
searchEngine: String?,
): Pair<String, WebView> {
val app = context.applicationContext
val wrapper = MutableContextWrapper(nightThemedContext(app, themeType))
@@ -118,7 +120,7 @@ object BrowserPopups {
// the popup must see the same logged-in session anyway.
NappletWebViewProfile.apply(app, webView, webViewProfile)
BrowserWebTools.applyBrowserSettings(webView)
val entry = Pending(webView, wrapper, proxyPort, useTor, themeType, webViewProfile)
val entry = Pending(webView, wrapper, proxyPort, useTor, themeType, webViewProfile, searchEngine)
WebViewCompat.addWebMessageListener(webView, NappletWebContract.BRIDGE_NAME, setOf("*")) { view, message, origin, isMainFrame, reply ->
entry.dispatch(view, message, origin, isMainFrame, reply)
}
@@ -67,6 +67,11 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
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.graphics.ColorUtils
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.DownloadCooldown
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.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
@@ -129,6 +138,9 @@ class NappletBrowserActivity : ComponentActivity() {
private var themeType: String = "SYSTEM"
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.
private var leaseKey: String = ""
@@ -136,6 +148,21 @@ class NappletBrowserActivity : ComponentActivity() {
private var root: FrameLayout? = null
private var loadingView: 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
// The pill, find, console and page dialogs — the shared Compose chrome (see BrowserChromeHost).
@@ -296,6 +323,7 @@ class NappletBrowserActivity : ComponentActivity() {
useTor = popup.useTor
themeType = popup.themeType
webViewProfile = popup.webViewProfile
searchEngine = popup.searchEngine
leaseKey = "popup:$popupToken"
} else {
startUrl = intent.getStringExtra(EXTRA_URL)?.takeIf { it.isNotBlank() } ?: run {
@@ -306,6 +334,7 @@ class NappletBrowserActivity : ComponentActivity() {
useTor = intent.getBooleanExtra(EXTRA_USE_TOR, true)
themeType = intent.getStringExtra(EXTRA_THEME).orEmpty().ifBlank { "SYSTEM" }
webViewProfile = intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE)
searchEngine = intent.getStringExtra(EXTRA_SEARCH_ENGINE)
leaseKey = startUrl
}
title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
@@ -353,9 +382,11 @@ class NappletBrowserActivity : ComponentActivity() {
val wv = buildWebView(popup)
contentFrame.addView(wv, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
if (popup == null) {
if (!guardOnion(wv, startUrl, isMainFrame = true)) {
loadingView = buildLoadingView().also { contentFrame.addView(it) }
// Wait for this page's route to be in effect before the first request leaves.
claimRoute { if (webView === wv) wv.loadUrl(startUrl) }
}
} else {
wv.url?.let { if (it.isNotBlank() && it != "about:blank") startUrl = it }
}
@@ -651,7 +682,7 @@ class NappletBrowserActivity : ComponentActivity() {
): Boolean {
if (!isUserGesture) 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
resultMsg.sendToTarget()
startActivity(popupIntent(this@NappletBrowserActivity, token))
@@ -719,7 +750,7 @@ class NappletBrowserActivity : ComponentActivity() {
): Boolean {
val uri = request.url
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) }
}
@@ -731,6 +762,8 @@ class NappletBrowserActivity : ComponentActivity() {
// A fresh main-frame navigation: arm history gating and show the new address.
pendingMainFrameUrl = url
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
// one, even a next one that never talks to the bridge.
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
// 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()))
}
@@ -777,6 +817,7 @@ class NappletBrowserActivity : ComponentActivity() {
// The page has painted its first frame — drop the loading screen.
loadingView?.let { contentFrame.removeView(it) }
loadingView = null
if (!mainFrameLoadFailed) hideLoadError()
showUrl(url)
}
@@ -796,6 +837,7 @@ class NappletBrowserActivity : ComponentActivity() {
syncNavigation(view)
updateChromeState { copy(isLoading = false) }
showUrl(url)
if (!mainFrameLoadFailed) hideLoadError()
// Record only a clean http(s) main-frame load — never a typed-but-failed address.
if (!mainFrameLoadFailed && (url.startsWith("https://") || url.startsWith("http://"))) {
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. */
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) {
useTor = true
updateChromeState { copy(torOn = true, torForced = false) }
@@ -1798,6 +1840,85 @@ class NappletBrowserActivity : ComponentActivity() {
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. */
private fun showCrashView(url: String) {
crashView?.let { contentFrame.removeView(it) }
@@ -1884,6 +2005,7 @@ class NappletBrowserActivity : ComponentActivity() {
private const val EXTRA_THEME = "theme"
private const val EXTRA_IS_FAVORITE = "isFavorite"
private const val EXTRA_POPUP_TOKEN = "popupToken"
private const val EXTRA_SEARCH_ENGINE = "searchEngine"
/** `TaskDescription.Builder.setIcon(Icon)` exists from this SDK on. */
private const val ICON_BUILDER_SDK = 37
@@ -1897,6 +2019,7 @@ class NappletBrowserActivity : ComponentActivity() {
theme: String = "SYSTEM",
isFavorite: Boolean = false,
webViewProfile: String? = null,
searchEngine: String? = null,
): Intent =
Intent()
.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
// name for the concept across every WebView creation site.
.putExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE, webViewProfile)
.putExtra(EXTRA_SEARCH_ENGINE, searchEngine)
// Distinct task identity per URL for documentLaunchMode=intoExisting.
.setData(url.toUri())
@@ -338,6 +338,12 @@ object NappletBrowserContract {
*/
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,
* minted in the main process). Partitions cookies/localStorage/IndexedDB/service workers per
@@ -108,6 +108,8 @@ class NappletBrowserService : Service() {
val themeType: String,
// Opaque per-account WebView storage-profile name (see NappletWebViewProfile).
val webViewProfile: String?,
// SearchEngines id the main process chose; handed to popups, whose address bar resolves here.
val searchEngine: String?,
) {
var webView: WebView? = null
@@ -272,6 +274,7 @@ class NappletBrowserService : Service() {
bgColor = data.getInt(NappletBrowserContract.KEY_BG_COLOR, Color.WHITE),
themeType = data.getString(NappletBrowserContract.KEY_THEME).orEmpty().ifBlank { "SYSTEM" },
webViewProfile = data.getString(NappletBrowserContract.KEY_WEBVIEW_PROFILE),
searchEngine = data.getString(NappletBrowserContract.KEY_SEARCH_ENGINE),
)
tabs[sessionId] = tab
// 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
if (!isUserGesture) return false
val transport = resultMsg.obj as? WebView.WebViewTransport ?: return false
val (token, child) = BrowserPopups.create(this@NappletBrowserService, shimJs, tab.proxyPort, tab.useTor, tab.themeType, tab.webViewProfile)
val (token, child) = BrowserPopups.create(this@NappletBrowserService, shimJs, tab.proxyPort, tab.useTor, tab.themeType, tab.webViewProfile, tab.searchEngine)
transport.webView = child
resultMsg.sendToTarget()
runCatching { startActivity(NappletBrowserActivity.popupIntent(this@NappletBrowserService, token)) }
@@ -97,6 +97,9 @@ object WebViewProxyPolicy {
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. */
fun observeRoute(
owner: Any,