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