diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index fe1db25f64..051a64807f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 56689d78ff..7f842b1a1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { DrawerSettingsScreen(accountViewModel, nav) } composableFromEnd { HomeTabsSettingsScreen(accountViewModel, nav) } composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) } + composableFromEnd { SearchEngineSettingsScreen(accountViewModel, nav) } composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } composableFromEnd { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index e521f7a1c9..d78d6584c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -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()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index c58edfe3a2..49d3e6d20d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt index b761283ace..bfb369c71c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt @@ -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) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt index ed555546e9..c964173ae3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt @@ -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" - return Resolved(url, isOnion(url)) - } - return Resolved(searchPrefix + encodeQuery(text), forceTor = false) + 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)) } + /** + * 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() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailure.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailure.kt new file mode 100644 index 0000000000..b8df9e7294 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailure.kt @@ -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 + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/SearchEngines.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/SearchEngines.kt new file mode 100644 index 0000000000..bb3e8a0aca --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/SearchEngines.kt @@ -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 = + 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 +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettings.kt index eb8ae128e1..c3557d3816 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettings.kt @@ -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( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettingsFlow.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettingsFlow.kt index 6a0870cfa8..f8132f8542 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettingsFlow.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/UiSettingsFlow.kt @@ -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 = MutableStateFlow(""), val showOnchainWallet: MutableStateFlow = MutableStateFlow(true), val showPayToZapChip: MutableStateFlow = MutableStateFlow(true), + val searchEngine: MutableStateFlow = MutableStateFlow(SearchEngines.DEFAULT.id), ) { val listOfFlows: List> = listOf>( @@ -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), ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt index f2a0d2e832..35fdd3f429 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt @@ -581,6 +581,8 @@ sealed class Route { @Serializable object ProfileUiSettings : Route() + @Serializable object SearchEngineSettings : Route() + @Serializable object VideoPlayerSettings : Route() @Serializable object CallSettings : Route() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStore.kt index c1606b0440..a7bc5ddad7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStore.kt @@ -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 } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt index 00b82b6756..4bd7907762 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt @@ -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 { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInputTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInputTest.kt index a8dc9d0c1c..65f48b6e80 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInputTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInputTest.kt @@ -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) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailureTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailureTest.kt new file mode 100644 index 0000000000..28ff4979a4 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailureTest.kt @@ -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")) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/SearchEnginesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/SearchEnginesTest.kt new file mode 100644 index 0000000000..a8ba647a41 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/SearchEnginesTest.kt @@ -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) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt index 1057a07ea2..93904c12fb 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt @@ -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()) diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.android.kt index ecf6c4b0d3..a6d91595ae 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.android.kt @@ -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 diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index fb8465f84e..99b2ba83b9 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -987,6 +987,30 @@ Reload The in-app browser needs Android 11 or newer. Couldn't load this app. + This site can't be found + %1$s doesn't exist. Check the address for typos. + This site can't be reached + %1$s didn't respond. It may be down, or the address may not exist. + Tor couldn't reach %1$s. The site may be down, or the address may not exist. + Tor isn't connected + This site goes through Tor, and Tor isn't ready yet. Try again in a moment. + This site took too long to respond + %1$s didn't answer in time. + You're offline + Check your connection and try again. + This connection isn't private + %1$s didn't present a valid certificate, so the page wasn't loaded. + This page isn't working + %1$s redirected too many times. + This site needs Tor + %1$s is an onion site, which only opens over Tor. + Open with Tor + Try without Tor + Some sites block Tor. Without it, %1$s sees your IP address, and this site stays off Tor until you turn Tor back on. + This site isn't secure + %1$s only offers an unencrypted connection, which Android blocks for this address. + This page couldn't load + Something went wrong loading %1$s. Open Clear Sites from people you follow @@ -6363,6 +6387,11 @@ Record a voice note Stop and send Search or enter address + Go to %1$s + Search for “%1$s” + Search engine + What the browser's address bar searches with when what you type isn't a web address. + search engine, browser, address bar, omnibox, duckduckgo, google Untitled nApplet Site permissions Use your camera diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/PageLoadError.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/PageLoadError.kt new file mode 100644 index 0000000000..b63bdf50e7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/PageLoadError.kt @@ -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) + } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt index 3175737aec..f453f01c07 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt @@ -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) { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.kt index 86c657d161..0f2ecee75d 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.kt @@ -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 diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/browser/BrowserScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/browser/BrowserScreen.kt index 6e68890907..18009f4d45 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -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(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>>(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,52 +449,176 @@ private fun OmniBar( if (nav.canPop()) { IconButton(onClick = nav::popBack) { ArrowBackIcon() } } - TextField( - value = field, - onValueChange = onValueChange, - modifier = Modifier.weight(1f), - singleLine = true, - placeholder = { Text(stringRes(Res.string.browser_address_hint)) }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Go, - ), - keyboardActions = KeyboardActions(onGo = { onOpen() }), - trailingIcon = { - if (field.text.isNotEmpty()) { - IconButton(onClick = onClear) { - Icon(MaterialSymbols.Clear, contentDescription = stringRes(Res.string.browser_clear)) - } + 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, + 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 - * 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, iconKeys: Set, historyUrls: Set, + 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)) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SearchEngineSettingsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SearchEngineSettingsScreen.kt new file mode 100644 index 0000000000..aeeb2b8a91 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SearchEngineSettingsScreen.kt @@ -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) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 35f29cf0cc..5df536a62b 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -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), diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.ios.kt index c89f1cb3b6..d16cd4ac99 100644 --- a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.ios.kt +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.ios.kt @@ -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 diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.jvm.kt index b1cb251cb1..7649ef9b39 100644 --- a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.jvm.kt +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/util/ClipboardExt.jvm.kt @@ -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 + } + } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt index f052b8caf7..42dab5ee94 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt @@ -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 { 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) } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 3000dfb1a9..c4c10be001 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -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(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) { - 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) } + 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()) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index a308d66242..3b3ec74526 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -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 diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 665a609d7c..0221baeb22 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -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)) } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt index 201dc6672f..4bd2a97b4e 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt @@ -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,