diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt
index f7ea23d615..c964173ae3 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/OmniboxInput.kt
@@ -103,7 +103,8 @@ object OmniboxInput {
.substringBefore('#')
.substringBefore(':')
- private fun isOnion(url: String): Boolean = hostOf(url)?.endsWith(".onion", ignoreCase = true) == true
+ /** Whether [url]'s host is a Tor onion service, which only resolves over Tor. */
+ fun isOnion(url: String): Boolean = hostOf(url)?.endsWith(".onion", ignoreCase = true) == true
private const val UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
private val HEX = "0123456789ABCDEF".toCharArray()
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailure.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailure.kt
new file mode 100644
index 0000000000..b8df9e7294
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailure.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.browser
+
+/**
+ * Why a page's main frame failed to load, grouped by what the person can do about it. The WebView's own
+ * error page only prints the network error code, and over Tor that code is a SOCKS failure whatever the
+ * real cause was, so "net::ERR_SOCKS_CONNECTION_FAILED" is all someone sees for a misspelled address.
+ */
+enum class PageLoadFailure {
+ /** The name does not exist (DNS said so). */
+ NOT_FOUND,
+
+ /** The site did not answer: refused, reset, or (through a proxy) unreachable for a reason we can't see. */
+ UNREACHABLE,
+
+ /** The local proxy itself refused the connection: Tor is not running or not ready yet. */
+ PROXY_DOWN,
+
+ TIMED_OUT,
+ OFFLINE,
+
+ /** Certificate or TLS failure. */
+ INSECURE,
+ REDIRECT_LOOP,
+
+ /** A `.onion` address that went out without Tor; it can only resolve over Tor. */
+ ONION_NEEDS_TOR,
+
+ /** Plain http to a host the platform only allows over https. */
+ CLEARTEXT_BLOCKED,
+ OTHER,
+ ;
+
+ companion object {
+ // WebViewClient.ERROR_* values, so this stays free of android.webkit.
+ private const val ERROR_HOST_LOOKUP = -2
+ private const val ERROR_PROXY_AUTHENTICATION = -5
+ private const val ERROR_CONNECT = -6
+ private const val ERROR_TIMEOUT = -8
+ private const val ERROR_REDIRECT_LOOP = -9
+ private const val ERROR_FAILED_SSL_HANDSHAKE = -11
+
+ /**
+ * Classifies a main-frame error from its Chromium [description] ("net::ERR_NAME_NOT_RESOLVED"),
+ * falling back to the WebView [errorCode]. Returns null for a navigation that was cancelled rather
+ * than failed (ERR_ABORTED: a stop, a download, a redirect to another app), which needs no error page.
+ */
+ fun classify(
+ errorCode: Int,
+ description: String?,
+ ): PageLoadFailure? {
+ val code =
+ description
+ ?.trim()
+ ?.removePrefix("net::")
+ ?.uppercase()
+ .orEmpty()
+ return when {
+ code == "ERR_ABORTED" -> null
+ code == "ERR_NAME_NOT_RESOLVED" || code == "ERR_NAME_RESOLUTION_FAILED" -> NOT_FOUND
+ code == "ERR_PROXY_CONNECTION_FAILED" -> PROXY_DOWN
+ code.startsWith("ERR_SOCKS_") || code == "ERR_TUNNEL_CONNECTION_FAILED" -> UNREACHABLE
+ code == "ERR_INTERNET_DISCONNECTED" || code == "ERR_NETWORK_CHANGED" -> OFFLINE
+ code == "ERR_TIMED_OUT" || code == "ERR_CONNECTION_TIMED_OUT" -> TIMED_OUT
+ code == "ERR_TOO_MANY_REDIRECTS" -> REDIRECT_LOOP
+ code == "ERR_CLEARTEXT_NOT_PERMITTED" -> CLEARTEXT_BLOCKED
+ code.startsWith("ERR_CERT_") || code.startsWith("ERR_SSL_") || code == "ERR_BAD_SSL_CLIENT_AUTH_CERT" -> INSECURE
+ code.startsWith("ERR_CONNECTION_") || code == "ERR_EMPTY_RESPONSE" || code == "ERR_ADDRESS_UNREACHABLE" -> UNREACHABLE
+ else -> fromErrorCode(errorCode) ?: OTHER
+ }
+ }
+
+ /**
+ * [classify] for a page at [url]: an onion address that failed while the page was not on Tor is
+ * reported as [ONION_NEEDS_TOR] (its "name not resolved" would otherwise read as "doesn't exist").
+ */
+ fun forPage(
+ errorCode: Int,
+ description: String?,
+ url: String,
+ viaTor: Boolean,
+ ): PageLoadFailure? {
+ val failure = classify(errorCode, description) ?: return null
+ return if (!viaTor && OmniboxInput.isOnion(url)) ONION_NEEDS_TOR else failure
+ }
+
+ /** Whether retrying the same page on the open web might help: Tor reached nothing, or too slowly. */
+ fun mayBeTorBlocked(failure: PageLoadFailure): Boolean = failure == UNREACHABLE || failure == TIMED_OUT
+
+ private fun fromErrorCode(errorCode: Int): PageLoadFailure? =
+ when (errorCode) {
+ ERROR_HOST_LOOKUP -> NOT_FOUND
+ ERROR_PROXY_AUTHENTICATION -> PROXY_DOWN
+ ERROR_CONNECT -> UNREACHABLE
+ ERROR_TIMEOUT -> TIMED_OUT
+ ERROR_REDIRECT_LOOP -> REDIRECT_LOOP
+ ERROR_FAILED_SSL_HANDSHAKE -> INSECURE
+ else -> null
+ }
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt
index 00b82b6756..4bd7907762 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt
@@ -68,6 +68,12 @@ class NappletProxyClaims {
return route()
}
+ /**
+ * Whether a surface other than [owner] wants Tor — in which case the process stays on Tor even if
+ * [owner] switches to the open web, so offering [owner] an open-web retry would change nothing.
+ */
+ fun torWantedByOthers(owner: Any): Boolean = claims.any { (other, port) -> other !== owner && port > 0 }
+
fun route(): Route = Route(claims.values.lastOrNull { it > 0 } ?: NO_PROXY)
companion object {
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailureTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailureTest.kt
new file mode 100644
index 0000000000..28ff4979a4
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/PageLoadFailureTest.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.browser
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class PageLoadFailureTest {
+ @Test
+ fun torSocksFailureIsUnreachableNotAProxyProblem() {
+ // What the WebView reports over Tor for a host that does not exist.
+ assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.classify(-1, "net::ERR_SOCKS_CONNECTION_FAILED"))
+ assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.classify(-6, "net::ERR_SOCKS_CONNECTION_HOST_UNREACHABLE"))
+ }
+
+ @Test
+ fun localProxyRefusalMeansTorIsDown() {
+ assertEquals(PageLoadFailure.PROXY_DOWN, PageLoadFailure.classify(-6, "net::ERR_PROXY_CONNECTION_FAILED"))
+ }
+
+ @Test
+ fun dnsMissIsNotFound() {
+ assertEquals(PageLoadFailure.NOT_FOUND, PageLoadFailure.classify(-2, "net::ERR_NAME_NOT_RESOLVED"))
+ }
+
+ @Test
+ fun cancelledNavigationShowsNoErrorPage() {
+ assertNull(PageLoadFailure.classify(-1, "net::ERR_ABORTED"))
+ }
+
+ @Test
+ fun commonCodesMapToTheirGroup() {
+ assertEquals(PageLoadFailure.OFFLINE, PageLoadFailure.classify(-1, "net::ERR_INTERNET_DISCONNECTED"))
+ assertEquals(PageLoadFailure.TIMED_OUT, PageLoadFailure.classify(-8, "net::ERR_CONNECTION_TIMED_OUT"))
+ assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.classify(-6, "net::ERR_CONNECTION_REFUSED"))
+ assertEquals(PageLoadFailure.INSECURE, PageLoadFailure.classify(-11, "net::ERR_CERT_AUTHORITY_INVALID"))
+ assertEquals(PageLoadFailure.REDIRECT_LOOP, PageLoadFailure.classify(-9, "net::ERR_TOO_MANY_REDIRECTS"))
+ assertEquals(PageLoadFailure.CLEARTEXT_BLOCKED, PageLoadFailure.classify(-1, "net::ERR_CLEARTEXT_NOT_PERMITTED"))
+ }
+
+ @Test
+ fun onionOffTorNeedsTorInsteadOfNotFound() {
+ val onion = "http://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/"
+ assertEquals(PageLoadFailure.ONION_NEEDS_TOR, PageLoadFailure.forPage(-2, "net::ERR_NAME_NOT_RESOLVED", onion, viaTor = false))
+ // Over Tor an onion failure is an ordinary reachability failure.
+ assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.forPage(-1, "net::ERR_SOCKS_CONNECTION_FAILED", onion, viaTor = true))
+ assertEquals(PageLoadFailure.NOT_FOUND, PageLoadFailure.forPage(-2, "net::ERR_NAME_NOT_RESOLVED", "https://example.com/", viaTor = false))
+ assertNull(PageLoadFailure.forPage(-1, "net::ERR_ABORTED", onion, viaTor = false))
+ }
+
+ @Test
+ fun onlyReachabilityFailuresSuggestTryingWithoutTor() {
+ assertEquals(true, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.UNREACHABLE))
+ assertEquals(true, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.TIMED_OUT))
+ assertEquals(false, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.NOT_FOUND))
+ assertEquals(false, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.INSECURE))
+ assertEquals(false, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.PROXY_DOWN))
+ }
+
+ @Test
+ fun unknownDescriptionFallsBackToTheWebViewCode() {
+ assertEquals(PageLoadFailure.NOT_FOUND, PageLoadFailure.classify(-2, null))
+ assertEquals(PageLoadFailure.TIMED_OUT, PageLoadFailure.classify(-8, "net::ERR_SOMETHING_NEW"))
+ assertEquals(PageLoadFailure.OTHER, PageLoadFailure.classify(-1, "net::ERR_SOMETHING_NEW"))
+ }
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt
index 1057a07ea2..93904c12fb 100644
--- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt
@@ -31,6 +31,17 @@ class NappletProxyClaimsTest {
private val torTab = Any()
private val openTab = Any()
+ @Test
+ fun torWantedByOthersIgnoresTheAskingSurface() {
+ claims.claim(torTab, 9050)
+ assertEquals(false, claims.torWantedByOthers(torTab))
+ assertEquals(true, claims.torWantedByOthers(openTab))
+ claims.claim(openTab, NappletProxyClaims.NO_PROXY)
+ assertEquals(false, claims.torWantedByOthers(torTab))
+ claims.release(torTab)
+ assertEquals(false, claims.torWantedByOthers(openTab))
+ }
+
@Test
fun noClaimsMeansNoProxy() {
assertEquals(DIRECT, claims.route())
diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml
index 6c37dacf47..b5b5e06038 100644
--- a/commonsUI/src/commonMain/composeResources/values/strings.xml
+++ b/commonsUI/src/commonMain/composeResources/values/strings.xml
@@ -981,6 +981,30 @@
Reload
The in-app browser needs Android 11 or newer.
Couldn't load this app.
+ This site can't be found
+ %1$s doesn't exist. Check the address for typos.
+ This site can't be reached
+ %1$s didn't respond. It may be down, or the address may not exist.
+ Tor couldn't reach %1$s. The site may be down, or the address may not exist.
+ Tor isn't connected
+ This site goes through Tor, and Tor isn't ready yet. Try again in a moment.
+ This site took too long to respond
+ %1$s didn't answer in time.
+ You're offline
+ Check your connection and try again.
+ This connection isn't private
+ %1$s didn't present a valid certificate, so the page wasn't loaded.
+ This page isn't working
+ %1$s redirected too many times.
+ This site needs Tor
+ %1$s is an onion site, which only opens over Tor.
+ Open with Tor
+ Try without Tor
+ Some sites block Tor. Without it, %1$s sees your IP address, and this site stays off Tor until you turn Tor back on.
+ This site isn't secure
+ %1$s only offers an unencrypted connection, which Android blocks for this address.
+ This page couldn't load
+ Something went wrong loading %1$s.
Open
Clear
Sites from people you follow
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/PageLoadError.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/PageLoadError.kt
new file mode 100644
index 0000000000..b63bdf50e7
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/PageLoadError.kt
@@ -0,0 +1,216 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.browser.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.browser.PageLoadFailure
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.page_error_cleartext_body
+import com.vitorpamplona.amethyst.commons.resources.page_error_cleartext_title
+import com.vitorpamplona.amethyst.commons.resources.page_error_insecure_body
+import com.vitorpamplona.amethyst.commons.resources.page_error_insecure_title
+import com.vitorpamplona.amethyst.commons.resources.page_error_not_found_body
+import com.vitorpamplona.amethyst.commons.resources.page_error_not_found_title
+import com.vitorpamplona.amethyst.commons.resources.page_error_offline_body
+import com.vitorpamplona.amethyst.commons.resources.page_error_offline_title
+import com.vitorpamplona.amethyst.commons.resources.page_error_onion_body
+import com.vitorpamplona.amethyst.commons.resources.page_error_onion_title
+import com.vitorpamplona.amethyst.commons.resources.page_error_open_with_tor
+import com.vitorpamplona.amethyst.commons.resources.page_error_other_body
+import com.vitorpamplona.amethyst.commons.resources.page_error_other_title
+import com.vitorpamplona.amethyst.commons.resources.page_error_proxy_down_body
+import com.vitorpamplona.amethyst.commons.resources.page_error_proxy_down_title
+import com.vitorpamplona.amethyst.commons.resources.page_error_redirect_loop_body
+import com.vitorpamplona.amethyst.commons.resources.page_error_redirect_loop_title
+import com.vitorpamplona.amethyst.commons.resources.page_error_timed_out_body
+import com.vitorpamplona.amethyst.commons.resources.page_error_timed_out_title
+import com.vitorpamplona.amethyst.commons.resources.page_error_try_without_tor
+import com.vitorpamplona.amethyst.commons.resources.page_error_try_without_tor_note
+import com.vitorpamplona.amethyst.commons.resources.page_error_unreachable_body
+import com.vitorpamplona.amethyst.commons.resources.page_error_unreachable_title
+import com.vitorpamplona.amethyst.commons.resources.page_error_unreachable_tor_body
+import com.vitorpamplona.amethyst.commons.resources.try_again
+import com.vitorpamplona.amethyst.commons.ui.stringRes
+import kotlinx.coroutines.delay
+import org.jetbrains.compose.resources.StringResource
+
+/**
+ * The browser's own page for a main frame that failed to load, in place of the WebView's built-in one
+ * (an Android robot over raw "net::ERR_…" text, unreadable in dark mode and with no way to retry).
+ * Names the cause in words, keeps the raw [detail] code small underneath for anyone debugging, and
+ * offers [onRetry], which turns into a spinner while [retrying]. [viaTor] rewords the reachability case: over Tor every failure to reach the site
+ * looks the same, so it can't promise the address exists. [onSwitchTor], when given, adds the one route
+ * change that could help: "Open with Tor" for an onion address, else "Try without Tor" with a note on
+ * what that gives away — the caller offers it only where switching would actually change the route.
+ */
+@Composable
+fun PageLoadError(
+ failure: PageLoadFailure,
+ host: String,
+ detail: String?,
+ viaTor: Boolean,
+ retrying: Boolean,
+ onRetry: () -> Unit,
+ onSwitchTor: (() -> Unit)? = null,
+ modifier: Modifier = Modifier,
+) {
+ val copy = failure.errorCopy(viaTor)
+ // A dead name over Tor fails again within a millisecond, which would make Try again look like it did
+ // nothing. Hold the spinner briefly so every tap visibly retries.
+ var justTapped by remember { mutableStateOf(false) }
+ LaunchedEffect(justTapped) {
+ if (justTapped) {
+ delay(MIN_RETRY_SPINNER_MS)
+ justTapped = false
+ }
+ }
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Column(Modifier.widthIn(max = 420.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ symbol = copy.symbol,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(20.dp))
+ Text(
+ text = stringRes(copy.title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringRes(copy.body, host),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ if (!detail.isNullOrBlank()) {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = detail,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ textAlign = TextAlign.Center,
+ )
+ }
+ Spacer(Modifier.height(24.dp))
+ Box(Modifier.height(48.dp), contentAlignment = Alignment.Center) {
+ if (retrying || justTapped) {
+ CircularProgressIndicator(Modifier.size(32.dp), strokeWidth = 3.dp)
+ } else {
+ Button(
+ onClick = {
+ justTapped = true
+ onRetry()
+ },
+ ) {
+ Text(stringRes(Res.string.try_again))
+ }
+ }
+ }
+ if (onSwitchTor != null && !retrying && !justTapped) {
+ val toTor = failure == PageLoadFailure.ONION_NEEDS_TOR
+ Spacer(Modifier.height(8.dp))
+ OutlinedButton(
+ onClick = {
+ justTapped = true
+ onSwitchTor()
+ },
+ ) {
+ Text(stringRes(if (toTor) Res.string.page_error_open_with_tor else Res.string.page_error_try_without_tor))
+ }
+ if (!toTor) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringRes(Res.string.page_error_try_without_tor_note, host),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.outline,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+ }
+}
+
+private const val MIN_RETRY_SPINNER_MS = 700L
+
+private class PageErrorCopy(
+ val symbol: MaterialSymbol,
+ val title: StringResource,
+ val body: StringResource,
+)
+
+private fun PageLoadFailure.errorCopy(viaTor: Boolean): PageErrorCopy =
+ when (this) {
+ PageLoadFailure.NOT_FOUND -> PageErrorCopy(MaterialSymbols.PublicOff, Res.string.page_error_not_found_title, Res.string.page_error_not_found_body)
+ PageLoadFailure.UNREACHABLE ->
+ PageErrorCopy(
+ MaterialSymbols.PublicOff,
+ Res.string.page_error_unreachable_title,
+ if (viaTor) Res.string.page_error_unreachable_tor_body else Res.string.page_error_unreachable_body,
+ )
+ PageLoadFailure.PROXY_DOWN -> PageErrorCopy(MaterialSymbols.SyncProblem, Res.string.page_error_proxy_down_title, Res.string.page_error_proxy_down_body)
+ PageLoadFailure.TIMED_OUT -> PageErrorCopy(MaterialSymbols.Timer, Res.string.page_error_timed_out_title, Res.string.page_error_timed_out_body)
+ PageLoadFailure.OFFLINE -> PageErrorCopy(MaterialSymbols.PublicOff, Res.string.page_error_offline_title, Res.string.page_error_offline_body)
+ PageLoadFailure.INSECURE -> PageErrorCopy(MaterialSymbols.NoEncryption, Res.string.page_error_insecure_title, Res.string.page_error_insecure_body)
+ PageLoadFailure.REDIRECT_LOOP -> PageErrorCopy(MaterialSymbols.Warning, Res.string.page_error_redirect_loop_title, Res.string.page_error_redirect_loop_body)
+ PageLoadFailure.ONION_NEEDS_TOR -> PageErrorCopy(MaterialSymbols.Lock, Res.string.page_error_onion_title, Res.string.page_error_onion_body)
+ PageLoadFailure.CLEARTEXT_BLOCKED -> PageErrorCopy(MaterialSymbols.NoEncryption, Res.string.page_error_cleartext_title, Res.string.page_error_cleartext_body)
+ PageLoadFailure.OTHER -> PageErrorCopy(MaterialSymbols.PublicOff, Res.string.page_error_other_title, Res.string.page_error_other_body)
+ }
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
index d77dc6cee8..9c70af5d3a 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt
@@ -68,6 +68,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
@@ -85,7 +90,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
@@ -141,6 +149,21 @@ class NappletBrowserActivity : ComponentActivity() {
private var root: FrameLayout? = null
private var loadingView: View? = null
private var crashView: View? = null
+
+ /** Our own page over the WebView's built-in one after a main-frame load failure; see [showLoadError]. */
+ private var loadErrorView: View? = null
+ private var loadError by mutableStateOf(null)
+
+ private data class LoadErrorState(
+ val failure: PageLoadFailure,
+ val host: String,
+ val detail: String?,
+ val viaTor: Boolean,
+ /** Whether flipping this page's Tor choice could change the outcome; see [canSwitchTor]. */
+ val canSwitchTor: Boolean,
+ val retrying: Boolean = false,
+ )
+
private var resumed = false
// The pill, find, console and page dialogs — the shared Compose chrome (see BrowserChromeHost).
@@ -366,9 +389,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 }
}
@@ -732,7 +757,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) }
}
@@ -744,6 +769,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()
@@ -771,7 +798,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()))
}
@@ -790,6 +824,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)
}
@@ -809,6 +844,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)
@@ -1811,6 +1847,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) }
diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt
index 201dc6672f..4bd2a97b4e 100644
--- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt
+++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt
@@ -97,6 +97,9 @@ object WebViewProxyPolicy {
sync(null)
}
+ /** Whether another live surface needs Tor, which keeps the shared route on Tor whatever [owner] picks. */
+ fun torWantedByOthers(owner: Any): Boolean = claims.torWantedByOthers(owner)
+
/** Tells [listener] (now, and on every change) whether the process currently routes through Tor. */
fun observeRoute(
owner: Any,