mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
co-authored by
Claude Opus 5.5
parent
e7a0c9643e
commit
b6a72d6754
+2
-1
@@ -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()
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.browser
|
||||
|
||||
/**
|
||||
* Why a page's main frame failed to load, grouped by what the person can do about it. The WebView's own
|
||||
* error page only prints the network error code, and over Tor that code is a SOCKS failure whatever the
|
||||
* real cause was, so "net::ERR_SOCKS_CONNECTION_FAILED" is all someone sees for a misspelled address.
|
||||
*/
|
||||
enum class PageLoadFailure {
|
||||
/** The name does not exist (DNS said so). */
|
||||
NOT_FOUND,
|
||||
|
||||
/** The site did not answer: refused, reset, or (through a proxy) unreachable for a reason we can't see. */
|
||||
UNREACHABLE,
|
||||
|
||||
/** The local proxy itself refused the connection: Tor is not running or not ready yet. */
|
||||
PROXY_DOWN,
|
||||
|
||||
TIMED_OUT,
|
||||
OFFLINE,
|
||||
|
||||
/** Certificate or TLS failure. */
|
||||
INSECURE,
|
||||
REDIRECT_LOOP,
|
||||
|
||||
/** A `.onion` address that went out without Tor; it can only resolve over Tor. */
|
||||
ONION_NEEDS_TOR,
|
||||
|
||||
/** Plain http to a host the platform only allows over https. */
|
||||
CLEARTEXT_BLOCKED,
|
||||
OTHER,
|
||||
;
|
||||
|
||||
companion object {
|
||||
// WebViewClient.ERROR_* values, so this stays free of android.webkit.
|
||||
private const val ERROR_HOST_LOOKUP = -2
|
||||
private const val ERROR_PROXY_AUTHENTICATION = -5
|
||||
private const val ERROR_CONNECT = -6
|
||||
private const val ERROR_TIMEOUT = -8
|
||||
private const val ERROR_REDIRECT_LOOP = -9
|
||||
private const val ERROR_FAILED_SSL_HANDSHAKE = -11
|
||||
|
||||
/**
|
||||
* Classifies a main-frame error from its Chromium [description] ("net::ERR_NAME_NOT_RESOLVED"),
|
||||
* falling back to the WebView [errorCode]. Returns null for a navigation that was cancelled rather
|
||||
* than failed (ERR_ABORTED: a stop, a download, a redirect to another app), which needs no error page.
|
||||
*/
|
||||
fun classify(
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
): PageLoadFailure? {
|
||||
val code =
|
||||
description
|
||||
?.trim()
|
||||
?.removePrefix("net::")
|
||||
?.uppercase()
|
||||
.orEmpty()
|
||||
return when {
|
||||
code == "ERR_ABORTED" -> null
|
||||
code == "ERR_NAME_NOT_RESOLVED" || code == "ERR_NAME_RESOLUTION_FAILED" -> NOT_FOUND
|
||||
code == "ERR_PROXY_CONNECTION_FAILED" -> PROXY_DOWN
|
||||
code.startsWith("ERR_SOCKS_") || code == "ERR_TUNNEL_CONNECTION_FAILED" -> UNREACHABLE
|
||||
code == "ERR_INTERNET_DISCONNECTED" || code == "ERR_NETWORK_CHANGED" -> OFFLINE
|
||||
code == "ERR_TIMED_OUT" || code == "ERR_CONNECTION_TIMED_OUT" -> TIMED_OUT
|
||||
code == "ERR_TOO_MANY_REDIRECTS" -> REDIRECT_LOOP
|
||||
code == "ERR_CLEARTEXT_NOT_PERMITTED" -> CLEARTEXT_BLOCKED
|
||||
code.startsWith("ERR_CERT_") || code.startsWith("ERR_SSL_") || code == "ERR_BAD_SSL_CLIENT_AUTH_CERT" -> INSECURE
|
||||
code.startsWith("ERR_CONNECTION_") || code == "ERR_EMPTY_RESPONSE" || code == "ERR_ADDRESS_UNREACHABLE" -> UNREACHABLE
|
||||
else -> fromErrorCode(errorCode) ?: OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [classify] for a page at [url]: an onion address that failed while the page was not on Tor is
|
||||
* reported as [ONION_NEEDS_TOR] (its "name not resolved" would otherwise read as "doesn't exist").
|
||||
*/
|
||||
fun forPage(
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
url: String,
|
||||
viaTor: Boolean,
|
||||
): PageLoadFailure? {
|
||||
val failure = classify(errorCode, description) ?: return null
|
||||
return if (!viaTor && OmniboxInput.isOnion(url)) ONION_NEEDS_TOR else failure
|
||||
}
|
||||
|
||||
/** Whether retrying the same page on the open web might help: Tor reached nothing, or too slowly. */
|
||||
fun mayBeTorBlocked(failure: PageLoadFailure): Boolean = failure == UNREACHABLE || failure == TIMED_OUT
|
||||
|
||||
private fun fromErrorCode(errorCode: Int): PageLoadFailure? =
|
||||
when (errorCode) {
|
||||
ERROR_HOST_LOOKUP -> NOT_FOUND
|
||||
ERROR_PROXY_AUTHENTICATION -> PROXY_DOWN
|
||||
ERROR_CONNECT -> UNREACHABLE
|
||||
ERROR_TIMEOUT -> TIMED_OUT
|
||||
ERROR_REDIRECT_LOOP -> REDIRECT_LOOP
|
||||
ERROR_FAILED_SSL_HANDSHAKE -> INSECURE
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -68,6 +68,12 @@ class NappletProxyClaims {
|
||||
return route()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a surface other than [owner] wants Tor — in which case the process stays on Tor even if
|
||||
* [owner] switches to the open web, so offering [owner] an open-web retry would change nothing.
|
||||
*/
|
||||
fun torWantedByOthers(owner: Any): Boolean = claims.any { (other, port) -> other !== owner && port > 0 }
|
||||
|
||||
fun route(): Route = Route(claims.values.lastOrNull { it > 0 } ?: NO_PROXY)
|
||||
|
||||
companion object {
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.browser
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class PageLoadFailureTest {
|
||||
@Test
|
||||
fun torSocksFailureIsUnreachableNotAProxyProblem() {
|
||||
// What the WebView reports over Tor for a host that does not exist.
|
||||
assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.classify(-1, "net::ERR_SOCKS_CONNECTION_FAILED"))
|
||||
assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.classify(-6, "net::ERR_SOCKS_CONNECTION_HOST_UNREACHABLE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localProxyRefusalMeansTorIsDown() {
|
||||
assertEquals(PageLoadFailure.PROXY_DOWN, PageLoadFailure.classify(-6, "net::ERR_PROXY_CONNECTION_FAILED"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dnsMissIsNotFound() {
|
||||
assertEquals(PageLoadFailure.NOT_FOUND, PageLoadFailure.classify(-2, "net::ERR_NAME_NOT_RESOLVED"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelledNavigationShowsNoErrorPage() {
|
||||
assertNull(PageLoadFailure.classify(-1, "net::ERR_ABORTED"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commonCodesMapToTheirGroup() {
|
||||
assertEquals(PageLoadFailure.OFFLINE, PageLoadFailure.classify(-1, "net::ERR_INTERNET_DISCONNECTED"))
|
||||
assertEquals(PageLoadFailure.TIMED_OUT, PageLoadFailure.classify(-8, "net::ERR_CONNECTION_TIMED_OUT"))
|
||||
assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.classify(-6, "net::ERR_CONNECTION_REFUSED"))
|
||||
assertEquals(PageLoadFailure.INSECURE, PageLoadFailure.classify(-11, "net::ERR_CERT_AUTHORITY_INVALID"))
|
||||
assertEquals(PageLoadFailure.REDIRECT_LOOP, PageLoadFailure.classify(-9, "net::ERR_TOO_MANY_REDIRECTS"))
|
||||
assertEquals(PageLoadFailure.CLEARTEXT_BLOCKED, PageLoadFailure.classify(-1, "net::ERR_CLEARTEXT_NOT_PERMITTED"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onionOffTorNeedsTorInsteadOfNotFound() {
|
||||
val onion = "http://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/"
|
||||
assertEquals(PageLoadFailure.ONION_NEEDS_TOR, PageLoadFailure.forPage(-2, "net::ERR_NAME_NOT_RESOLVED", onion, viaTor = false))
|
||||
// Over Tor an onion failure is an ordinary reachability failure.
|
||||
assertEquals(PageLoadFailure.UNREACHABLE, PageLoadFailure.forPage(-1, "net::ERR_SOCKS_CONNECTION_FAILED", onion, viaTor = true))
|
||||
assertEquals(PageLoadFailure.NOT_FOUND, PageLoadFailure.forPage(-2, "net::ERR_NAME_NOT_RESOLVED", "https://example.com/", viaTor = false))
|
||||
assertNull(PageLoadFailure.forPage(-1, "net::ERR_ABORTED", onion, viaTor = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyReachabilityFailuresSuggestTryingWithoutTor() {
|
||||
assertEquals(true, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.UNREACHABLE))
|
||||
assertEquals(true, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.TIMED_OUT))
|
||||
assertEquals(false, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.NOT_FOUND))
|
||||
assertEquals(false, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.INSECURE))
|
||||
assertEquals(false, PageLoadFailure.mayBeTorBlocked(PageLoadFailure.PROXY_DOWN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownDescriptionFallsBackToTheWebViewCode() {
|
||||
assertEquals(PageLoadFailure.NOT_FOUND, PageLoadFailure.classify(-2, null))
|
||||
assertEquals(PageLoadFailure.TIMED_OUT, PageLoadFailure.classify(-8, "net::ERR_SOMETHING_NEW"))
|
||||
assertEquals(PageLoadFailure.OTHER, PageLoadFailure.classify(-1, "net::ERR_SOMETHING_NEW"))
|
||||
}
|
||||
}
|
||||
+11
@@ -31,6 +31,17 @@ class NappletProxyClaimsTest {
|
||||
private val torTab = Any()
|
||||
private val openTab = Any()
|
||||
|
||||
@Test
|
||||
fun torWantedByOthersIgnoresTheAskingSurface() {
|
||||
claims.claim(torTab, 9050)
|
||||
assertEquals(false, claims.torWantedByOthers(torTab))
|
||||
assertEquals(true, claims.torWantedByOthers(openTab))
|
||||
claims.claim(openTab, NappletProxyClaims.NO_PROXY)
|
||||
assertEquals(false, claims.torWantedByOthers(torTab))
|
||||
claims.release(torTab)
|
||||
assertEquals(false, claims.torWantedByOthers(openTab))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noClaimsMeansNoProxy() {
|
||||
assertEquals(DIRECT, claims.route())
|
||||
|
||||
@@ -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>
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.browser.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.browser.PageLoadFailure
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_cleartext_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_cleartext_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_insecure_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_insecure_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_not_found_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_not_found_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_offline_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_offline_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_onion_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_onion_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_open_with_tor
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_other_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_other_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_proxy_down_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_proxy_down_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_redirect_loop_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_redirect_loop_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_timed_out_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_timed_out_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_try_without_tor
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_try_without_tor_note
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_unreachable_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_unreachable_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.page_error_unreachable_tor_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.try_again
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* The browser's own page for a main frame that failed to load, in place of the WebView's built-in one
|
||||
* (an Android robot over raw "net::ERR_…" text, unreadable in dark mode and with no way to retry).
|
||||
* Names the cause in words, keeps the raw [detail] code small underneath for anyone debugging, and
|
||||
* offers [onRetry], which turns into a spinner while [retrying]. [viaTor] rewords the reachability case: over Tor every failure to reach the site
|
||||
* looks the same, so it can't promise the address exists. [onSwitchTor], when given, adds the one route
|
||||
* change that could help: "Open with Tor" for an onion address, else "Try without Tor" with a note on
|
||||
* what that gives away — the caller offers it only where switching would actually change the route.
|
||||
*/
|
||||
@Composable
|
||||
fun PageLoadError(
|
||||
failure: PageLoadFailure,
|
||||
host: String,
|
||||
detail: String?,
|
||||
viaTor: Boolean,
|
||||
retrying: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onSwitchTor: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val copy = failure.errorCopy(viaTor)
|
||||
// A dead name over Tor fails again within a millisecond, which would make Try again look like it did
|
||||
// nothing. Hold the spinner briefly so every tap visibly retries.
|
||||
var justTapped by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(justTapped) {
|
||||
if (justTapped) {
|
||||
delay(MIN_RETRY_SPINNER_MS)
|
||||
justTapped = false
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Column(Modifier.widthIn(max = 420.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
symbol = copy.symbol,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
text = stringRes(copy.title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringRes(copy.body, host),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (!detail.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Box(Modifier.height(48.dp), contentAlignment = Alignment.Center) {
|
||||
if (retrying || justTapped) {
|
||||
CircularProgressIndicator(Modifier.size(32.dp), strokeWidth = 3.dp)
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
justTapped = true
|
||||
onRetry()
|
||||
},
|
||||
) {
|
||||
Text(stringRes(Res.string.try_again))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onSwitchTor != null && !retrying && !justTapped) {
|
||||
val toTor = failure == PageLoadFailure.ONION_NEEDS_TOR
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
justTapped = true
|
||||
onSwitchTor()
|
||||
},
|
||||
) {
|
||||
Text(stringRes(if (toTor) Res.string.page_error_open_with_tor else Res.string.page_error_try_without_tor))
|
||||
}
|
||||
if (!toTor) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringRes(Res.string.page_error_try_without_tor_note, host),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MIN_RETRY_SPINNER_MS = 700L
|
||||
|
||||
private class PageErrorCopy(
|
||||
val symbol: MaterialSymbol,
|
||||
val title: StringResource,
|
||||
val body: StringResource,
|
||||
)
|
||||
|
||||
private fun PageLoadFailure.errorCopy(viaTor: Boolean): PageErrorCopy =
|
||||
when (this) {
|
||||
PageLoadFailure.NOT_FOUND -> PageErrorCopy(MaterialSymbols.PublicOff, Res.string.page_error_not_found_title, Res.string.page_error_not_found_body)
|
||||
PageLoadFailure.UNREACHABLE ->
|
||||
PageErrorCopy(
|
||||
MaterialSymbols.PublicOff,
|
||||
Res.string.page_error_unreachable_title,
|
||||
if (viaTor) Res.string.page_error_unreachable_tor_body else Res.string.page_error_unreachable_body,
|
||||
)
|
||||
PageLoadFailure.PROXY_DOWN -> PageErrorCopy(MaterialSymbols.SyncProblem, Res.string.page_error_proxy_down_title, Res.string.page_error_proxy_down_body)
|
||||
PageLoadFailure.TIMED_OUT -> PageErrorCopy(MaterialSymbols.Timer, Res.string.page_error_timed_out_title, Res.string.page_error_timed_out_body)
|
||||
PageLoadFailure.OFFLINE -> PageErrorCopy(MaterialSymbols.PublicOff, Res.string.page_error_offline_title, Res.string.page_error_offline_body)
|
||||
PageLoadFailure.INSECURE -> PageErrorCopy(MaterialSymbols.NoEncryption, Res.string.page_error_insecure_title, Res.string.page_error_insecure_body)
|
||||
PageLoadFailure.REDIRECT_LOOP -> PageErrorCopy(MaterialSymbols.Warning, Res.string.page_error_redirect_loop_title, Res.string.page_error_redirect_loop_body)
|
||||
PageLoadFailure.ONION_NEEDS_TOR -> PageErrorCopy(MaterialSymbols.Lock, Res.string.page_error_onion_title, Res.string.page_error_onion_body)
|
||||
PageLoadFailure.CLEARTEXT_BLOCKED -> PageErrorCopy(MaterialSymbols.NoEncryption, Res.string.page_error_cleartext_title, Res.string.page_error_cleartext_body)
|
||||
PageLoadFailure.OTHER -> PageErrorCopy(MaterialSymbols.PublicOff, Res.string.page_error_other_title, Res.string.page_error_other_body)
|
||||
}
|
||||
+117
-2
@@ -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) }
|
||||
|
||||
+3
@@ -97,6 +97,9 @@ object WebViewProxyPolicy {
|
||||
sync(null)
|
||||
}
|
||||
|
||||
/** Whether another live surface needs Tor, which keeps the shared route on Tor whatever [owner] picks. */
|
||||
fun torWantedByOthers(owner: Any): Boolean = claims.torWantedByOthers(owner)
|
||||
|
||||
/** Tells [listener] (now, and on every change) whether the process currently routes through Tor. */
|
||||
fun observeRoute(
|
||||
owner: Any,
|
||||
|
||||
Reference in New Issue
Block a user