diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java index 4d17fb59f..5cfe5b82c 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java @@ -2402,9 +2402,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe sAppliedGeoExclusionsV6 = mGeoExcludedV6; - if (profile.getDnsServers() != null) + /* F_Protection: a chosen resolver (filtering or not) wins over the profile's */ + java.util.List chosenDns = org.strongswan.android.logic.protection.Protection.dnsServers(CharonVpnService.this); + String dnsServers = chosenDns != null ? android.text.TextUtils.join(" ", chosenDns) : profile.getDnsServers(); + if (dnsServers != null) { - for (String server : profile.getDnsServers().split("\\s+")) + for (String server : dnsServers.split("\\s+")) { try { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/Protection.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/Protection.kt new file mode 100644 index 000000000..32294b62e --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/protection/Protection.kt @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.protection + +import android.content.Context +import androidx.preference.PreferenceManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** The DNS server of the tunnel (F_Protection «DNS server»). */ +enum class DnsChoice { PROFILE, CLOUDFLARE, ADGUARD, CUSTOM } + +/** + * DNS and blocking (F_Protection). The blocking is done by the resolver + * itself, so it follows from the choice: AdGuard DNS blocks ads, trackers + * and phishing together, Cloudflare's 1.1.1.2 blocks malware and phishing + * only. The servers replace the profile's ones on the next connect. + */ +data class ProtectionState( + val choice: DnsChoice = DnsChoice.PROFILE, + /** with CLOUDFLARE: the malware / phishing filtering resolver */ + val phishingFilter: Boolean = false, + val custom: String = "", +) +{ + val blocksAds: Boolean get() = choice == DnsChoice.ADGUARD + val blocksTrackers: Boolean get() = choice == DnsChoice.ADGUARD + val blocksPhishing: Boolean get() = choice == DnsChoice.ADGUARD || (choice == DnsChoice.CLOUDFLARE && phishingFilter) + + /** null: keep the profile's (or the server's) DNS */ + fun servers(): List? = when (choice) + { + DnsChoice.PROFILE -> null + DnsChoice.ADGUARD -> listOf("94.140.14.14", "94.140.15.15", "2a10:50c0::ad1:ff", "2a10:50c0::ad2:ff") + DnsChoice.CLOUDFLARE -> if (phishingFilter) listOf("1.1.1.2", "1.0.0.2", "2606:4700:4700::1112", "2606:4700:4700::1002") + else listOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001") + DnsChoice.CUSTOM -> parseCustom(custom).takeIf { it.isNotEmpty() } + } + + fun withAds(on: Boolean): ProtectionState = when + { + on -> copy(choice = DnsChoice.ADGUARD) + choice == DnsChoice.ADGUARD -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = true) + else -> this + } + + fun withPhishing(on: Boolean): ProtectionState = when + { + on && choice == DnsChoice.ADGUARD -> this + on -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = true) + choice == DnsChoice.ADGUARD -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = false) + else -> copy(phishingFilter = false) + } + + companion object + { + /** «1.2.3.4, 2001:db8::1» → the addresses that parse as IPs */ + fun parseCustom(text: String): List = text.split(',', ' ', ';').map { it.trim() }.filter { it.isNotEmpty() }.filter { isIp(it) } + + private fun isIp(s: String): Boolean = + Regex("""^(\d{1,3})(\.\d{1,3}){3}$""").matches(s) && s.split('.').all { it.toInt() in 0..255 } || + (s.contains(':') && Regex("""^[0-9a-fA-F:]+$""").matches(s)) + } +} + +object Protection +{ + private const val PREF_CHOICE = "pref_dns_choice" + private const val PREF_PHISHING = "pref_dns_phishing" + private const val PREF_CUSTOM = "pref_dns_custom" + + private val _state = MutableStateFlow(null) + + @JvmStatic + fun get(context: Context): ProtectionState + { + _state.value?.let { return it } + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val state = ProtectionState( + choice = runCatching { DnsChoice.valueOf(prefs.getString(PREF_CHOICE, DnsChoice.PROFILE.name)!!) }.getOrDefault(DnsChoice.PROFILE), + phishingFilter = prefs.getBoolean(PREF_PHISHING, false), + custom = prefs.getString(PREF_CUSTOM, "") ?: "", + ) + _state.value = state + return state + } + + fun flow(context: Context): StateFlow + { + get(context) + return _state + } + + fun set(context: Context, state: ProtectionState) + { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(PREF_CHOICE, state.choice.name).putBoolean(PREF_PHISHING, state.phishingFilter).putString(PREF_CUSTOM, state.custom).apply() + _state.value = state + } + + /** For CharonVpnService: the servers replacing the profile's ones, or null. */ + @JvmStatic + fun dnsServers(context: Context): List? = get(context).servers() +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt index 053f2bfef..02f0f0584 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt @@ -580,3 +580,120 @@ internal fun InstallPermissionSheet(onOpen: () -> Unit, onDismiss: () -> Unit) } } } + +@Composable +private fun dnsName(choice: org.strongswan.android.logic.protection.DnsChoice): String = when (choice) +{ + org.strongswan.android.logic.protection.DnsChoice.PROFILE -> stringResource(R.string.prot_profile) + org.strongswan.android.logic.protection.DnsChoice.CLOUDFLARE -> "Cloudflare" + org.strongswan.android.logic.protection.DnsChoice.ADGUARD -> "AdGuard DNS" + org.strongswan.android.logic.protection.DnsChoice.CUSTOM -> stringResource(R.string.prot_custom) +} + +/** «Cloudflare · blocking on» line of the settings list. */ +@Composable +internal fun protectionSub(state: org.strongswan.android.logic.protection.ProtectionState?): String +{ + state ?: return "" + val blocking = state.blocksAds || state.blocksPhishing + return dnsName(state.choice) + " · " + stringResource(if (blocking) R.string.prot_on else R.string.prot_off).lowercase() +} + +/** «DNS and ads» (F_Protection): what is blocked, the blocking switches, the DNS server. */ +@Composable +internal fun ProtectionPage() +{ + val c = LocalShxColors.current + val context = LocalContext.current + val stateNullable by remember { org.strongswan.android.logic.protection.Protection.flow(context) }.collectAsStateWithLifecycle() + val state = stateNullable ?: return + var custom by remember { mutableStateOf(state.custom) } + val applies = stringResource(R.string.prot_applies) + fun update(next: org.strongswan.android.logic.protection.ProtectionState) + { + if (next == state) return + org.strongswan.android.logic.protection.Protection.set(context, next) + /* the TUN device gets its DNS servers when it is built */ + val service = org.strongswan.android.logic.VpnStateService.getLiveInstance() + if (service != null && service.state == org.strongswan.android.logic.VpnStateService.State.CONNECTED) service.reconnect() + else android.widget.Toast.makeText(context, applies, android.widget.Toast.LENGTH_SHORT).show() + } + Column(Modifier.padding(horizontal = 16.dp).padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) + { + /* what the resolver blocks (the app cannot count the blocked queries: the resolver answers them) */ + val blocked = listOfNotNull( + if (state.blocksAds) stringResource(R.string.prot_list_ads) else null, + if (state.blocksTrackers) stringResource(R.string.prot_list_trackers) else null, + if (state.blocksPhishing) stringResource(R.string.prot_list_phishing) else null, + ) + val on = blocked.isNotEmpty() + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(if (on) c.successContainer else c.tile).padding(18.dp), + ) + { + Text(stringResource(if (on) R.string.prot_on else R.string.prot_off), style = ShxType.chip, color = if (on) c.onSuccess else c.textSoft) + Text(dnsName(state.choice), style = ShxType.number.copy(fontSize = 30.sp, lineHeight = 34.sp), color = c.text) + Text( + if (on) stringResource(R.string.prot_what, blocked.joinToString(" · ")) else stringResource(R.string.prot_none), + style = ShxType.chip.copy(fontWeight = FontWeight.SemiBold), color = if (on) c.onSuccess else c.textSub, + ) + } + ShxSectionHeader(stringResource(R.string.prot_block).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp)) + ShxPanel() + { + ShxToggleRow(stringResource(R.string.prot_ads), state.blocksAds, { update(state.withAds(it)) }, subtitle = stringResource(R.string.prot_ads_sub), divider = true) + ShxToggleRow(stringResource(R.string.prot_trackers), state.blocksTrackers, { update(state.withAds(it)) }, subtitle = stringResource(R.string.prot_trackers_sub), divider = true) + ShxToggleRow(stringResource(R.string.prot_phishing), state.blocksPhishing, { update(state.withPhishing(it)) }, subtitle = stringResource(R.string.prot_phishing_sub)) + } + Text(stringResource(R.string.prot_together), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp)) + ShxSectionHeader(stringResource(R.string.prot_dns).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp)) + ShxPanel() + { + val rows = listOf( + org.strongswan.android.logic.protection.DnsChoice.PROFILE to (stringResource(R.string.prot_profile) to stringResource(R.string.prot_profile_sub)), + org.strongswan.android.logic.protection.DnsChoice.CLOUDFLARE to ("Cloudflare" to stringResource(R.string.prot_cloudflare_sub)), + org.strongswan.android.logic.protection.DnsChoice.ADGUARD to ("AdGuard DNS" to stringResource(R.string.prot_adguard_sub)), + org.strongswan.android.logic.protection.DnsChoice.CUSTOM to (stringResource(R.string.prot_custom) to stringResource(R.string.prot_custom_sub)), + ) + rows.forEachIndexed { i, (choice, texts) -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().height(62.dp).selectable(selected = state.choice == choice, role = Role.RadioButton) { + if (choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM && org.strongswan.android.logic.protection.ProtectionState.parseCustom(custom).isEmpty()) + { /* the field below takes the address first */ + org.strongswan.android.logic.protection.Protection.set(context, state.copy(choice = choice)) + } + else update(state.copy(choice = choice, phishingFilter = if (choice == org.strongswan.android.logic.protection.DnsChoice.CLOUDFLARE) state.phishingFilter else false)) + }.padding(horizontal = 16.dp), + ) + { + androidx.compose.material3.RadioButton(selected = state.choice == choice, onClick = null, colors = androidx.compose.material3.RadioButtonDefaults.colors(selectedColor = c.accent)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) + { + Text(texts.first, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text) + Text(texts.second, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub) + } + } + if (i < rows.lastIndex) + { + Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile)) + } + } + if (state.choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM) + { + Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) + { + org.strongswan.android.ui.compose.design.ShxFormField(label = stringResource(R.string.prot_custom), value = custom, onValueChange = { custom = it }, placeholder = "9.9.9.9") + ShxButton( + stringResource(R.string.server_save), { update(state.copy(custom = custom)) }, + style = ShxButtonStyle.TONAL, height = 44.dp, modifier = Modifier.fillMaxWidth(), + enabled = org.strongswan.android.logic.protection.ProtectionState.parseCustom(custom).isNotEmpty(), + ) + } + } + } + Text(stringResource(R.string.prot_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp)) + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt index 0c6bbd291..04fef9804 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt @@ -400,6 +400,11 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) } } + if (page == SettingsPage.PROTECTION) + { + ProtectionPage() + } + if (page == SettingsPage.NOTIFICATIONS) { NotifSettingsPage() @@ -724,6 +729,7 @@ enum class SettingsPage(val titleRes: Int) KILLSWITCH(R.string.settings_killswitch), HELP(R.string.settings_help), NOTIFICATIONS(R.string.settings_notifications), + PROTECTION(R.string.prot_title), } /** One row of the settings list (F_Settings): 62dp, 36dp grey icon well, 15/700 title, 12 sub. */ @@ -758,6 +764,7 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat val certs = remember { runCatching { TrustedCertificateManager.getInstance().load().getAllCACertificates().size }.getOrDefault(0) } val scenarioList by remember { org.strongswan.android.logic.scenarios.ScenarioEngine.flow(context) }.collectAsStateWithLifecycle() val scenarioCount = scenarioList.count { it.enabled } + val protection by remember { org.strongswan.android.logic.protection.Protection.flow(context) }.collectAsStateWithLifecycle() /* «2 from profiles · 1 expires soon» */ val certSummary by produceState(0 to 0) { value = withContext(Dispatchers.IO) { runCatching { org.strongswan.android.ui.certificateSummary(context) }.getOrDefault(0 to 0) } @@ -783,6 +790,11 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat ) { context.startActivity(Intent(context, ConnectivityCheckActivity::class.java)) }, ), stringResource(R.string.settings_protection) to listOf( + SettingsEntry( + stringResource(R.string.prot_title), + protectionSub(protection), + ShxIcons.Shield, + ) { onOpen(SettingsPage.PROTECTION) }, SettingsEntry( stringResource(R.string.settings_killswitch), stringResource(if (lockdown) R.string.settings_killswitch_on else R.string.settings_killswitch_off), diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index 89a33ea11..4f9f67ed6 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -1292,4 +1292,30 @@ Не надо Обновить профиль Отключить VPN + DNS и реклама + Блокировка включена + Блокировка выключена + через %1$s + Блокируются: %1$s + Сайты открываются без фильтрации + Блокировать + Рекламу + Баннеры в браузере и приложениях + Трекеры + Аналитика и сбор данных + Фишинг + Поддельные сайты банков и сервисов + Реклама и трекеры блокируются вместе — через AdGuard DNS; только фишинг — через Cloudflare 1.1.1.2. + DNS-сервер + Из профиля сервера + Как настроил администратор + 1.1.1.1 · через туннель + Со встроенной фильтрацией + Свой адрес + IPv4 или IPv6 через запятую + Запросы к этому DNS идут через туннель — провайдер не сможет подменить ответ. Приложения, которые идут мимо VPN, используют DNS сети. + Применится при переподключении + реклама + трекеры + фишинг diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index db975a287..b94a63c7b 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1278,4 +1278,30 @@ No thanks Update profile Turn VPN off + DNS and ads + Blocking is on + Blocking is off + by %1$s + Blocked: %1$s + Sites resolve without filtering + Block + Ads + Banners in the browser and apps + Trackers + Analytics and data collection + Phishing + Fake bank and service sites + Ads and trackers are blocked together by AdGuard DNS; phishing alone by Cloudflare 1.1.1.2. + DNS server + From the server profile + As the admin set it up + 1.1.1.1 · through the tunnel + With built-in filtering + Own address + IPv4 or IPv6, comma separated + Queries to this DNS go through the tunnel — the provider cannot change the answer. Apps that bypass the VPN use the network\'s DNS. + Applies on reconnect + ads + trackers + phishing diff --git a/src/frontends/android/app/src/test/java/org/strongswan/android/logic/protection/ProtectionTest.kt b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/protection/ProtectionTest.kt new file mode 100644 index 000000000..4f0cc3ca0 --- /dev/null +++ b/src/frontends/android/app/src/test/java/org/strongswan/android/logic/protection/ProtectionTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + */ + +package org.strongswan.android.logic.protection + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class ProtectionTest +{ + @Test + fun profileKeepsItsDns() + { + assertThat(ProtectionState().servers()).isNull() + } + + @Test + fun adsMeanAdGuard() + { + val s = ProtectionState().withAds(true) + assertThat(s.choice).isEqualTo(DnsChoice.ADGUARD) + assertThat(s.blocksTrackers && s.blocksPhishing).isTrue() + assertThat(s.servers()).contains("94.140.14.14") + } + + @Test + fun adsOffKeepsPhishing() + { + val s = ProtectionState().withAds(true).withAds(false) + assertThat(s.choice).isEqualTo(DnsChoice.CLOUDFLARE) + assertThat(s.blocksAds).isFalse() + assertThat(s.blocksPhishing).isTrue() + assertThat(s.servers()).contains("1.1.1.2") + } + + @Test + fun phishingAloneIsCloudflareFiltering() + { + val s = ProtectionState().withPhishing(true) + assertThat(s.servers()).containsExactly("1.1.1.2", "1.0.0.2", "2606:4700:4700::1112", "2606:4700:4700::1002") + assertThat(s.withPhishing(false).servers()).contains("1.1.1.1") + } + + @Test + fun customAddresses() + { + assertThat(ProtectionState.parseCustom("9.9.9.9, 2620:fe::fe, bad, 300.1.1.1")).containsExactly("9.9.9.9", "2620:fe::fe") + assertThat(ProtectionState(choice = DnsChoice.CUSTOM, custom = "x").servers()).isNull() + } +}