diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts index 975cddb14..d5e3e762d 100644 --- a/manager/build.gradle.kts +++ b/manager/build.gradle.kts @@ -97,6 +97,9 @@ android { packaging { resources { excludes += "META-INF/**" + // Java resources only, so it is inert against the Android artifact, which ships its + // public suffix list under assets/. Pinning the JVM variant would move that list back + // to okhttp3/internal/publicsuffix/ and this line would then delete it. excludes += "okhttp3/**" excludes += "kotlin/**" excludes += "**.properties" diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt index 715129e3f..c11d68847 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/github/GitHubRepository.kt @@ -699,6 +699,23 @@ class GitHubRepository( } ?: CommitPerson(login = name, avatarUrl = null, profileUrl = null) } + /** + * The releases page, or null when GitHub could not be reached. + * + * Both readers of this endpoint are launched from a `viewModelScope` or a `LaunchedEffect`, + * neither of which has a `CoroutineExceptionHandler` anywhere in this app — so a throw here is + * a fatal on the main thread rather than a card that stays empty. Every other request in this + * file already guards itself; these two did not, which is how a resolver failure took the whole + * manager down instead of leaving the update card blank. + * + * One helper for both because it is the same request: fetching it twice under two URLs built + * from the same three constants is how the two lists drift apart. + */ + private fun releaseListJson(freshness: Freshness): String? = + runCatching { get("$API/$REPO/releases?per_page=$CANARY_FETCH", freshness) } + .onFailure { e -> Log.w(Constants.TAG, "update: github release list unavailable", e) } + .getOrNull() + /** * The canary builds, newest first. * @@ -714,9 +731,7 @@ class GitHubRepository( */ suspend fun canaryBuilds(freshness: Freshness = Freshness.Revalidate): List = withContext(Dispatchers.IO) { - val body = - get("$API/$REPO/releases?per_page=$CANARY_FETCH", freshness) - ?: return@withContext emptyList() + val body = releaseListJson(freshness) ?: return@withContext emptyList() runCatching { json.decodeFromString>(body) } .onFailure { e -> Log.e(Constants.TAG, "update: canary release list unreadable", e) } @@ -757,9 +772,7 @@ class GitHubRepository( suspend fun frameworkReleases(freshness: Freshness = Freshness.Revalidate): List = withContext(Dispatchers.IO) { - val body = - get("$API/$REPO/releases?per_page=$CANARY_FETCH", freshness) - ?: return@withContext emptyList() + val body = releaseListJson(freshness) ?: return@withContext emptyList() runCatching { json.decodeFromString>(body) } .onFailure { e -> Log.e(Constants.TAG, "update: release list unreadable", e) } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt index d4d16bb92..fc384379a 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/log/CrashRecorder.kt @@ -53,7 +53,7 @@ object CrashRecorder { @Volatile private var installed = false /** - * Takes over the default handler, once per process. + * Takes over the default handler, once per process, and discards what another build left. * * Called from [org.matrix.vector.manager.di.ServiceLocator.attach], early in the activity's * `onCreate` and before anything that could fail, so the handler is in place before any screen @@ -64,6 +64,7 @@ object CrashRecorder { if (installed) return installed = true val application = context.applicationContext ?: context + runCatching { discardOtherBuilds(application) } val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> runCatching { record(application, thread, throwable) } @@ -90,6 +91,29 @@ object CrashRecorder { runCatching { files(context).forEach { it.delete() } } } + /** + * Drops the records that some other build of the manager wrote. + * + * A record outlives the build that made it — the directory is this process's cache, which an + * update does not clear — so the status card goes on showing a crash that the running build + * has already fixed, until somebody thinks to press clear. That is not a stale line on a + * screen. It is what a reporter copies into the issue to show the fix did not work: #799 was + * answered with five traces from the build before the one that fixed them. + * + * Only at [install], never at [record]: this is about what a *new* build inherits, and a crash + * loop within one build must keep every record it makes. + * + * A file whose second line cannot be read is kept. Losing a trace is the worse of the two + * failures, and a record this class cannot parse is exactly the one worth still having. + */ + private fun discardOtherBuilds(context: Context) { + val current = BUILD + files(context).forEach { file -> + val theirs = runCatching { file.useLines { it.drop(1).firstOrNull() } }.getOrNull() + if (theirs != null && !theirs.startsWith(current)) runCatching { file.delete() } + } + } + /** Newest first, which is the order both the card and the clipboard want. */ private fun files(context: Context): List = directory(context) @@ -124,11 +148,22 @@ object CrashRecorder { val parasitic = context.packageName == BuildConfig.INJECTED_PACKAGE_NAME val host = if (parasitic) "parasitic in ${context.packageName}" else "standalone" return "${TIMESTAMP.format(Date(at))}\n" + - "manager ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) " + - "${BuildConfig.VERSION_HASH} · $host · thread ${thread.name} · " + + "$BUILD · $host · thread ${thread.name} · " + "android ${android.os.Build.VERSION.RELEASE} (sdk ${android.os.Build.VERSION.SDK_INT})" } + /** + * How a record says which build wrote it, and so what [discardOtherBuilds] matches on. + * + * The whole line rather than the hash alone: a build from a dirty tree carries the hash of the + * commit it was built from, so two of them can share it while differing by everything that was + * uncommitted at the time. + */ + private val BUILD: String + get() = + "manager ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) " + + BuildConfig.VERSION_HASH + private const val SUFFIX = ".log" private val TIMESTAMP diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt b/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt index ec924389c..5296dfd08 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/di/ServiceLocator.kt @@ -12,6 +12,10 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.SharingStarted import android.annotation.SuppressLint import android.content.Context +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.network.okhttp.OkHttpNetworkFetcherFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -36,6 +40,7 @@ import org.matrix.vector.manager.data.repository.SettingsRepository import org.matrix.vector.manager.ipc.DaemonClient import org.matrix.vector.manager.ipc.packageEventsFlow import org.matrix.vector.manager.net.HttpClientFactory +import org.matrix.vector.manager.net.VectorDns /** * Hand-rolled service location, deliberately not a DI framework. @@ -77,7 +82,16 @@ object ServiceLocator { val daemon: DaemonClient by lazy { DaemonClient(service) } - val http: OkHttpClient by lazy { HttpClientFactory.create(context, settings) } + private val net: HttpClientFactory.NetStack by lazy { + HttpClientFactory.create(context, settings) + } + + val http: OkHttpClient + get() = net.client + + /** The resolver inside [http], so the settings sheet can report what DoH is actually doing. */ + val dns: VectorDns + get() = net.dns val settings: SettingsRepository by lazy { SettingsRepository(context) } @@ -195,6 +209,21 @@ object ServiceLocator { // Before anything else that could fail. Nothing below is load-bearing for it, and a crash // during startup is exactly the one that is hardest to catch on a cable. CrashRecorder.install(appContext!!) + + // Coil is configured by hand rather than through its manifest hooks, for the same reason + // OkHttp is: parasitically this app's manifest is never installed, so nothing that + // self-registers there ever runs. Here rather than in the activity because every entry + // point comes through `attach` — including the debug demo host, which never opens + // MainActivity and so had no image loader at all while this lived there. + // + // The factory is not called until the first image, so this costs nothing at startup and + // does not build the OkHttp client before something asks for it. + SingletonImageLoader.setSafe { platformContext: PlatformContext -> + ImageLoader.Builder(platformContext) + .components { add(OkHttpNetworkFetcherFactory(callFactory = { http })) } + .build() + } + observePackageChanges() } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/net/HttpClientFactory.kt b/manager/src/main/kotlin/org/matrix/vector/manager/net/HttpClientFactory.kt index a55e3f3ba..87e4df483 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/net/HttpClientFactory.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/net/HttpClientFactory.kt @@ -4,6 +4,7 @@ import android.content.Context import java.io.File import java.util.concurrent.TimeUnit import okhttp3.Cache +import okhttp3.OkHttp import okhttp3.OkHttpClient import org.matrix.vector.manager.data.repository.SettingsRepository @@ -25,7 +26,30 @@ object HttpClientFactory { private const val CACHE_DIR = "http_cache" private const val CACHE_SIZE_BYTES = 16L * 1024 * 1024 - fun create(context: Context, settings: SettingsRepository): OkHttpClient { + /** + * The client and the resolver inside it. + * + * The resolver comes back alongside rather than being fished out of `client.dns` later: it is + * the only thing that knows whether DoH is actually working, the settings sheet reports that, + * and a cast back from the `Dns` interface would be a promise that nothing checks. + */ + class NetStack(val client: OkHttpClient, val dns: VectorDns) + + fun create(context: Context, settings: SettingsRepository): NetStack { + // OkHttp's Android artifact ships the public suffix list as an *asset* and reaches it + // through a process-static Context that `PlatformInitializer` sets from `androidx.startup`. + // Parasitically this app's manifest is never installed, so that provider never runs and the + // first DoH lookup — which asks the list whether a host is private before opening any + // socket — dies with "Unable to load PublicSuffixDatabase.list". OkHttp latches that + // failure for the life of the process, so it has to be prevented rather than recovered + // from. + // + // Here rather than in the activity because this is the only place a client is built, which + // puts it on the path to every request in both parasitic and standalone runs and in the + // debug demo host, which never opens MainActivity. Idempotent, so it is a no-op in the + // standalone install, where the Startup initializer did run. + OkHttp.initialize(context) + val cache = Cache(File(context.cacheDir, CACHE_DIR), CACHE_SIZE_BYTES) val base = @@ -40,6 +64,7 @@ object HttpClientFactory { // and the shared client — with its connection pool and its disk cache — is never rebuilt. // `base` is passed in as the bootstrap client because a DoH client must not itself resolve // through DoH. - return base.newBuilder().dns(VectorDns(settings, base)).build() + val dns = VectorDns(settings, base) + return NetStack(base.newBuilder().dns(dns).build(), dns) } } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt b/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt index 278fa112a..9d1e5fbf5 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/net/VectorDns.kt @@ -5,14 +5,48 @@ import android.util.Log import java.net.InetAddress import java.net.Proxy import java.net.ProxySelector -import java.net.UnknownHostException import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import okhttp3.Dns import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.dnsoverhttps.DnsOverHttps import org.matrix.vector.manager.data.repository.SettingsRepository +/** + * What the last name lookup of this session actually did. + * + * Deliberately a record of the real resolver rather than something a probe could produce. A "test + * DoH" button would be a second code path — another host, another moment — and it can pass while + * the client that fetches the module list is failing. Only the shared resolver knows the truth, so + * only the shared resolver reports it. + */ +sealed interface DohStatus { + /** Nothing has been resolved yet, so there is nothing to report. */ + data object Untested : DohStatus + + /** The setting is off; names went to the system resolver. */ + data object Disabled : DohStatus + + /** A proxy is configured, so resolving is its job and DoH was skipped. */ + data object Bypassed : DohStatus + + /** The last lookup went through the DoH endpoint. */ + data class Working(val host: String) : DohStatus + + /** + * DoH failed and the session has fallen back to the system resolver. + * + * No hostname here on purpose. What failed is reaching the DoH endpoint; whichever name was + * being looked up at that moment is incidental — it is simply whatever the app asked for first + * — and naming it reads as though that host were the subject of a test. The log line keeps it + * for anyone diagnosing; the sheet does not need it. + */ + data class FellBack(val reason: String) : DohStatus +} + /** * Name resolution: DNS over HTTPS when it helps, the system resolver when it does not. * @@ -27,15 +61,24 @@ import org.matrix.vector.manager.data.repository.SettingsRepository * - a configured HTTP proxy disables DoH entirely, because the proxy is doing the resolving and * bootstrap IPs are meaningless to it. * - * The setting is read **per lookup** rather than baked into the client at construction. OkHttp - * cannot have its DNS swapped on a live client, and rebuilding the shared client would drop the - * connection pool and orphan the disk cache, so reading it here is what lets the switch take effect - * before the next process start. + * Every one of those branches used to be invisible: off, bypassed and latched all looked like the + * same working switch, and the fallback existed only as a log line. [status] is what each lookup + * did, so the sheet that owns the switch can say which of them is happening. + * + * The setting and the proxy are both read **per lookup** rather than baked into the client at + * construction. OkHttp cannot have its DNS swapped on a live client, and rebuilding the shared + * client would drop the connection pool and orphan the disk cache, so reading them here is what + * lets a switch — or joining a VPN — take effect before the next process start. */ class VectorDns(private val settings: SettingsRepository, bootstrapClient: OkHttpClient) : Dns { private val endpoint = "https://cloudflare-dns.com/dns-query".toHttpUrl() + private val _status = MutableStateFlow(DohStatus.Untested) + + /** What the last lookup did. See [DohStatus] for why this is observed and never probed. */ + val status: StateFlow = _status.asStateFlow() + /** * Latched once the DoH endpoint proves unreachable. * @@ -66,20 +109,57 @@ class VectorDns(private val settings: SettingsRepository, bootstrapClient: OkHtt .build() } - /** True when nothing is proxying our traffic, which is the only case where DoH is ours to do. */ - private val direct: Boolean by lazy { + /** + * True when nothing is proxying our traffic, which is the only case where DoH is ours to do. + * + * Asked on every lookup rather than cached. A proxy can appear mid-session — joining a VPN or a + * work profile does exactly that — and a value read once at startup would keep sending queries + * to Cloudflare long after the answer changed, while [status] claimed a state that was no + * longer true. The call is local and a lookup is about to do network I/O anyway. + */ + private fun direct(): Boolean = runCatching { ProxySelector.getDefault().select(endpoint.toUri()).firstOrNull() == Proxy.NO_PROXY } .getOrDefault(true) + + /** + * Clears the session latch so the next lookup tries DoH again. + * + * The latch is what keeps a blocked endpoint from costing five seconds per name, but "the + * session" here is `com.android.shell`, a process nobody can restart on purpose — so without + * this a single bad lookup on a captive portal disables DoH until something else happens to + * kill the host. This is the way back, and it is offered only once the fallback has happened. + */ + fun retry() { + dohUnavailable = false + _status.value = DohStatus.Untested } override fun lookup(hostname: String): List { - if (settings.dohEnabled.value && direct && !dohUnavailable) { + if (!settings.dohEnabled.value) { + _status.value = DohStatus.Disabled + } else if (!direct()) { + _status.value = DohStatus.Bypassed + } else if (!dohUnavailable) { try { - return doh.lookup(hostname) - } catch (e: UnknownHostException) { + val resolved = doh.lookup(hostname) + _status.value = DohStatus.Working(hostname) + return resolved + } catch (e: Exception) { + // Every way DoH can fail, not only "no such host". A blocked endpoint raises + // UnknownHostException, a slow one raises InterruptedIOException from the timeouts + // above, and a resolver that cannot read its own public suffix list raises + // IllegalStateException before a socket is ever opened — which used to escape this + // method entirely, so the latch never closed and the fallback this class is built + // around never engaged. Anything arriving here means DoH is not usable, which is + // the condition documented above. + // + // Exception and not Throwable: an OutOfMemoryError is not a DNS outcome. There is + // no CancellationException to preserve either — this is a plain blocking call on an + // OkHttp dispatcher thread, with no coroutine in the stack. dohUnavailable = true + _status.value = DohStatus.FellBack(e.describe(hostname)) Log.w( Constants.TAG, "dns: DoH lookup of $hostname failed, using the system resolver for this session", @@ -87,7 +167,21 @@ class VectorDns(private val settings: SettingsRepository, bootstrapClient: OkHtt ) } } + // Latched: the status already says why, and repeating it on every name would only replace + // the host that actually failed with whichever one asked next. return Dns.SYSTEM.lookup(hostname) } + /** + * A line short enough to sit under a switch, and worth the room it takes. + * + * The class name whenever the message would not add anything. `UnknownHostException` carries + * the hostname as its entire message, so using it verbatim printed the name twice — "could not + * resolve example.org (example.org)" — while the one thing a reader wants, *which way* it + * failed, went missing. The class name is jargon, but it is jargon that distinguishes a blocked + * endpoint from a timeout, and it is what a bug report needs to carry anyway. + */ + private fun Throwable.describe(host: String): String = + message?.takeIf { it.isNotBlank() && !it.equals(host, ignoreCase = true) } + ?: javaClass.simpleName } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt index 840c5b275..aad3d911b 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/MainActivity.kt @@ -5,10 +5,6 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import coil3.ImageLoader -import coil3.PlatformContext -import coil3.SingletonImageLoader -import coil3.network.okhttp.OkHttpNetworkFetcherFactory import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ui.screens.splash.SplashGate import org.matrix.vector.manager.ui.theme.LocalizedContent @@ -33,19 +29,10 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) // Idempotent, and safe whether or not the daemon already called Constants.setBinder. + // Configures Coil, among the rest: it used to be done here, which left the debug demo host + // without it. ServiceLocator.attach(this) - // Coil is configured explicitly rather than through its manifest hooks: parasitically this - // app's manifest is never installed, so nothing that self-registers there ever runs. - // It shares the one OkHttp client, which carries the DoH configuration and the disk cache. - SingletonImageLoader.setSafe { platformContext: PlatformContext -> - ImageLoader.Builder(platformContext) - .components { - add(OkHttpNetworkFetcherFactory(callFactory = { ServiceLocator.http })) - } - .build() - } - // Started here rather than from the panels that need it: the splash is dead time the app // is spending anyway, and these reads are what makes a panel's first visit slower than its // second. By the time the splash has played, most of them have already answered. diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SheetParts.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SheetParts.kt index 2c51b915d..3331ad94b 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SheetParts.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/components/SheetParts.kt @@ -3,6 +3,7 @@ package org.matrix.vector.manager.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -16,6 +17,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -106,6 +108,44 @@ fun ToggleRow( ) } +/** + * What the setting above is currently doing, and — when it is not working — the way back. + * + * Indented to the same column as a [ToggleRow]'s subtitle rather than given a row of its own, + * because it is not another setting: it belongs to the switch above it and has to read as a + * consequence of that switch, not as a sibling of it. + * + * The action is optional and deliberately quiet. A row that always carries a button trains people + * to press it, and most of the states here are the ones where there is nothing to fix. + */ +@Composable +fun StatusNote( + text: String, + modifier: Modifier = Modifier, + tone: Color? = null, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, +) { + Row( + modifier = + modifier.fillMaxWidth().padding(start = 72.dp, end = 24.dp, top = 2.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = tone ?: MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (actionLabel != null && onAction != null) { + Spacer(Modifier.width(8.dp)) + TextButton(onClick = onAction, contentPadding = PaddingValues(horizontal = 12.dp)) { + Text(actionLabel, style = MaterialTheme.typography.labelLarge) + } + } + } +} + /** * One thing the sheet can do. * diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt index fd4d6e5c8..85497604f 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/home/HomeAppearanceSheet.kt @@ -68,7 +68,9 @@ import org.matrix.vector.manager.ui.theme.LocalizedOverlay import org.matrix.vector.manager.R import org.matrix.vector.manager.ui.components.ChoiceRow import org.matrix.vector.manager.ui.components.SheetHeading +import org.matrix.vector.manager.ui.components.StatusNote import org.matrix.vector.manager.ui.components.ToggleRow +import org.matrix.vector.manager.net.DohStatus import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ui.components.ColorWheel import org.matrix.vector.manager.ui.components.ambience.AmbienceKind @@ -107,6 +109,7 @@ fun HomeAppearanceSheet(onDismiss: () -> Unit) { val windowMonths by settings.activityWindowMonths.collectAsStateWithLifecycle() val openExternally by settings.openLinksExternally.collectAsStateWithLifecycle() val doh by settings.dohEnabled.collectAsStateWithLifecycle() + val dohStatus by ServiceLocator.dns.status.collectAsStateWithLifecycle() // The default state, deliberately. `skipPartiallyExpanded` removes the half-height stop, which // is the only thing a drag on a sheet can *do* other than dismiss it, so a sheet taller than @@ -209,6 +212,44 @@ LocalizedOverlay { checked = doh, onCheckedChange = settings::setDohEnabled, ) + // The switch says what was asked for; this says what happened. They come apart more + // often than the switch admits — a proxy takes the decision away entirely, and one + // unreachable lookup latches the fallback for the rest of the session — and until now + // all three cases looked identical from here. + // + // Only while the switch is on. Off, the switch has already said so, and a second line + // repeating it would be the one piece of this that carries no information. + if (doh) { + when (val state = dohStatus) { + is DohStatus.Untested -> + StatusNote(stringResource(R.string.settings_doh_untested)) + + is DohStatus.Bypassed -> + StatusNote(stringResource(R.string.settings_doh_bypassed)) + + is DohStatus.Working -> + StatusNote( + stringResource(R.string.settings_doh_working, state.host), + tone = MaterialTheme.colorScheme.primary, + ) + + // The one state with something to offer. Not an error colour: falling back is + // the designed behaviour and the app is working, so this is the shade the rest + // of the app uses for "worth knowing", not for "something is broken". + is DohStatus.FellBack -> + StatusNote( + stringResource(R.string.settings_doh_fell_back, state.reason), + tone = MaterialTheme.colorScheme.tertiary, + actionLabel = stringResource(R.string.settings_doh_retry), + onAction = ServiceLocator.dns::retry, + ) + + // Reachable only in the gap between flipping the switch on and the next lookup + // recording something newer. + is DohStatus.Disabled -> + StatusNote(stringResource(R.string.settings_doh_untested)) + } + } ToggleRow( title = stringResource(R.string.settings_open_externally), diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml index 2f9694016..e261d27fa 100644 --- a/manager/src/main/res/values-ar/strings.xml +++ b/manager/src/main/res/values-ar/strings.xml @@ -440,4 +440,9 @@ الشبكة تحليل الأسماء عبر HTTPS يحلّل الأسماء عبر Cloudflare بدلاً من محلّل الشبكة. للشبكات التي تحجب الـ DNS أو تعبث به. + لم يُحلَّل أي اسم بعد. + غير مستخدَم: هناك وكيل مُهيَّأ، وهو الذي يحلّل الأسماء. + يعمل — حُلّل %1$s عبر Cloudflare. + تعذّر الوصول إلى Cloudflare (%1$s)، لذا سيُستخدَم محلّل الشبكة لبقية هذه الجلسة. + إعادة المحاولة diff --git a/manager/src/main/res/values-de/strings.xml b/manager/src/main/res/values-de/strings.xml index 75f7dc999..12834592e 100644 --- a/manager/src/main/res/values-de/strings.xml +++ b/manager/src/main/res/values-de/strings.xml @@ -396,4 +396,9 @@ Netzwerk Namen über HTTPS auflösen Löst Namen über Cloudflare auf statt über den Resolver des Netzwerks. Für Netzwerke, die DNS blockieren oder verfälschen. + Bisher wurden keine Namen aufgelöst. + Nicht in Verwendung: Ein Proxy ist eingerichtet und übernimmt die Auflösung. + Funktioniert — %1$s wurde über Cloudflare aufgelöst. + Cloudflare ist nicht erreichbar (%1$s); für den Rest dieser Sitzung wird der Resolver des Netzwerks verwendet. + Erneut versuchen diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml index cbedd0bfd..81b3fc94c 100644 --- a/manager/src/main/res/values-es/strings.xml +++ b/manager/src/main/res/values-es/strings.xml @@ -396,4 +396,9 @@ Red Resolver nombres por HTTPS Resuelve los nombres a través de Cloudflare en lugar del resolutor de la red. Para redes que bloquean o manipulan el DNS. + Todavía no se ha resuelto ningún nombre. + Sin usar: hay un proxy configurado, así que es él quien resuelve los nombres. + Funciona: %1$s se resolvió a través de Cloudflare. + No se pudo contactar con Cloudflare (%1$s), así que se usará el resolutor de la red durante el resto de esta sesión. + Reintentar diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml index dc6a33b4f..821c9081b 100644 --- a/manager/src/main/res/values-fa/strings.xml +++ b/manager/src/main/res/values-fa/strings.xml @@ -396,4 +396,9 @@ شبکه تحلیل نام‌ها از راه HTTPS نام‌ها را به‌جای تحلیل‌گر شبکه از راه Cloudflare پیدا می‌کند. برای شبکه‌هایی که DNS را مسدود یا دستکاری می‌کنند. + هنوز هیچ نامی تحلیل نشده است. + استفاده نمی‌شود: یک پیشکار پیکربندی شده و نام‌ها را او تحلیل می‌کند. + کار می‌کند — %1$s از راه Cloudflare تحلیل شد. + دسترسی به Cloudflare ممکن نشد (%1$s)، بنابراین تا پایان این نشست از تحلیل‌گر شبکه استفاده می‌شود. + تلاش دوباره diff --git a/manager/src/main/res/values-fr/strings.xml b/manager/src/main/res/values-fr/strings.xml index c64cb3027..8daed06c8 100644 --- a/manager/src/main/res/values-fr/strings.xml +++ b/manager/src/main/res/values-fr/strings.xml @@ -396,4 +396,9 @@ Réseau Résoudre les noms via HTTPS Résout les noms via Cloudflare plutôt que par le résolveur du réseau. Pour les réseaux qui bloquent ou altèrent le DNS. + Aucun nom résolu pour l\'instant. + Inutilisé : un proxy est configuré, c\'est donc lui qui résout les noms. + Fonctionne — %1$s a été résolu via Cloudflare. + Cloudflare est injoignable (%1$s) ; le résolveur du réseau est utilisé pour le reste de cette session. + Réessayer diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml index 46c040970..9cc0accb1 100644 --- a/manager/src/main/res/values-in/strings.xml +++ b/manager/src/main/res/values-in/strings.xml @@ -389,4 +389,9 @@ Jaringan Selesaikan nama lewat HTTPS Menyelesaikan nama lewat Cloudflare alih-alih resolver jaringan. Untuk jaringan yang memblokir atau mengubah DNS. + Belum ada nama yang diselesaikan. + Tidak dipakai: proxy telah dikonfigurasi, jadi proxy yang menyelesaikan nama. + Berfungsi — %1$s diselesaikan lewat Cloudflare. + Cloudflare tidak dapat dihubungi (%1$s), jadi resolver jaringan dipakai untuk sisa sesi ini. + Coba lagi diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml index 93a367478..e5695f94f 100644 --- a/manager/src/main/res/values-it/strings.xml +++ b/manager/src/main/res/values-it/strings.xml @@ -396,4 +396,9 @@ Rete Risolvere i nomi via HTTPS Risolve i nomi tramite Cloudflare invece che con il resolver della rete. Per reti che bloccano o alterano il DNS. + Nessun nome ancora risolto. + Non in uso: è configurato un proxy, quindi è lui a risolvere i nomi. + Funziona — %1$s è stato risolto tramite Cloudflare. + Cloudflare non è raggiungibile (%1$s), quindi per il resto di questa sessione viene usato il resolver della rete. + Riprova diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml index da20cea1e..6be53a4e0 100644 --- a/manager/src/main/res/values-iw/strings.xml +++ b/manager/src/main/res/values-iw/strings.xml @@ -422,4 +422,9 @@ רשת פענוח שמות דרך HTTPS מפענח שמות דרך Cloudflare במקום דרך המפענח של הרשת. לרשתות שחוסמות או משבשות DNS. + עדיין לא פוענח אף שם. + לא בשימוש: מוגדר פרוקסי, ולכן הוא מפענח את השמות. + עובד — %1$s פוענח דרך Cloudflare. + לא ניתן היה להגיע ל-Cloudflare (%1$s), ולכן ייעשה שימוש במפענח של הרשת בהמשך ההפעלה הזו. + ניסיון נוסף diff --git a/manager/src/main/res/values-ja/strings.xml b/manager/src/main/res/values-ja/strings.xml index 425ba47cb..509905424 100644 --- a/manager/src/main/res/values-ja/strings.xml +++ b/manager/src/main/res/values-ja/strings.xml @@ -385,4 +385,9 @@ ネットワーク 名前を HTTPS で解決する 名前をネットワークのリゾルバではなく Cloudflare 経由で解決します。DNS を遮断または改変するネットワーク向けです。 + まだ名前を解決していません。 + 未使用: プロキシが設定されているため、名前解決はプロキシが行います。 + 動作中 — %1$s を Cloudflare 経由で解決しました。 + Cloudflare に接続できませんでした (%1$s)。このセッションの残りはネットワークのリゾルバを使用します。 + 再試行 diff --git a/manager/src/main/res/values-ko/strings.xml b/manager/src/main/res/values-ko/strings.xml index 673176c7f..fc068ce4b 100644 --- a/manager/src/main/res/values-ko/strings.xml +++ b/manager/src/main/res/values-ko/strings.xml @@ -385,4 +385,9 @@ 네트워크 HTTPS로 이름 확인 네트워크의 확인자 대신 Cloudflare를 통해 이름을 확인합니다. DNS를 차단하거나 변조하는 네트워크용입니다. + 아직 확인한 이름이 없습니다. + 사용 안 함: 프록시가 설정되어 있어 프록시가 이름을 확인합니다. + 작동 중 — %1$s을(를) Cloudflare로 확인했습니다. + Cloudflare에 연결할 수 없어(%1$s) 이 세션의 나머지 동안 네트워크의 확인자를 사용합니다. + 다시 시도 diff --git a/manager/src/main/res/values-pl/strings.xml b/manager/src/main/res/values-pl/strings.xml index e22057ae9..dbe11afc9 100644 --- a/manager/src/main/res/values-pl/strings.xml +++ b/manager/src/main/res/values-pl/strings.xml @@ -418,4 +418,9 @@ Sieć Rozwiązuj nazwy przez HTTPS Rozwiązuje nazwy przez Cloudflare zamiast przez resolver sieci. Dla sieci, które blokują lub zmieniają DNS. + Nie rozwiązano jeszcze żadnej nazwy. + Nieużywane: skonfigurowano proxy, więc to ono rozwiązuje nazwy. + Działa — %1$s rozwiązano przez Cloudflare. + Nie udało się połączyć z Cloudflare (%1$s), więc przez resztę tej sesji używany jest resolver sieci. + Spróbuj ponownie diff --git a/manager/src/main/res/values-pt-rBR/strings.xml b/manager/src/main/res/values-pt-rBR/strings.xml index 2f97d71cf..e3395cc84 100644 --- a/manager/src/main/res/values-pt-rBR/strings.xml +++ b/manager/src/main/res/values-pt-rBR/strings.xml @@ -396,4 +396,9 @@ Rede Resolver nomes por HTTPS Resolve nomes pela Cloudflare em vez do resolvedor da rede. Para redes que bloqueiam ou adulteram o DNS. + Nenhum nome resolvido ainda. + Sem uso: há um proxy configurado, então é ele que resolve os nomes. + Funcionando — %1$s foi resolvido pela Cloudflare. + Não foi possível alcançar a Cloudflare (%1$s), então o resolvedor da rede será usado no resto desta sessão. + Tentar de novo diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml index 326d130fd..d6927c64b 100644 --- a/manager/src/main/res/values-ru/strings.xml +++ b/manager/src/main/res/values-ru/strings.xml @@ -399,4 +399,9 @@ Сеть Разрешать имена через HTTPS Разрешает имена через Cloudflare, а не через резолвер сети. Для сетей, которые блокируют или подменяют DNS. + Пока ни одно имя не разрешалось. + Не используется: настроен прокси, и разрешением имён занимается он. + Работает — %1$s разрешено через Cloudflare. + Cloudflare недоступен (%1$s), поэтому до конца этого сеанса используется резолвер сети. + Повторить diff --git a/manager/src/main/res/values-tr/strings.xml b/manager/src/main/res/values-tr/strings.xml index ef4e7cc31..dec883c10 100644 --- a/manager/src/main/res/values-tr/strings.xml +++ b/manager/src/main/res/values-tr/strings.xml @@ -396,4 +396,9 @@ Adları HTTPS üzerinden çöz Adları ağın çözümleyicisi yerine Cloudflare üzerinden çözer. DNS\'i engelleyen veya kurcalayan ağlar için. + Henüz hiçbir ad çözümlenmedi. + Kullanılmıyor: bir proxy yapılandırılmış, adları o çözümlüyor. + Çalışıyor — %1$s Cloudflare üzerinden çözümlendi. + Cloudflare\'a ulaşılamadı (%1$s), bu oturumun kalanında ağın çözümleyicisi kullanılacak. + Yeniden dene diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml index 0107635e9..41bd5667f 100644 --- a/manager/src/main/res/values-uk/strings.xml +++ b/manager/src/main/res/values-uk/strings.xml @@ -418,4 +418,9 @@ Мережа Розвʼязувати імена через HTTPS Розвʼязує імена через Cloudflare, а не через резолвер мережі. Для мереж, які блокують або підміняють DNS. + Поки жодне імʼя не розвʼязувалося. + Не використовується: налаштовано проксі, і розвʼязуванням імен займається він. + Працює — %1$s розвʼязано через Cloudflare. + Cloudflare недоступний (%1$s), тому до кінця цього сеансу використовується резолвер мережі. + Повторити diff --git a/manager/src/main/res/values-vi/strings.xml b/manager/src/main/res/values-vi/strings.xml index 47eddf2c3..272b3fa77 100644 --- a/manager/src/main/res/values-vi/strings.xml +++ b/manager/src/main/res/values-vi/strings.xml @@ -385,4 +385,9 @@ Mạng Phân giải tên qua HTTPS Phân giải tên qua Cloudflare thay vì trình phân giải của mạng. Dành cho mạng chặn hoặc can thiệp DNS. + Chưa phân giải tên nào. + Không dùng: đã cấu hình proxy nên proxy đang phân giải tên. + Đang hoạt động — %1$s được phân giải qua Cloudflare. + Không kết nối được tới Cloudflare (%1$s), nên trình phân giải của mạng sẽ được dùng trong phần còn lại của phiên này. + Thử lại diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml index 12f2bd372..91b77ad80 100644 --- a/manager/src/main/res/values-zh-rCN/strings.xml +++ b/manager/src/main/res/values-zh-rCN/strings.xml @@ -386,4 +386,9 @@ 网络 通过 HTTPS 解析域名 通过 Cloudflare 而非网络自带的解析器查询域名。适用于屏蔽或篡改 DNS 的网络。 + 尚未解析任何域名。 + 未使用:已配置代理,域名由代理解析。 + 正常 — 已通过 Cloudflare 解析 %1$s。 + 无法连接 Cloudflare(%1$s),本次会话的剩余时间将使用网络自带的解析器。 + 重试 diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml index f69093b14..bd6cb415f 100644 --- a/manager/src/main/res/values-zh-rTW/strings.xml +++ b/manager/src/main/res/values-zh-rTW/strings.xml @@ -386,4 +386,9 @@ 網路 透過 HTTPS 解析網域 透過 Cloudflare 而非網路自帶的解析器查詢網域。適用於封鎖或竄改 DNS 的網路。 + 尚未解析任何網域。 + 未使用:已設定 Proxy,網域由 Proxy 解析。 + 正常 — 已透過 Cloudflare 解析 %1$s。 + 無法連線至 Cloudflare(%1$s),本次工作階段的其餘時間將使用網路自帶的解析器。 + 重試 diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index 05966c55d..7db5e3e12 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -430,4 +430,9 @@ Network Resolve names over HTTPS Looks names up through Cloudflare instead of the network\'s resolver. For networks that block or tamper with DNS. + No names looked up yet. + Not in use: a proxy is configured, so it is resolving names. + Working — %1$s was resolved through Cloudflare. + Cloudflare could not be reached (%1$s), so the network\'s resolver is being used for the rest of this session. + Try again