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,