feat: SNITCH проверка (β) (#49)

This commit is contained in:
xtclovver 2026-05-07 01:21:35 +03:00
parent 859f3ca515
commit e2d2d1fb11
19 changed files with 981 additions and 3 deletions

View file

@ -2,9 +2,9 @@
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<SelectionState runConfigName="start">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-04-08T19:55:08.785572700Z">
<DropdownSelection timestamp="2026-05-06T21:44:57.162357200Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\usedcvnt\.android\avd\Pixel_9_Pro.avd" />

1
.idea/misc.xml generated
View file

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View file

@ -45,6 +45,7 @@ internal object CheckResultMarkdownExportFormatter {
appendCategorySection(builder, context.getString(R.string.main_card_direct_signs), result.directSigns, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_indirect_signs), result.indirectSigns, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_icmp_spoofing), result.icmpSpoofing, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_rtt_triangulation), result.rttTriangulation, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_location_signals), result.locationSignals, snapshot.privacyMode)
appendIpChannelsSection(builder, result.ipConsensus, snapshot.privacyMode)
appendBypassSection(builder, context, result.bypassResult, snapshot.privacyMode)
@ -142,6 +143,12 @@ internal object CheckResultMarkdownExportFormatter {
status = sectionStatusTag(result.icmpSpoofing.detected, result.icmpSpoofing.needsReview, result.icmpSpoofing.hasError),
summary = buildCategorySummary(result.icmpSpoofing, snapshot.privacyMode),
)
appendSectionSummaryRow(
builder,
title = context.getString(R.string.main_card_rtt_triangulation),
status = sectionStatusTag(result.rttTriangulation.detected, result.rttTriangulation.needsReview, result.rttTriangulation.hasError),
summary = buildCategorySummary(result.rttTriangulation, snapshot.privacyMode),
)
appendSectionSummaryRow(
builder,
title = context.getString(R.string.main_card_location_signals),

View file

@ -61,6 +61,7 @@ object DebugDiagnosticsFormatter {
appendCategory(builder, "directSigns", result.directSigns)
appendCategory(builder, "indirectSigns", result.indirectSigns)
appendCategory(builder, "icmpSpoofing", result.icmpSpoofing)
appendCategory(builder, "rttTriangulation", result.rttTriangulation)
appendIndirectPerformance(builder, result.indirectSigns)
appendCategory(builder, "locationSignals", result.locationSignals)
appendBypass(builder, result.bypassResult)

View file

@ -203,6 +203,7 @@ class MainActivity : AppCompatActivity() {
INDIRECT,
NATIVE_SIGNS,
ICMP,
RTT_TRIANGULATION,
LOCATION,
IP_CONSENSUS,
BYPASS,
@ -232,6 +233,10 @@ class MainActivity : AppCompatActivity() {
private lateinit var iconIcmpSpoofing: ImageView
private lateinit var statusIcmpSpoofing: TextView
private lateinit var findingsIcmpSpoofing: LinearLayout
private lateinit var cardRttTriangulation: MaterialCardView
private lateinit var iconRttTriangulation: ImageView
private lateinit var statusRttTriangulation: TextView
private lateinit var findingsRttTriangulation: LinearLayout
private lateinit var cardNativeSigns: MaterialCardView
private lateinit var iconNativeSigns: ImageView
private lateinit var statusNativeSigns: TextView
@ -477,6 +482,10 @@ class MainActivity : AppCompatActivity() {
iconIcmpSpoofing = findViewById(R.id.iconIcmpSpoofing)
statusIcmpSpoofing = findViewById(R.id.statusIcmpSpoofing)
findingsIcmpSpoofing = findViewById(R.id.findingsIcmpSpoofing)
cardRttTriangulation = findViewById(R.id.cardRttTriangulation)
iconRttTriangulation = findViewById(R.id.iconRttTriangulation)
statusRttTriangulation = findViewById(R.id.statusRttTriangulation)
findingsRttTriangulation = findViewById(R.id.findingsRttTriangulation)
cardNativeSigns = findViewById(R.id.cardNativeSigns)
iconNativeSigns = findViewById(R.id.iconNativeSigns)
statusNativeSigns = findViewById(R.id.statusNativeSigns)
@ -556,6 +565,7 @@ class MainActivity : AppCompatActivity() {
Triple(CATEGORY_NAT, getString(R.string.main_card_native_signs), R.drawable.ic_lock),
Triple(CATEGORY_STN, getString(R.string.main_card_call_transport), R.drawable.ic_call),
Triple(CATEGORY_ICM, getString(R.string.main_card_icmp_spoofing), R.drawable.ic_network),
Triple(CATEGORY_RTT, getString(R.string.main_card_rtt_triangulation), R.drawable.ic_pin),
Triple(CATEGORY_LOC, getString(R.string.main_card_location_signals), R.drawable.ic_location_on),
Triple(CATEGORY_BYP, getString(R.string.settings_split_tunnel), R.drawable.ic_call_split),
)
@ -595,6 +605,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_NAT -> R.id.cardNativeSigns
CATEGORY_STN -> R.id.cardCallTransport
CATEGORY_ICM -> R.id.cardIcmpSpoofing
CATEGORY_RTT -> R.id.cardRttTriangulation
CATEGORY_LOC -> R.id.cardLocation
CATEGORY_BYP -> R.id.cardBypass
else -> error("Unknown category id: $id")
@ -609,6 +620,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_NAT -> R.id.headerNativeSigns
CATEGORY_STN -> R.id.headerCallTransport
CATEGORY_ICM -> R.id.headerIcmpSpoofing
CATEGORY_RTT -> R.id.headerRttTriangulation
CATEGORY_LOC -> R.id.headerLocation
CATEGORY_BYP -> R.id.headerBypass
else -> error("Unknown category id: $id")
@ -623,6 +635,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_NAT -> R.id.headerDotNativeSigns
CATEGORY_STN -> R.id.headerDotCallTransport
CATEGORY_ICM -> R.id.headerDotIcmpSpoofing
CATEGORY_RTT -> R.id.headerDotRttTriangulation
CATEGORY_LOC -> R.id.headerDotLocation
CATEGORY_BYP -> R.id.headerDotBypass
else -> error("Unknown category id: $id")
@ -637,6 +650,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_NAT -> R.id.headerIconNativeSigns
CATEGORY_STN -> R.id.headerIconCallTransport
CATEGORY_ICM -> R.id.headerIconIcmpSpoofing
CATEGORY_RTT -> R.id.headerIconRttTriangulation
CATEGORY_LOC -> R.id.headerIconLocation
CATEGORY_BYP -> R.id.headerIconBypass
else -> error("Unknown category id: $id")
@ -651,6 +665,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_NAT -> R.id.headerTitleNativeSigns
CATEGORY_STN -> R.id.headerTitleCallTransport
CATEGORY_ICM -> R.id.headerTitleIcmpSpoofing
CATEGORY_RTT -> R.id.headerTitleRttTriangulation
CATEGORY_LOC -> R.id.headerTitleLocation
CATEGORY_BYP -> R.id.headerTitleBypass
else -> error("Unknown category id: $id")
@ -665,6 +680,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_NAT -> R.id.headerHintNativeSigns
CATEGORY_STN -> R.id.headerHintCallTransport
CATEGORY_ICM -> R.id.headerHintIcmpSpoofing
CATEGORY_RTT -> R.id.headerHintRttTriangulation
CATEGORY_LOC -> R.id.headerHintLocation
CATEGORY_BYP -> R.id.headerHintBypass
else -> error("Unknown category id: $id")
@ -679,6 +695,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_NAT -> R.id.chevronNativeSigns
CATEGORY_STN -> R.id.chevronCallTransport
CATEGORY_ICM -> R.id.chevronIcmpSpoofing
CATEGORY_RTT -> R.id.chevronRttTriangulation
CATEGORY_LOC -> R.id.chevronLocation
CATEGORY_BYP -> R.id.chevronBypass
else -> error("Unknown category id: $id")
@ -693,6 +710,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_NAT -> R.id.bodyNativeSigns
CATEGORY_STN -> R.id.bodyCallTransport
CATEGORY_ICM -> R.id.bodyIcmpSpoofing
CATEGORY_RTT -> R.id.bodyRttTriangulation
CATEGORY_LOC -> R.id.bodyLocation
CATEGORY_BYP -> R.id.bodyBypass
else -> error("Unknown category id: $id")
@ -1050,6 +1068,7 @@ class MainActivity : AppCompatActivity() {
val callTransportProbeEnabled = prefs.getBoolean(SettingsActivity.PREF_CALL_TRANSPORT_PROBE_ENABLED, false)
val cdnPullingEnabled = prefs.getBoolean(SettingsActivity.PREF_CDN_PULLING_ENABLED, false)
val cdnPullingMeduzaEnabled = prefs.getBoolean(SettingsActivity.PREF_CDN_PULLING_MEDUZA_ENABLED, true)
val rttTriangulationEnabled = prefs.getBoolean(SettingsActivity.PREF_RTT_TRIANGULATION_ENABLED, false)
val tunProbeDebugEnabled = prefs.getBoolean(SettingsActivity.PREF_TUN_PROBE_DEBUG_ENABLED, false)
val tunProbeModeOverride = com.notcvnt.rknhardering.probe.TunProbeModeOverride.fromPref(
prefs.getString(
@ -1078,6 +1097,7 @@ class MainActivity : AppCompatActivity() {
callTransportProbeEnabled = callTransportProbeEnabled,
cdnPullingEnabled = cdnPullingEnabled,
cdnPullingMeduzaEnabled = cdnPullingMeduzaEnabled,
rttTriangulationEnabled = rttTriangulationEnabled,
tunProbeDebugEnabled = tunProbeDebugEnabled,
tunProbeModeOverride = tunProbeModeOverride,
resolverConfig = resolverConfig,
@ -1246,6 +1266,9 @@ class MainActivity : AppCompatActivity() {
findingsIcmpSpoofing.removeAllViews()
findingsIcmpSpoofing.visibility = View.GONE
findingsRttTriangulation.removeAllViews()
findingsRttTriangulation.visibility = View.GONE
textNativeSignsSummary.text = ""
textNativeSignsSummary.visibility = View.GONE
findingsNativeSigns.removeAllViews()
@ -1271,6 +1294,9 @@ class MainActivity : AppCompatActivity() {
if (settings.icmpSpoofingEnabled) {
stages += RunningStage.ICMP
}
if (settings.rttTriangulationEnabled) {
stages += RunningStage.RTT_TRIANGULATION
}
}
stages += RunningStage.DIRECT
stages += RunningStage.INDIRECT
@ -1329,6 +1355,20 @@ class MainActivity : AppCompatActivity() {
updateTileFromCategory(CATEGORY_ICM, update.result)
if (animate) animateContentReveal(findingsIcmpSpoofing)
}
is CheckUpdate.RttTriangulationReady -> {
markStageCompleted(RunningStage.RTT_TRIANGULATION)
ensureCardVisible(cardRttTriangulation, animate = false)
displayCategory(
update.result,
cardRttTriangulation,
iconRttTriangulation,
statusRttTriangulation,
findingsRttTriangulation,
activeCheckPrivacyMode,
)
updateTileFromCategory(CATEGORY_RTT, update.result)
if (animate) animateContentReveal(findingsRttTriangulation)
}
is CheckUpdate.DirectSignsReady -> {
markStageCompleted(RunningStage.DIRECT)
ensureCardVisible(cardDirect, animate = false)
@ -1414,6 +1454,7 @@ class MainActivity : AppCompatActivity() {
RunningStage.INDIRECT -> CATEGORY_IND
RunningStage.NATIVE_SIGNS -> CATEGORY_NAT
RunningStage.ICMP -> CATEGORY_ICM
RunningStage.RTT_TRIANGULATION -> CATEGORY_RTT
RunningStage.LOCATION -> CATEGORY_LOC
RunningStage.IP_CONSENSUS -> CATEGORY_IPS
RunningStage.BYPASS -> CATEGORY_BYP
@ -1447,6 +1488,14 @@ class MainActivity : AppCompatActivity() {
findingsContainer = findingsIcmpSpoofing,
hint = stageLoadingMessage(stage),
)
RunningStage.RTT_TRIANGULATION -> showCategoryLoading(
stage = stage,
card = cardRttTriangulation,
icon = iconRttTriangulation,
status = statusRttTriangulation,
findingsContainer = findingsRttTriangulation,
hint = stageLoadingMessage(stage),
)
RunningStage.DIRECT -> showCategoryLoading(
stage = stage,
card = cardDirect,
@ -1569,6 +1618,13 @@ class MainActivity : AppCompatActivity() {
findingsContainer = findingsIcmpSpoofing,
message = stageStoppedMessage(stage),
)
RunningStage.RTT_TRIANGULATION -> showCategoryStopped(
card = cardRttTriangulation,
icon = iconRttTriangulation,
status = statusRttTriangulation,
findingsContainer = findingsRttTriangulation,
message = stageStoppedMessage(stage),
)
RunningStage.DIRECT -> showCategoryStopped(
card = cardDirect,
icon = iconDirect,
@ -1731,6 +1787,7 @@ class MainActivity : AppCompatActivity() {
RunningStage.IP_COMPARISON -> getString(R.string.main_loading_ip_comparison)
RunningStage.CDN_PULLING -> getString(R.string.main_loading_cdn_pulling)
RunningStage.ICMP -> getString(R.string.main_loading_icmp)
RunningStage.RTT_TRIANGULATION -> getString(R.string.main_loading_rtt_triangulation)
RunningStage.DIRECT -> getString(R.string.main_loading_direct)
RunningStage.INDIRECT -> getString(R.string.main_loading_indirect)
RunningStage.NATIVE_SIGNS -> getString(R.string.main_loading_native_signs)
@ -1753,6 +1810,7 @@ class MainActivity : AppCompatActivity() {
RunningStage.IP_COMPARISON -> cardIpComparison
RunningStage.CDN_PULLING -> cardCdnPulling
RunningStage.ICMP -> cardIcmpSpoofing
RunningStage.RTT_TRIANGULATION -> cardRttTriangulation
RunningStage.DIRECT -> cardDirect
RunningStage.INDIRECT -> cardIndirect
RunningStage.NATIVE_SIGNS -> cardNativeSigns
@ -1768,6 +1826,7 @@ class MainActivity : AppCompatActivity() {
RunningStage.IP_COMPARISON -> statusIpComparison
RunningStage.CDN_PULLING -> statusCdnPulling
RunningStage.ICMP -> statusIcmpSpoofing
RunningStage.RTT_TRIANGULATION -> statusRttTriangulation
RunningStage.DIRECT -> statusDirect
RunningStage.INDIRECT -> statusIndirect
RunningStage.NATIVE_SIGNS -> statusNativeSigns
@ -3090,6 +3149,7 @@ class MainActivity : AppCompatActivity() {
private const val CATEGORY_IND = "ind"
private const val CATEGORY_STN = "stn"
private const val CATEGORY_ICM = "icmp"
private const val CATEGORY_RTT = "rtt"
private const val CATEGORY_LOC = "loc"
private const val CATEGORY_BYP = "byp"
private const val CATEGORY_NAT = "nat"
@ -3350,6 +3410,10 @@ class MainActivity : AppCompatActivity() {
if (findingsIcmpSpoofing.childCount > 0) return
syncHintOnlyContainer(findingsIcmpSpoofing, previewMessageForCategory(id))
}
CATEGORY_RTT -> {
if (findingsRttTriangulation.childCount > 0) return
syncHintOnlyContainer(findingsRttTriangulation, previewMessageForCategory(id))
}
CATEGORY_LOC -> {
if (locationInfoSection.childCount > 0 || findingsLocation.childCount > 0) return
syncHintOnlyContainer(findingsLocation, previewMessageForCategory(id))
@ -3372,6 +3436,7 @@ class MainActivity : AppCompatActivity() {
CATEGORY_NAT -> getString(R.string.main_preview_native_signs)
CATEGORY_STN -> getString(R.string.main_preview_call_transport)
CATEGORY_ICM -> getString(R.string.main_preview_icmp)
CATEGORY_RTT -> getString(R.string.main_preview_rtt_triangulation)
CATEGORY_LOC -> getString(R.string.main_preview_location)
CATEGORY_BYP -> getString(R.string.main_preview_bypass)
else -> getString(R.string.tile_hint_placeholder)

View file

@ -93,6 +93,7 @@ class SettingsActivity : AppCompatActivity() {
const val PREF_CDN_PULLING_ENABLED = SettingsPrefs.PREF_CDN_PULLING_ENABLED
const val PREF_CDN_PULLING_MEDUZA_ENABLED = SettingsPrefs.PREF_CDN_PULLING_MEDUZA_ENABLED
const val PREF_CALL_TRANSPORT_PROBE_ENABLED = SettingsPrefs.PREF_CALL_TRANSPORT_PROBE_ENABLED
const val PREF_RTT_TRIANGULATION_ENABLED = SettingsPrefs.PREF_RTT_TRIANGULATION_ENABLED
const val PREF_TUN_PROBE_DEBUG_ENABLED = SettingsPrefs.PREF_TUN_PROBE_DEBUG_ENABLED
const val PREF_TUN_PROBE_MODE_OVERRIDE = SettingsPrefs.PREF_TUN_PROBE_MODE_OVERRIDE
const val PREF_DNS_RESOLVER_MODE = SettingsPrefs.PREF_DNS_RESOLVER_MODE

View file

@ -22,6 +22,8 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
private lateinit var switchCdnPullingMeduza: MaterialSwitch
private lateinit var cardCallTransportProbe: MaterialCardView
private lateinit var switchCallTransportProbe: MaterialSwitch
private lateinit var cardRttTriangulation: MaterialCardView
private lateinit var switchRttTriangulation: MaterialSwitch
private var suppressAutoUpdateToggleCallback = false
private var suppressCdnPullingToggleCallback = false
@ -55,6 +57,8 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
switchCdnPullingMeduza = view.findViewById(R.id.switchCdnPullingMeduza)
cardCallTransportProbe = view.findViewById(R.id.cardCallTransportProbe)
switchCallTransportProbe = view.findViewById(R.id.switchCallTransportProbe)
cardRttTriangulation = view.findViewById(R.id.cardRttTriangulation)
switchRttTriangulation = view.findViewById(R.id.switchRttTriangulation)
}
private fun loadSettings() {
@ -64,11 +68,13 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
switchCdnPulling.isChecked = prefs.getBoolean(SettingsPrefs.PREF_CDN_PULLING_ENABLED, false)
switchCdnPullingMeduza.isChecked = prefs.getBoolean(SettingsPrefs.PREF_CDN_PULLING_MEDUZA_ENABLED, true)
switchCallTransportProbe.isChecked = prefs.getBoolean(SettingsPrefs.PREF_CALL_TRANSPORT_PROBE_ENABLED, false)
switchRttTriangulation.isChecked = prefs.getBoolean(SettingsPrefs.PREF_RTT_TRIANGULATION_ENABLED, false)
updateAutoUpdateEnabled(networkRequestsEnabled)
updateCdnPullingEnabled(networkRequestsEnabled)
updateCdnPullingMeduzaVisible(switchCdnPulling.isChecked)
updateCallTransportEnabled(networkRequestsEnabled)
updateRttTriangulationEnabled(networkRequestsEnabled)
}
private fun setupListeners() {
@ -81,6 +87,7 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
updateAutoUpdateEnabled(true)
updateCdnPullingEnabled(true)
updateCallTransportEnabled(true)
updateRttTriangulationEnabled(true)
}
}
@ -106,6 +113,10 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
switchCallTransportProbe.setOnCheckedChangeListener { _, isChecked ->
prefs.edit { putBoolean(SettingsPrefs.PREF_CALL_TRANSPORT_PROBE_ENABLED, isChecked) }
}
switchRttTriangulation.setOnCheckedChangeListener { _, isChecked ->
prefs.edit { putBoolean(SettingsPrefs.PREF_RTT_TRIANGULATION_ENABLED, isChecked) }
}
}
private fun updateAutoUpdateEnabled(enabled: Boolean) {
@ -134,6 +145,11 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
setViewAndChildrenEnabled(cardCallTransportProbe, enabled)
}
private fun updateRttTriangulationEnabled(enabled: Boolean) {
cardRttTriangulation.alpha = if (enabled) 1.0f else 0.5f
setViewAndChildrenEnabled(cardRttTriangulation, enabled)
}
private fun showCdnPullingWarning() {
cdnWarningDialog = AlertDialog.Builder(requireContext())
.setTitle(R.string.settings_cdn_pulling_warning_title)
@ -172,6 +188,7 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
updateAutoUpdateEnabled(false)
updateCdnPullingEnabled(false)
updateCallTransportEnabled(false)
updateRttTriangulationEnabled(false)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
setNetworkRequestsSwitch(true)

View file

@ -13,6 +13,7 @@ internal object SettingsPrefs {
const val PREF_CDN_PULLING_ENABLED = "pref_cdn_pulling_enabled"
const val PREF_CDN_PULLING_MEDUZA_ENABLED = "pref_cdn_pulling_meduza_enabled"
const val PREF_CALL_TRANSPORT_PROBE_ENABLED = "pref_call_transport_probe_enabled"
const val PREF_RTT_TRIANGULATION_ENABLED = "pref_rtt_triangulation_enabled"
const val PREF_TUN_PROBE_DEBUG_ENABLED = "pref_tun_probe_debug_enabled"
const val PREF_TUN_PROBE_MODE_OVERRIDE = "pref_tun_probe_mode_override"
const val PREF_DNS_RESOLVER_MODE = "pref_dns_resolver_mode"

View file

@ -0,0 +1,328 @@
package com.notcvnt.rknhardering.checker
import android.content.Context
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.GeoIpFacts
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.ResolverNetworkStack
import com.notcvnt.rknhardering.probe.SystemPingProber
import com.notcvnt.rknhardering.util.HomeCountryResolver
import java.io.IOException
import java.net.Inet4Address
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
internal object RttTriangulationChecker {
private const val PING_COUNT = 4
private const val THRESHOLD_HOME_MS = 80.0
private const val JITTER_LIMIT_MS = 60.0
private val RU_TARGETS = listOf(
"yandex.ru",
"mail.ru",
"vk.com",
"sberbank.ru",
"gosuslugi.ru",
)
private val FOREIGN_TARGETS = listOf(
"facebook.com",
"github.com",
"twitter.com",
"reddit.com",
"instagram.com",
)
internal enum class TargetGroup { RU, FOREIGN }
internal data class TargetSpec(val host: String, val group: TargetGroup)
internal data class TargetOutcome(
val spec: TargetSpec,
val ip: String,
val medianRtt: Double?,
val jitter: Double?,
val sent: Int,
val received: Int,
)
internal data class Dependencies(
val resolveIpv4: (String, DnsResolverConfig) -> String = { host, resolverConfig ->
val addresses = ResolverNetworkStack.lookup(
hostname = host,
config = resolverConfig,
cancellationSignal = ScanExecutionContext.currentOrDefault().cancellationSignal,
)
val ipv4 = addresses.firstOrNull { it is Inet4Address }
?: throw IOException("No IPv4 address resolved for $host")
ipv4.hostAddress ?: throw IOException("Resolved IPv4 address is empty for $host")
},
val ping: suspend (String, Int) -> SystemPingProber.PingResult = { address, count ->
SystemPingProber.probe(address = address, count = count)
},
)
@Volatile
internal var dependenciesOverride: Dependencies? = null
suspend fun check(
context: Context,
resolverConfig: DnsResolverConfig,
geoFacts: GeoIpFacts?,
): CategoryResult = withContext(Dispatchers.IO) {
val resolved = HomeCountryResolver.resolve(context, geoFacts)
if (resolved.country == null) {
return@withContext CategoryResult(
name = "RTT triangulation",
detected = false,
needsReview = true,
findings = listOf(
Finding(
description = context.getString(R.string.checker_rtt_summary_inconclusive),
needsReview = true,
source = EvidenceSource.RTT_TRIANGULATION,
confidence = EvidenceConfidence.LOW,
),
),
)
}
if (resolved.country != "RU") {
return@withContext CategoryResult(
name = "RTT triangulation",
detected = false,
needsReview = true,
findings = listOf(
Finding(
description = context.getString(R.string.checker_rtt_summary_unsupported_country),
needsReview = true,
source = EvidenceSource.RTT_TRIANGULATION,
confidence = EvidenceConfidence.LOW,
),
),
)
}
val deps = dependenciesOverride ?: Dependencies()
val allSpecs = RU_TARGETS.map { TargetSpec(it, TargetGroup.RU) } +
FOREIGN_TARGETS.map { TargetSpec(it, TargetGroup.FOREIGN) }
val outcomes: List<TargetOutcome> = coroutineScope {
allSpecs.map { spec ->
async {
val (ip, pingResult) = try {
val address = deps.resolveIpv4(spec.host, resolverConfig)
val result = deps.ping(address, PING_COUNT)
address to result
} catch (_: Throwable) {
return@async TargetOutcome(
spec = spec,
ip = "unresolved",
medianRtt = null,
jitter = null,
sent = 0,
received = 0,
)
}
val medianRtt = if (pingResult.received > 0) pingResult.avgRttMs else null
val jitter = if (pingResult.received >= 2 &&
pingResult.maxRttMs != null &&
pingResult.minRttMs != null
) {
pingResult.maxRttMs - pingResult.minRttMs
} else {
null
}
TargetOutcome(
spec = spec,
ip = ip,
medianRtt = medianRtt,
jitter = jitter,
sent = pingResult.sent,
received = pingResult.received,
)
}
}.awaitAll()
}
val ruOutcomes = outcomes.filter { it.spec.group == TargetGroup.RU }
val foreignOutcomes = outcomes.filter { it.spec.group == TargetGroup.FOREIGN }
val homeMedian = median(ruOutcomes.mapNotNull { it.medianRtt })
val foreignMedian = median(foreignOutcomes.mapNotNull { it.medianRtt })
val allReachable = outcomes.filter { it.medianRtt != null }
val highJitterCount = allReachable.count { it.jitter != null && it.jitter > JITTER_LIMIT_MS }
val highJitterMajority = allReachable.isNotEmpty() &&
highJitterCount.toDouble() / allReachable.size > 0.5
val (detected, baseConfidence, needsReview, descriptionKey) = when {
homeMedian == null && foreignMedian == null -> Quadruple(
first = false,
second = EvidenceConfidence.MEDIUM,
third = true,
fourth = R.string.checker_rtt_summary_unavailable,
)
homeMedian != null &&
homeMedian > THRESHOLD_HOME_MS &&
(foreignMedian == null || homeMedian > foreignMedian) -> Quadruple(
first = true,
second = EvidenceConfidence.MEDIUM,
third = true,
fourth = R.string.checker_rtt_summary_detected,
)
homeMedian != null &&
homeMedian > THRESHOLD_HOME_MS &&
foreignMedian != null &&
homeMedian <= foreignMedian -> Quadruple(
first = false,
second = EvidenceConfidence.LOW,
third = true,
fourth = R.string.checker_rtt_summary_needs_review,
)
else -> Quadruple(
first = false,
second = EvidenceConfidence.MEDIUM,
third = false,
fourth = R.string.checker_rtt_summary_clean,
)
}
val finalConfidence = if (highJitterMajority) downgrade(baseConfidence) else baseConfidence
val mainFinding = Finding(
description = context.getString(descriptionKey),
detected = detected,
needsReview = needsReview,
source = EvidenceSource.RTT_TRIANGULATION,
confidence = finalConfidence,
)
val homeLabel = context.getString(R.string.checker_rtt_target_home)
val foreignLabel = context.getString(R.string.checker_rtt_target_foreign)
val unavailable = context.getString(R.string.checker_rtt_value_unavailable)
val mediansFinding = Finding(
description = context.getString(
R.string.checker_rtt_finding_medians,
homeMedian?.let { String.format(Locale.US, "%.0f", it) } ?: unavailable,
foreignMedian?.let { String.format(Locale.US, "%.0f", it) } ?: unavailable,
THRESHOLD_HOME_MS,
),
isInformational = true,
)
val perTargetFindings = outcomes.map { outcome ->
val groupLabel = when (outcome.spec.group) {
TargetGroup.RU -> homeLabel
TargetGroup.FOREIGN -> foreignLabel
}
val description = if (outcome.medianRtt != null) {
context.getString(
R.string.checker_rtt_finding_target,
groupLabel,
outcome.spec.host,
outcome.ip,
outcome.received,
outcome.sent,
outcome.medianRtt,
outcome.jitter ?: 0.0,
)
} else {
context.getString(
R.string.checker_rtt_finding_target_unreachable,
groupLabel,
outcome.spec.host,
outcome.ip,
)
}
Finding(description = description, isInformational = true)
}
val noteFindings = mutableListOf<Finding>()
if (detected) {
noteFindings += Finding(
description = context.getString(R.string.checker_rtt_finding_threshold_note),
isInformational = true,
)
}
if (highJitterMajority) {
noteFindings += Finding(
description = context.getString(R.string.checker_rtt_finding_jitter_note),
isInformational = true,
)
}
val allFindings = listOf(mainFinding, mediansFinding) + noteFindings + perTargetFindings
val evidence = outcomes.map { outcome ->
val groupLabel = when (outcome.spec.group) {
TargetGroup.RU -> context.getString(R.string.checker_rtt_target_home)
TargetGroup.FOREIGN -> context.getString(R.string.checker_rtt_target_foreign)
}
val description = if (outcome.medianRtt != null) {
context.getString(
R.string.checker_rtt_evidence_template,
"[$groupLabel] ${outcome.spec.host}",
outcome.medianRtt,
outcome.jitter ?: 0.0,
)
} else {
context.getString(
R.string.checker_rtt_evidence_unreachable,
"[$groupLabel] ${outcome.spec.host}",
)
}
val evidenceDetected = detected &&
outcome.spec.group == TargetGroup.RU &&
outcome.medianRtt != null &&
outcome.medianRtt > THRESHOLD_HOME_MS
EvidenceItem(
source = EvidenceSource.RTT_TRIANGULATION,
detected = evidenceDetected,
confidence = EvidenceConfidence.MEDIUM,
description = description,
)
}
CategoryResult(
name = "RTT triangulation",
detected = detected,
needsReview = needsReview,
findings = allFindings,
evidence = evidence,
)
}
private fun median(values: List<Double>): Double? {
if (values.isEmpty()) return null
val sorted = values.sorted()
val mid = sorted.size / 2
return if (sorted.size % 2 == 0) (sorted[mid - 1] + sorted[mid]) / 2.0 else sorted[mid]
}
private fun downgrade(confidence: EvidenceConfidence): EvidenceConfidence = when (confidence) {
EvidenceConfidence.HIGH -> EvidenceConfidence.MEDIUM
EvidenceConfidence.MEDIUM -> EvidenceConfidence.LOW
EvidenceConfidence.LOW -> EvidenceConfidence.LOW
}
private data class Quadruple(
val first: Boolean,
val second: EvidenceConfidence,
val third: Boolean,
val fourth: Int,
)
}

View file

@ -37,6 +37,7 @@ data class CheckSettings(
val cdnPullingEnabled: Boolean = false,
val cdnPullingMeduzaEnabled: Boolean = true,
val icmpSpoofingEnabled: Boolean = true,
val rttTriangulationEnabled: Boolean = false,
val tunProbeDebugEnabled: Boolean = false,
val tunProbeModeOverride: TunProbeModeOverride = TunProbeModeOverride.AUTO,
val resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
@ -50,6 +51,7 @@ sealed interface CheckUpdate {
data class IpComparisonReady(val result: IpComparisonResult) : CheckUpdate
data class CdnPullingReady(val result: CdnPullingResult) : CheckUpdate
data class IcmpSpoofingReady(val result: CategoryResult) : CheckUpdate
data class RttTriangulationReady(val result: CategoryResult) : CheckUpdate
data class DirectSignsReady(val result: CategoryResult) : CheckUpdate
data class IndirectSignsReady(val result: CategoryResult) : CheckUpdate
data class LocationSignalsReady(val result: CategoryResult) : CheckUpdate
@ -71,6 +73,8 @@ object VpnCheckRunner {
{ ctx, resolverConfig, meduzaEnabled -> CdnPullingChecker.check(ctx, resolverConfig = resolverConfig, meduzaEnabled = meduzaEnabled) },
val icmpSpoofingCheck: suspend (Context, DnsResolverConfig) -> CategoryResult =
{ ctx, resolverConfig -> IcmpSpoofingChecker.check(ctx, resolverConfig) },
val rttTriangulationCheck: suspend (Context, DnsResolverConfig, GeoIpFacts?) -> CategoryResult =
{ ctx, cfg, geo -> RttTriangulationChecker.check(ctx, cfg, geo) },
val underlyingProbe: suspend (
Context,
DnsResolverConfig,
@ -191,6 +195,18 @@ object VpnCheckRunner {
),
),
)
fun rtt(error: Throwable): CategoryResult = CategoryResult(
name = "RTT triangulation",
detected = false,
needsReview = true,
findings = listOf(
Finding(
error.message ?: error::class.java.simpleName,
isError = true,
source = EvidenceSource.RTT_TRIANGULATION,
),
),
)
fun direct(context: Context, error: Throwable): CategoryResult = CategoryResult(
name = context.getString(R.string.checker_direct_category_name),
detected = false,
@ -257,6 +273,13 @@ object VpnCheckRunner {
}
} else null
val rttTriangulationDeferred = if (settings.networkRequestsEnabled && settings.rttTriangulationEnabled) {
safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.rtt(it) }) {
val geoFacts = geoIpDeferred?.await()?.geoFacts
dependencies.rttTriangulationCheck(context, settings.resolverConfig, geoFacts)
}
} else null
val tunActiveProbeDeferred = if (settings.splitTunnelEnabled) {
safeAsync(fallback = { Fallbacks.probe(it) }) {
dependencies.underlyingProbe(
@ -341,6 +364,14 @@ object VpnCheckRunner {
}
}
}
val rttTriangulationReadyDeferred = rttTriangulationDeferred?.let { deferred ->
async {
deferred.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.RttTriangulationReady(result))
}
}
}
val directReadyDeferred = async {
directDeferred.await().also { result ->
executionContext.throwIfCancelled()
@ -399,6 +430,11 @@ object VpnCheckRunner {
detected = false,
findings = emptyList(),
)
val emptyRttTriangulation = CategoryResult(
name = context.getString(R.string.main_card_rtt_triangulation),
detected = false,
findings = emptyList(),
)
val emptyBypass = BypassResult(
proxyEndpoint = null,
proxyOwner = null,
@ -415,6 +451,7 @@ object VpnCheckRunner {
val ipComparison = ipComparisonReadyDeferred?.await() ?: emptyIpComparison
val cdnPulling = cdnPullingReadyDeferred?.await() ?: emptyCdnPulling
val icmpSpoofing = icmpSpoofingReadyDeferred?.await() ?: emptyIcmpSpoofing
val rttTriangulation = rttTriangulationReadyDeferred?.await() ?: emptyRttTriangulation
val directSigns = directReadyDeferred.await()
val indirectSigns = indirectReadyDeferred.await()
val locationSignals = locationReadyDeferred.await()
@ -463,6 +500,7 @@ object VpnCheckRunner {
tunProbeDiagnostics = tunProbeResult?.tunProbeDiagnostics,
nativeSigns = nativeSigns,
icmpSpoofing = icmpSpoofing,
rttTriangulation = rttTriangulation,
ipConsensus = ipConsensus,
)
}

View file

@ -25,6 +25,7 @@ enum class EvidenceSource {
DIRECT_NETWORK_CAPABILITIES,
INDIRECT_NETWORK_CAPABILITIES,
ICMP_SPOOFING,
RTT_TRIANGULATION,
SYSTEM_PROXY,
INSTALLED_APP,
VPN_SERVICE_DECLARATION,
@ -347,5 +348,10 @@ data class CheckResult(
detected = false,
findings = emptyList(),
),
val rttTriangulation: CategoryResult = CategoryResult(
name = "",
detected = false,
findings = emptyList(),
),
val ipConsensus: IpConsensusResult = IpConsensusResult.empty(),
)

View file

@ -0,0 +1,39 @@
package com.notcvnt.rknhardering.util
import android.content.Context
import android.telephony.TelephonyManager
import com.notcvnt.rknhardering.model.GeoIpFacts
internal object HomeCountryResolver {
data class Resolved(
val country: String?,
val sources: List<String>,
val conflict: Boolean,
)
fun resolve(context: Context, geoFacts: GeoIpFacts?): Resolved {
val geo = geoFacts?.countryCode?.trim()?.uppercase()?.takeIf { it.isNotEmpty() }
if (geo != null) {
return Resolved(geo, listOf("geoip"), conflict = false)
}
val telephony = linkedMapOf<String, String>()
runCatching {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
tm?.simCountryIso?.trim()?.uppercase()?.takeIf { it.isNotEmpty() }
?.let { telephony["sim"] = it }
tm?.networkCountryIso?.trim()?.uppercase()?.takeIf { it.isNotEmpty() }
?.let { telephony["network"] = it }
}
if (telephony.isEmpty()) return Resolved(null, emptyList(), false)
val unique = telephony.values.toSet()
return if (unique.size == 1) {
Resolved(unique.single(), telephony.keys.toList(), conflict = false)
} else {
Resolved(null, telephony.keys.toList(), conflict = true)
}
}
}

View file

@ -1204,6 +1204,139 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardRttTriangulation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
app:cardBackgroundColor="@android:color/transparent"
app:cardCornerRadius="0dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/headerRttTriangulation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_category_header_collapsed"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="48dp"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingTop="11dp"
android:paddingEnd="14dp"
android:paddingBottom="11dp">
<View
android:id="@+id/headerDotRttTriangulation"
android:layout_width="8dp"
android:layout_height="8dp"
android:background="@drawable/indicator_status_neutral" />
<ImageView
android:id="@+id/headerIconRttTriangulation"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="10dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_pin"
app:tint="?android:attr/textColorSecondary" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/headerTitleRttTriangulation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorPrimary"
android:textSize="14sp"
android:textStyle="bold"
tools:text="RTT-триангуляция" />
<TextView
android:id="@+id/headerHintRttTriangulation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:attr/textColorSecondary"
android:textSize="12sp"
tools:text="Измерение задержек RTT" />
</LinearLayout>
<ImageView
android:id="@+id/chevronRttTriangulation"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_chevron_right"
app:tint="?android:attr/textColorSecondary" />
</LinearLayout>
<LinearLayout
android:id="@+id/bodyRttTriangulation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_category_body"
android:orientation="vertical"
android:paddingStart="14dp"
android:paddingTop="4dp"
android:paddingEnd="14dp"
android:paddingBottom="14dp"
android:visibility="gone">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:visibility="gone">
<ImageView
android:id="@+id/iconRttTriangulation"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:contentDescription="@null"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/statusRttTriangulation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/findingsRttTriangulation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardLocation"
android:layout_width="match_parent"

View file

@ -246,5 +246,52 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardRttTriangulation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer"
app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_rtt_triangulation_title"
android:textColor="?android:attr/textColorPrimary"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/settings_rtt_triangulation_description"
android:textColor="?android:attr/textColorSecondary"
android:textSize="13sp" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchRttTriangulation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="false" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -197,6 +197,7 @@
<string name="main_preview_native_signs">بررسی اینترفیس‌های بومی، مسیرها و نشانه‌های سطح‌پایین کتابخانه‌ها انجام خواهد شد.</string>
<string name="main_preview_call_transport">پاسخ‌های STUN serverها و نشانه‌های نشت call transport بررسی خواهند شد.</string>
<string name="main_preview_icmp">resolve و ping میزبان‌های مسدود و کنترلی از طریق مسیر فعال انجام خواهد شد.</string>
<string name="main_preview_rtt_triangulation">سرویس‌های محلی و خارجی پینگ خواهند شد تا تاخیر مقایسه شود.</string>
<string name="main_preview_location">جمع‌آوری نشانه‌های اپراتور، دکل و Wi-Fiهای نزدیک انجام خواهد شد.</string>
<string name="main_preview_bypass">جست‌وجوی split tunnel bypass محلی و proxy endpointهای در دسترس انجام خواهد شد.</string>
@ -204,6 +205,7 @@
<string name="main_loading_ip_comparison">در حال مقایسه IP عمومی از طریق چند سرویس مستقل.</string>
<string name="main_loading_cdn_pulling">در حال بررسی اینکه endpointهای مسدود یا CDN-backed چه داده‌ای درباره شبکه شما برمی‌گردانند.</string>
<string name="main_loading_icmp">در حال resolve و ping کردن میزبان‌های مسدود و کنترلی از طریق مسیر فعال.</string>
<string name="main_loading_rtt_triangulation">در حال پینگ سرویس‌های محلی و خارجی برای مقایسه تاخیر.</string>
<string name="main_loading_direct">در حال جست‌وجوی نشانه‌های مستقیم VPN، proxy و کلاینت‌های نصب‌شده.</string>
<string name="main_loading_indirect">در حال بررسی اینترفیس‌ها، DNS، مسیرها و نشانه‌های فنی محلی.</string>
<string name="main_loading_native_signs">در حال بررسی اینترفیس‌های بومی، مسیرها و نشانه‌های سطح‌پایین کتابخانه‌ها.</string>
@ -271,6 +273,26 @@
<string name="checker_icmp_evidence_both">%1$s پاسخ داد و RTT برای %2$s پایین‌تر از آستانه مورد انتظار ماند</string>
<string name="checker_icmp_evidence_blocked_replied">%1$s روی مسیر فعال از طریق ICMP پاسخ داد</string>
<string name="checker_icmp_evidence_control_too_fast">RTT برای %1$s روی مسیر فعال پایین‌تر از آستانه مورد انتظار ماند</string>
<string name="main_card_rtt_triangulation">مثلث‌بندی RTT β</string>
<string name="main_card_rtt_triangulation_description">شناسایی VPN با مقایسهٔ تاخیر به سرویس‌های محلی و خارجی</string>
<string name="checker_rtt_summary_clean">الگوی تاخیر با منطقهٔ شما مطابقت دارد</string>
<string name="checker_rtt_summary_detected">سرویس‌های محلی کندتر از خارجی هستند — احتمال تونل</string>
<string name="checker_rtt_summary_needs_review">تاخیر غیرمعمول است اما قطعی نیست</string>
<string name="checker_rtt_summary_inconclusive">کشور خانگی شناسایی نشد</string>
<string name="checker_rtt_summary_unsupported_country">در این نسخه فقط روسیه پشتیبانی می‌شود</string>
<string name="checker_rtt_summary_unavailable">اندازه‌گیری RTT در دسترس نیست</string>
<string name="checker_rtt_target_home">محلی</string>
<string name="checker_rtt_target_foreign">خارجی</string>
<string name="checker_rtt_evidence_template">%1$s — %2$.0f ms (لرزش %3$.0f ms)</string>
<string name="checker_rtt_evidence_unreachable">%1$s — در دسترس نیست</string>
<string name="checker_rtt_finding_target">%1$s %2$s (%3$s): پاسخ‌ها %4$d/%5$d، avg %6$.0f ms، jitter %7$.0f ms</string>
<string name="checker_rtt_finding_target_unreachable">%1$s %2$s (%3$s): در دسترس نیست</string>
<string name="checker_rtt_finding_medians">میانه تاخیر: محلی %1$s ms، خارجی %2$s ms (حد %3$.0f ms)</string>
<string name="checker_rtt_finding_threshold_note">میانه محلی بالاتر از حد، یعنی ترافیک پیش از رسیدن به سرویس‌های محلی، از کشور خارج می‌شود.</string>
<string name="checker_rtt_finding_jitter_note">jitter بالا در اکثر مقاصد — اطمینان کاهش یافت.</string>
<string name="checker_rtt_value_unavailable">ندارد</string>
<string name="settings_rtt_triangulation_title">مثلث‌بندی RTT</string>
<string name="settings_rtt_triangulation_description">آزمایشی: با پینگ سرویس‌های محلی و خارجی، VPN را از طریق ناهنجاری‌های تاخیر شناسایی می‌کند. ممکن است در شبکه‌های موبایل، ماهواره‌ای، CGNAT و شلوغ نتایج اشتباه بدهد.</string>
<string name="narrative_meaning_detected">نتیجه قرمز یعنی بررسی خودکار نشانه‌های کافی از دور زدن را جمع کرده است.</string>
<string name="narrative_meaning_needs_review">نتیجه زرد یعنی نشانه‌هایی وجود دارد، اما نیاز به بررسی دستی دارد.</string>

View file

@ -197,6 +197,7 @@
<string name="main_preview_native_signs">Будет выполнена проверка нативных интерфейсов, маршрутов и низкоуровневых сигналов библиотек.</string>
<string name="main_preview_call_transport">Будут проверены ответы STUN-серверов и сигналы утечки call transport.</string>
<string name="main_preview_icmp">Будет выполнен резолв и пинг заблокированной и контрольной цели через активный маршрут.</string>
<string name="main_preview_rtt_triangulation">Будут пингнуты локальные и зарубежные сервисы для сравнения задержек.</string>
<string name="main_preview_location">Будет выполнен сбор сигналов оператора, вышек и ближайших Wi-Fi точек.</string>
<string name="main_preview_bypass">Будет выполнен поиск локального split tunnel bypass и доступных proxy endpoints.</string>
@ -204,6 +205,7 @@
<string name="main_loading_ip_comparison">Сравниваем внешний IP через несколько независимых сервисов.</string>
<string name="main_loading_cdn_pulling">Проверяем, какие сетевые данные вернут заблокированные или CDN-backed endpoints.</string>
<string name="main_loading_icmp">Резолвим и пингуем заблокированную и контрольную цели через активный маршрут.</string>
<string name="main_loading_rtt_triangulation">Пингуем локальные и зарубежные сервисы для сравнения задержек.</string>
<string name="main_loading_direct">Ищем прямые признаки VPN, прокси и установленных клиентов.</string>
<string name="main_loading_indirect">Проверяем интерфейсы, DNS, маршруты и локальные технические сигналы.</string>
<string name="main_loading_native_signs">Проверяем нативные интерфейсы, маршруты и низкоуровневые сигналы библиотек.</string>
@ -557,6 +559,26 @@
<string name="checker_icmp_evidence_both">%1$s ответил, а RTT у %2$s оказался ниже ожидаемого порога</string>
<string name="checker_icmp_evidence_blocked_replied">%1$s ответил по ICMP через активный маршрут</string>
<string name="checker_icmp_evidence_control_too_fast">%1$s показал RTT ниже ожидаемого порога на активном маршруте</string>
<string name="main_card_rtt_triangulation">RTT-триангуляция β</string>
<string name="main_card_rtt_triangulation_description">Детекция VPN по задержкам до отечественных и зарубежных сервисов</string>
<string name="checker_rtt_summary_clean">Профиль задержек соответствует региону</string>
<string name="checker_rtt_summary_detected">Локальные сервисы медленнее зарубежных — возможен туннель</string>
<string name="checker_rtt_summary_needs_review">Задержки нестандартные, требуется ручная проверка</string>
<string name="checker_rtt_summary_inconclusive">Не удалось определить домашнюю страну</string>
<string name="checker_rtt_summary_unsupported_country">RTT-триангуляция в этой сборке поддерживает только РФ</string>
<string name="checker_rtt_summary_unavailable">Замеры RTT недоступны</string>
<string name="checker_rtt_target_home">Локальный</string>
<string name="checker_rtt_target_foreign">Зарубежный</string>
<string name="checker_rtt_evidence_template">%1$s — %2$.0f мс (jitter %3$.0f мс)</string>
<string name="checker_rtt_evidence_unreachable">%1$s — недоступен</string>
<string name="checker_rtt_finding_target">%1$s %2$s (%3$s): ответов %4$d/%5$d, avg %6$.0f мс, jitter %7$.0f мс</string>
<string name="checker_rtt_finding_target_unreachable">%1$s %2$s (%3$s): недоступен</string>
<string name="checker_rtt_finding_medians">Медиана задержек: локальные %1$s мс, зарубежные %2$s мс (порог %3$.0f мс)</string>
<string name="checker_rtt_finding_threshold_note">Локальная медиана выше порога означает, что трафик выходит за пределы страны прежде чем достичь локальных сервисов.</string>
<string name="checker_rtt_finding_jitter_note">Высокий jitter у большинства таргетов — уверенность понижена.</string>
<string name="checker_rtt_value_unavailable">н/д</string>
<string name="settings_rtt_triangulation_title">RTT-триангуляция</string>
<string name="settings_rtt_triangulation_description">Эксперимент: пингует локальные и зарубежные сервисы для детекции VPN по аномалиям задержки. Возможны ложные срабатывания в мобильных, спутниковых, CGNAT и перегруженных сетях.</string>
<string name="checker_native_card_title">Нативные низкоуровневые проверки β</string>
<string name="checker_native_unavailable">Нативная библиотека не загружена; проверки недоступны.</string>
<string name="checker_native_unavailable_with_reason">Нативная библиотека не загружена: %1$s</string>

View file

@ -197,6 +197,7 @@
<string name="main_preview_native_signs">将检查原生接口、路由和底层库信号。</string>
<string name="main_preview_call_transport">将检查 STUN 服务器响应和 call transport 泄漏信号。</string>
<string name="main_preview_icmp">将通过当前活动路由解析并 ping 被封锁和对照主机。</string>
<string name="main_preview_rtt_triangulation">将 ping 本地和境外服务以比较往返延迟。</string>
<string name="main_preview_location">将收集运营商、基站和附近 Wi-Fi 的迹象。</string>
<string name="main_preview_bypass">将搜索本地 split tunnel bypass 和可用的 proxy endpoint。</string>
@ -204,6 +205,7 @@
<string name="main_loading_ip_comparison">正在通过多个独立服务比较公网 IP。</string>
<string name="main_loading_cdn_pulling">正在检查被封锁或 CDN 支持的端点会返回哪些网络数据。</string>
<string name="main_loading_icmp">正在通过当前活动路由解析并 ping 被封锁和对照主机。</string>
<string name="main_loading_rtt_triangulation">正在 ping 本地和境外服务以比较往返延迟。</string>
<string name="main_loading_direct">正在查找 VPN、代理和已安装客户端的直接迹象。</string>
<string name="main_loading_indirect">正在检查接口、DNS、路由和本地技术迹象。</string>
<string name="main_loading_native_signs">正在检查原生接口、路由和底层库信号。</string>
@ -270,6 +272,26 @@
<string name="checker_icmp_evidence_both">%1$s 已响应,且 %2$s 的 RTT 一直低于预期阈值</string>
<string name="checker_icmp_evidence_blocked_replied">%1$s 在当前活动路由上通过 ICMP 返回了响应</string>
<string name="checker_icmp_evidence_control_too_fast">%1$s 的 RTT 在当前活动路由上一直低于预期阈值</string>
<string name="main_card_rtt_triangulation">RTT 三角定位 β</string>
<string name="main_card_rtt_triangulation_description">通过比较本地与境外服务的延迟检测 VPN</string>
<string name="checker_rtt_summary_clean">延迟特征与您所在地区一致</string>
<string name="checker_rtt_summary_detected">本地服务慢于境外 — 可能存在隧道</string>
<string name="checker_rtt_summary_needs_review">延迟异常但无法确定</string>
<string name="checker_rtt_summary_inconclusive">无法确定主国家</string>
<string name="checker_rtt_summary_unsupported_country">本版本仅支持俄罗斯</string>
<string name="checker_rtt_summary_unavailable">RTT 探测不可用</string>
<string name="checker_rtt_target_home">本地</string>
<string name="checker_rtt_target_foreign">境外</string>
<string name="checker_rtt_evidence_template">%1$s — %2$.0f 毫秒 (抖动 %3$.0f 毫秒)</string>
<string name="checker_rtt_evidence_unreachable">%1$s — 无法连接</string>
<string name="checker_rtt_finding_target">%1$s %2$s (%3$s): 回应 %4$d/%5$d, avg %6$.0f 毫秒, jitter %7$.0f 毫秒</string>
<string name="checker_rtt_finding_target_unreachable">%1$s %2$s (%3$s): 无法连接</string>
<string name="checker_rtt_finding_medians">延迟中位数: 本地 %1$s 毫秒, 境外 %2$s 毫秒 (阈值 %3$.0f 毫秒)</string>
<string name="checker_rtt_finding_threshold_note">本地中位数高于阈值表示流量在到达本地服务之前已离开本国。</string>
<string name="checker_rtt_finding_jitter_note">大多数目标的 jitter 较高 — 置信度已降低。</string>
<string name="checker_rtt_value_unavailable"></string>
<string name="settings_rtt_triangulation_title">RTT 三角定位</string>
<string name="settings_rtt_triangulation_description">实验性:通过 ping 本地与境外服务,根据延迟异常检测 VPN。在移动、卫星、CGNAT 或拥塞网络中可能误报。</string>
<string name="narrative_meaning_detected">红色结论表示自动检查已经收集到足够的绕过迹象。</string>
<string name="narrative_meaning_needs_review">黄色结论表示存在一些迹象,但仍需人工复核。</string>

View file

@ -196,6 +196,7 @@
<string name="main_preview_native_signs">Native interfaces, routes, and low-level library signals will be checked.</string>
<string name="main_preview_call_transport">STUN server responses and call transport leak signals will be checked.</string>
<string name="main_preview_icmp">Blocked and control hosts will be resolved and pinged through the active route.</string>
<string name="main_preview_rtt_triangulation">Local and foreign services will be pinged to compare round-trip latency.</string>
<string name="main_preview_location">Carrier, cell tower, and nearby Wi-Fi signals will be collected.</string>
<string name="main_preview_bypass">A search for local split tunnel bypasses and available proxy endpoints will be performed.</string>
@ -203,6 +204,7 @@
<string name="main_loading_ip_comparison">Comparing the public IP through multiple independent services.</string>
<string name="main_loading_cdn_pulling">Checking what blocked or CDN-backed endpoints return about your network.</string>
<string name="main_loading_icmp">Resolving and pinging blocked and control hosts through the active route.</string>
<string name="main_loading_rtt_triangulation">Pinging local and foreign services to compare round-trip latency.</string>
<string name="main_loading_direct">Looking for direct signs of VPNs, proxies, and installed clients.</string>
<string name="main_loading_indirect">Checking interfaces, DNS, routes, and local technical signals.</string>
<string name="main_loading_native_signs">Checking native interfaces, routes, and low-level library signals.</string>
@ -552,6 +554,26 @@
<string name="checker_icmp_evidence_both">%1$s replied and %2$s RTT stayed below the expected threshold</string>
<string name="checker_icmp_evidence_blocked_replied">%1$s replied over ICMP on the active route</string>
<string name="checker_icmp_evidence_control_too_fast">%1$s RTT stayed below the expected threshold on the active route</string>
<string name="main_card_rtt_triangulation">RTT triangulation β</string>
<string name="main_card_rtt_triangulation_description">Detects VPN by comparing latency to local vs foreign services</string>
<string name="checker_rtt_summary_clean">Latency profile matches your region</string>
<string name="checker_rtt_summary_detected">Local services slower than foreign — possible tunnel</string>
<string name="checker_rtt_summary_needs_review">Latency unusual but inconclusive</string>
<string name="checker_rtt_summary_inconclusive">Home country undetermined</string>
<string name="checker_rtt_summary_unsupported_country">RTT triangulation supports RU only in this build</string>
<string name="checker_rtt_summary_unavailable">RTT probes unavailable</string>
<string name="checker_rtt_target_home">Local</string>
<string name="checker_rtt_target_foreign">Foreign</string>
<string name="checker_rtt_evidence_template">%1$s — %2$.0f ms (jitter %3$.0f ms)</string>
<string name="checker_rtt_evidence_unreachable">%1$s — unreachable</string>
<string name="checker_rtt_finding_target">%1$s %2$s (%3$s): %4$d/%5$d replies, avg %6$.0f ms, jitter %7$.0f ms</string>
<string name="checker_rtt_finding_target_unreachable">%1$s %2$s (%3$s): unreachable</string>
<string name="checker_rtt_finding_medians">Median latency: local %1$s ms, foreign %2$s ms (threshold %3$.0f ms)</string>
<string name="checker_rtt_finding_threshold_note">A local median above the threshold suggests traffic is leaving the country before reaching local services.</string>
<string name="checker_rtt_finding_jitter_note">High jitter on the majority of targets — confidence reduced.</string>
<string name="checker_rtt_value_unavailable">n/a</string>
<string name="settings_rtt_triangulation_title">RTT triangulation</string>
<string name="settings_rtt_triangulation_description">Experimental: pings local and foreign servers to detect VPN by latency anomalies. May produce false positives on cellular, satellite, CGNAT, or congested networks.</string>
<string name="checker_native_card_title">Native low-level checks β</string>
<string name="checker_native_unavailable">Native library not loaded; native checks are unavailable.</string>
<string name="checker_native_unavailable_with_reason">Native library not loaded: %1$s</string>

View file

@ -0,0 +1,208 @@
package com.notcvnt.rknhardering.checker
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.GeoIpFacts
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.probe.SystemPingProber
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.IOException
@RunWith(RobolectricTestRunner::class)
class RttTriangulationCheckerTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private val ruGeoFacts = GeoIpFacts(countryCode = "RU")
@After
fun tearDown() {
RttTriangulationChecker.dependenciesOverride = null
}
// Case 1: no geoFacts, no SIM/network (Robolectric TelephonyManager returns empty ISOs)
// → country undetermined → detected=false, needsReview=true, finding contains "country undetermined"
@Test
fun `home_country_unknown_returns_inconclusive`() = runBlocking {
val result = RttTriangulationChecker.check(
context = context,
resolverConfig = DnsResolverConfig.system(),
geoFacts = null,
)
assertFalse(result.detected)
assertTrue(result.needsReview)
assertTrue(result.findings.any { "country undetermined" in it.description })
}
// Case 2: RU 25ms, foreign 120ms, country=RU
// homeMedian=25 ≤ 80 → "no anomaly" branch → detected=false, needsReview=false
@Test
fun `home_fast_foreign_slow_clean`() = runBlocking {
RttTriangulationChecker.dependenciesOverride = uniformDependencies(
ruAvgMs = 25.0,
foreignAvgMs = 120.0,
)
val result = RttTriangulationChecker.check(
context = context,
resolverConfig = DnsResolverConfig.system(),
geoFacts = ruGeoFacts,
)
assertFalse(result.detected)
assertFalse(result.needsReview)
}
// Case 3: RU 150ms, foreign 30ms, country=RU
// homeMedian=150 > 80, foreignMedian=30, homeMedian > foreignMedian → detected=true, MEDIUM
@Test
fun `home_slow_foreign_fast_detected_medium`() = runBlocking {
RttTriangulationChecker.dependenciesOverride = uniformDependencies(
ruAvgMs = 150.0,
foreignAvgMs = 30.0,
)
val result = RttTriangulationChecker.check(
context = context,
resolverConfig = DnsResolverConfig.system(),
geoFacts = ruGeoFacts,
)
assertTrue(result.detected)
val mainFinding = result.findings.first()
assertEquals(EvidenceConfidence.MEDIUM, mainFinding.confidence)
}
// Case 4: RU 150ms, foreign 250ms, country=RU
// homeMedian=150 > 80, foreignMedian=250, homeMedian <= foreignMedian → detected=false, LOW, needsReview=true
@Test
fun `home_slow_foreign_slower_low_confidence`() = runBlocking {
RttTriangulationChecker.dependenciesOverride = uniformDependencies(
ruAvgMs = 150.0,
foreignAvgMs = 250.0,
)
val result = RttTriangulationChecker.check(
context = context,
resolverConfig = DnsResolverConfig.system(),
geoFacts = ruGeoFacts,
)
assertFalse(result.detected)
assertTrue(result.needsReview)
val mainFinding = result.findings.first()
assertEquals(EvidenceConfidence.LOW, mainFinding.confidence)
}
// Case 5: RU 150ms with high jitter, foreign 30ms with high jitter
// homeMedian=150 > foreignMedian=30 → MEDIUM before jitter check
// All 10 targets have jitter=80 > 60 → 10/10 = 100% > 50% → downgrade MEDIUM→LOW
//
// RU: avg=150, min=70, max=150 → jitter=80 > 60
// Foreign: avg=30, min=0, max=80 → jitter=80 > 60
@Test
fun `high_jitter_downgrades_confidence`() = runBlocking {
val ruHosts = setOf("yandex.ru", "mail.ru", "vk.com", "sberbank.ru", "gosuslugi.ru")
RttTriangulationChecker.dependenciesOverride = RttTriangulationChecker.Dependencies(
resolveIpv4 = { host, _ -> "10.0.${if (host in ruHosts) "1" else "2"}.1" },
ping = { address, _ ->
if (address.startsWith("10.0.1")) {
makePingResult(received = 3, avg = 150.0, min = 70.0, max = 150.0)
} else {
makePingResult(received = 3, avg = 30.0, min = 0.0, max = 80.0)
}
},
)
val result = RttTriangulationChecker.check(
context = context,
resolverConfig = DnsResolverConfig.system(),
geoFacts = ruGeoFacts,
)
assertTrue(result.detected)
val mainFinding = result.findings.first()
assertEquals(EvidenceConfidence.LOW, mainFinding.confidence)
}
// Case 6: all resolve/ping fail → homeMedian=null, foreignMedian=null → detected=false, needsReview=true, "unavailable"
@Test
fun `all_unreachable_unavailable`() = runBlocking {
RttTriangulationChecker.dependenciesOverride = RttTriangulationChecker.Dependencies(
resolveIpv4 = { host, _ -> throw IOException("network unreachable for $host") },
ping = { _, _ -> error("should not be called") },
)
val result = RttTriangulationChecker.check(
context = context,
resolverConfig = DnsResolverConfig.system(),
geoFacts = ruGeoFacts,
)
assertFalse(result.detected)
assertTrue(result.needsReview)
assertTrue(result.findings.any { "unavailable" in it.description })
}
// Case 7: countryCode="DE" → detected=false, finding contains "only RU supported"
@Test
fun `non_ru_country_inconclusive`() = runBlocking {
val deGeoFacts = GeoIpFacts(countryCode = "DE")
val result = RttTriangulationChecker.check(
context = context,
resolverConfig = DnsResolverConfig.system(),
geoFacts = deGeoFacts,
)
assertFalse(result.detected)
assertTrue(result.findings.any { "RU only" in it.description })
}
// Helpers
/**
* Builds Dependencies where all RU targets return [ruAvgMs] and all foreign targets return
* [foreignAvgMs]. Jitter is low (max - min = 10 ms) so it never triggers the downgrade rule.
*/
private fun uniformDependencies(
ruAvgMs: Double,
foreignAvgMs: Double,
): RttTriangulationChecker.Dependencies {
val ruHosts = setOf("yandex.ru", "mail.ru", "vk.com", "sberbank.ru", "gosuslugi.ru")
return RttTriangulationChecker.Dependencies(
resolveIpv4 = { host, _ -> "10.0.${if (host in ruHosts) "1" else "2"}.1" },
ping = { address, _ ->
val avg = if (address.startsWith("10.0.1")) ruAvgMs else foreignAvgMs
// jitter = max - min = 10 ms ≤ 60 → no downgrade
makePingResult(received = 3, avg = avg, min = avg - 5.0, max = avg + 5.0)
},
)
}
private fun makePingResult(
received: Int,
avg: Double? = null,
min: Double? = null,
max: Double? = null,
): SystemPingProber.PingResult {
return SystemPingProber.PingResult(
address = "0.0.0.0",
sent = 4,
received = received,
minRttMs = min,
avgRttMs = avg,
maxRttMs = max,
exitCode = if (received > 0) 0 else 1,
rawOutput = "",
)
}
}