mirror of
https://github.com/xtclovver/RKNHardering.git
synced 2026-07-09 17:19:25 +00:00
fix: Исправлено ложноположительное срабатывание при роуминге иностранной SIM в России (issue #63)
Some checks failed
CI / build (push) Has been cancelled
Some checks failed
CI / build (push) Has been cancelled
This commit is contained in:
parent
562b13e729
commit
dd33d2f03f
26 changed files with 929 additions and 43 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
8
fastlane/metadata/android/en-US/changelogs/20701.txt
Normal file
8
fastlane/metadata/android/en-US/changelogs/20701.txt
Normal 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.
|
||||
8
fastlane/metadata/android/fa/changelogs/20701.txt
Normal file
8
fastlane/metadata/android/fa/changelogs/20701.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
رفع نتیجه مثبت کاذب در رومینگ سیمکارت خارجی در روسیه (issue #63): رومینگ از مسیر خانگی اکنون بر اساس MCC و ASN اپراتور خانگی شناسایی میشود.
|
||||
در کارت حکم هنگام تشخیص رومینگ از مسیر خانگی یک پیام توضیحی افزوده شد.
|
||||
رفع منطق وضعیت رومینگ: اکنون وقتی MCC سیم با MCC شبکه ناهمخوان باشد «Roaming: yes» نمایش داده میشود، حتی اگر تلفن «no» گزارش کند.
|
||||
رفع برچسب کشور سیم: سیمهای خارجی در شبکه میزبان روسیه دیگر بهعنوان RU نمایش داده نمیشوند.
|
||||
وقتی همه اهداف CDN یک IP یکسان برگردانند، CDN pulling دیگر با «نیاز به بررسی» علامت نمیخورد.
|
||||
نقاط پایانی SOCKS5 محلی نیازمند احراز هویت بدون مالک شناساییشده دیگر حکم را بزرگنمایی نمیکنند.
|
||||
خروجی JSON اکنون شامل تشخیص رومینگ و اطلاعات بیشتر GeoIP (کد ASN، ISP، سازمان) است.
|
||||
اطلاعات بیشتر در خروجی داده.
|
||||
8
fastlane/metadata/android/ru-RU/changelogs/20701.txt
Normal file
8
fastlane/metadata/android/ru-RU/changelogs/20701.txt
Normal 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, организация).
|
||||
Больше информации в экспорте данных.
|
||||
8
fastlane/metadata/android/zh-CN/changelogs/20701.txt
Normal file
8
fastlane/metadata/android/zh-CN/changelogs/20701.txt
Normal 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、组织)。
|
||||
数据导出包含更多信息。
|
||||
Loading…
Add table
Add a link
Reference in a new issue