fiz: баги

This commit is contained in:
xtclovver 2026-05-11 20:54:33 +03:00
parent e9b7bb6667
commit ff6cb6b534
9 changed files with 442 additions and 25 deletions

View file

@ -311,6 +311,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var verdictLabel: TextView
private lateinit var verdictTitle: TextView
private lateinit var verdictSubtitle: TextView
private lateinit var verdictHomeRoutedRoamingNote: TextView
private lateinit var hiddenLegacyCardsHost: LinearLayout
private lateinit var btnPrivacyInfo: MaterialButton
private val tiles = mutableMapOf<String, TileHolder>()
@ -544,6 +545,7 @@ class MainActivity : AppCompatActivity() {
verdictLabel = findViewById(R.id.verdictLabel)
verdictTitle = findViewById(R.id.verdictTitle)
verdictSubtitle = findViewById(R.id.verdictSubtitle)
verdictHomeRoutedRoamingNote = findViewById(R.id.verdictHomeRoutedRoamingNote)
hiddenLegacyCardsHost = findViewById(R.id.hiddenLegacyCardsHost)
btnPrivacyInfo = findViewById(R.id.btnPrivacyInfo)
btnPrivacyInfo.setOnClickListener { showPrivacyFooterDialog() }
@ -1202,6 +1204,7 @@ class MainActivity : AppCompatActivity() {
)
activeCheckSettings = null
lastCompletedResult = event.result
refreshCompletedCategoryViews(event.result, event.privacyMode)
displayVerdict(event.result, event.privacyMode)
bindVerdictHero(event.result)
if (animate) animateContentReveal(verdictHero)
@ -1233,6 +1236,30 @@ class MainActivity : AppCompatActivity() {
}
}
private fun refreshCompletedCategoryViews(result: CheckResult, privacyMode: Boolean) {
if (cardCdnPulling.isVisible || result.cdnPulling != CdnPullingResult.empty()) {
displayCdnPulling(result.cdnPulling, privacyMode)
updateTileFromCdn(result.cdnPulling)
}
val icmpHasContent = result.icmpSpoofing.findings.isNotEmpty() ||
result.icmpSpoofing.evidence.isNotEmpty() ||
result.icmpSpoofing.detected ||
result.icmpSpoofing.needsReview ||
result.icmpSpoofing.hasError
if (cardIcmpSpoofing.isVisible || icmpHasContent) {
displayCategory(
result.icmpSpoofing,
cardIcmpSpoofing,
iconIcmpSpoofing,
statusIcmpSpoofing,
findingsIcmpSpoofing,
privacyMode,
)
updateTileFromCategory(CATEGORY_ICM, result.icmpSpoofing)
}
}
private fun clearStageContent() {
geoIpInfoSection.removeAllViews()
geoIpInfoSection.visibility = View.GONE
@ -3021,18 +3048,7 @@ 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
}
bindHomeRoutedRoamingNote(narrative.homeRoutedRoamingNote)
verdictDetailsContent.removeAllViews()
addVerdictSection(
@ -3055,6 +3071,24 @@ class MainActivity : AppCompatActivity() {
updateVerdictDetailsButton()
}
private fun bindHomeRoutedRoamingNote(note: String?) {
if (note != null) {
textVerdictHomeRoutedRoamingNote.text = note
textVerdictHomeRoutedRoamingNote.visibility = View.VISIBLE
textVerdictHomeRoutedRoamingNote.setTextColor(onSurfaceColor())
textVerdictHomeRoutedRoamingNote.setBackgroundResource(
R.drawable.bg_verdict_home_routed_roaming_note,
)
verdictHomeRoutedRoamingNote.text = note
verdictHomeRoutedRoamingNote.visibility = View.VISIBLE
} else {
textVerdictHomeRoutedRoamingNote.text = ""
textVerdictHomeRoutedRoamingNote.visibility = View.GONE
verdictHomeRoutedRoamingNote.text = ""
verdictHomeRoutedRoamingNote.visibility = View.GONE
}
}
private fun addVerdictSection(title: String, content: List<View>) {
if (content.isEmpty()) return
@ -3126,8 +3160,7 @@ class MainActivity : AppCompatActivity() {
textVerdict.text = ""
textVerdictExplanation.text = ""
textVerdictExplanation.visibility = View.GONE
textVerdictHomeRoutedRoamingNote.text = ""
textVerdictHomeRoutedRoamingNote.visibility = View.GONE
bindHomeRoutedRoamingNote(null)
verdictDetailsDivider.visibility = View.GONE
btnVerdictDetails.visibility = View.GONE
btnVerdictDetails.text = getString(R.string.main_verdict_details)
@ -3494,6 +3527,7 @@ class MainActivity : AppCompatActivity() {
verdictLabel.text = getString(R.string.verdict_label)
verdictTitle.text = getString(R.string.verdict_title_idle)
verdictSubtitle.text = getString(R.string.verdict_subtitle_idle)
bindHomeRoutedRoamingNote(null)
}
private fun bindVerdictHeroRunning() {
@ -3503,6 +3537,7 @@ class MainActivity : AppCompatActivity() {
verdictLabel.text = getString(R.string.verdict_label)
verdictTitle.text = getString(R.string.verdict_title_idle)
verdictSubtitle.text = getString(R.string.verdict_subtitle_running)
bindHomeRoutedRoamingNote(null)
}
private fun bindVerdictHero(result: CheckResult) {

View file

@ -283,9 +283,17 @@ object BypassChecker {
}
internal fun proxyChecksNeedReview(proxyChecks: List<LocalProxyCheckResult>): Boolean {
return proxyChecks.any {
it.status != LocalProxyCheckStatus.SAME_IP &&
it.status != LocalProxyCheckStatus.CONFIRMED_BYPASS
return proxyChecks.any(::proxyCheckNeedsReview)
}
private fun proxyCheckNeedsReview(proxyCheck: LocalProxyCheckResult): Boolean {
return when (proxyCheck.status) {
LocalProxyCheckStatus.SAME_IP,
LocalProxyCheckStatus.CONFIRMED_BYPASS -> false
LocalProxyCheckStatus.AUTH_REQUIRED ->
proxyCheck.ownerStatus != LocalProxyOwnerStatus.UNRESOLVED
LocalProxyCheckStatus.PROXY_IP_UNAVAILABLE,
LocalProxyCheckStatus.DIRECT_IP_UNAVAILABLE -> true
}
}

View file

@ -18,6 +18,7 @@ import android.telephony.CellInfo
import android.telephony.CellInfoGsm
import android.telephony.CellInfoLte
import android.telephony.CellInfoWcdma
import android.telephony.SubscriptionManager
import android.telephony.TelephonyManager
import android.telephony.gsm.GsmCellLocation
import androidx.annotation.DoNotInline
@ -49,6 +50,13 @@ object LocationSignalsChecker {
val operatorName: String?,
val isRoaming: Boolean?,
val simMnc: String? = null,
val isActiveDataSubscription: Boolean = false,
val isDefaultDataSubscription: Boolean = false,
)
private data class DataSubscriptionIds(
val active: Int?,
val default: Int?,
)
private data class CellCollectionResult(
@ -258,6 +266,7 @@ object LocationSignalsChecker {
} else {
null
}
val dataSubscriptionIds = getDataSubscriptionIds()
if (!subscriptions.isNullOrEmpty()) {
return subscriptions.mapNotNull { info ->
@ -282,6 +291,8 @@ object LocationSignalsChecker {
?: subTm.simOperatorName?.takeIf { it.isNotEmpty() }
?: subTm.networkOperatorName?.takeIf { it.isNotEmpty() },
isRoaming = subTm.isNetworkRoaming,
isActiveDataSubscription = info.subscriptionId == dataSubscriptionIds.active,
isDefaultDataSubscription = info.subscriptionId == dataSubscriptionIds.default,
)
}.getOrNull()
}
@ -306,6 +317,8 @@ object LocationSignalsChecker {
operatorName = tm.simOperatorName?.takeIf { it.isNotEmpty() }
?: tm.networkOperatorName?.takeIf { it.isNotEmpty() },
isRoaming = tm.isNetworkRoaming,
isActiveDataSubscription = dataSubscriptionIds.active != null,
isDefaultDataSubscription = dataSubscriptionIds.default != null,
)
)
}.getOrElse { emptyList() }
@ -314,10 +327,34 @@ object LocationSignalsChecker {
@Suppress("MissingPermission")
private fun getActiveSubscriptions(context: Context): List<android.telephony.SubscriptionInfo>? {
val subscriptionManager = context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE)
as? android.telephony.SubscriptionManager
as? SubscriptionManager
return runCatching { subscriptionManager?.activeSubscriptionInfoList }.getOrNull()
}
private fun getDataSubscriptionIds(): DataSubscriptionIds {
val defaultDataSubscriptionId = runCatching {
normalizeSubscriptionId(SubscriptionManager.getDefaultDataSubscriptionId())
}.getOrNull()
val activeDataSubscriptionId = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
normalizeSubscriptionId(SubscriptionManager.getActiveDataSubscriptionId())
} else {
defaultDataSubscriptionId
}
}.getOrNull()
return DataSubscriptionIds(
active = activeDataSubscriptionId,
default = defaultDataSubscriptionId,
)
}
private fun normalizeSubscriptionId(subscriptionId: Int): Int? {
return subscriptionId.takeIf {
it != SubscriptionManager.INVALID_SUBSCRIPTION_ID &&
it != SubscriptionManager.DEFAULT_SUBSCRIPTION_ID
}
}
private suspend fun collectCellCandidates(
context: Context,
tm: TelephonyManager,
@ -892,10 +929,7 @@ 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 homeSim = selectHomeSim(snapshot)
val homeSimCountryIsRussia = homeSim?.simMcc == RUSSIA_MCC
val networkIsRussia = snapshot.networkMcc == RUSSIA_MCC
val homeRoutedRoaming = networkIsRussia &&
@ -1123,6 +1157,23 @@ object LocationSignalsChecker {
)
}
private fun selectHomeSim(snapshot: LocationSnapshot): SimCardInfo? {
val simsWithMcc = snapshot.simCards.filter { !it.simMcc.isNullOrBlank() }
return snapshot.simCards.firstOrNull { it.isActiveDataSubscription }
?: snapshot.simCards.firstOrNull { it.isDefaultDataSubscription }
?: simsWithMcc.firstOrNull { it.matchesRegisteredNetwork(snapshot) }
?: simsWithMcc.firstOrNull()
?: snapshot.simCards.firstOrNull()
}
private fun SimCardInfo.matchesRegisteredNetwork(snapshot: LocationSnapshot): Boolean {
if (simMcc.isNullOrBlank() || snapshot.networkMcc.isNullOrBlank()) return false
if (simMcc != snapshot.networkMcc) return false
return snapshot.networkMnc.isNullOrBlank() ||
simMnc.isNullOrBlank() ||
simMnc == snapshot.networkMnc
}
private val MAC_ADDRESS_REGEX = Regex("^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$")
/**

View file

@ -280,9 +280,15 @@ object VpnCheckRunner {
// by the SIM home network.
if (!result.detected && !result.needsReview) return result
return result.copy(
detected = false,
needsReview = false,
findings = result.findings.map { finding ->
if (finding.needsReview) finding.copy(needsReview = false) else finding
when {
finding.isError -> finding.copy(needsReview = false)
finding.detected || finding.needsReview ->
finding.copy(detected = false, needsReview = false, isInformational = true)
else -> finding
}
},
)
}
@ -293,6 +299,7 @@ object VpnCheckRunner {
// reply via ICMP cannot be treated as suspicious.
if (!result.needsReview && result.evidence.none { it.detected }) return result
return result.copy(
detected = false,
needsReview = false,
evidence = result.evidence.map { item ->
if (item.source == EvidenceSource.ICMP_SPOOFING && item.detected) {
@ -300,7 +307,7 @@ object VpnCheckRunner {
} else item
},
findings = result.findings.map { finding ->
if (finding.needsReview) finding.copy(needsReview = false) else finding
if (finding.needsReview) finding.copy(detected = false, needsReview = false, isInformational = true) else finding
},
)
}

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/verdictHero"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -112,5 +113,18 @@
android:layout_marginTop="10dp"
android:textColor="?android:attr/textColorSecondary"
android:textSize="12sp" />
<TextView
android:id="@+id/verdictHomeRoutedRoamingNote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/bg_verdict_home_routed_roaming_note"
android:padding="10dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="12sp"
android:visibility="gone"
tools:text="@string/narrative_home_routed_roaming_generic"
tools:visibility="visible" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View file

@ -19,6 +19,10 @@ import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.Channel
import com.notcvnt.rknhardering.model.CdnPullingResponse
import com.notcvnt.rknhardering.model.CdnPullingResult
import com.notcvnt.rknhardering.model.CheckResult
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.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpCheckerResponse
@ -26,8 +30,10 @@ import com.notcvnt.rknhardering.model.IpCheckerScope
import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.IpFamily
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.LocationSignalsFacts
import com.notcvnt.rknhardering.model.ObservedIp
import com.notcvnt.rknhardering.model.TargetGroup
import com.notcvnt.rknhardering.model.Verdict
import org.junit.Before
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@ -478,6 +484,102 @@ class MainActivityUiRenderingTest {
)
}
@Test
fun `completed event refreshes reconciled cdn and icmp cards`() {
val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()
val rawCdn = CdnPullingResult(
detected = true,
needsReview = true,
summary = "Raw CDN signal",
responses = listOf(
CdnPullingResponse(
targetLabel = "meduza.io",
url = "https://meduza.io/cdn-cgi/trace",
ip = "203.0.113.64",
),
),
)
val rawIcmp = CategoryResult(
name = "icmp",
detected = false,
findings = listOf(Finding("Raw ICMP signal", needsReview = true)),
needsReview = true,
evidence = listOf(
EvidenceItem(
source = EvidenceSource.ICMP_SPOOFING,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = "Blocked target replied",
),
),
)
invokePrivate<Unit>(activity, "displayCdnPulling", rawCdn, false)
invokePrivate<Unit>(activity, "updateTileFromCdn", rawCdn)
invokePrivate<Unit>(
activity,
"displayCategory",
rawIcmp,
activity.findViewById<MaterialCardView>(R.id.cardIcmpSpoofing),
activity.findViewById<ImageView>(R.id.iconIcmpSpoofing),
activity.findViewById<TextView>(R.id.statusIcmpSpoofing),
activity.findViewById<LinearLayout>(R.id.findingsIcmpSpoofing),
false,
)
invokePrivate<Unit>(activity, "updateTileFromCategory", "icmp", rawIcmp)
val finalCdn = rawCdn.copy(detected = false, needsReview = false, summary = "Reconciled CDN signal")
val finalIcmp = rawIcmp.copy(
findings = listOf(Finding("Reconciled ICMP signal", isInformational = true)),
needsReview = false,
evidence = emptyList(),
)
invokePrivate<Unit>(
activity,
"applyScanEvent",
ScanEvent.Completed(
checkResult(cdnPulling = finalCdn, icmpSpoofing = finalIcmp),
privacyMode = false,
),
false,
)
assertEquals(activity.getString(R.string.main_card_status_clean), activity.findViewById<TextView>(R.id.statusCdnPulling).text.toString())
assertEquals(activity.getString(R.string.tile_hint_clean_count, 1), tileHint(activity, "cdn"))
assertEquals(activity.getString(R.string.main_card_status_clean), activity.findViewById<TextView>(R.id.statusIcmpSpoofing).text.toString())
assertEquals(activity.getString(R.string.tile_hint_clean), tileHint(activity, "icmp"))
}
@Test
fun `completed event shows home routed roaming note in verdict hero`() {
val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()
val location = category("location").copy(
locationFacts = LocationSignalsFacts(
networkMcc = "250",
networkIsRussia = true,
homeSimMcc = "208",
homeSimCountryIso = "FR",
homeSimOperatorName = "Free Mobile",
homeRoutedRoaming = true,
),
)
invokePrivate<Unit>(
activity,
"applyScanEvent",
ScanEvent.Completed(
checkResult(locationSignals = location),
privacyMode = false,
),
false,
)
val note = activity.findViewById<TextView>(R.id.verdictHomeRoutedRoamingNote)
assertEquals(View.VISIBLE, note.visibility)
assertTrueContains(note.text.toString(), "Free Mobile")
assertTrueContains(note.text.toString(), "FR")
}
private fun collectText(view: View): String {
if (view is TextView) return view.text.toString()
if (view !is ViewGroup) return ""
@ -511,6 +613,46 @@ class MainActivityUiRenderingTest {
)
}
private fun checkResult(
cdnPulling: CdnPullingResult = CdnPullingResult.empty(),
icmpSpoofing: CategoryResult = category("icmp"),
locationSignals: CategoryResult = category("location"),
verdict: Verdict = Verdict.NOT_DETECTED,
): CheckResult {
return CheckResult(
geoIp = category("geo"),
ipComparison = IpComparisonResult(
detected = false,
summary = "",
ruGroup = emptyIpCheckerGroup("RU"),
nonRuGroup = emptyIpCheckerGroup("NON_RU"),
),
cdnPulling = cdnPulling,
directSigns = category("direct"),
indirectSigns = category("indirect"),
locationSignals = locationSignals,
bypassResult = BypassResult(
proxyEndpoint = null,
directIp = null,
proxyIp = null,
xrayApiScanResult = null,
findings = emptyList(),
detected = false,
),
verdict = verdict,
icmpSpoofing = icmpSpoofing,
ipConsensus = IpConsensusResult.empty(),
)
}
private fun category(name: String): CategoryResult {
return CategoryResult(
name = name,
detected = false,
findings = emptyList(),
)
}
@Suppress("UNCHECKED_CAST")
private fun <T> getPrivateField(target: Any, name: String): T {
val field = target::class.java.getDeclaredField(name)

View file

@ -594,7 +594,7 @@ class BypassCheckerTest {
assertFalse(evaluation.confirmedBypass)
assertEquals(LocalProxyCheckStatus.AUTH_REQUIRED, evaluation.proxyChecks.single().status)
assertTrue(BypassChecker.proxyChecksNeedReview(evaluation.proxyChecks))
assertFalse(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
@ -610,6 +610,17 @@ class BypassCheckerTest {
assertFalse(evidence.any { it.source == EvidenceSource.LOCAL_PROXY })
}
@Test
fun `auth required proxy with known owner still needs review`() {
val proxyCheck = com.notcvnt.rknhardering.model.LocalProxyCheckResult(
endpoint = ProxyEndpoint(host = "127.0.0.1", port = 10808, type = ProxyType.SOCKS5, authRequired = true),
ownerStatus = LocalProxyOwnerStatus.AMBIGUOUS,
status = LocalProxyCheckStatus.AUTH_REQUIRED,
)
assertTrue(BypassChecker.proxyChecksNeedReview(listOf(proxyCheck)))
}
@Test
fun `proxy evaluation inspects every candidate when proxy ip is unavailable`() = runBlocking {
val findings = mutableListOf<Finding>()

View file

@ -450,6 +450,73 @@ class LocationSignalsCheckerTest {
assertTrue(result.findings.any { it.description == "SIM[0] Roaming: yes" })
}
@Test
fun `active russian data sim prevents home routed roaming from inactive foreign sim`() {
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 = true,
),
sim(
slotIndex = 1,
subscriptionId = 2,
simMcc = "250",
simMnc = "02",
simCountryIso = "ru",
operatorName = "MegaFon",
isRoaming = false,
isActiveDataSubscription = true,
isDefaultDataSubscription = true,
),
),
),
)
assertFalse(result.locationFacts!!.homeRoutedRoaming)
assertEquals("250", result.locationFacts!!.homeSimMcc)
assertFalse(result.evidence.any { it.source == EvidenceSource.HOME_ROUTED_ROAMING })
}
@Test
fun `registered network sim is preferred over first foreign sim when data id is unavailable`() {
val result = LocationSignalsChecker.evaluate(
snapshot(
networkMcc = "250",
networkOperatorName = "MegaFon",
simCards = listOf(
sim(
slotIndex = 0,
subscriptionId = 1,
simMcc = "208",
simCountryIso = "fr",
operatorName = "Free Mobile",
isRoaming = true,
),
sim(
slotIndex = 1,
subscriptionId = 2,
simMcc = "250",
simCountryIso = "ru",
operatorName = "MegaFon",
isRoaming = false,
),
),
),
)
assertFalse(result.locationFacts!!.homeRoutedRoaming)
assertEquals("250", result.locationFacts!!.homeSimMcc)
}
@Test
fun `empty sim cards list produces no sim findings and medium confidence for non-ru network`() {
val result = LocationSignalsChecker.evaluate(
@ -475,16 +542,22 @@ class LocationSignalsCheckerTest {
slotIndex: Int = 0,
subscriptionId: Int = 1,
simMcc: String? = "250",
simMnc: String? = null,
simCountryIso: String? = "ru",
operatorName: String? = "MegaFon",
isRoaming: Boolean? = false,
isActiveDataSubscription: Boolean = false,
isDefaultDataSubscription: Boolean = false,
) = LocationSignalsChecker.SimCardInfo(
slotIndex = slotIndex,
subscriptionId = subscriptionId,
simMcc = simMcc,
simMnc = simMnc,
simCountryIso = simCountryIso,
operatorName = operatorName,
isRoaming = isRoaming,
isActiveDataSubscription = isActiveDataSubscription,
isDefaultDataSubscription = isDefaultDataSubscription,
)
private fun snapshot(

View file

@ -17,8 +17,10 @@ import com.notcvnt.rknhardering.model.Channel
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.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.LocationSignalsFacts
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.model.TargetGroup
@ -489,6 +491,80 @@ class VpnCheckRunnerTest {
assertTrue(updates.any { it is CheckUpdate.CdnPullingReady })
}
@Test
fun `home routed roaming relaxes final cdn and icmp categories`() = runBlocking {
val rawCdn = CdnPullingResult(
detected = true,
needsReview = true,
summary = "CDN targets exposed foreign IP",
responses = listOf(
CdnPullingResponse(
targetLabel = "meduza.io",
url = "https://meduza.io/cdn-cgi/trace",
ip = "203.0.113.64",
ipv4 = "203.0.113.64",
),
),
findings = listOf(Finding("meduza.io: IP: 203.0.113.64", detected = true, needsReview = true)),
)
val rawIcmp = category(
name = "icmp",
needsReview = true,
evidence = listOf(
EvidenceItem(
source = EvidenceSource.ICMP_SPOOFING,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = "Blocked target replied",
),
),
)
VpnCheckRunner.dependenciesOverride = VpnCheckRunner.Dependencies(
geoIpCheck = { _, _ -> category("geo") },
ipComparisonCheck = { _, _ -> emptyIpComparison() },
cdnPullingCheck = { _, _, _ -> rawCdn },
icmpSpoofingCheck = { _, _ -> rawIcmp },
directCheck = { _, _ -> category("direct") },
indirectCheck = { _, _, _, _ -> category("indirect") },
locationCheck = { _, _, _ ->
CategoryResult(
name = "location",
detected = false,
findings = listOf(Finding("network_mcc_ru:true")),
locationFacts = LocationSignalsFacts(
networkMcc = "250",
networkIsRussia = true,
homeSimMcc = "208",
homeSimCountryIso = "FR",
homeRoutedRoaming = true,
),
)
},
nativeCheck = { _ -> category("native") },
bypassCheck = { _, _, _, _, _, _, _, _, _, _ ->
error("BypassChecker should not run when split tunnel is disabled")
},
)
val result = VpnCheckRunner.run(
context = context,
settings = CheckSettings(
splitTunnelEnabled = false,
networkRequestsEnabled = true,
cdnPullingEnabled = true,
resolverConfig = DnsResolverConfig.system(),
),
)
assertFalse(result.cdnPulling.detected)
assertFalse(result.cdnPulling.needsReview)
assertTrue(result.cdnPulling.findings.none { it.detected || it.needsReview })
assertFalse(result.icmpSpoofing.detected)
assertFalse(result.icmpSpoofing.needsReview)
assertTrue(result.icmpSpoofing.evidence.none { it.detected })
}
@Test
fun `cdn pulling stays disabled when toggle is off`() = runBlocking {
val updates = mutableListOf<CheckUpdate>()