From 4d94cb65d30231f363ae9fb4424cbd912717fdad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 11:03:17 +0000 Subject: [PATCH 1/2] fix(network): Server auch bei aktivem Tunnel erreichbar; GateDrop mit mehreren Bildern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DNS des Servers (ApiClientProvider → neu VpnSafeDns): - Der Resolver im Tunnel (dnsmasq auf dem Gateway) beantwortet den Servernamen absichtlich mit der VPN-Adresse (Split-Horizon). Läuft die App selbst durch den Tunnel (keine Split-Tunnel-Ausnahme für GateControl), ist das der richtige Weg: die Adresse wird jetzt genutzt, bekannte öffentliche Adressen folgen als Ausweichweg. Bisher wurde sie verworfen – ohne Cache (z. B. nach einem Neustart der App bei aktivem Tunnel) schlug jeder API-Aufruf mit UnknownHostException fehl, mit Cache ging der Aufruf an die öffentliche Adresse durch den Tunnel und lief in Timeouts. - Ist die App vom Tunnel ausgenommen, wird eine VPN-Adresse weiter verworfen; dann wird der Name über ein Netz außerhalb des VPN aufgelöst (Network.getAllByName), danach aus den Adressen dieses Laufs und zuletzt aus den zuletzt bekannten öffentlichen Adressen, die jetzt dauerhaft gespeichert werden. - Ob die App durch den Tunnel geht, entscheidet ihr eigenes Standardnetz (TRANSPORT_VPN), nicht eine Annahme über die Split-Tunnel-Einstellung. - Unit-Tests für die Regeln. GateDrop (Teilen-Blatt): - Das Blatt schließt sich beim Senden an Geräte nicht mehr sofort mit einem Toast, sondern zeigt den Fortschritt, „Gesendet – An …“ oder den Fehler; Schließen ist jederzeit möglich, das Senden läuft weiter. - Ist der Server nicht erreichbar, sagt das Blatt und eine Mitteilung „Noch nicht gesendet – GateControl versucht es von selbst erneut“ (bisher bis zu Stunden ohne jede Rückmeldung). - Nicht lesbare geteilte Dateien werden genannt statt still ausgelassen. - „Gesendet und Empfangen ansehen“ im Blatt öffnet Einstellungen › Geteilt; die Gruppe dort heißt jetzt „GateDrop“. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../android/chat/ChatShareActivity.kt | 21 +- .../android/gatedrop/GateDropNotifier.kt | 22 ++ .../android/gatedrop/GateDropRepository.kt | 2 + .../android/gatedrop/GateDropWorkers.kt | 7 +- .../android/service/TunnelConnector.kt | 6 +- .../android/ui/gatedrop/GateDropShareSheet.kt | 39 +++- .../ui/gatedrop/GateDropShareViewModel.kt | 32 ++- .../main/res/values-de/strings_gatedrop.xml | 10 +- app/src/main/res/values/strings_gatedrop.xml | 10 +- .../android/network/ApiClientProvider.kt | 137 ++++++------ .../gatecontrol/android/network/VpnSafeDns.kt | 210 ++++++++++++++++++ .../android/network/VpnSafeDnsTest.kt | 177 +++++++++++++++ 12 files changed, 574 insertions(+), 99 deletions(-) create mode 100644 core/network/src/main/java/com/gatecontrol/android/network/VpnSafeDns.kt create mode 100644 core/network/src/test/java/com/gatecontrol/android/network/VpnSafeDnsTest.kt diff --git a/app/src/main/java/com/gatecontrol/android/chat/ChatShareActivity.kt b/app/src/main/java/com/gatecontrol/android/chat/ChatShareActivity.kt index e8668022..31b0262c 100644 --- a/app/src/main/java/com/gatecontrol/android/chat/ChatShareActivity.kt +++ b/app/src/main/java/com/gatecontrol/android/chat/ChatShareActivity.kt @@ -13,7 +13,6 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.core.content.IntentCompat @@ -33,6 +32,8 @@ import com.gatecontrol.android.ui.theme.GateControlTheme import com.gatecontrol.android.familie.PluginDirectory import com.gatecontrol.android.push.PushNavTarget import com.gatecontrol.android.push.PushNavigation +import com.gatecontrol.android.push.PushOpenActivity +import com.gatecontrol.android.gatedrop.GateDropNotifier import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -167,12 +168,6 @@ class ChatShareActivity : ComponentActivity() { } GateControlTheme(darkTheme = dark) { val ui by gateDrop.ui.collectAsStateWithLifecycle() - LaunchedEffect(ui.stage) { - if (ui.stage == GdShareStage.DONE) { - Toast.makeText(this@ChatShareActivity, R.string.gatedrop_sending_toast, Toast.LENGTH_SHORT).show() - finish() - } - } GateDropShareSheet( ui = ui, serverUrl = serverUrl, @@ -193,7 +188,17 @@ class ChatShareActivity : ComponentActivity() { onCloseLink = gateDrop::closeLinkOptions, onRetry = gateDrop::loadTargets, onSend = gateDrop::send, - onDismiss = { finish() }, + onDismiss = { + // Closed while it still sends: the worker goes on, its notifications tell the rest. + if (ui.stage == GdShareStage.WAITING || ui.stage == GdShareStage.PREPARING) { + Toast.makeText(this@ChatShareActivity, R.string.gatedrop_sending_toast, Toast.LENGTH_SHORT).show() + } + finish() + }, + onOpenShared = { + runCatching { startActivity(PushOpenActivity.sharedIntent(this@ChatShareActivity, GateDropNotifier.TAB_SENT)) } + finish() + }, ) } } diff --git a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropNotifier.kt b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropNotifier.kt index c1e2ec1a..3138c14e 100644 --- a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropNotifier.kt +++ b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropNotifier.kt @@ -176,6 +176,28 @@ class GateDropNotifier @Inject constructor( ) } + /** + * Not through yet (server not reachable, busy): the send waits and is + * tried again by itself. Replaced by [sent] / [sendFailed] (same id). + */ + suspend fun sendWaiting(key: String, names: String?) { + val ctx = localized() + ensureChannels(ctx) + val text = ctx.getString(R.string.gatedrop_send_waiting_desc) + post( + resultId(key), + NotificationCompat.Builder(ctx, CHANNEL_PROGRESS) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(ctx.getString(R.string.gatedrop_send_waiting)) + .setContentText(names?.let { ctx.getString(R.string.gatedrop_sent_to, it) } ?: text) + .setStyle(NotificationCompat.BigTextStyle().bigText(listOfNotNull(names?.let { ctx.getString(R.string.gatedrop_sent_to, it) }, text).joinToString("\n"))) + .setOnlyAlertOnce(true) + .setSilent(true) + .setContentIntent(sharedTap(TAB_SENT, slot = 17)) + .build(), + ) + } + /** The link for externals is ready: URL (and PIN) with Kopieren / Teilen. */ suspend fun linkReady(key: String, link: GdLinkResult) { val ctx = localized() diff --git a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropRepository.kt b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropRepository.kt index d831319a..0e3c6b67 100644 --- a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropRepository.kt +++ b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropRepository.kt @@ -40,6 +40,8 @@ sealed interface GdJobState { /** GateDrop 1.4 `423 scan_pending`: a file is still being checked for viruses, the send is asked again soon. */ data object Checking : GdJobState data class Direct(val percent: Int?) : GdJobState + /** The server was not reachable (or busy): the worker tries again later by itself. */ + data object Retrying : GdJobState data class Done(val sendId: String?, val link: GdLinkResult?) : GdJobState /** [name]: the file a virus-scan refusal names (`422 infected`, `423 scan_blocked`). */ data class Failed(val code: String?, val name: String? = null) : GdJobState diff --git a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropWorkers.kt b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropWorkers.kt index 7b0e0427..734b8556 100644 --- a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropWorkers.kt +++ b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropWorkers.kt @@ -77,6 +77,8 @@ class GateDropSendWorker(appContext: Context, params: WorkerParameters) : Corout } } catch (e: CancellationException) { repo.setJobState(jobId, GdJobState.Failed("cancelled")) + // an earlier run's "will try again" notice must not stay behind + notifier.cancel(GateDropNotifier.resultId(jobId)) throw e } return when (outcome) { @@ -90,7 +92,10 @@ class GateDropSendWorker(appContext: Context, params: WorkerParameters) : Corout is GdJobOutcome.Retry -> if (runAttemptCount >= MAX_RUNS) { giveUp(repo, notifier, outcome.code, null) } else { - repo.setJobState(jobId, GdJobState.Uploading(null)) + Timber.i("GateDrop: send not through yet (%s), tried again later", outcome.code) + repo.setJobState(jobId, GdJobState.Retrying) + // Without it nothing would say why the send waits (hours of backoff in the worst case). + notifier.sendWaiting(jobId, inputData.getString(KEY_NAMES)) Result.retry() } is GdJobOutcome.Failed -> giveUp(repo, notifier, outcome.code, outcome.name) diff --git a/app/src/main/java/com/gatecontrol/android/service/TunnelConnector.kt b/app/src/main/java/com/gatecontrol/android/service/TunnelConnector.kt index 9528579b..1b6ad3a2 100644 --- a/app/src/main/java/com/gatecontrol/android/service/TunnelConnector.kt +++ b/app/src/main/java/com/gatecontrol/android/service/TunnelConnector.kt @@ -59,9 +59,9 @@ class TunnelConnector @Inject constructor( applyVpnSubnet(config) // Pre-resolve server hostname before the tunnel comes up. Once the - // VPN is established the system DNS points to 10.8.0.1, which is - // unreachable from the GateControl app itself when the app is in - // the user's exclude list. + // VPN is established the system DNS points to 10.8.0.1, which answers + // the server's name with its VPN address — unusable when the app is + // in the user's exclude list (ApiClientProvider / VpnSafeDns). if (serverUrl.isNotEmpty()) { try { val host = java.net.URI(serverUrl).host diff --git a/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareSheet.kt b/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareSheet.kt index 2419ded0..d0b084ca 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareSheet.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareSheet.kt @@ -110,6 +110,8 @@ fun GateDropShareSheet( onRetry: () -> Unit, onSend: () -> Unit, onDismiss: () -> Unit, + /** Opens GateDrop's "Geteilt" (Gesendet / Empfangen) in the app. */ + onOpenShared: () -> Unit = {}, ) { ModalBottomSheet( onDismissRequest = onDismiss, @@ -126,8 +128,9 @@ fun GateDropShareSheet( GdShareStage.LINK_OPTIONS -> LinkOptions(ui, onLinkDraft, onApplyLink, onRemoveLink, onCloseLink) GdShareStage.PREPARING, GdShareStage.WAITING -> Progress(ui) GdShareStage.RESULT -> LinkResult(ui, onDismiss) - GdShareStage.FAILED -> Failed(ui.failure, onDismiss) - else -> Main(ui, serverUrl, onShareInChat, onTogglePerson, onToggleDevice, onToggleNas, onToggleDrive, onNote, onOpenLink, onRetry, onSend) + GdShareStage.SENT -> Sent(ui, onDismiss, onOpenShared) + GdShareStage.FAILED -> Failed(ui.failure, onDismiss, onOpenShared) + else -> Main(ui, serverUrl, onShareInChat, onTogglePerson, onToggleDevice, onToggleNas, onToggleDrive, onNote, onOpenLink, onRetry, onSend, onOpenShared) } } } @@ -167,6 +170,7 @@ private fun Main( onOpenLink: () -> Unit, onRetry: () -> Unit, onSend: () -> Unit, + onOpenShared: () -> Unit, ) { val extra = GateControlTheme.extraColors Header(ui) @@ -278,6 +282,8 @@ private fun Main( if (onShareInChat != null) { GcSecondaryButton(text = stringResource(R.string.gatedrop_in_chat), onClick = onShareInChat) } + // GateDrop's history lives in Settings › Geteilt: reachable from where GateDrop is used. + TextButton(onClick = onOpenShared, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.gatedrop_open_shared)) } } @Composable @@ -453,17 +459,23 @@ private fun Progress(ui: GdShareUi) { Header(ui) Column(Modifier.fillMaxWidth().padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { val p = ui.percent + val toLink = ui.selection.link != null Text( when { ui.stage == GdShareStage.PREPARING -> stringResource(R.string.gatedrop_preparing) + ui.retrying -> stringResource(R.string.gatedrop_send_waiting_desc) ui.checking -> stringResource(R.string.scan_detail_checking) - p != null -> stringResource(R.string.gatedrop_uploading_percent, p) - else -> stringResource(R.string.gatedrop_waiting_link) + p != null && toLink -> stringResource(R.string.gatedrop_uploading_percent, p) + p != null -> stringResource(R.string.gatedrop_sending_percent, p) + toLink -> stringResource(R.string.gatedrop_waiting_link) + else -> stringResource(R.string.gatedrop_progress_sending) }, style = MaterialTheme.typography.bodyMedium, - color = extra.muted, + color = if (ui.retrying) MaterialTheme.colorScheme.onSurface else extra.muted, ) + ui.targetNames?.let { Text(stringResource(R.string.gatedrop_sent_to, it), style = MaterialTheme.typography.bodySmall, color = extra.muted) } if (p != null) LinearProgressIndicator(progress = { p / 100f }, modifier = Modifier.fillMaxWidth()) else LinearProgressIndicator(Modifier.fillMaxWidth()) + if (ui.unread > 0) Text(pluralStringResource(R.plurals.gatedrop_unread, ui.unread, ui.unread), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) Text(stringResource(R.string.gatedrop_progress_background), style = MaterialTheme.typography.bodySmall, color = extra.faint) } } @@ -510,12 +522,27 @@ private fun LinkResult(ui: GdShareUi, onDone: () -> Unit) { } @Composable -private fun Failed(reason: String?, onDone: () -> Unit) { +private fun Failed(reason: String?, onDone: () -> Unit, onOpenShared: () -> Unit) { GcBanner(tone = GcTone.Error, icon = GcIcons.Alert) { Column { Text(stringResource(R.string.gatedrop_send_failed), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) Text(reason ?: stringResource(R.string.gatedrop_send_failed_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) } } + GcOutlineButton(text = stringResource(R.string.gatedrop_open_shared), onClick = onOpenShared) GcSecondaryButton(text = stringResource(R.string.gatedrop_done), onClick = onDone) } + +/** Delivered (no link for externals): "Gesendet an …", the way to "Geteilt › Gesendet". */ +@Composable +private fun Sent(ui: GdShareUi, onDone: () -> Unit, onOpenShared: () -> Unit) { + GcBanner(tone = GcTone.Ok, icon = GcIcons.Check) { + Column { + Text(stringResource(R.string.gatedrop_sent_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) + ui.targetNames?.let { Text(stringResource(R.string.gatedrop_sent_to, it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) } + if (ui.unread > 0) Text(pluralStringResource(R.plurals.gatedrop_unread, ui.unread, ui.unread), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + } + } + GcOutlineButton(text = stringResource(R.string.gatedrop_open_shared), onClick = onOpenShared) + GcPrimaryButton(text = stringResource(R.string.gatedrop_done), onClick = onDone) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareViewModel.kt index 8bf34335..7804ce2b 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareViewModel.kt @@ -37,8 +37,8 @@ import javax.inject.Inject /** A file shared from another app (read only when sending). */ data class GdSource(val uri: Uri, val name: String, val mime: String, val size: Long?) -/** What the share sheet shows. */ -enum class GdShareStage { EDIT, LINK_OPTIONS, PREPARING, WAITING, RESULT, DONE, FAILED } +/** What the share sheet shows ([SENT]: delivered, without a link for externals). */ +enum class GdShareStage { EDIT, LINK_OPTIONS, PREPARING, WAITING, RESULT, SENT, FAILED } data class GdShareUi( val sources: List = emptyList(), @@ -60,6 +60,12 @@ data class GdShareUi( val checking: Boolean = false, /** Why the send failed when the virus scan refused it (" abgelehnt – Schadsoftware gefunden."). */ val failure: String? = null, + /** The server was not reachable: the send waits and is tried again by itself. */ + val retrying: Boolean = false, + /** Shared files that could not be read when sending (left out of the send). */ + val unread: Int = 0, + /** Who it goes to ("FEC Laptop, Mein Drive") for "Gesendet an …". */ + val targetNames: String? = null, ) { val totalBytes: Long get() = sources.sumOf { it.size ?: 0L } val canSend: Boolean get() = targets != null && GateDropLogic.canSend(selection, targets, sources.size, !text.isNullOrBlank()) @@ -124,6 +130,8 @@ class GateDropShareViewModel @Inject constructor( _ui.update { it.copy(stage = GdShareStage.FAILED) } return@launch } + val unread = s.sources.size - files.size + if (unread > 0) Timber.w("GateDrop: %d of %d shared files not readable, left out", unread, s.sources.size) val to = GateDropLogic.recipients(s.selection, targets) val shareLink = if (files.isEmpty()) GateDropLogic.asLink(s.text) else null val job = GdJob( @@ -138,24 +146,28 @@ class GateDropShareViewModel @Inject constructor( repository.store.saveJob(job) repository.setJobState(jobId, GdJobState.Preparing) val names = GateDropLogic.chosenNames(s.selection, targets, getApplication().getString(com.gatecontrol.android.R.string.gatedrop_drive)).joinToString(", ").ifBlank { null } - GateDropSendWorker.enqueue(getApplication(), jobId, names) - if (to.link == null) { - _ui.update { it.copy(stage = GdShareStage.DONE) } + if (GateDropSendWorker.enqueue(getApplication(), jobId, names) == null) { + repository.store.dropJob(jobId) + _ui.update { it.copy(stage = GdShareStage.FAILED) } return@launch } - _ui.update { it.copy(stage = GdShareStage.WAITING) } + // The sheet stays and shows how the send goes (progress, "Gesendet an …", the error); closing it + // leaves the send to the worker and its notifications. + _ui.update { it.copy(stage = GdShareStage.WAITING, unread = unread, targetNames = names) } waitJob = launch { repository.jobs.mapNotNull { it[jobId] }.first { st -> when (st) { - is GdJobState.Uploading -> _ui.update { it.copy(percent = st.percent, checking = false) } - is GdJobState.Direct -> _ui.update { it.copy(percent = st.percent, checking = false) } - GdJobState.Checking -> _ui.update { it.copy(checking = true) } + is GdJobState.Uploading -> _ui.update { it.copy(percent = st.percent, checking = false, retrying = false) } + is GdJobState.Direct -> _ui.update { it.copy(percent = st.percent, checking = false, retrying = false) } + GdJobState.Sending -> _ui.update { it.copy(checking = false, retrying = false) } + GdJobState.Checking -> _ui.update { it.copy(checking = true, retrying = false) } + GdJobState.Retrying -> _ui.update { it.copy(retrying = true, checking = false, percent = null) } else -> Unit } st is GdJobState.Done || st is GdJobState.Failed }.let { st -> when (st) { - is GdJobState.Done -> _ui.update { it.copy(stage = if (st.link != null) GdShareStage.RESULT else GdShareStage.DONE, link = st.link) } + is GdJobState.Done -> _ui.update { it.copy(stage = if (st.link != null) GdShareStage.RESULT else GdShareStage.SENT, link = st.link, retrying = false) } is GdJobState.Failed -> _ui.update { it.copy(stage = GdShareStage.FAILED, failure = scanCodeText(getApplication(), st.code, name = st.name)) } diff --git a/app/src/main/res/values-de/strings_gatedrop.xml b/app/src/main/res/values-de/strings_gatedrop.xml index df002ffb..36afa975 100644 --- a/app/src/main/res/values-de/strings_gatedrop.xml +++ b/app/src/main/res/values-de/strings_gatedrop.xml @@ -45,6 +45,7 @@ Wird vorbereitet … Link wird erstellt … Wird hochgeladen … %1$d %% + Wird gesendet … %1$d %% Du kannst das schließen – das Senden läuft im Hintergrund weiter. Wird gesendet … Fertig @@ -87,6 +88,13 @@ %1$s\nPIN: %2$s Nicht gesendet Dein GateControl-Server war nicht erreichbar oder hat es abgelehnt. + Noch nicht gesendet + Dein GateControl-Server ist gerade nicht erreichbar – GateControl versucht es von selbst erneut. + Gesendet und Empfangen ansehen + + %d Datei konnte nicht gelesen werden und wird nicht gesendet. + %d Dateien konnten nicht gelesen werden und werden nicht gesendet. + Jemand %1$s hat dir %2$s geschickt GateDrop: etwas Neues für dich @@ -105,7 +113,7 @@ Von %1$s - Teilen + GateDrop Empfangen, Gesendet und Links für Externe Geteilt Wer darf an dieses Gerät senden? diff --git a/app/src/main/res/values/strings_gatedrop.xml b/app/src/main/res/values/strings_gatedrop.xml index cca4fe79..3bcca7fb 100644 --- a/app/src/main/res/values/strings_gatedrop.xml +++ b/app/src/main/res/values/strings_gatedrop.xml @@ -45,6 +45,7 @@ Preparing … Creating the link … Uploading … %1$d %% + Sending … %1$d %% You can close this – sending continues in the background. Sending … Done @@ -87,6 +88,13 @@ %1$s\nPIN: %2$s Not sent Your GateControl server could not be reached or refused it. + Not sent yet + Your GateControl server cannot be reached right now – GateControl tries again by itself. + Show sent and received + + %d file could not be read and is not sent. + %d files could not be read and are not sent. + Someone %1$s sent you %2$s GateDrop: something new for you @@ -105,7 +113,7 @@ From %1$s - Sharing + GateDrop Received, sent and links for externals Shared Who may send to this device? diff --git a/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt b/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt index ba019ae1..52a7e9ae 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt @@ -4,6 +4,8 @@ import com.gatecontrol.android.common.VpnSubnet import com.gatecontrol.android.data.MachineFingerprint import android.content.Context import android.content.pm.ApplicationInfo +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.TypeAdapter @@ -20,7 +22,6 @@ import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.net.InetAddress -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -36,21 +37,9 @@ class ApiClientProvider @Inject constructor( private val speedTestCache = mutableMapOf() private val lock = Any() - /** - * DNS cache for VPN-safe resolution. When the VPN is active, Android's - * system DNS points to the VPN-internal resolver (10.8.0.1). The app's own - * traffic goes through the tunnel unless a per-app split tunnel leaves the - * GateControl app out; then 10.8.0.1 is unreachable for it and system DNS - * lookups fail with EAI_NODATA. Split-horizon DNS may also answer the - * server's own name with a VPN-internal address. We cache DNS results - * resolved BEFORE the VPN starts and fall back to the cache when system - * DNS fails. - */ - private val dnsCache = ConcurrentHashMap>() - /** * VPN-internal subnet of the current server (from the WireGuard config's - * Address). Addresses inside it are never usable from outside the tunnel. + * Address). Addresses inside it are only reachable through the tunnel. * Set by the connect path before the tunnel comes up. */ @Volatile @@ -59,74 +48,84 @@ class ApiClientProvider @Inject constructor( private fun isVpnInternal(addr: InetAddress): Boolean = VpnSubnet.contains(vpnSubnet, addr.hostAddress ?: "") + private val connectivity: ConnectivityManager? + get() = try { + context.getSystemService(ConnectivityManager::class.java) + } catch (_: Exception) { + null + } + /** - * Resolve and cache the server hostname. Safe to call from any thread. - * Re-resolves to pick up DNS changes, but rejects VPN-internal addresses - * (inside [vpnSubnet]) that appear when the tunnel's split-horizon DNS is active. - * Call clearDnsCache() on disconnect so the next connect starts fresh. + * Server name resolution that works with and without the tunnel (see + * [VpnSafeDns]): whether a VPN-internal answer is usable depends on the + * route of this app's own traffic — through the tunnel (no per-app + * exception for GateControl) it is the way in, outside it is not. */ - suspend fun preResolveDns(hostname: String) { - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - try { - val addresses = InetAddress.getAllByName(hostname) - if (addresses.isNotEmpty()) { - // Reject VPN-internal addresses — when the tunnel is up, - // system DNS may return the VPN gateway (10.8.0.1) instead - // of the real public IP. - if (addresses.all(::isVpnInternal)) { - timber.log.Timber.d("DNS for $hostname returned VPN-internal address — keeping cache") - return@withContext - } - - val prev = dnsCache[hostname]?.map { it.hostAddress } - dnsCache[hostname] = addresses.toList() - val resolved = addresses.map { it.hostAddress } - if (prev != null && prev != resolved) { - timber.log.Timber.i("DNS changed for $hostname: $prev -> $resolved") - } else { - timber.log.Timber.d("DNS pre-resolved: $hostname -> $resolved") - } - } - } catch (e: Exception) { - if (dnsCache.containsKey(hostname)) { - timber.log.Timber.d("DNS re-resolve failed for $hostname, keeping cached entry: ${e.message}") - } else { - timber.log.Timber.w("DNS pre-resolve failed for $hostname: ${e.message}") - } - } - } - } + private val vpnSafeDns = VpnSafeDns( + system = Dns.SYSTEM::lookup, + isVpnInternal = ::isVpnInternal, + routedThroughVpn = ::appUsesVpn, + outsideVpn = ::lookupOutsideVpn, + lastKnown = PrefsDnsLastKnown { context.getSharedPreferences(PrefsDnsLastKnown.PREFS_NAME, Context.MODE_PRIVATE) }, + ) /** - * Clear the DNS cache. Call on VPN disconnect so the next connect - * picks up any DNS changes that occurred while the tunnel was active. + * This app's own traffic goes through a VPN: its default network (per + * app — a split-tunnel exception makes it the underlying network) is one. */ - fun clearDnsCache() { - dnsCache.clear() - timber.log.Timber.d("DNS cache cleared") + private fun appUsesVpn(): Boolean { + val cm = connectivity ?: return false + val network = cm.activeNetwork ?: return false + return cm.getNetworkCapabilities(network)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true } - private val vpnSafeDns = object : Dns { - override fun lookup(hostname: String): List { - // System DNS may return VPN-internal addresses (10.8.x.x) even - // when the GateControl app is excluded from the tunnel: Android - // caches the WG-internal resolver's answer system-wide. The app - // can't reach 10.8.0.1 from outside the tunnel, so OkHttp would - // hit SocketTimeout from the local Wi-Fi IP / ECONNREFUSED via - // VPN. Filter those out and prefer the pre-resolve cache, - // which preResolveDns() guarantees never holds VPN-internal addresses. - val systemResults = try { - Dns.SYSTEM.lookup(hostname).filterNot(::isVpnInternal) + /** + * [hostname] resolved by the DNS of a network outside the VPN (validated + * networks first). The app owns the VPN and may use the underlying + * networks, like the tunnel's own socket does. + */ + private fun lookupOutsideVpn(hostname: String): List { + val cm = connectivity ?: return emptyList() + @Suppress("DEPRECATION") + val candidates = cm.allNetworks.mapNotNull { network -> + val caps = cm.getNetworkCapabilities(network) ?: return@mapNotNull null + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) return@mapNotNull null + network to caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + }.sortedByDescending { it.second }.map { it.first } + for (network in candidates) { + val found = try { + network.getAllByName(hostname).toList() } catch (_: Exception) { emptyList() } - if (systemResults.isNotEmpty()) return systemResults - return dnsCache[hostname] ?: throw java.net.UnknownHostException( - "DNS lookup failed for $hostname (system DNS returned only VPN-internal addresses, no cache)" - ) + if (found.isNotEmpty()) return found + } + return emptyList() + } + + /** + * Resolve and remember the server hostname. Safe to call from any thread. + * Re-resolves to pick up DNS changes; a VPN-internal answer (the tunnel's + * split-horizon DNS) is never kept — the network outside the VPN is asked + * instead. Call clearDnsCache() on disconnect so the next connect starts fresh. + */ + suspend fun preResolveDns(hostname: String) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + vpnSafeDns.preResolve(hostname) } } + /** + * Forget the addresses of this connection. Call on VPN disconnect so the + * next connect picks up any DNS changes that occurred while the tunnel was + * active. The last known public addresses stay (persisted) as the last + * resort after an app restart with the tunnel up. + */ + fun clearDnsCache() { + vpnSafeDns.clear() + timber.log.Timber.d("DNS cache cleared") + } + companion object { /** Headers never written to the HTTP log. */ val SENSITIVE_HEADERS = listOf( diff --git a/core/network/src/main/java/com/gatecontrol/android/network/VpnSafeDns.kt b/core/network/src/main/java/com/gatecontrol/android/network/VpnSafeDns.kt new file mode 100644 index 00000000..5e516fbc --- /dev/null +++ b/core/network/src/main/java/com/gatecontrol/android/network/VpnSafeDns.kt @@ -0,0 +1,210 @@ +package com.gatecontrol.android.network + +import android.content.SharedPreferences +import okhttp3.Dns +import timber.log.Timber +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.concurrent.ConcurrentHashMap + +/** + * Public addresses of a host that worked before, kept across app restarts + * (last resort of [VpnSafeDns] when no DNS answers with a usable address). + */ +interface DnsLastKnown { + fun get(hostname: String): List? + fun put(hostname: String, addresses: List) +} + +/** + * Name resolution of the GateControl server while the WireGuard tunnel may + * be up. The server's resolver in the tunnel (dnsmasq on the gateway) + * answers the server's own name with its VPN address (split horizon, see + * the server's entrypoint.sh): API traffic of a tunneled app is meant to go + * to the gateway through the tunnel — the public address seen from inside + * the tunnel often does not answer at all. + * + * So the rule follows the route of this app's own traffic, not a guess: + * * the app goes through a VPN (its default network is the VPN — the + * tunnel without a per-app exception for GateControl): the VPN address + * is the way in and is used first, known public addresses follow; + * * the app is left out of the tunnel (split tunnel: excluded, or not in + * the include list) or no tunnel is up: a VPN address is unreachable and + * dropped; when nothing else is left the name is resolved over the + * network outside the VPN ([outsideVpn]), then from the addresses of + * this run ([preResolve], earlier answers) and finally from + * [lastKnown], which survives a restart of the app. + * + * Pure apart from the functions passed in, unit-tested. + */ +class VpnSafeDns( + private val system: (String) -> List, + private val isVpnInternal: (InetAddress) -> Boolean, + private val routedThroughVpn: () -> Boolean, + private val outsideVpn: (String) -> List, + private val lastKnown: DnsLastKnown, +) : Dns { + + /** Public addresses learned in this run (cleared on disconnect, see [clear]). */ + private val fresh = ConcurrentHashMap>() + + override fun lookup(hostname: String): List { + val answered = try { + system(hostname) + } catch (_: Exception) { + emptyList() + } + val (internal, public) = answered.partition(isVpnInternal) + if (public.isNotEmpty()) remember(hostname, public) + if (internal.isEmpty() && public.isNotEmpty()) return public + + if (internal.isNotEmpty() && appUsesVpn()) { + // Split horizon: the tunnel's resolver points this app to the gateway through the tunnel. + return (internal + public + known(hostname)).distinctBy { it.hostAddress } + } + if (public.isNotEmpty()) return public + + fresh[hostname]?.takeIf { it.isNotEmpty() }?.let { return it } + resolveOutsideVpn(hostname)?.let { return it } + lastKnown.get(hostname)?.takeIf { it.isNotEmpty() }?.let { + Timber.i("DNS for %s: using the last known public address", hostname) + return it + } + throw UnknownHostException( + if (internal.isNotEmpty()) { + "DNS lookup failed for $hostname (system DNS returned only VPN-internal addresses, not usable outside the tunnel; no public address known)" + } else { + "DNS lookup failed for $hostname (no answer, no public address known)" + }, + ) + } + + /** + * Resolve [hostname] before the tunnel comes up (or again later) and keep + * its public addresses. A VPN-internal answer is never kept: then the + * network outside the VPN is asked. Keeps what was known on failure. + */ + fun preResolve(hostname: String) { + val answered = try { + system(hostname) + } catch (e: Exception) { + Timber.d("DNS pre-resolve of %s: %s", hostname, e.message) + emptyList() + } + val public = answered.filterNot(isVpnInternal).ifEmpty { resolveOutsideVpn(hostname).orEmpty() } + if (public.isEmpty()) { + if (fresh.containsKey(hostname) || lastKnown.get(hostname) != null) { + Timber.d("DNS pre-resolve of %s gave no public address, keeping the known one", hostname) + } else { + Timber.w("DNS pre-resolve failed for %s", hostname) + } + return + } + val prev = fresh[hostname]?.map { it.hostAddress } + remember(hostname, public) + val resolved = public.map { it.hostAddress } + if (prev != null && prev != resolved) { + Timber.i("DNS changed for %s: %s -> %s", hostname, prev, resolved) + } else { + Timber.d("DNS pre-resolved: %s -> %s", hostname, resolved) + } + } + + /** Forget the addresses of this run (on disconnect); the last known ones stay as the last resort. */ + fun clear() { + fresh.clear() + } + + private fun appUsesVpn(): Boolean = try { + routedThroughVpn() + } catch (_: Exception) { + false + } + + private fun known(hostname: String): List = + fresh[hostname] ?: lastKnown.get(hostname).orEmpty() + + private fun resolveOutsideVpn(hostname: String): List? { + val found = try { + outsideVpn(hostname).filterNot(isVpnInternal) + } catch (_: Exception) { + emptyList() + } + if (found.isEmpty()) return null + Timber.i("DNS for %s resolved outside the VPN", hostname) + remember(hostname, found) + return found + } + + private fun remember(hostname: String, addresses: List) { + fresh[hostname] = addresses + try { + lastKnown.put(hostname, addresses) + } catch (_: Exception) { + } + } +} + +/** + * [DnsLastKnown] in SharedPreferences (one entry per host, the addresses as + * IP literals). [prefs] is opened on first use; without it only this run + * remembers. + */ +class PrefsDnsLastKnown(prefs: () -> SharedPreferences?) : DnsLastKnown { + + private val prefs by lazy { + try { + prefs() + } catch (_: Exception) { + null + } + } + private val memory = ConcurrentHashMap>() + + override fun get(hostname: String): List? { + memory[hostname]?.let { return it } + val stored = try { + prefs?.getString(KEY_PREFIX + hostname, null) + } catch (_: Exception) { + null + } + return decode(hostname, stored)?.also { memory[hostname] = it } + } + + override fun put(hostname: String, addresses: List) { + if (addresses.isEmpty()) return + val value = encode(addresses) + if (get(hostname)?.let { encode(it) } == value) return + memory[hostname] = addresses + try { + prefs?.edit()?.putString(KEY_PREFIX + hostname, value)?.apply() + } catch (_: Exception) { + } + } + + companion object { + const val PREFS_NAME = "vpn_safe_dns" + private const val KEY_PREFIX = "host:" + private const val MAX_ADDRESSES = 8 + private val IPV4_RE = Regex("^\\d{1,3}(\\.\\d{1,3}){3}$") + private val IPV6_RE = Regex("^[0-9a-fA-F:.]+$") + + fun encode(addresses: List): String = + addresses.mapNotNull { it.hostAddress?.substringBefore('%') }.distinct().take(MAX_ADDRESSES).joinToString(",") + + /** The stored literals of [hostname]; anything that is not an IP literal is skipped (never a DNS lookup). */ + fun decode(hostname: String, value: String?): List? { + if (value.isNullOrBlank()) return null + val out = value.split(',').map { it.trim() }.filter { it.isNotEmpty() }.take(MAX_ADDRESSES).mapNotNull { literal -> + val isLiteral = IPV4_RE.matches(literal) || (literal.contains(':') && IPV6_RE.matches(literal)) + if (!isLiteral) return@mapNotNull null + try { + InetAddress.getByAddress(hostname, InetAddress.getByName(literal).address) + } catch (_: Exception) { + null + } + } + return out.ifEmpty { null } + } + } +} diff --git a/core/network/src/test/java/com/gatecontrol/android/network/VpnSafeDnsTest.kt b/core/network/src/test/java/com/gatecontrol/android/network/VpnSafeDnsTest.kt new file mode 100644 index 00000000..a134c714 --- /dev/null +++ b/core/network/src/test/java/com/gatecontrol/android/network/VpnSafeDnsTest.kt @@ -0,0 +1,177 @@ +package com.gatecontrol.android.network + +import com.gatecontrol.android.common.VpnSubnet +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.net.InetAddress +import java.net.UnknownHostException + +class VpnSafeDnsTest { + + private val host = "gc.example.com" + private val gateway = "10.8.0.1" + private val public = "89.163.231.212" + private val nat64 = "64:ff9b:0:0:0:0:59a3:e7d4" // as InetAddress.hostAddress writes it + + private fun addr(ip: String): InetAddress = InetAddress.getByAddress(host, InetAddress.getByName(ip).address) + + private class MemoryLastKnown : DnsLastKnown { + val map = mutableMapOf>() + var puts = 0 + override fun get(hostname: String) = map[hostname] + override fun put(hostname: String, addresses: List) { + puts++ + map[hostname] = addresses + } + } + + private class Fake { + var system: List? = emptyList() + var outside: List = emptyList() + var viaVpn = false + var outsideCalls = 0 + var routeChecks = 0 + } + + private fun dns(fake: Fake, lastKnown: DnsLastKnown = MemoryLastKnown()) = VpnSafeDns( + system = { fake.system?.map(::addr) ?: throw UnknownHostException("no answer") }, + isVpnInternal = { VpnSubnet.contains("10.8.0.0/24", it.hostAddress ?: "") }, + routedThroughVpn = { fake.routeChecks++; fake.viaVpn }, + outsideVpn = { fake.outsideCalls++; fake.outside.map(::addr) }, + lastKnown = lastKnown, + ) + + private fun List.ips() = map { it.hostAddress } + + @Test + fun `a public answer is used as it is, without asking the network state`() { + val fake = Fake().apply { system = listOf(public) } + val last = MemoryLastKnown() + assertEquals(listOf(public), dns(fake, last).lookup(host).ips()) + assertEquals(0, fake.routeChecks) + assertEquals(listOf(public), last.map[host]?.ips()) + } + + @Test + fun `app routed through the tunnel - the split-horizon VPN address is used first, known public ones follow`() { + val fake = Fake().apply { system = listOf(public) } + val dns = dns(fake) + dns.lookup(host) // learned before the tunnel came up + fake.system = listOf(gateway) + fake.viaVpn = true + assertEquals(listOf(gateway, public), dns.lookup(host).ips()) + assertEquals(0, fake.outsideCalls) + } + + @Test + fun `app routed through the tunnel - no cache after a restart is no failure`() { + // Process restarted with the tunnel up: nothing learned, nothing persisted. + val fake = Fake().apply { system = listOf(gateway); viaVpn = true } + assertEquals(listOf(gateway), dns(fake).lookup(host).ips()) + } + + @Test + fun `app excluded from the tunnel - a VPN address is dropped and the name is resolved outside the VPN`() { + val fake = Fake().apply { system = listOf(gateway); viaVpn = false; outside = listOf(public) } + val last = MemoryLastKnown() + val dns = dns(fake, last) + assertEquals(listOf(public), dns.lookup(host).ips()) + assertEquals(1, fake.outsideCalls) + assertEquals(listOf(public), last.map[host]?.ips()) + // learned: the next lookup does not ask the other network again + assertEquals(listOf(public), dns.lookup(host).ips()) + assertEquals(1, fake.outsideCalls) + } + + @Test + fun `app excluded - outside resolution fails, the persisted address of an earlier run helps`() { + val last = MemoryLastKnown().apply { map[host] = listOf(addr(public)) } + val fake = Fake().apply { system = listOf(gateway); outside = emptyList() } + assertEquals(listOf(public), dns(fake, last).lookup(host).ips()) + } + + @Test + fun `app excluded - outside answers only VPN addresses, they are not taken`() { + val fake = Fake().apply { system = listOf(gateway); outside = listOf(gateway) } + val e = assertThrows { dns(fake).lookup(host) } + assertTrue(e.message!!.contains("only VPN-internal")) + } + + @Test + fun `no answer at all - outside the VPN, then the last known address, else a clear error`() { + val fake = Fake().apply { system = null; outside = listOf(nat64) } + assertEquals(listOf(nat64), dns(fake).lookup(host).ips()) + + val last = MemoryLastKnown().apply { map[host] = listOf(addr(public)) } + val offline = Fake().apply { system = null } + assertEquals(listOf(public), dns(offline, last).lookup(host).ips()) + + val e = assertThrows { dns(Fake().apply { system = null }).lookup(host) } + assertTrue(e.message!!.contains("no answer")) + } + + @Test + fun `mixed answer outside the tunnel keeps only the public part`() { + val fake = Fake().apply { system = listOf(gateway, public); viaVpn = false } + assertEquals(listOf(public), dns(fake).lookup(host).ips()) + } + + @Test + fun `a failing network check counts as outside the tunnel`() { + val dns = VpnSafeDns( + system = { listOf(addr(gateway)) }, + isVpnInternal = { VpnSubnet.contains("10.8.0.0/24", it.hostAddress ?: "") }, + routedThroughVpn = { throw SecurityException("no access") }, + outsideVpn = { listOf(addr(public)) }, + lastKnown = MemoryLastKnown(), + ) + assertEquals(listOf(public), dns.lookup(host).ips()) + } + + @Test + fun `pre-resolve never keeps a VPN address and asks outside the VPN instead`() { + val fake = Fake().apply { system = listOf(gateway); outside = listOf(public) } + val last = MemoryLastKnown() + dns(fake, last).preResolve(host) + assertEquals(listOf(public), last.map[host]?.ips()) + + val nothing = Fake().apply { system = listOf(gateway) } + val keep = MemoryLastKnown().apply { map[host] = listOf(addr(public)) } + dns(nothing, keep).preResolve(host) + assertEquals(listOf(public), keep.map[host]?.ips()) + } + + @Test + fun `clear forgets this run but keeps the last known address`() { + val fake = Fake().apply { system = listOf(public) } + val last = MemoryLastKnown() + val dns = dns(fake, last) + dns.preResolve(host) + dns.clear() + fake.system = listOf(gateway) + assertEquals(listOf(public), dns.lookup(host).ips()) + assertEquals(1, fake.outsideCalls) + } + + @Test + fun `stored addresses round-trip as literals and never trigger a lookup`() { + val encoded = PrefsDnsLastKnown.encode(listOf(addr(public), addr(nat64), addr(public))) + assertEquals("$public,$nat64", encoded) + val decoded = PrefsDnsLastKnown.decode(host, encoded)!! + assertEquals(listOf(public, nat64), decoded.ips()) + assertEquals(host, decoded.first().hostName) + assertEquals(listOf(public), PrefsDnsLastKnown.decode(host, "evil.example.com,$public")?.ips()) + assertNull(PrefsDnsLastKnown.decode(host, "")) + assertNull(PrefsDnsLastKnown.decode(host, "not-an-ip")) + } + + @Test + fun `persisted store without preferences still remembers in memory`() { + val store = PrefsDnsLastKnown { throw IllegalStateException("no context") } + store.put(host, listOf(addr(public))) + assertEquals(listOf(public), store.get(host)?.ips()) + } +} From fc198b1c29c9b7a8ab062e210e98ae5277eee63f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 11:24:19 +0000 Subject: [PATCH 2/2] fix(network): ACCESS_NETWORK_STATE im Manifest von core:network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VpnSafeDns fragt das Standardnetz der App und die Netze außerhalb des VPN ab (ConnectivityManager); Lint verlangt die Berechtigung im Modul selbst. Die App deklarierte sie schon, am Verhalten ändert sich nichts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- core/network/src/main/AndroidManifest.xml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 core/network/src/main/AndroidManifest.xml diff --git a/core/network/src/main/AndroidManifest.xml b/core/network/src/main/AndroidManifest.xml new file mode 100644 index 00000000..64df04a0 --- /dev/null +++ b/core/network/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + +