android: DNS and ads as in the Final 2026 mockup: filtering resolvers (AdGuard, Cloudflare 1.1.1.2), own DNS, applied to the tunnel

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 16:04:01 +07:00
1 parent dcf8ace6e1
commit 8880601ef6
7 files changed
+358 -2

No files matched your search

@@ -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<String> 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
{
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* 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<String>? = 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<String> = 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<ProtectionState?>(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<ProtectionState?>
{
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<String>? = get(context).servers()
}
@@ -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))
}
}
@@ -400,6 +400,11 @@ fun SettingsScreen(profiles: List<VpnProfile>, 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),
@@ -1292,4 +1292,30 @@
<string name="suggest_no">Не надо</string>
<string name="home_problem_update_profile">Обновить профиль</string>
<string name="home_stop_vpn">Отключить VPN</string>
<string name="prot_title">DNS и реклама</string>
<string name="prot_on">Блокировка включена</string>
<string name="prot_off">Блокировка выключена</string>
<string name="prot_by">через %1$s</string>
<string name="prot_what">Блокируются: %1$s</string>
<string name="prot_none">Сайты открываются без фильтрации</string>
<string name="prot_block">Блокировать</string>
<string name="prot_ads">Рекламу</string>
<string name="prot_ads_sub">Баннеры в браузере и приложениях</string>
<string name="prot_trackers">Трекеры</string>
<string name="prot_trackers_sub">Аналитика и сбор данных</string>
<string name="prot_phishing">Фишинг</string>
<string name="prot_phishing_sub">Поддельные сайты банков и сервисов</string>
<string name="prot_together">Реклама и трекеры блокируются вместе — через AdGuard DNS; только фишинг — через Cloudflare 1.1.1.2.</string>
<string name="prot_dns">DNS-сервер</string>
<string name="prot_profile">Из профиля сервера</string>
<string name="prot_profile_sub">Как настроил администратор</string>
<string name="prot_cloudflare_sub">1.1.1.1 · через туннель</string>
<string name="prot_adguard_sub">Со встроенной фильтрацией</string>
<string name="prot_custom">Свой адрес</string>
<string name="prot_custom_sub">IPv4 или IPv6 через запятую</string>
<string name="prot_note">Запросы к этому DNS идут через туннель — провайдер не сможет подменить ответ. Приложения, которые идут мимо VPN, используют DNS сети.</string>
<string name="prot_applies">Применится при переподключении</string>
<string name="prot_list_ads">реклама</string>
<string name="prot_list_trackers">трекеры</string>
<string name="prot_list_phishing">фишинг</string>
</resources>
@@ -1278,4 +1278,30 @@
<string name="suggest_no">No thanks</string>
<string name="home_problem_update_profile">Update profile</string>
<string name="home_stop_vpn">Turn VPN off</string>
<string name="prot_title">DNS and ads</string>
<string name="prot_on">Blocking is on</string>
<string name="prot_off">Blocking is off</string>
<string name="prot_by">by %1$s</string>
<string name="prot_what">Blocked: %1$s</string>
<string name="prot_none">Sites resolve without filtering</string>
<string name="prot_block">Block</string>
<string name="prot_ads">Ads</string>
<string name="prot_ads_sub">Banners in the browser and apps</string>
<string name="prot_trackers">Trackers</string>
<string name="prot_trackers_sub">Analytics and data collection</string>
<string name="prot_phishing">Phishing</string>
<string name="prot_phishing_sub">Fake bank and service sites</string>
<string name="prot_together">Ads and trackers are blocked together by AdGuard DNS; phishing alone by Cloudflare 1.1.1.2.</string>
<string name="prot_dns">DNS server</string>
<string name="prot_profile">From the server profile</string>
<string name="prot_profile_sub">As the admin set it up</string>
<string name="prot_cloudflare_sub">1.1.1.1 · through the tunnel</string>
<string name="prot_adguard_sub">With built-in filtering</string>
<string name="prot_custom">Own address</string>
<string name="prot_custom_sub">IPv4 or IPv6, comma separated</string>
<string name="prot_note">Queries to this DNS go through the tunnel — the provider cannot change the answer. Apps that bypass the VPN use the network\'s DNS.</string>
<string name="prot_applies">Applies on reconnect</string>
<string name="prot_list_ads">ads</string>
<string name="prot_list_trackers">trackers</string>
<string name="prot_list_phishing">phishing</string>
</resources>
@@ -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()
}
}