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/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 @@ + + + + + + + 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()) + } +}