feat(browser): readable load-error page, Try without Tor, onion guard

The full-screen browser showed the WebView's built-in error page: an
Android robot over raw text, unreadable in dark mode, no retry. Over Tor
every failure reads net::ERR_SOCKS_CONNECTION_FAILED, so a misspelled
address looked like a proxy problem.

- PageLoadFailure (commons) groups main-frame errors by what the user
  can do: not found, unreachable, Tor not running, timed out, offline,
  certificate, redirect loop, cleartext blocked, onion without Tor.
  ERR_ABORTED shows nothing.
- PageLoadError (commonsUI) covers the built-in page in the app theme
  with the cause in words, the raw code in small print, and Try again.
  It stays up through a retry, showing a spinner, and leaves only when a
  page actually paints, so the robot page never flashes. A dead name
  over Tor fails again within ~1ms, so the spinner is held for 700ms.
- Try without Tor: offered only for unreachable/timed-out failures on a
  page that chose Tor, never for an onion, and only when no other
  surface also claims Tor (the route is shared and Tor wins). It flips
  the same remembered per-site switch as the pill, and says the site
  will see the user's IP.
- Onion guard: a main-frame link to a .onion from a page with Tor off
  used to leave on the open web. It now switches the page to Tor first,
  as a typed onion address already did, and the window's first load is
  checked the same way. With no Tor available it shows "This site needs
  Tor" instead of "doesn't exist".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-10-02 11:45:42 -04:00
co-authored by Claude Opus 5.5
parent e7a0c9643e
commit b6a72d6754
9 changed files with 587 additions and 6 deletions
@@ -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()
@@ -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
}
}
}
@@ -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 {
@@ -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"))
}
}
@@ -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())
@@ -981,6 +981,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>
@@ -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)
}
@@ -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<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).
@@ -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) {
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) }
@@ -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,