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

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