fix: Исправлено ложноположительное срабатывание при роуминге иностранной SIM в России (issue #63)
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
xtclovver 2026-05-08 16:32:49 +03:00
parent 562b13e729
commit dd33d2f03f
26 changed files with 929 additions and 43 deletions

View file

@ -29,8 +29,8 @@ android {
applicationId = "com.notcvnt.rknhardering"
minSdk = 26
targetSdk = 36
versionCode = 20700
versionName = "2.7.0"
versionCode = 20701
versionName = "2.7.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
androidResources.localeFilters += listOf("en", "ru", "fa", "zh-rCN")

View file

@ -14,6 +14,7 @@ import com.notcvnt.rknhardering.model.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpCheckerResponse
import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.LocationSignalsFacts
import com.notcvnt.rknhardering.model.LocalProxyCheckResult
import com.notcvnt.rknhardering.model.LocalProxyOwner
import com.notcvnt.rknhardering.model.MatchedVpnApp
@ -74,6 +75,35 @@ internal object CheckResultJsonExportFormatter {
},
)
put("reasons", jsonArray(narrative.reasonRows))
narrative.homeRoutedRoamingNote?.let { put("homeRoutedRoamingNote", it) }
val locationFacts = snapshot.result.locationSignals.locationFacts
if (locationFacts != null) {
put("homeRoutedRoaming", locationFacts.homeRoutedRoaming)
put(
"roamingDiagnostics",
JSONObject().apply {
put("networkMcc", locationFacts.networkMcc)
put("networkMnc", locationFacts.networkMnc)
put("networkCountryIso", locationFacts.networkCountryIso)
put("networkIsRussia", locationFacts.networkIsRussia)
put("homeSimMcc", locationFacts.homeSimMcc)
put("homeSimMnc", locationFacts.homeSimMnc)
put("homeSimCountryIso", locationFacts.homeSimCountryIso)
put("homeSimCountryIsRussia", locationFacts.homeSimCountryIsRussia)
put("homeSimOperatorName", locationFacts.homeSimOperatorName?.let {
maskExportValue(it, snapshot.privacyMode)
})
put("anySimReportedRoaming", locationFacts.anySimReportedRoaming)
put("homeRoutedRoamingReason", locationFacts.homeRoutedRoamingReason)
val expectedExit = snapshot.result.geoIp.geoFacts?.expectedRoamingExit == true
put("expectedRoamingExit", expectedExit)
put(
"expectedRoamingExitReason",
snapshot.result.geoIp.geoFacts?.expectedRoamingExitReason,
)
},
)
}
},
)
root.put(
@ -111,6 +141,7 @@ internal object CheckResultJsonExportFormatter {
put("callTransportLeaks", JSONArray().apply { category.callTransportLeaks.forEach { put(callTransportLeakToJson(it, privacyMode)) } })
put("stunProbeGroups", JSONArray().apply { category.stunProbeGroups.forEach { put(stunProbeGroupToJson(it, privacyMode)) } })
category.geoFacts?.let { put("geoFacts", geoFactsToJson(it, privacyMode)) }
category.locationFacts?.let { put("locationFacts", locationFactsToJson(it, privacyMode)) }
}
}
@ -119,10 +150,33 @@ internal object CheckResultJsonExportFormatter {
put("ip", maskExportIp(facts.ip, privacyMode))
put("countryCode", facts.countryCode)
put("asn", facts.asn?.let { maskExportValue(it, privacyMode) })
put("asnCode", facts.asnCode)
put("isp", facts.isp?.let { maskExportValue(it, privacyMode) })
put("org", facts.org?.let { maskExportValue(it, privacyMode) })
put("outsideRu", facts.outsideRu)
put("hosting", facts.hosting)
put("proxyDb", facts.proxyDb)
put("fetchError", facts.fetchError)
put("expectedRoamingExit", facts.expectedRoamingExit)
put("expectedRoamingExitReason", facts.expectedRoamingExitReason)
}
}
private fun locationFactsToJson(facts: LocationSignalsFacts, privacyMode: Boolean): JSONObject {
return JSONObject().apply {
put("networkMcc", facts.networkMcc)
put("networkMnc", facts.networkMnc)
put("networkCountryIso", facts.networkCountryIso)
put("networkOperatorName", facts.networkOperatorName?.let { maskExportValue(it, privacyMode) })
put("networkIsRussia", facts.networkIsRussia)
put("homeSimMcc", facts.homeSimMcc)
put("homeSimMnc", facts.homeSimMnc)
put("homeSimCountryIso", facts.homeSimCountryIso)
put("homeSimCountryIsRussia", facts.homeSimCountryIsRussia)
put("homeSimOperatorName", facts.homeSimOperatorName?.let { maskExportValue(it, privacyMode) })
put("anySimReportedRoaming", facts.anySimReportedRoaming)
put("homeRoutedRoaming", facts.homeRoutedRoaming)
put("homeRoutedRoamingReason", facts.homeRoutedRoamingReason)
}
}

View file

@ -273,6 +273,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var iconVerdict: ImageView
private lateinit var textVerdict: TextView
private lateinit var textVerdictExplanation: TextView
private lateinit var textVerdictHomeRoutedRoamingNote: TextView
private lateinit var btnVerdictDetails: MaterialButton
private lateinit var verdictDetailsDivider: View
private lateinit var verdictDetailsContent: LinearLayout
@ -522,6 +523,7 @@ class MainActivity : AppCompatActivity() {
iconVerdict = findViewById(R.id.iconVerdict)
textVerdict = findViewById(R.id.textVerdict)
textVerdictExplanation = findViewById(R.id.textVerdictExplanation)
textVerdictHomeRoutedRoamingNote = findViewById(R.id.textVerdictHomeRoutedRoamingNote)
btnVerdictDetails = findViewById(R.id.btnVerdictDetails)
verdictDetailsDivider = findViewById(R.id.verdictDetailsDivider)
verdictDetailsContent = findViewById(R.id.verdictDetailsContent)
@ -3019,6 +3021,19 @@ class MainActivity : AppCompatActivity() {
textVerdictExplanation.text = narrative.explanation
textVerdictExplanation.visibility = View.VISIBLE
val note = narrative.homeRoutedRoamingNote
if (note != null) {
textVerdictHomeRoutedRoamingNote.text = note
textVerdictHomeRoutedRoamingNote.visibility = View.VISIBLE
textVerdictHomeRoutedRoamingNote.setTextColor(onSurfaceColor())
textVerdictHomeRoutedRoamingNote.setBackgroundResource(
R.drawable.bg_verdict_home_routed_roaming_note,
)
} else {
textVerdictHomeRoutedRoamingNote.text = ""
textVerdictHomeRoutedRoamingNote.visibility = View.GONE
}
verdictDetailsContent.removeAllViews()
addVerdictSection(
title = getString(R.string.main_verdict_section_meaning),
@ -3111,6 +3126,8 @@ class MainActivity : AppCompatActivity() {
textVerdict.text = ""
textVerdictExplanation.text = ""
textVerdictExplanation.visibility = View.GONE
textVerdictHomeRoutedRoamingNote.text = ""
textVerdictHomeRoutedRoamingNote.visibility = View.GONE
verdictDetailsDivider.visibility = View.GONE
btnVerdictDetails.visibility = View.GONE
btnVerdictDetails.text = getString(R.string.main_verdict_details)

View file

@ -40,6 +40,7 @@ data class VerdictNarrative(
val meaningRows: List<String>,
val discoveredRows: List<NarrativeRow>,
val reasonRows: List<String>,
val homeRoutedRoamingNote: String? = null,
)
object VerdictNarrativeBuilder {
@ -57,9 +58,30 @@ object VerdictNarrativeBuilder {
meaningRows = buildMeaningRows(context, result.verdict, exposureStatus),
discoveredRows = buildDiscoveredRows(context, snapshot, exposureStatus, privacyMode),
reasonRows = buildReasonRows(context, result),
homeRoutedRoamingNote = buildHomeRoutedRoamingNote(context, result),
)
}
private fun buildHomeRoutedRoamingNote(context: Context, result: CheckResult): String? {
val facts = result.locationSignals.locationFacts ?: return null
if (!facts.homeRoutedRoaming) return null
val operator = facts.homeSimOperatorName?.takeIf { it.isNotBlank() }
val country = facts.homeSimCountryIso?.takeIf { it.isNotBlank() }
val asnConfirmed = result.geoIp.geoFacts?.expectedRoamingExit == true
return when {
asnConfirmed && operator != null && country != null ->
context.getString(R.string.narrative_home_routed_roaming_confirmed, operator, country)
asnConfirmed && country != null ->
context.getString(R.string.narrative_home_routed_roaming_confirmed_country, country)
operator != null && country != null ->
context.getString(R.string.narrative_home_routed_roaming_likely, operator, country)
country != null ->
context.getString(R.string.narrative_home_routed_roaming_likely_country, country)
else ->
context.getString(R.string.narrative_home_routed_roaming_generic)
}
}
private fun collectSnapshot(context: Context, result: CheckResult): Snapshot {
val xrayApi = result.bypassResult.xrayApiScanResult
val gatewayLeakFinding = result.bypassResult.findings.firstOrNull {

View file

@ -473,31 +473,40 @@ object BypassChecker {
val proxyDetected = proxyCheck.status == LocalProxyCheckStatus.CONFIRMED_BYPASS
// Treat AUTH_REQUIRED endpoints with no resolved owner as informational
// noise: bypass cannot be confirmed (no probe possible) and we can't
// attribute the listening socket to a known app. Emitting them as
// evidence inflates the verdict and confuses the user.
val authRequiredUnresolved = proxyCheck.status == LocalProxyCheckStatus.AUTH_REQUIRED &&
proxyCheck.ownerStatus == LocalProxyOwnerStatus.UNRESOLVED
findings.add(
Finding(
description = description,
detected = proxyDetected,
needsReview = !proxyDetected,
needsReview = !proxyDetected && !authRequiredUnresolved,
isInformational = authRequiredUnresolved,
source = EvidenceSource.LOCAL_PROXY,
confidence = EvidenceConfidence.MEDIUM,
family = familySuffix,
packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner),
),
)
evidence.add(
EvidenceItem(
source = EvidenceSource.LOCAL_PROXY,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = buildString {
append("Detected open ${proxyEndpoint.type.name} proxy at ${formatHostPort(proxyEndpoint.host, proxyEndpoint.port)}")
append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus))
append(ownerMetadataSuffix)
},
family = familySuffix,
packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner),
),
)
if (!authRequiredUnresolved) {
evidence.add(
EvidenceItem(
source = EvidenceSource.LOCAL_PROXY,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = buildString {
append("Detected open ${proxyEndpoint.type.name} proxy at ${formatHostPort(proxyEndpoint.host, proxyEndpoint.port)}")
append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus))
append(ownerMetadataSuffix)
},
family = familySuffix,
packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner),
),
)
}
if (proxyCheck.status != LocalProxyCheckStatus.AUTH_REQUIRED) {
findings.add(Finding(context.getString(R.string.checker_bypass_proxy_ip, proxyCheck.proxyIp ?: unavailable)))

View file

@ -207,16 +207,23 @@ object CdnPullingChecker {
}.distinct()
val allSuccessfulResponsesExposeIp = successfulResponses.isNotEmpty() && successfulResponses.all { it.ip != null }
val allActionableResponsesExposeIp = actionableResponses.isNotEmpty() && actionableResponses.all { it.ip != null }
val hasError = successfulCount == 0
val detected = actionableCount > 0
val ipv4Conflict = actionableIpv4s.size > 1
val ipv6Conflict = actionableIpv6s.size > 1
// Only raise review if we actually saw conflicting IPs across actionable
// targets. A single actionable target without an exposed IP is not a
// signal — it just means the endpoint did not advertise one. Likewise,
// partial successes (some endpoints failed entirely) are reported via
// the per-finding error path, not by flagging the whole card.
val ipv4MismatchAcrossAll = allIpv4s.size > 1
val ipv6MismatchAcrossAll = allIpv6s.size > 1
val needsReview = detected && (
ipv4Conflict ||
ipv6Conflict ||
!allActionableResponsesExposeIp
ipv4MismatchAcrossAll ||
ipv6MismatchAcrossAll
)
val findings = buildFindings(successfulResponses, responses)

View file

@ -539,10 +539,14 @@ object GeoIpChecker {
val countryCode = snapshot.countryCode.uppercase().ifBlank { null }
val outsideRu = countryCode != null && countryCode != "RU"
val asn = snapshot.asn.takeUnless { it.isBlank() || it == "N/A" }
val geoFacts = GeoIpFacts(
ip = snapshot.ip.takeUnless { it.isBlank() || it == "N/A" },
countryCode = countryCode,
asn = snapshot.asn.takeUnless { it.isBlank() || it == "N/A" },
asn = asn,
asnCode = HomeNetworkCatalog.extractAsnCode(asn),
isp = snapshot.isp.takeUnless { it.isBlank() || it == "N/A" },
org = snapshot.org.takeUnless { it.isBlank() || it == "N/A" },
outsideRu = outsideRu,
hosting = snapshot.isHosting,
proxyDb = snapshot.isProxy,

View file

@ -0,0 +1,281 @@
package com.notcvnt.rknhardering.checker
/**
* Static catalog of home networks for SIM operators we want to recognize as
* "expected roaming exit" the operator's home APN/PGW always terminates the
* data session, so when the user is roaming the public IP belongs to the home
* network instead of the visited country. Without this knowledge such legit
* roaming is indistinguishable from a real bypass.
*
* Keys are `MCC` ("250") or `MCC-MNC` ("208-15"). MCC-only entries match any
* MNC of that country (used as a fallback when MNC is unknown).
*
* The entry stores ASN codes (e.g. 51207) and substring keywords that we look
* for in the GeoIP `isp` / `org` / `asn` strings. ASN match is the strong
* signal; keywords are a fallback when the ASN field isn't structured.
*/
internal data class HomeNetworkProfile(
val mcc: String,
val mnc: String?,
val country: String,
val operator: String,
val asnCodes: Set<String>,
val keywords: Set<String>,
)
internal object HomeNetworkCatalog {
private val PROFILES: List<HomeNetworkProfile> = listOf(
// France — Free Mobile (issue #63)
HomeNetworkProfile(
mcc = "208",
mnc = "15",
country = "FR",
operator = "Free Mobile",
asnCodes = setOf("51207"),
keywords = setOf("free mobile", "free sas"),
),
HomeNetworkProfile(
mcc = "208",
mnc = "16",
country = "FR",
operator = "Free Mobile",
asnCodes = setOf("51207"),
keywords = setOf("free mobile", "free sas"),
),
// France — Orange
HomeNetworkProfile(
mcc = "208",
mnc = "01",
country = "FR",
operator = "Orange",
asnCodes = setOf("3215"),
keywords = setOf("orange s.a.", "france telecom", "orange france"),
),
HomeNetworkProfile(
mcc = "208",
mnc = "02",
country = "FR",
operator = "Orange",
asnCodes = setOf("3215"),
keywords = setOf("orange s.a.", "france telecom", "orange france"),
),
// France — SFR
HomeNetworkProfile(
mcc = "208",
mnc = "10",
country = "FR",
operator = "SFR",
asnCodes = setOf("15557"),
keywords = setOf("sfr", "societe francaise du radiotelephone"),
),
// France — Bouygues Telecom
HomeNetworkProfile(
mcc = "208",
mnc = "20",
country = "FR",
operator = "Bouygues Telecom",
asnCodes = setOf("5410"),
keywords = setOf("bouygues"),
),
// Germany — Deutsche Telekom
HomeNetworkProfile(
mcc = "262",
mnc = "01",
country = "DE",
operator = "Telekom Deutschland",
asnCodes = setOf("3320"),
keywords = setOf("deutsche telekom", "telekom deutschland"),
),
// Germany — Vodafone
HomeNetworkProfile(
mcc = "262",
mnc = "02",
country = "DE",
operator = "Vodafone",
asnCodes = setOf("3209", "1273"),
keywords = setOf("vodafone gmbh", "vodafone germany"),
),
// Germany — Telefonica O2
HomeNetworkProfile(
mcc = "262",
mnc = "07",
country = "DE",
operator = "Telefonica O2",
asnCodes = setOf("6805", "13184"),
keywords = setOf("telefonica germany", "o2 deutschland"),
),
// United Kingdom — EE / BT
HomeNetworkProfile(
mcc = "234",
mnc = "30",
country = "GB",
operator = "EE",
asnCodes = setOf("12576", "5378"),
keywords = setOf("ee limited", "everything everywhere"),
),
// United Kingdom — Vodafone
HomeNetworkProfile(
mcc = "234",
mnc = "15",
country = "GB",
operator = "Vodafone UK",
asnCodes = setOf("1273"),
keywords = setOf("vodafone limited", "vodafone uk"),
),
// United Kingdom — O2
HomeNetworkProfile(
mcc = "234",
mnc = "10",
country = "GB",
operator = "O2 UK",
asnCodes = setOf("5607"),
keywords = setOf("telefonica uk", "o2 uk"),
),
// Italy — TIM
HomeNetworkProfile(
mcc = "222",
mnc = "01",
country = "IT",
operator = "TIM",
asnCodes = setOf("3269"),
keywords = setOf("telecom italia", "tim s.p.a."),
),
// Italy — Vodafone
HomeNetworkProfile(
mcc = "222",
mnc = "10",
country = "IT",
operator = "Vodafone Italia",
asnCodes = setOf("30722"),
keywords = setOf("vodafone italia"),
),
// Spain — Movistar
HomeNetworkProfile(
mcc = "214",
mnc = "07",
country = "ES",
operator = "Movistar",
asnCodes = setOf("3352"),
keywords = setOf("telefonica de espana", "movistar"),
),
// Netherlands — KPN
HomeNetworkProfile(
mcc = "204",
mnc = "08",
country = "NL",
operator = "KPN",
asnCodes = setOf("1136"),
keywords = setOf("kpn b.v.", "kpn mobile"),
),
// Poland — Orange / Play / Plus / T-Mobile
HomeNetworkProfile(
mcc = "260",
mnc = "03",
country = "PL",
operator = "Orange Polska",
asnCodes = setOf("5617"),
keywords = setOf("orange polska"),
),
// United States — T-Mobile
HomeNetworkProfile(
mcc = "310",
mnc = "260",
country = "US",
operator = "T-Mobile USA",
asnCodes = setOf("21928"),
keywords = setOf("t-mobile usa"),
),
// United States — AT&T
HomeNetworkProfile(
mcc = "310",
mnc = "410",
country = "US",
operator = "AT&T Mobility",
asnCodes = setOf("20057", "7018"),
keywords = setOf("at&t mobility", "cellco partnership", "at&t services"),
),
// United States — Verizon
HomeNetworkProfile(
mcc = "311",
mnc = "480",
country = "US",
operator = "Verizon Wireless",
asnCodes = setOf("22394", "6167"),
keywords = setOf("verizon wireless", "cellco partnership"),
),
// Belarus — A1 / MTS / Life
HomeNetworkProfile(
mcc = "257",
mnc = null,
country = "BY",
operator = "Belarus mobile carrier",
asnCodes = setOf("6697", "25106", "44087"),
keywords = setOf("a1 belarus", "mobile telesystems llc", "best ltd"),
),
// Kazakhstan — Beeline / Kcell / Tele2
HomeNetworkProfile(
mcc = "401",
mnc = null,
country = "KZ",
operator = "Kazakhstan mobile carrier",
asnCodes = setOf("21299", "29355", "35168"),
keywords = setOf("kar-tel", "kcell", "mobile telecom-service"),
),
// Ukraine — Kyivstar / Vodafone UA / lifecell
HomeNetworkProfile(
mcc = "255",
mnc = null,
country = "UA",
operator = "Ukraine mobile carrier",
asnCodes = setOf("15895", "21497", "34058"),
keywords = setOf("kyivstar", "pjsc vf ukraine", "lifecell"),
),
// Turkey — Turkcell / Vodafone TR
HomeNetworkProfile(
mcc = "286",
mnc = "01",
country = "TR",
operator = "Turkcell",
asnCodes = setOf("16135"),
keywords = setOf("turkcell"),
),
)
fun lookup(mcc: String?, mnc: String?): HomeNetworkProfile? {
if (mcc.isNullOrBlank()) return null
if (!mnc.isNullOrBlank()) {
val exact = PROFILES.firstOrNull { it.mcc == mcc && it.mnc == mnc }
if (exact != null) return exact
}
return PROFILES.firstOrNull { it.mcc == mcc && it.mnc == null }
}
/**
* Try to match `geoFacts` ASN/ISP strings against the SIM home network.
* Returns reason string (for debugging/UI) on match, null otherwise.
*/
fun matchExpectedExit(profile: HomeNetworkProfile, asn: String?, isp: String?, org: String?): String? {
val asnCode = extractAsnCode(asn)
if (asnCode != null && asnCode in profile.asnCodes) {
return "ASN AS$asnCode matches ${profile.operator} home network (${profile.country})"
}
val haystack = listOf(asn, isp, org)
.filterNotNull()
.joinToString(" | ") { it.lowercase() }
if (haystack.isBlank()) return null
val keyword = profile.keywords.firstOrNull { it in haystack }
return if (keyword != null) {
"ISP/ORG matches ${profile.operator} home network (${profile.country})"
} else {
null
}
}
private val ASN_REGEX = Regex("""AS\s*(\d+)""", RegexOption.IGNORE_CASE)
fun extractAsnCode(asn: String?): String? {
if (asn.isNullOrBlank()) return null
return ASN_REGEX.find(asn)?.groupValues?.getOrNull(1)
}
}

View file

@ -28,6 +28,7 @@ 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.LocationSignalsFacts
import com.notcvnt.rknhardering.network.DnsResolverConfig
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers
@ -47,6 +48,7 @@ object LocationSignalsChecker {
val simCountryIso: String?,
val operatorName: String?,
val isRoaming: Boolean?,
val simMnc: String? = null,
)
private data class CellCollectionResult(
@ -74,6 +76,7 @@ object LocationSignalsChecker {
val networkCountryIso: String?,
val networkOperatorName: String?,
val simCards: List<SimCardInfo>,
val networkMnc: String? = null,
val cellCountryCode: String?,
val cellLookupSummary: String?,
val cellCandidatesCount: Int,
@ -133,6 +136,7 @@ object LocationSignalsChecker {
val wifiFeatureAvailable = context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI)
var networkMcc: String? = null
var networkMnc: String? = null
var networkCountryIso: String? = null
var networkOperatorName: String? = null
var cellCountryCode: String? = null
@ -145,6 +149,8 @@ object LocationSignalsChecker {
val networkOperator = tm.networkOperator
if (!networkOperator.isNullOrEmpty() && networkOperator.length >= 3) {
networkMcc = networkOperator.substring(0, 3)
networkMnc = networkOperator.substring(3)
.takeIf { it.isNotEmpty() }
}
networkCountryIso = tm.networkCountryIso?.takeIf { it.isNotEmpty() }
networkOperatorName = tm.networkOperatorName?.takeIf { it.isNotEmpty() }
@ -192,6 +198,7 @@ object LocationSignalsChecker {
return LocationSnapshot(
networkMcc = networkMcc,
networkMnc = networkMnc,
networkCountryIso = networkCountryIso,
networkOperatorName = networkOperatorName,
simCards = simCards,
@ -260,12 +267,20 @@ object LocationSignalsChecker {
val simMcc = if (!simOperator.isNullOrEmpty() && simOperator.length >= 3) {
simOperator.substring(0, 3)
} else null
val simMnc = if (!simOperator.isNullOrEmpty() && simOperator.length > 3) {
simOperator.substring(3)
} else null
val simCountryIso = subTm.simCountryIso?.takeIf { it.isNotEmpty() }
?: info.countryIso?.takeIf { it.isNotEmpty() }
SimCardInfo(
slotIndex = info.simSlotIndex,
subscriptionId = info.subscriptionId,
simMcc = simMcc,
simCountryIso = subTm.simCountryIso?.takeIf { it.isNotEmpty() },
operatorName = subTm.networkOperatorName?.takeIf { it.isNotEmpty() },
simMnc = simMnc,
simCountryIso = simCountryIso,
operatorName = info.carrierName?.toString()?.takeIf { it.isNotEmpty() }
?: subTm.simOperatorName?.takeIf { it.isNotEmpty() }
?: subTm.networkOperatorName?.takeIf { it.isNotEmpty() },
isRoaming = subTm.isNetworkRoaming,
)
}.getOrNull()
@ -278,13 +293,18 @@ object LocationSignalsChecker {
val simMcc = if (!simOperator.isNullOrEmpty() && simOperator.length >= 3) {
simOperator.substring(0, 3)
} else null
val simMnc = if (!simOperator.isNullOrEmpty() && simOperator.length > 3) {
simOperator.substring(3)
} else null
listOf(
SimCardInfo(
slotIndex = 0,
subscriptionId = -1,
simMcc = simMcc,
simMnc = simMnc,
simCountryIso = tm.simCountryIso?.takeIf { it.isNotEmpty() },
operatorName = tm.networkOperatorName?.takeIf { it.isNotEmpty() },
operatorName = tm.simOperatorName?.takeIf { it.isNotEmpty() }
?: tm.networkOperatorName?.takeIf { it.isNotEmpty() },
isRoaming = tm.isNetworkRoaming,
)
)
@ -872,11 +892,38 @@ object LocationSignalsChecker {
val evidence = mutableListOf<EvidenceItem>()
var needsReview = false
// Pick "home" SIM for the home-routed-roaming heuristic. Prefer the
// first SIM with a known MCC; fall back to the first card we have.
val homeSim = snapshot.simCards.firstOrNull { !it.simMcc.isNullOrBlank() }
?: snapshot.simCards.firstOrNull()
val homeSimCountryIsRussia = homeSim?.simMcc == RUSSIA_MCC
val networkIsRussia = snapshot.networkMcc == RUSSIA_MCC
val homeRoutedRoaming = networkIsRussia &&
homeSim?.simMcc != null &&
!homeSimCountryIsRussia
val homeRoutedRoamingReason = if (homeRoutedRoaming && homeSim != null) {
val simCountry = homeSim.simCountryIso?.uppercase(Locale.US)
?: countryFromMcc(homeSim.simMcc)
?: "?"
"Home SIM MCC ${homeSim.simMcc} ($simCountry) on visited Russian network MCC ${snapshot.networkMcc}"
} else {
null
}
val anySimReportedRoaming = snapshot.simCards.any { it.isRoaming == true } ||
// Telephony sometimes returns isRoaming=false for foreign SIM in RU
// (e.g. Free Mobile in RU); fall back to MCC mismatch heuristic.
snapshot.simCards.any {
!it.simMcc.isNullOrBlank() &&
!snapshot.networkMcc.isNullOrBlank() &&
it.simMcc != snapshot.networkMcc
}
if (snapshot.networkMcc == null) {
findings += Finding("PLMN: network MCC is unavailable")
} else {
val networkCountry = snapshot.networkCountryIso?.uppercase(Locale.US) ?: "N/A"
val networkIsRussia = snapshot.networkMcc == RUSSIA_MCC
val networkCountry = snapshot.networkCountryIso?.uppercase(Locale.US)
?: countryFromMcc(snapshot.networkMcc)
?: "N/A"
findings += Finding(
description = "Network operator: ${snapshot.networkOperatorName ?: "N/A"} ($networkCountry)",
@ -891,22 +938,63 @@ object LocationSignalsChecker {
}
for (sim in snapshot.simCards) {
val simCountry = sim.simCountryIso?.uppercase(Locale.US) ?: "N/A"
// Prefer simCountryIso for the SIM label — historically this
// was sourced from the network, mislabelling foreign SIMs as
// RU when registered on a Russian visited network.
val simCountry = sim.simCountryIso?.uppercase(Locale.US)
?: countryFromMcc(sim.simMcc)
?: "N/A"
val operatorPart = sim.operatorName?.let { ", $it" } ?: ""
findings += Finding(
description = "SIM[${sim.slotIndex}] MCC: ${sim.simMcc ?: "N/A"} ($simCountry)$operatorPart",
isInformational = true,
)
when (sim.isRoaming) {
true -> findings += Finding("SIM[${sim.slotIndex}] Roaming: yes", isInformational = true)
false -> findings += Finding("SIM[${sim.slotIndex}] Roaming: no", isInformational = true)
null -> Unit
val effectiveRoaming: Boolean? = when {
sim.isRoaming == true -> true
!sim.simMcc.isNullOrBlank() &&
!snapshot.networkMcc.isNullOrBlank() &&
sim.simMcc != snapshot.networkMcc -> true
sim.isRoaming == false -> false
else -> null
}
val roamingLabel = when (effectiveRoaming) {
true -> "yes"
false -> "no"
null -> null
}
if (roamingLabel != null) {
findings += Finding(
description = "SIM[${sim.slotIndex}] Roaming: $roamingLabel",
isInformational = true,
)
}
}
if (homeRoutedRoaming) {
val description = "home_routed_roaming:true (home SIM MCC ${homeSim?.simMcc}, " +
"visited network MCC ${snapshot.networkMcc})"
findings += Finding(
description = description,
source = EvidenceSource.HOME_ROUTED_ROAMING,
confidence = EvidenceConfidence.MEDIUM,
isInformational = false,
)
evidence += EvidenceItem(
source = EvidenceSource.HOME_ROUTED_ROAMING,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = homeRoutedRoamingReason
?: "Foreign SIM connected via Russian visited network",
)
}
if (!networkIsRussia) {
// Foreign visited network. If this is a Russian SIM roaming
// abroad (homeSimCountryIsRussia), the public IP is expected
// to belong to the home (Russian) operator and bypass cannot
// be inferred from country alone — drop confidence to LOW.
val matchingSim = snapshot.simCards.firstOrNull { it.simMcc == snapshot.networkMcc }
val confidence = if (matchingSim?.isRoaming == true) {
val confidence = if (matchingSim?.isRoaming == true || homeSimCountryIsRussia) {
EvidenceConfidence.LOW
} else {
EvidenceConfidence.MEDIUM
@ -980,15 +1068,35 @@ object LocationSignalsChecker {
}
val detected = evidence.any {
it.detected && it.confidence >= EvidenceConfidence.MEDIUM
it.detected &&
it.confidence >= EvidenceConfidence.MEDIUM &&
it.source != EvidenceSource.HOME_ROUTED_ROAMING
}
val locationFacts = LocationSignalsFacts(
networkMcc = snapshot.networkMcc,
networkMnc = snapshot.networkMnc,
networkCountryIso = snapshot.networkCountryIso?.uppercase(Locale.US),
networkOperatorName = snapshot.networkOperatorName,
networkIsRussia = networkIsRussia,
homeSimMcc = homeSim?.simMcc,
homeSimMnc = homeSim?.simMnc,
homeSimCountryIso = homeSim?.simCountryIso?.uppercase(Locale.US)
?: countryFromMcc(homeSim?.simMcc),
homeSimCountryIsRussia = homeSimCountryIsRussia,
homeSimOperatorName = homeSim?.operatorName,
anySimReportedRoaming = anySimReportedRoaming,
homeRoutedRoaming = homeRoutedRoaming,
homeRoutedRoamingReason = homeRoutedRoamingReason,
)
val result = CategoryResult(
name = "Location signals",
detected = detected,
findings = findings,
needsReview = needsReview,
evidence = evidence,
locationFacts = locationFacts,
)
return LocationSignalsDiagnosticsRegistry.attach(
result,
@ -1016,4 +1124,75 @@ object LocationSignalsChecker {
}
private val MAC_ADDRESS_REGEX = Regex("^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$")
/**
* Lightweight MCC ISO 3166-1 alpha-2 lookup for the most common
* countries. Used to label SIM/Network country when telephony APIs
* return blank simCountryIso (typical when SIM-MCC and Network-MCC
* disagree). Not exhaustive falls back to null.
*/
private fun countryFromMcc(mcc: String?): String? {
if (mcc.isNullOrBlank()) return null
return MCC_TO_ISO[mcc]
}
private val MCC_TO_ISO: Map<String, String> = mapOf(
"202" to "GR", "204" to "NL", "206" to "BE", "208" to "FR",
"212" to "MC", "213" to "AD", "214" to "ES", "216" to "HU",
"218" to "BA", "219" to "HR", "220" to "RS", "222" to "IT",
"226" to "RO", "228" to "CH", "230" to "CZ", "231" to "SK",
"232" to "AT", "234" to "GB", "235" to "GB", "238" to "DK",
"240" to "SE", "242" to "NO", "244" to "FI", "246" to "LT",
"247" to "LV", "248" to "EE", "250" to "RU", "255" to "UA",
"257" to "BY", "259" to "MD", "260" to "PL", "262" to "DE",
"266" to "GI", "268" to "PT", "270" to "LU", "272" to "IE",
"274" to "IS", "276" to "AL", "278" to "MT", "280" to "CY",
"282" to "GE", "283" to "AM", "284" to "BG", "286" to "TR",
"288" to "FO", "290" to "GL", "293" to "SI", "294" to "MK",
"295" to "LI", "297" to "ME", "310" to "US", "311" to "US",
"312" to "US", "313" to "US", "314" to "US", "315" to "US",
"316" to "US", "330" to "PR", "334" to "MX", "338" to "JM",
"340" to "MQ", "342" to "BB", "346" to "KY", "348" to "VG",
"350" to "BM", "352" to "GD", "354" to "MS", "356" to "KN",
"358" to "LC", "360" to "VC", "362" to "AN", "363" to "AW",
"364" to "BS", "365" to "AI", "366" to "DM", "368" to "CU",
"370" to "DO", "372" to "HT", "374" to "TT", "376" to "TC",
"400" to "AZ", "401" to "KZ", "402" to "BT", "404" to "IN",
"405" to "IN", "410" to "PK", "412" to "AF", "413" to "LK",
"414" to "MM", "415" to "LB", "416" to "JO", "417" to "SY",
"418" to "IQ", "419" to "KW", "420" to "SA", "421" to "YE",
"422" to "OM", "424" to "AE", "425" to "IL", "426" to "BH",
"427" to "QA", "428" to "MN", "429" to "NP", "430" to "AE",
"431" to "AE", "432" to "IR", "434" to "UZ", "436" to "TJ",
"437" to "KG", "438" to "TM", "440" to "JP", "441" to "JP",
"450" to "KR", "452" to "VN", "454" to "HK", "455" to "MO",
"456" to "KH", "457" to "LA", "460" to "CN", "461" to "CN",
"466" to "TW", "467" to "KP", "470" to "BD", "472" to "MV",
"502" to "MY", "505" to "AU", "510" to "ID", "514" to "TL",
"515" to "PH", "520" to "TH", "525" to "SG", "528" to "BN",
"530" to "NZ", "534" to "MP", "535" to "GU", "536" to "NR",
"537" to "PG", "539" to "TO", "540" to "SB", "541" to "VU",
"542" to "FJ", "543" to "WF", "544" to "AS", "545" to "KI",
"546" to "NC", "547" to "PF", "548" to "CK", "549" to "WS",
"550" to "FM", "551" to "MH", "552" to "PW", "602" to "EG",
"603" to "DZ", "604" to "MA", "605" to "TN", "606" to "LY",
"607" to "GM", "608" to "SN", "609" to "MR", "610" to "ML",
"611" to "GN", "612" to "CI", "613" to "BF", "614" to "NE",
"615" to "TG", "616" to "BJ", "617" to "MU", "618" to "LR",
"619" to "SL", "620" to "GH", "621" to "NG", "622" to "TD",
"623" to "CF", "624" to "CM", "625" to "CV", "626" to "ST",
"627" to "GQ", "628" to "GA", "629" to "CG", "630" to "CD",
"631" to "AO", "632" to "GW", "633" to "SC", "634" to "SD",
"635" to "RW", "636" to "ET", "637" to "SO", "638" to "DJ",
"639" to "KE", "640" to "TZ", "641" to "UG", "642" to "BI",
"643" to "MZ", "645" to "ZM", "646" to "MG", "647" to "RE",
"648" to "ZW", "649" to "NA", "650" to "MW", "651" to "LS",
"652" to "BW", "653" to "SZ", "654" to "KM", "655" to "ZA",
"657" to "ER", "659" to "SS", "702" to "BZ", "704" to "GT",
"706" to "SV", "708" to "HN", "710" to "NI", "712" to "CR",
"714" to "PA", "716" to "PE", "722" to "AR", "724" to "BR",
"730" to "CL", "732" to "CO", "734" to "VE", "736" to "BO",
"738" to "GY", "740" to "EC", "744" to "PY", "746" to "SR",
"748" to "UY",
)
}

View file

@ -77,14 +77,19 @@ object VerdictEngine {
it.description.contains("cell_country_ru:true") ||
it.description.contains("location_country_ru:true")
}
val homeRoutedRoaming = locationSignals.locationFacts?.homeRoutedRoaming == true
val geo = geoIp.geoFacts
// Home-routed roaming: foreign SIM on a Russian visited network
// legitimately exits via the SIM's home country. Treat the resulting
// geo mismatch as expected and never auto-detect bypass on geo alone.
val expectedRoamingExit = geo?.expectedRoamingExit == true || homeRoutedRoaming
val geoAxisAvailable = geoCheckAvailable && geo?.fetchError != true
val anyOtherSignal = directSigns.evidence.any { it.detected } ||
indirectSigns.evidence.any { it.detected } ||
ipConsensus.crossChannelMismatch ||
ipConsensus.probeTargetDivergence ||
ipConsensus.probeTargetDirectDivergence
if (locationConfirmsRussia && geo?.outsideRu == true) {
if (locationConfirmsRussia && geo?.outsideRu == true && !expectedRoamingExit) {
return Verdict.DETECTED
}
if (locationConfirmsRussia &&
@ -96,7 +101,7 @@ object VerdictEngine {
}
// R5 — 3-bit matrix (geo x direct x indirect)
val geoHit = geo?.outsideRu == true
val geoHit = geo?.outsideRu == true && !expectedRoamingExit
val directHit = directSigns.evidence.any { it.detected && it.source in MATRIX_DIRECT_SOURCES }
val indirectHit = indirectSigns.evidence.any { it.detected && it.source in MATRIX_INDIRECT_SOURCES } ||
nativeSigns.evidence.any { it.detected && it.source in MATRIX_INDIRECT_SOURCES }
@ -122,7 +127,7 @@ object VerdictEngine {
val tunProbeReview = directSigns.evidence.any {
it.source == EvidenceSource.TUN_ACTIVE_PROBE && !it.detected
}
val locationSignalHit = locationSignals.detected
val locationSignalHit = locationSignals.detected && !expectedRoamingExit
if (matrix == Verdict.NOT_DETECTED && (
bypassResult.needsReview ||
directSigns.needsReview ||

View file

@ -9,12 +9,14 @@ import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.CdnPullingResult
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.CheckResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.GeoIpFacts
import com.notcvnt.rknhardering.model.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.LocationSignalsFacts
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.probe.TunProbeModeOverride
@ -240,6 +242,61 @@ object VpnCheckRunner {
)
}
private fun annotateExpectedRoamingExit(
geoIp: CategoryResult,
locationFacts: LocationSignalsFacts?,
): CategoryResult {
val geoFacts = geoIp.geoFacts ?: return geoIp
if (locationFacts == null) return geoIp
if (!locationFacts.homeRoutedRoaming) return geoIp
val mcc = locationFacts.homeSimMcc ?: return geoIp
val profile = HomeNetworkCatalog.lookup(mcc, locationFacts.homeSimMnc) ?: return geoIp
val reason = HomeNetworkCatalog.matchExpectedExit(
profile = profile,
asn = geoFacts.asn,
isp = geoFacts.isp,
org = geoFacts.org,
) ?: return geoIp
return geoIp.copy(
geoFacts = geoFacts.copy(
expectedRoamingExit = true,
expectedRoamingExitReason = reason,
),
)
}
private fun relaxCdnPullingForHomeRoutedRoaming(result: CdnPullingResult): CdnPullingResult {
// Home-routed roaming legitimately exposes a foreign IP in CDN trace
// responses. The original detection still records it informationally,
// but it must not raise needsReview when the signal is fully explained
// by the SIM home network.
if (!result.detected && !result.needsReview) return result
return result.copy(
needsReview = false,
findings = result.findings.map { finding ->
if (finding.needsReview) finding.copy(needsReview = false) else finding
},
)
}
private fun relaxIcmpSpoofingForHomeRoutedRoaming(result: CategoryResult): CategoryResult {
// The ICMP-spoofing checker assumes a Russian egress; with home-routed
// roaming the egress is intentionally abroad, so blocked targets that
// reply via ICMP cannot be treated as suspicious.
if (!result.needsReview && result.evidence.none { it.detected }) return result
return result.copy(
needsReview = false,
evidence = result.evidence.map { item ->
if (item.source == EvidenceSource.ICMP_SPOOFING && item.detected) {
item.copy(detected = false, confidence = EvidenceConfidence.LOW)
} else item
},
findings = result.findings.map { finding ->
if (finding.needsReview) finding.copy(needsReview = false) else finding
},
)
}
suspend fun run(
context: Context,
settings: CheckSettings = CheckSettings(),
@ -447,10 +504,10 @@ object VpnCheckRunner {
detected = false,
)
val geoIp = geoIpReadyDeferred?.await() ?: emptyGeoIpCategory
val rawGeoIp = geoIpReadyDeferred?.await() ?: emptyGeoIpCategory
val ipComparison = ipComparisonReadyDeferred?.await() ?: emptyIpComparison
val cdnPulling = cdnPullingReadyDeferred?.await() ?: emptyCdnPulling
val icmpSpoofing = icmpSpoofingReadyDeferred?.await() ?: emptyIcmpSpoofing
val rawCdnPulling = cdnPullingReadyDeferred?.await() ?: emptyCdnPulling
val rawIcmpSpoofing = icmpSpoofingReadyDeferred?.await() ?: emptyIcmpSpoofing
val rttTriangulation = rttTriangulationReadyDeferred?.await() ?: emptyRttTriangulation
val directSigns = directReadyDeferred.await()
val indirectSigns = indirectReadyDeferred.await()
@ -459,6 +516,19 @@ object VpnCheckRunner {
val bypassResult = bypassReadyDeferred?.await() ?: emptyBypass
val tunProbeResult = tunActiveProbeDeferred?.await()
// Cross-checker reconciliation: when the SIM home network is foreign
// and the visited network is in Russia, the ISP-level egress will
// legitimately appear abroad. Try to confirm via ASN match against
// GeoIP to suppress false positives in CDN/ICMP/GeoIP categories.
val geoIp = annotateExpectedRoamingExit(rawGeoIp, locationSignals.locationFacts)
val homeRoutedRoaming = locationSignals.locationFacts?.homeRoutedRoaming == true
val cdnPulling = if (homeRoutedRoaming) {
relaxCdnPullingForHomeRoutedRoaming(rawCdnPulling)
} else rawCdnPulling
val icmpSpoofing = if (homeRoutedRoaming) {
relaxIcmpSpoofingForHomeRoutedRoaming(rawIcmpSpoofing)
} else rawIcmpSpoofing
val ipConsensus = runCatching {
IpConsensusBuilder.build(
geoIp = geoIp,

View file

@ -8,10 +8,36 @@ data class GeoIpFacts(
val ip: String? = null,
val countryCode: String? = null,
val asn: String? = null,
val asnCode: String? = null,
val isp: String? = null,
val org: String? = null,
val outsideRu: Boolean = false,
val hosting: Boolean = false,
val proxyDb: Boolean = false,
val fetchError: Boolean = false,
val expectedRoamingExit: Boolean = false,
val expectedRoamingExitReason: String? = null,
)
/**
* Structured signals from LocationSignalsChecker, used by VerdictEngine and the
* UI to recognise "home-routed roaming" a foreign SIM connected via the
* Russian visited network whose data plane still exits via the home country.
*/
data class LocationSignalsFacts(
val networkMcc: String? = null,
val networkMnc: String? = null,
val networkCountryIso: String? = null,
val networkOperatorName: String? = null,
val networkIsRussia: Boolean = false,
val homeSimMcc: String? = null,
val homeSimMnc: String? = null,
val homeSimCountryIso: String? = null,
val homeSimCountryIsRussia: Boolean = false,
val homeSimOperatorName: String? = null,
val anySimReportedRoaming: Boolean = false,
val homeRoutedRoaming: Boolean = false,
val homeRoutedRoamingReason: String? = null,
)
enum class EvidenceConfidence {
@ -39,6 +65,7 @@ enum class EvidenceSource {
PROXY_TECHNICAL_SIGNAL,
DUMPSYS,
LOCATION_SIGNALS,
HOME_ROUTED_ROAMING,
VPN_GATEWAY_LEAK,
VPN_NETWORK_BINDING,
TUN_ACTIVE_PROBE,
@ -233,6 +260,7 @@ data class CategoryResult(
val callTransportLeaks: List<CallTransportLeakResult> = emptyList(),
val stunProbeGroups: List<StunProbeGroupResult> = emptyList(),
val geoFacts: GeoIpFacts? = null,
val locationFacts: LocationSignalsFacts? = null,
) {
val hasError: Boolean
get() = findings.any { it.isError }

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/colorSurfaceContainer" />
<stroke
android:width="1dp"
android:color="?attr/colorOutlineVariant" />
<corners android:radius="12dp" />
</shape>

View file

@ -1730,6 +1730,16 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/textVerdictHomeRoutedRoamingNote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:padding="10dp"
android:textSize="13sp"
android:visibility="gone"
tools:visibility="visible" />
<View
android:id="@+id/verdictDetailsDivider"
android:layout_width="match_parent"

View file

@ -351,6 +351,12 @@
<string name="narrative_reason_fallback_review">نشانه‌ها ناقص یا متناقض‌اند، بنابراین بررسی دستی لازم است.</string>
<string name="narrative_reason_fallback_clean">هیچ نشانه تعیین‌کننده‌ای از دور زدن پیدا نشد.</string>
<string name="narrative_home_routed_roaming_confirmed">یک سیم‌کارت خارجی (%1$s، %2$s) از طریق شبکه میزبان روسیه متصل است. آی‌پی عمومی متعلق به ASN اپراتور خانگی است؛ این یک رومینگ معمول از مسیر خانگی است و نه دور زدن.</string>
<string name="narrative_home_routed_roaming_confirmed_country">یک سیم‌کارت خارجی (%1$s) از طریق شبکه میزبان روسیه متصل است. آی‌پی عمومی متعلق به ASN اپراتور خانگی است؛ این یک رومینگ معمول از مسیر خانگی است و نه دور زدن.</string>
<string name="narrative_home_routed_roaming_likely">یک سیم‌کارت خارجی (%1$s، %2$s) از طریق شبکه میزبان روسیه متصل است. به‌احتمال زیاد جلسه داده در کشور خانگی خروج پیدا می‌کند؛ این سناریوی مشروع شبیه دور زدن به نظر می‌رسد.</string>
<string name="narrative_home_routed_roaming_likely_country">یک سیم‌کارت خارجی (%1$s) از طریق شبکه میزبان روسیه متصل است. به‌احتمال زیاد جلسه داده در کشور خانگی خروج پیدا می‌کند؛ این سناریوی مشروع شبیه دور زدن به نظر می‌رسد.</string>
<string name="narrative_home_routed_roaming_generic">یک سیم‌کارت خارجی از طریق شبکه میزبان روسیه متصل است. ممکن است جلسه داده به‌طور قانونی در خارج خروج پیدا کند (رومینگ از مسیر خانگی).</string>
<string name="checker_yes">بله</string>
<string name="checker_no">خیر</string>
<string name="checker_proxy_owner_suffix"> (مالک: %1$s)</string>

View file

@ -327,6 +327,12 @@
<string name="narrative_reason_fallback_review">Сигналы частичные или противоречивые, поэтому нужен ручной разбор.</string>
<string name="narrative_reason_fallback_clean">Решающие сигналы обхода не найдены.</string>
<string name="narrative_home_routed_roaming_confirmed">Иностранная SIM (%1$s, %2$s) подключена в гостевую российскую сеть. Внешний IP принадлежит ASN домашнего оператора — это штатный home-routed роуминг, а не обход.</string>
<string name="narrative_home_routed_roaming_confirmed_country">Иностранная SIM (%1$s) подключена в гостевую российскую сеть. Внешний IP принадлежит ASN домашнего оператора — это штатный home-routed роуминг, а не обход.</string>
<string name="narrative_home_routed_roaming_likely">Иностранная SIM (%1$s, %2$s) подключена в гостевую российскую сеть. Скорее всего, дата-сессия выходит в интернет в домашней стране — это легитимный сценарий, который выглядит как обход.</string>
<string name="narrative_home_routed_roaming_likely_country">Иностранная SIM (%1$s) подключена в гостевую российскую сеть. Скорее всего, дата-сессия выходит в интернет в домашней стране — это легитимный сценарий, который выглядит как обход.</string>
<string name="narrative_home_routed_roaming_generic">Иностранная SIM подключена в гостевую российскую сеть. Дата-сессия может законно выходить в интернет за рубежом (home-routed роуминг).</string>
<!-- Checker strings -->
<string name="checker_yes">да</string>
<string name="checker_no">нет</string>

View file

@ -350,6 +350,12 @@
<string name="narrative_reason_fallback_review">信号不完整或相互矛盾,因此需要人工复核。</string>
<string name="narrative_reason_fallback_clean">未发现决定性的绕过迹象。</string>
<string name="narrative_home_routed_roaming_confirmed">外国 SIM%1$s%2$s通过俄罗斯访问网络接入。公网 IP 属于归属运营商的 ASN这是正常的归属路由漫游并非绕过。</string>
<string name="narrative_home_routed_roaming_confirmed_country">外国 SIM%1$s通过俄罗斯访问网络接入。公网 IP 属于归属运营商的 ASN这是正常的归属路由漫游并非绕过。</string>
<string name="narrative_home_routed_roaming_likely">外国 SIM%1$s%2$s通过俄罗斯访问网络接入。数据会话很可能在归属国出口这种合法情景看起来像绕过。</string>
<string name="narrative_home_routed_roaming_likely_country">外国 SIM%1$s通过俄罗斯访问网络接入。数据会话很可能在归属国出口这种合法情景看起来像绕过。</string>
<string name="narrative_home_routed_roaming_generic">外国 SIM 通过俄罗斯访问网络接入。数据会话可能合法地在境外出口(归属路由漫游)。</string>
<string name="checker_yes"></string>
<string name="checker_no"></string>
<string name="checker_proxy_owner_suffix">(所有者:%1$s</string>

View file

@ -324,6 +324,12 @@
<string name="narrative_reason_fallback_review">Signals are partial or contradictory, so manual review is needed.</string>
<string name="narrative_reason_fallback_clean">No decisive bypass signals were found.</string>
<string name="narrative_home_routed_roaming_confirmed">A foreign SIM (%1$s, %2$s) is connected via a Russian visited network. The public IP belongs to the home operator\'s ASN, which is normal home-routed roaming and not a bypass.</string>
<string name="narrative_home_routed_roaming_confirmed_country">A foreign SIM (%1$s) is connected via a Russian visited network. The public IP belongs to the home operator\'s ASN, which is normal home-routed roaming and not a bypass.</string>
<string name="narrative_home_routed_roaming_likely">A foreign SIM (%1$s, %2$s) is connected via a Russian visited network. The data session likely exits via the home country, which can look like a bypass.</string>
<string name="narrative_home_routed_roaming_likely_country">A foreign SIM (%1$s) is connected via a Russian visited network. The data session likely exits via the home country, which can look like a bypass.</string>
<string name="narrative_home_routed_roaming_generic">A foreign SIM is connected via a Russian visited network. The data session may legitimately exit abroad (home-routed roaming).</string>
<!-- Checker strings -->
<string name="checker_yes">yes</string>
<string name="checker_no">no</string>

View file

@ -596,13 +596,18 @@ class BypassCheckerTest {
assertEquals(LocalProxyCheckStatus.AUTH_REQUIRED, evaluation.proxyChecks.single().status)
assertTrue(BypassChecker.proxyChecksNeedReview(evaluation.proxyChecks))
assertFalse(evidence.any { it.source == EvidenceSource.SPLIT_TUNNEL_BYPASS && it.detected })
// AUTH_REQUIRED + UNRESOLVED owner is downgraded to informational so
// the verdict isn't inflated by an unattributable listener that we
// cannot probe further.
assertTrue(
findings.any {
it.source == EvidenceSource.LOCAL_PROXY &&
it.needsReview &&
it.isInformational &&
!it.needsReview &&
!it.detected
},
)
assertFalse(evidence.any { it.source == EvidenceSource.LOCAL_PROXY })
}
@Test

View file

@ -124,7 +124,8 @@ class CdnPullingCheckerTest {
)
assertTrue(result.detected)
assertTrue(result.needsReview)
// No IP conflict — partial trace data alone is informational.
assertFalse(result.needsReview)
assertEquals(
context.getString(R.string.checker_cdn_pulling_summary_detected_no_ip, 2, 2),
result.summary,
@ -132,6 +133,32 @@ class CdnPullingCheckerTest {
assertTrue(result.findings.any { it.description == "meduza.io: IP: 203.0.113.64, LOC: FI" })
}
@Test
fun `evaluate raises review when actionable IPs disagree`() {
val result = CdnPullingChecker.evaluate(
context = context,
responses = listOf(
CdnPullingResponse(
targetLabel = "meduza.io",
url = "https://meduza.io/cdn-cgi/trace",
ip = "203.0.113.64",
ipv4 = "203.0.113.64",
importantFields = linkedMapOf("IP" to "203.0.113.64"),
),
CdnPullingResponse(
targetLabel = "redirector.googlevideo.com",
url = "https://redirector.googlevideo.com/report_mapping?di=no",
ip = "198.51.100.42",
ipv4 = "198.51.100.42",
importantFields = linkedMapOf("IP" to "198.51.100.42"),
),
),
)
assertTrue(result.detected)
assertTrue(result.needsReview)
}
@Test
fun `fetchBodyWithRetries retries transient failures and returns success`() = runBlocking {
var attempts = 0

View file

@ -4,6 +4,7 @@ import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceSource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
@ -375,8 +376,11 @@ class LocationSignalsCheckerTest {
networkCountryIso = "gr",
networkOperatorName = "Cosmote",
simCards = listOf(
sim(slotIndex = 0, subscriptionId = 1, simMcc = "250", simCountryIso = "ru", operatorName = "MegaFon", isRoaming = false),
sim(slotIndex = 1, subscriptionId = 2, simMcc = "202", simCountryIso = "gr", operatorName = "Cosmote", isRoaming = false),
// Home SIM is foreign (GR) and matches the visited (GR) network — no
// Russian SIM means we cannot blame the geo signal on home-routed
// roaming, so confidence stays at MEDIUM.
sim(slotIndex = 0, subscriptionId = 1, simMcc = "202", simCountryIso = "gr", operatorName = "Cosmote", isRoaming = false),
sim(slotIndex = 1, subscriptionId = 2, simMcc = "208", simCountryIso = "fr", operatorName = "Orange", isRoaming = false),
),
),
)
@ -413,6 +417,39 @@ class LocationSignalsCheckerTest {
)
}
@Test
fun `foreign sim on russian visited network is flagged as home-routed roaming`() {
val result = LocationSignalsChecker.evaluate(
snapshot(
networkMcc = "250",
networkCountryIso = "ru",
networkOperatorName = "MegaFon",
simCards = listOf(
sim(
slotIndex = 0,
subscriptionId = 1,
simMcc = "208",
simCountryIso = "fr",
operatorName = "Free Mobile",
isRoaming = false,
),
),
),
)
assertFalse(result.needsReview)
assertFalse(result.detected)
assertNotNull(result.locationFacts)
assertTrue(result.locationFacts!!.homeRoutedRoaming)
assertEquals("208", result.locationFacts!!.homeSimMcc)
assertEquals("FR", result.locationFacts!!.homeSimCountryIso)
assertTrue(result.findings.any { it.description.startsWith("home_routed_roaming:true") })
assertTrue(result.evidence.any { it.source == EvidenceSource.HOME_ROUTED_ROAMING })
// SIM-MCC mismatch flips the displayed roaming state to "yes" even when
// telephony reported it as "no" (the bug from issue #63).
assertTrue(result.findings.any { it.description == "SIM[0] Roaming: yes" })
}
@Test
fun `empty sim cards list produces no sim findings and medium confidence for non-ru network`() {
val result = LocationSignalsChecker.evaluate(

View file

@ -14,6 +14,7 @@ import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.GeoIpFacts
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.LocationSignalsFacts
import com.notcvnt.rknhardering.model.Verdict
import org.junit.Assert.assertEquals
import org.junit.Test
@ -167,6 +168,69 @@ class VerdictEngineTest {
assertEquals(Verdict.DETECTED, verdict)
}
@Test
fun `home-routed roaming with foreign sim suppresses R4 detect`() {
val geo = category(
geoFacts = GeoIpFacts(
outsideRu = true,
countryCode = "FR",
expectedRoamingExit = true,
),
)
val location = CategoryResult(
name = "loc",
detected = false,
findings = listOf(Finding("network_mcc_ru:true")),
locationFacts = LocationSignalsFacts(
networkMcc = "250",
networkIsRussia = true,
homeSimMcc = "208",
homeSimCountryIso = "FR",
homeRoutedRoaming = true,
),
)
val verdict = VerdictEngine.evaluate(
geoIp = geo,
directSigns = category(),
indirectSigns = category(),
locationSignals = location,
bypassResult = bypass(),
ipConsensus = IpConsensusResult.empty(),
)
// Should fall through to NOT_DETECTED — this is the issue #63 case.
assertEquals(Verdict.NOT_DETECTED, verdict)
}
@Test
fun `home-routed roaming without ASN match still suppresses verdict`() {
// Even without GeoIP ASN confirmation, the home-routed-roaming flag
// alone is enough to gate R4 — the fallback for unknown carriers.
val geo = category(
geoFacts = GeoIpFacts(outsideRu = true, countryCode = "FR"),
)
val location = CategoryResult(
name = "loc",
detected = false,
findings = listOf(Finding("network_mcc_ru:true")),
locationFacts = LocationSignalsFacts(
networkMcc = "250",
networkIsRussia = true,
homeSimMcc = "208",
homeSimCountryIso = "FR",
homeRoutedRoaming = true,
),
)
val verdict = VerdictEngine.evaluate(
geoIp = geo,
directSigns = category(),
indirectSigns = category(),
locationSignals = location,
bypassResult = bypass(),
ipConsensus = IpConsensusResult.empty(),
)
assertEquals(Verdict.NOT_DETECTED, verdict)
}
@Test
fun `R4 regression issue 15 hosting only yields needs review`() {
val geo = category(
@ -519,14 +583,17 @@ class VerdictEngineTest {
needsReview: Boolean = false,
callTransportLeaks: List<CallTransportLeakResult> = emptyList(),
geoFacts: GeoIpFacts? = null,
findings: List<Finding> = emptyList(),
locationFacts: LocationSignalsFacts? = null,
): CategoryResult = CategoryResult(
name = "test",
detected = evidence.any { it.detected },
findings = emptyList(),
findings = findings,
needsReview = needsReview,
evidence = evidence,
callTransportLeaks = callTransportLeaks,
geoFacts = geoFacts,
locationFacts = locationFacts,
)
private fun bypass(

View file

@ -0,0 +1,8 @@
Fixed false-positive verdict for foreign SIM roaming in Russia (issue #63): home-routed roaming is now recognised by MCC and the home operator's ASN.
Added an explanatory banner to the verdict card when home-routed roaming is detected.
Fixed roaming status heuristic: "Roaming: yes" is now shown when SIM MCC mismatches network MCC, even if telephony reported "no".
Fixed SIM country label: foreign SIMs on a Russian visited network no longer show as RU.
CDN pulling no longer flags "needs review" when all targets returned the same IP.
Local SOCKS5 endpoints requiring auth with no resolved owner no longer inflate the verdict.
JSON export now includes roaming diagnostics and richer GeoIP facts (ASN code, ISP, organisation).
More information in data export.

View file

@ -0,0 +1,8 @@
رفع نتیجه مثبت کاذب در رومینگ سیم‌کارت خارجی در روسیه (issue #63): رومینگ از مسیر خانگی اکنون بر اساس MCC و ASN اپراتور خانگی شناسایی می‌شود.
در کارت حکم هنگام تشخیص رومینگ از مسیر خانگی یک پیام توضیحی افزوده شد.
رفع منطق وضعیت رومینگ: اکنون وقتی MCC سیم با MCC شبکه ناهمخوان باشد «Roaming: yes» نمایش داده می‌شود، حتی اگر تلفن «no» گزارش کند.
رفع برچسب کشور سیم: سیم‌های خارجی در شبکه میزبان روسیه دیگر به‌عنوان RU نمایش داده نمی‌شوند.
وقتی همه اهداف CDN یک IP یکسان برگردانند، CDN pulling دیگر با «نیاز به بررسی» علامت نمی‌خورد.
نقاط پایانی SOCKS5 محلی نیازمند احراز هویت بدون مالک شناسایی‌شده دیگر حکم را بزرگ‌نمایی نمی‌کنند.
خروجی JSON اکنون شامل تشخیص رومینگ و اطلاعات بیشتر GeoIP (کد ASN، ISP، سازمان) است.
اطلاعات بیشتر در خروجی داده.

View file

@ -0,0 +1,8 @@
Исправлено ложноположительное срабатывание при роуминге иностранной SIM в России (issue #63): home-routed роуминг теперь распознаётся по MCC и ASN домашнего оператора.
В карточку вердикта добавлен пояснительный баннер при home-routed роуминге.
Исправлена эвристика статуса роуминга: «Roaming: yes» теперь показывается, когда MCC SIM не совпадает с MCC сети, даже если телефония вернула «no».
Исправлена страна SIM в отчёте: больше не показывается RU для иностранных SIM в российской сети.
CDN pulling больше не помечается «требует проверки», когда все цели вернули один и тот же IP.
Локальные SOCKS5 с обязательной авторизацией без определённого владельца больше не раздувают вердикт.
В JSON-экспорт добавлена диагностика роуминга и расширены геофакты (ASN-код, ISP, организация).
Больше информации в экспорте данных.

View file

@ -0,0 +1,8 @@
修复在俄罗斯境内使用外国 SIM 漫游时的误报issue #63现在通过 MCC 和归属运营商 ASN 识别归属路由漫游。
当检测到归属路由漫游时,在裁决卡片中添加说明性提示。
修复漫游状态判定:当 SIM MCC 与网络 MCC 不一致时,即使电话子系统返回 "no",也会显示 "Roaming: yes"。
修复 SIM 国家标签:在俄罗斯访问网络下的外国 SIM 不再显示为 RU。
当所有 CDN 目标返回相同 IP 时CDN pulling 不再标记为 "需要复核"。
需要鉴权且无法解析所有者的本地 SOCKS5 端点不再夸大裁决。
JSON 导出新增漫游诊断及更丰富的 GeoIP 信息ASN 代码、ISP、组织
数据导出包含更多信息。