diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1626103..a80042a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,20 @@ plugins { val nativeNdkVersion = "28.2.13676358" val nativeCmakeVersion = "3.22.1" +val robolectricSdk33RuntimeDep by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} +val robolectricSdk35RuntimeDep by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} +val robolectricRuntimeDepsDir = layout.buildDirectory.dir("robolectric-runtime-deps") +val prepareRobolectricRuntimeDeps by tasks.registering(Copy::class) { + from(robolectricSdk33RuntimeDep) + from(robolectricSdk35RuntimeDep) + into(robolectricRuntimeDepsDir) +} android { namespace = "com.notcvnt.rknhardering" @@ -71,8 +85,10 @@ android { } tasks.withType().configureEach { - val testHomeDir = layout.projectDirectory.dir(".test-home").asFile - val tempDir = testHomeDir.resolve("tmp") + val testHomeDir = layout.buildDirectory.dir("test-home").get().asFile + val tempDir = layout.buildDirectory.dir("test-tmp").get().asFile + + dependsOn(prepareRobolectricRuntimeDeps) doFirst { testHomeDir.mkdirs() @@ -81,6 +97,9 @@ tasks.withType().configureEach { systemProperty("user.home", testHomeDir.absolutePath) systemProperty("java.io.tmpdir", tempDir.absolutePath) + systemProperty("robolectric.dependency.dir", robolectricRuntimeDepsDir.get().asFile.absolutePath) + systemProperty("robolectric.offline", "true") + systemProperty("robolectric.usePreinstrumentedJars", "false") } dependencies { @@ -100,6 +119,8 @@ dependencies { testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) + robolectricSdk33RuntimeDep("org.robolectric:android-all:13-robolectric-9030017") + robolectricSdk35RuntimeDep("org.robolectric:android-all:15-robolectric-13954326") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/app/src/main/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatter.kt b/app/src/main/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatter.kt index 321d7a9..7c4038c 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatter.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatter.kt @@ -3,6 +3,7 @@ package com.notcvnt.rknhardering import com.notcvnt.rknhardering.checker.CheckSettings import com.notcvnt.rknhardering.checker.DebugStepTiming import com.notcvnt.rknhardering.checker.IndirectCheckPerformanceRegistry +import com.notcvnt.rknhardering.checker.LocationSignalsDiagnosticsRegistry import com.notcvnt.rknhardering.checker.StunScopeTiming import com.notcvnt.rknhardering.checker.summarizeDominantDelay import com.notcvnt.rknhardering.model.ActiveVpnApp @@ -64,6 +65,7 @@ object DebugDiagnosticsFormatter { appendCategory(builder, "rttTriangulation", result.rttTriangulation) appendIndirectPerformance(builder, result.indirectSigns) appendCategory(builder, "locationSignals", result.locationSignals) + appendLocationSignalsDiagnostics(builder, result.locationSignals) appendBypass(builder, result.bypassResult) appendCategory(builder, "nativeSigns", result.nativeSigns) appendNativeSignsRaw(builder, privacyMode) @@ -414,6 +416,37 @@ object DebugDiagnosticsFormatter { } } + private fun appendLocationSignalsDiagnostics( + builder: StringBuilder, + category: CategoryResult, + ) { + val diagnostics = LocationSignalsDiagnosticsRegistry.find(category) ?: return + builder.appendLine() + builder.appendLine("[locationSignals.debug]") + builder.appendLine("fineLocationPermissionGranted: ${diagnostics.fineLocationPermissionGranted}") + builder.appendLine("nearbyWifiPermissionGranted: ${diagnostics.nearbyWifiPermissionGranted}") + builder.appendLine("locationServicesEnabled: ${diagnostics.locationServicesEnabled}") + builder.appendLine("telephonyRadioAccessAvailable: ${diagnostics.telephonyRadioAccessAvailable}") + builder.appendLine("wifiFeatureAvailable: ${diagnostics.wifiFeatureAvailable}") + builder.appendLine("networkRequestsEnabled: ${diagnostics.networkRequestsEnabled}") + builder.appendLine("cellRawInfoCount: ${diagnostics.cellRawInfoCount}") + builder.appendLine("cellRawInfoTypes: ${formatStringList(diagnostics.cellRawInfoTypes)}") + builder.appendLine("cellCandidateRadios: ${formatStringList(diagnostics.cellCandidateRadios)}") + builder.appendLine("beaconDbCellCandidatesUsedCount: ${diagnostics.beaconDbCellCandidatesUsedCount}") + builder.appendLine("beaconDbUnsupportedCellRadios: ${formatStringList(diagnostics.beaconDbUnsupportedCellRadios)}") + builder.appendLine("beaconDbWifiCandidatesUsedCount: ${diagnostics.beaconDbWifiCandidatesUsedCount}") + builder.appendLine("wifiAccessPointCandidatesCount: ${diagnostics.wifiAccessPointCandidatesCount}") + builder.appendLine("wifiCachedScanCandidatesCount: ${diagnostics.wifiCachedScanCandidatesCount}") + builder.appendLine("wifiFreshScanCandidatesCount: ${diagnostics.wifiFreshScanCandidatesCount?.toString() ?: ""}") + builder.appendLine("wifiConnectedCandidateAvailable: ${diagnostics.wifiConnectedCandidateAvailable}") + builder.appendLine("bssidSource: ${diagnostics.bssidSource ?: ""}") + builder.appendLine("bssidUnavailableReason: ${diagnostics.bssidUnavailableReason ?: ""}") + } + + private fun formatStringList(values: List): String { + return values.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: "" + } + private fun appendIpComparison( builder: StringBuilder, ipComparison: IpComparisonResult, diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/BeaconDbClient.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/BeaconDbClient.kt index 1c8c198..ac417fa 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/BeaconDbClient.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/BeaconDbClient.kt @@ -14,11 +14,19 @@ internal data class CellLookupCandidate( val mcc: String, val mnc: String, val areaCode: Long, - val cellId: Long, + val cellId: Long? = null, + val newRadioCellId: Long? = null, val registered: Boolean, val signalStrength: Int? = null, ) +internal data class BeaconDbInputDiagnostics( + val supportedCellCount: Int, + val unsupportedCellRadios: List, + val wifiUsedCount: Int, + val wifiCandidateCount: Int, +) + internal data class WifiLookupCandidate( val macAddress: String, val frequency: Int? = null, @@ -59,14 +67,14 @@ internal class BeaconDbClient( cells: List, wifiAccessPoints: List, ): CellLookupResult = withContext(Dispatchers.IO) { - val supportedCells = cells.filter { it.radio in SUPPORTED_RADIOS }.take(MAX_CELL_TOWERS) + val supportedCells = cells.filter(::isSupportedCell).take(MAX_CELL_TOWERS) val wifiForLookup = wifiAccessPoints.takeIf { it.size >= MIN_WIFI_ACCESS_POINTS }.orEmpty() if (supportedCells.isEmpty() && wifiForLookup.isEmpty()) { return@withContext CellLookupResult( countryCode = null, latitude = null, longitude = null, - summary = "BeaconDB: insufficient radio data", + summary = "BeaconDB: insufficient radio data (need >=2 Wi-Fi APs or >=1 supported cell tower)", ) } @@ -133,7 +141,7 @@ internal class BeaconDbClient( cells: List, wifiAccessPoints: List, ): String { - val supportedCells = cells.filter { it.radio in SUPPORTED_RADIOS }.take(MAX_CELL_TOWERS) + val supportedCells = cells.filter(::isSupportedCell).take(MAX_CELL_TOWERS) val wifiForLookup = wifiAccessPoints.takeIf { it.size >= MIN_WIFI_ACCESS_POINTS }.orEmpty() val cellJson = supportedCells.joinToString(",") { candidate -> buildString { @@ -173,6 +181,25 @@ internal class BeaconDbClient( } } + internal fun inputDiagnostics( + cells: List, + wifiAccessPoints: List, + ): BeaconDbInputDiagnostics { + val supportedCells = cells.filter(::isSupportedCell).take(MAX_CELL_TOWERS) + val unsupportedRadios = cells + .filterNot(::isSupportedCell) + .map { it.radio.lowercase(Locale.US) } + .distinct() + .sorted() + val wifiForLookup = wifiAccessPoints.takeIf { it.size >= MIN_WIFI_ACCESS_POINTS }.orEmpty() + return BeaconDbInputDiagnostics( + supportedCellCount = supportedCells.size, + unsupportedCellRadios = unsupportedRadios, + wifiUsedCount = wifiForLookup.take(MAX_WIFI_ACCESS_POINTS).size, + wifiCandidateCount = wifiAccessPoints.size, + ) + } + internal fun describeFailure(response: HttpResult): String { return when { response.code == 404 -> "BeaconDB: no matching location" @@ -204,6 +231,12 @@ internal class BeaconDbClient( return value.replace("\\", "\\\\").replace("\"", "\\\"") } + private fun isSupportedCell(candidate: CellLookupCandidate): Boolean { + val radio = candidate.radio.lowercase(Locale.US) + if (radio !in SUPPORTED_RADIOS) return false + return candidate.cellId != null + } + private companion object { private const val LOOKUP_URL = "https://api.beacondb.net/v1/geolocate" private const val MAX_CELL_TOWERS = 6 diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/LocationSignalsChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/LocationSignalsChecker.kt index d6399bb..2a495e6 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/LocationSignalsChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/LocationSignalsChecker.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.location.Geocoder +import android.location.LocationManager import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.wifi.ScanResult @@ -18,6 +19,7 @@ import android.telephony.CellInfoGsm import android.telephony.CellInfoLte import android.telephony.CellInfoWcdma import android.telephony.TelephonyManager +import android.telephony.gsm.GsmCellLocation import androidx.annotation.DoNotInline import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat @@ -47,6 +49,26 @@ object LocationSignalsChecker { val isRoaming: Boolean?, ) + private data class CellCollectionResult( + val candidates: List = emptyList(), + val rawInfoCount: Int = 0, + val rawInfoTypes: List = emptyList(), + val candidateRadios: List = emptyList(), + ) + + private data class WifiCollectionResult( + val candidates: List = emptyList(), + val cachedScanCandidatesCount: Int = 0, + val freshScanCandidatesCount: Int? = null, + val connectedCandidateAvailable: Boolean = false, + ) + + private data class BssidCollectionResult( + val bssid: String?, + val source: String?, + val unavailableReason: String?, + ) + internal data class LocationSnapshot( val networkMcc: String?, val networkCountryIso: String?, @@ -59,6 +81,22 @@ object LocationSignalsChecker { val bssid: String?, val cellLookupPermissionGranted: Boolean, val wifiPermissionGranted: Boolean, + val locationServicesEnabled: Boolean = true, + val telephonyRadioAccessAvailable: Boolean = true, + val wifiFeatureAvailable: Boolean = true, + val nearbyWifiPermissionGranted: Boolean = true, + val networkRequestsEnabled: Boolean = true, + val cellRawInfoCount: Int = 0, + val cellRawInfoTypes: List = emptyList(), + val cellCandidateRadios: List = emptyList(), + val beaconDbCellCandidatesUsedCount: Int = 0, + val beaconDbUnsupportedCellRadios: List = emptyList(), + val beaconDbWifiCandidatesUsedCount: Int = 0, + val wifiCachedScanCandidatesCount: Int = 0, + val wifiFreshScanCandidatesCount: Int? = null, + val wifiConnectedCandidateAvailable: Boolean = false, + val bssidSource: String? = null, + val bssidUnavailableReason: String? = null, ) private const val RUSSIA_MCC = "250" @@ -67,6 +105,7 @@ object LocationSignalsChecker { private const val WIFI_SCAN_TIMEOUT_MS = 3_000L private const val MAX_CELL_TOWERS = 6 private const val MAX_WIFI_ACCESS_POINTS = 12 + private const val MAX_NR_CELL_ID = 68_719_476_735L suspend fun check( context: Context, @@ -89,6 +128,9 @@ object LocationSignalsChecker { } val cellLookupPermissionGranted = fineLocationGranted val wifiPermissionGranted = fineLocationGranted && nearbyWifiGranted + val locationServicesEnabled = isLocationEnabled(context) + val telephonyRadioAccessAvailable = hasTelephonyRadioAccess(context) + val wifiFeatureAvailable = context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI) var networkMcc: String? = null var networkCountryIso: String? = null @@ -110,21 +152,29 @@ object LocationSignalsChecker { val simCards = collectSimCards(context, tm) - val cellCandidates = if (cellLookupPermissionGranted) { - collectCellCandidates(context, tm).also { cellCandidatesCount = it.size } + val cellCollection = if (cellLookupPermissionGranted && locationServicesEnabled && telephonyRadioAccessAvailable) { + collectCellCandidates(context, tm, simCards).also { cellCandidatesCount = it.candidates.size } } else { - emptyList() + CellCollectionResult() } - val wifiCandidates = if (wifiPermissionGranted) { - collectWifiCandidates(context).also { wifiAccessPointCandidatesCount = it.size } + val cellCandidates = cellCollection.candidates + val wifiCollection = if (wifiPermissionGranted && locationServicesEnabled && wifiFeatureAvailable) { + collectWifiCandidates(context).also { wifiAccessPointCandidatesCount = it.candidates.size } } else { - emptyList() + WifiCollectionResult() } + val wifiCandidates = wifiCollection.candidates + + val beaconDbClient = BeaconDbClient(countryResolver = { lat, lon -> + reverseGeocodeCountry(context, lat, lon) + }, resolverConfig = resolverConfig) + val beaconDbInput = beaconDbClient.inputDiagnostics(cellCandidates, wifiCandidates) + val beaconDbCellCandidatesUsedCount = beaconDbInput.supportedCellCount + val beaconDbUnsupportedCellRadios = beaconDbInput.unsupportedCellRadios + val beaconDbWifiCandidatesUsedCount = beaconDbInput.wifiUsedCount if ((cellLookupPermissionGranted || wifiPermissionGranted) && networkRequestsEnabled) { - val lookup = BeaconDbClient(countryResolver = { lat, lon -> - reverseGeocodeCountry(context, lat, lon) - }, resolverConfig = resolverConfig).lookup(cellCandidates, wifiCandidates) + val lookup = beaconDbClient.lookup(cellCandidates, wifiCandidates) cellCountryCode = lookup.countryCode cellLookupSummary = buildString { append(lookup.summary) @@ -134,10 +184,10 @@ object LocationSignalsChecker { } } - val bssid = if (wifiPermissionGranted) { - runCatching { getBssid(context) }.getOrNull() + val bssidResult = if (wifiPermissionGranted && locationServicesEnabled && wifiFeatureAvailable) { + collectBssid(context, wifiCandidates) } else { - null + BssidCollectionResult(bssid = null, source = null, unavailableReason = null) } return LocationSnapshot( @@ -149,9 +199,25 @@ object LocationSignalsChecker { cellLookupSummary = cellLookupSummary, cellCandidatesCount = cellCandidatesCount, wifiAccessPointCandidatesCount = wifiAccessPointCandidatesCount, - bssid = bssid, + bssid = bssidResult.bssid, cellLookupPermissionGranted = cellLookupPermissionGranted, wifiPermissionGranted = wifiPermissionGranted, + locationServicesEnabled = locationServicesEnabled, + telephonyRadioAccessAvailable = telephonyRadioAccessAvailable, + wifiFeatureAvailable = wifiFeatureAvailable, + nearbyWifiPermissionGranted = nearbyWifiGranted, + networkRequestsEnabled = networkRequestsEnabled, + cellRawInfoCount = cellCollection.rawInfoCount, + cellRawInfoTypes = cellCollection.rawInfoTypes, + cellCandidateRadios = cellCollection.candidateRadios, + beaconDbCellCandidatesUsedCount = beaconDbCellCandidatesUsedCount, + beaconDbUnsupportedCellRadios = beaconDbUnsupportedCellRadios, + beaconDbWifiCandidatesUsedCount = beaconDbWifiCandidatesUsedCount, + wifiCachedScanCandidatesCount = wifiCollection.cachedScanCandidatesCount, + wifiFreshScanCandidatesCount = wifiCollection.freshScanCandidatesCount, + wifiConnectedCandidateAvailable = wifiCollection.connectedCandidateAvailable, + bssidSource = bssidResult.source, + bssidUnavailableReason = bssidResult.unavailableReason, ) } @@ -159,6 +225,26 @@ object LocationSignalsChecker { return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } + private fun isLocationEnabled(context: Context): Boolean { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager + ?: return true + return runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + locationManager.isLocationEnabled + } else { + @Suppress("DEPRECATION") + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } + }.getOrDefault(true) + } + + private fun hasTelephonyRadioAccess(context: Context): Boolean { + val packageManager = context.packageManager + return packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY_RADIO_ACCESS) || + packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) + } + private fun collectSimCards(context: Context, tm: TelephonyManager): List { val subscriptions = if (hasPermission(context, Manifest.permission.READ_PHONE_STATE)) { getActiveSubscriptions(context) @@ -215,20 +301,59 @@ object LocationSignalsChecker { private suspend fun collectCellCandidates( context: Context, tm: TelephonyManager, - ): List { + simCards: List, + ): CellCollectionResult { if (!hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) { - return emptyList() + return CellCollectionResult() } - val fresh = requestFreshCellInfo(context, tm) - val fallback = getCachedCellInfo(tm) - return (fresh.ifEmpty { fallback }) + val subscriptionManagers = simCards + .mapNotNull { it.subscriptionId.takeIf { subscriptionId -> subscriptionId >= 0 } } + .distinct() + .mapNotNull { subscriptionId -> + runCatching { tm.createForSubscriptionId(subscriptionId) }.getOrNull() + } + val managers = (listOf(tm) + subscriptionManagers).distinct() + val cellInfo = managers.flatMap { collectCellInfo(context, it) } + val rawInfoTypes = cellInfo + .map(::cellInfoTypeName) + .distinct() + .sorted() + val candidates = cellInfo .mapNotNull(::toLookupCandidate) - .distinctBy { listOf(it.radio, it.mcc, it.mnc, it.areaCode, it.cellId) } + .distinctBy { listOf(it.radio, it.mcc, it.mnc, it.areaCode, it.cellId, it.newRadioCellId) } .sortedWith( compareByDescending { it.registered } .thenByDescending { it.signalStrength ?: Int.MIN_VALUE }, ) .take(MAX_CELL_TOWERS) + + if (candidates.isNotEmpty()) { + return CellCollectionResult( + candidates = candidates, + rawInfoCount = cellInfo.size, + rawInfoTypes = rawInfoTypes, + candidateRadios = summarizeCellRadios(candidates), + ) + } + + val legacyCandidates = managers + .mapNotNull(::legacyGsmCellCandidate) + .distinctBy { listOf(it.radio, it.mcc, it.mnc, it.areaCode, it.cellId, it.newRadioCellId) } + .take(MAX_CELL_TOWERS) + return CellCollectionResult( + candidates = legacyCandidates, + rawInfoCount = cellInfo.size, + rawInfoTypes = rawInfoTypes, + candidateRadios = summarizeCellRadios(legacyCandidates), + ) + } + + private suspend fun collectCellInfo( + context: Context, + tm: TelephonyManager, + ): List { + return (requestFreshCellInfo(context, tm) + getCachedCellInfo(tm)) + .distinctBy { it.toString() } } @Suppress("MissingPermission") @@ -250,6 +375,10 @@ object LocationSignalsChecker { override fun onCellInfo(cellInfo: MutableList) { resumeOnce(continuation, completed, cellInfo.toList()) } + + override fun onError(errorCode: Int, detail: Throwable?) { + resumeOnce(continuation, completed, emptyList()) + } }, ) }.isSuccess @@ -270,6 +399,22 @@ object LocationSignalsChecker { return runCatching { tm.allCellInfo.orEmpty() }.getOrDefault(emptyList()) } + @Suppress("DEPRECATION", "MissingPermission") + private fun legacyGsmCellCandidate(tm: TelephonyManager): CellLookupCandidate? { + val operator = normalizeOperatorCode(tm.networkOperator)?.takeIf { it.length >= 4 } ?: return null + val location = runCatching { tm.cellLocation as? GsmCellLocation }.getOrNull() ?: return null + val areaCode = normalizeCellValue(location.lac) ?: return null + val cellId = normalizeCellValue(location.cid) ?: return null + return CellLookupCandidate( + radio = "gsm", + mcc = operator.substring(0, 3), + mnc = operator.substring(3), + areaCode = areaCode, + cellId = cellId, + registered = true, + ) + } + private fun toLookupCandidate(info: CellInfo): CellLookupCandidate? { return when (info) { is CellInfoGsm -> { @@ -323,19 +468,87 @@ object LocationSignalsChecker { ) } - else -> null + else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Api29Impl.nrCandidate(info) + } else { + null + } } } - private suspend fun collectWifiCandidates(context: Context): List { + private suspend fun collectWifiCandidates(context: Context): WifiCollectionResult { val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager val cachedCandidates = currentWifiCandidates(wifiManager) val refreshedCandidates = requestFreshWifiScan(context, wifiManager) + val connectedCandidate = currentWifiConnectionCandidate(context, wifiManager) - return (refreshedCandidates ?: cachedCandidates) - .distinctBy { it.macAddress } - .sortedByDescending { it.signalStrength ?: Int.MIN_VALUE } - .take(MAX_WIFI_ACCESS_POINTS) + return WifiCollectionResult( + candidates = mergeWifiCandidates( + cached = cachedCandidates, + refreshed = refreshedCandidates.orEmpty(), + connected = connectedCandidate, + ), + cachedScanCandidatesCount = cachedCandidates.size, + freshScanCandidatesCount = refreshedCandidates?.size, + connectedCandidateAvailable = connectedCandidate != null, + ) + } + + private fun summarizeCellRadios(candidates: List): List { + return candidates + .map { it.radio.lowercase(Locale.US) } + .distinct() + .sorted() + } + + private fun cellInfoTypeName(info: CellInfo): String { + return info.javaClass.simpleName + .removePrefix("CellInfo") + .takeIf { it.isNotBlank() } + ?.lowercase(Locale.US) + ?: info.javaClass.name + } + + private fun collectBssid( + context: Context, + wifiCandidates: List, + ): BssidCollectionResult { + val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val wifiInfo = getWifiInfo(context, wifiManager) + val normalizedBssid = normalizeMacAddress(wifiInfo?.bssid) + if (normalizedBssid != null) { + return BssidCollectionResult( + bssid = normalizedBssid, + source = "connected Wi-Fi info", + unavailableReason = null, + ) + } + + val singleScanCandidate = wifiCandidates.singleOrNull() + if (singleScanCandidate != null) { + val reason = when { + wifiInfo == null -> "Wi-Fi info unavailable; using the only scan candidate" + wifiInfo.bssid == PLACEHOLDER_BSSID -> "connected Wi-Fi info is redacted by Android; using the only scan candidate" + else -> "connected Wi-Fi BSSID is unavailable; using the only scan candidate" + } + return BssidCollectionResult( + bssid = singleScanCandidate.macAddress, + source = "single Wi-Fi scan candidate", + unavailableReason = reason, + ) + } + + val reason = when { + wifiInfo == null -> "Wi-Fi info unavailable" + wifiInfo.bssid == PLACEHOLDER_BSSID -> "connected Wi-Fi info is redacted by Android" + wifiInfo.bssid.isNullOrBlank() -> "connected Wi-Fi BSSID is empty" + else -> "connected Wi-Fi BSSID is invalid" + } + return BssidCollectionResult( + bssid = null, + source = null, + unavailableReason = reason, + ) } @Suppress("MissingPermission", "DEPRECATION") @@ -394,6 +607,39 @@ object LocationSignalsChecker { }.getOrDefault(emptyList()) } + private fun currentWifiConnectionCandidate( + context: Context, + wifiManager: WifiManager, + ): WifiLookupCandidate? { + val wifiInfo = getWifiInfo(context, wifiManager) ?: return null + val macAddress = normalizeMacAddress(wifiInfo.bssid) ?: return null + val ssid = normalizeSsid(wifiInfo.ssid) + if (ssid?.endsWith("_nomap", ignoreCase = true) == true) return null + return WifiLookupCandidate( + macAddress = macAddress, + frequency = wifiInfo.frequency.takeIf { it > 0 }, + signalStrength = normalizeSignalStrength(wifiInfo.rssi), + ) + } + + internal fun mergeWifiCandidates( + cached: List, + refreshed: List, + connected: WifiLookupCandidate?, + ): List { + return (cached + refreshed + listOfNotNull(connected)) + .groupBy { it.macAddress } + .values + .mapNotNull { candidates -> + candidates.maxWithOrNull( + compareBy { it.signalStrength ?: Int.MIN_VALUE } + .thenBy { it.frequency ?: 0 }, + ) + } + .sortedByDescending { it.signalStrength ?: Int.MIN_VALUE } + .take(MAX_WIFI_ACCESS_POINTS) + } + private fun toWifiLookupCandidate(scanResult: ScanResult): WifiLookupCandidate? { val macAddress = normalizeMacAddress(scanResult.BSSID) ?: return null val ssid = normalizeSsid(scanResultSsid(scanResult)) ?: return null @@ -480,6 +726,29 @@ object LocationSignalsChecker { } } + // Keep newer cell identity accessors isolated so older devices never resolve them. + @RequiresApi(Build.VERSION_CODES.Q) + private object Api29Impl { + @DoNotInline + fun nrCandidate(info: CellInfo): CellLookupCandidate? { + if (info !is android.telephony.CellInfoNr) return null + val identity = info.cellIdentity as? android.telephony.CellIdentityNr ?: return null + val mcc = normalizeOperatorCode(identity.mccString) ?: return null + val mnc = normalizeOperatorCode(identity.mncString) ?: return null + val areaCode = normalizeCellValue(identity.tac) ?: return null + val newRadioCellId = normalizeNewRadioCellValue(identity.nci) ?: return null + return CellLookupCandidate( + radio = "nr", + mcc = mcc, + mnc = mnc, + areaCode = areaCode, + newRadioCellId = newRadioCellId, + registered = info.isRegistered, + signalStrength = normalizeSignalStrength(info.cellSignalStrength.dbm), + ) + } + } + // Keep API 28-only operator accessors isolated so pre-P devices never resolve them. @RequiresApi(Build.VERSION_CODES.P) private object Api28Impl { @@ -528,6 +797,10 @@ object LocationSignalsChecker { return value.toLong().takeIf { it in 0 until Int.MAX_VALUE.toLong() } } + private fun normalizeNewRadioCellValue(value: Long): Long? { + return value.takeIf { it in 0..MAX_NR_CELL_ID } + } + private fun normalizeSignalStrength(value: Int): Int? { return value.takeIf { it in -150..0 } } @@ -575,15 +848,22 @@ object LocationSignalsChecker { @Suppress("DEPRECATION") private fun getBssid(context: Context): String? { + val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + return getWifiInfo(context, wifiManager)?.bssid + } + + @Suppress("DEPRECATION") + private fun getWifiInfo(context: Context, wifiManager: WifiManager): WifiInfo? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val network = cm.activeNetwork ?: return null - val caps = cm.getNetworkCapabilities(network) ?: return null - if (!caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return null - (caps.transportInfo as? WifiInfo)?.bssid + val network = cm.activeNetwork + val caps = network?.let { cm.getNetworkCapabilities(it) } + val transportInfo = caps + ?.takeIf { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } + ?.transportInfo as? WifiInfo + transportInfo ?: wifiManager.connectionInfo } else { - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - wm.connectionInfo?.bssid + wifiManager.connectionInfo } } @@ -648,8 +928,16 @@ object LocationSignalsChecker { } } + if (!snapshot.locationServicesEnabled) { + findings += Finding("Location services: disabled") + } + if (!snapshot.cellLookupPermissionGranted) { findings += Finding("Cell lookup: ACCESS_FINE_LOCATION permission is not granted") + } else if (!snapshot.locationServicesEnabled) { + findings += Finding("Cell lookup: system location is disabled") + } else if (!snapshot.telephonyRadioAccessAvailable) { + findings += Finding("Cell lookup: telephony radio access is unavailable") } else { findings += Finding("Cell lookup candidates: ${snapshot.cellCandidatesCount}") if (snapshot.cellCandidatesCount == 0) { @@ -659,6 +947,10 @@ object LocationSignalsChecker { if (!snapshot.wifiPermissionGranted) { findings += Finding("Wi-Fi scan: permissions are not granted") + } else if (!snapshot.locationServicesEnabled) { + findings += Finding("Wi-Fi scan: system location is disabled") + } else if (!snapshot.wifiFeatureAvailable) { + findings += Finding("Wi-Fi scan: Wi-Fi feature is unavailable") } else { findings += Finding("Wi-Fi scan candidates: ${snapshot.wifiAccessPointCandidatesCount}") if (snapshot.wifiAccessPointCandidatesCount == 0) { @@ -677,19 +969,50 @@ object LocationSignalsChecker { if (!snapshot.wifiPermissionGranted) { findings += Finding("BSSID: permission is not granted") + } else if (!snapshot.locationServicesEnabled) { + findings += Finding("BSSID: system location is disabled") + } else if (!snapshot.wifiFeatureAvailable) { + findings += Finding("BSSID: Wi-Fi feature is unavailable") } else if (snapshot.bssid == null || snapshot.bssid == PLACEHOLDER_BSSID) { findings += Finding("BSSID: unavailable") } else { findings += Finding("BSSID: ${snapshot.bssid}") } - return CategoryResult( + val detected = evidence.any { + it.detected && it.confidence >= EvidenceConfidence.MEDIUM + } + + val result = CategoryResult( name = "Location signals", - detected = false, + detected = detected, findings = findings, needsReview = needsReview, evidence = evidence, ) + return LocationSignalsDiagnosticsRegistry.attach( + result, + LocationSignalsDiagnostics( + fineLocationPermissionGranted = snapshot.cellLookupPermissionGranted, + nearbyWifiPermissionGranted = snapshot.nearbyWifiPermissionGranted, + locationServicesEnabled = snapshot.locationServicesEnabled, + telephonyRadioAccessAvailable = snapshot.telephonyRadioAccessAvailable, + wifiFeatureAvailable = snapshot.wifiFeatureAvailable, + networkRequestsEnabled = snapshot.networkRequestsEnabled, + cellRawInfoCount = snapshot.cellRawInfoCount, + cellRawInfoTypes = snapshot.cellRawInfoTypes, + cellCandidateRadios = snapshot.cellCandidateRadios, + beaconDbCellCandidatesUsedCount = snapshot.beaconDbCellCandidatesUsedCount, + beaconDbUnsupportedCellRadios = snapshot.beaconDbUnsupportedCellRadios, + beaconDbWifiCandidatesUsedCount = snapshot.beaconDbWifiCandidatesUsedCount, + wifiAccessPointCandidatesCount = snapshot.wifiAccessPointCandidatesCount, + wifiCachedScanCandidatesCount = snapshot.wifiCachedScanCandidatesCount, + wifiFreshScanCandidatesCount = snapshot.wifiFreshScanCandidatesCount, + wifiConnectedCandidateAvailable = snapshot.wifiConnectedCandidateAvailable, + bssidSource = snapshot.bssidSource, + bssidUnavailableReason = snapshot.bssidUnavailableReason, + ), + ) } private val MAC_ADDRESS_REGEX = Regex("^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$") diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/LocationSignalsDiagnostics.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/LocationSignalsDiagnostics.kt new file mode 100644 index 0000000..95c18ca --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/LocationSignalsDiagnostics.kt @@ -0,0 +1,40 @@ +package com.notcvnt.rknhardering.checker + +import com.notcvnt.rknhardering.model.CategoryResult +import java.util.Collections +import java.util.WeakHashMap + +data class LocationSignalsDiagnostics( + val fineLocationPermissionGranted: Boolean, + val nearbyWifiPermissionGranted: Boolean, + val locationServicesEnabled: Boolean, + val telephonyRadioAccessAvailable: Boolean, + val wifiFeatureAvailable: Boolean, + val networkRequestsEnabled: Boolean, + val cellRawInfoCount: Int, + val cellRawInfoTypes: List, + val cellCandidateRadios: List, + val beaconDbCellCandidatesUsedCount: Int, + val beaconDbUnsupportedCellRadios: List, + val beaconDbWifiCandidatesUsedCount: Int, + val wifiAccessPointCandidatesCount: Int, + val wifiCachedScanCandidatesCount: Int, + val wifiFreshScanCandidatesCount: Int?, + val wifiConnectedCandidateAvailable: Boolean, + val bssidSource: String?, + val bssidUnavailableReason: String?, +) + +object LocationSignalsDiagnosticsRegistry { + private val diagnosticsByCategory = + Collections.synchronizedMap(WeakHashMap()) + + fun attach(category: CategoryResult, diagnostics: LocationSignalsDiagnostics): CategoryResult { + diagnosticsByCategory[category] = diagnostics + return category + } + + fun find(category: CategoryResult): LocationSignalsDiagnostics? { + return diagnosticsByCategory[category] + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt index bbc4dae..4be5466 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt @@ -119,8 +119,10 @@ object VerdictEngine { val tunProbeReview = directSigns.evidence.any { it.source == EvidenceSource.TUN_ACTIVE_PROBE && !it.detected } + val locationSignalHit = locationSignals.detected if (matrix == Verdict.NOT_DETECTED && ( bypassResult.needsReview || + locationSignalHit || hasActionableCallTransportLeak || icmpSpoofing.needsReview || nativeReviewHit || diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index b48c85f..bbb032a 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -84,6 +84,9 @@ 8.8.8.8, 8.8.4.4 حالت حریم خصوصی IP به شکل 185.22.*.* نمایش داده می‌شود + نماد برنامه + کلاسیک + جدید تم روشن تیره @@ -497,6 +500,9 @@ (نیازمند تأیید دور زدن) Xray gRPC API: پیدا نشد Xray gRPC API: %1$s + Xray gRPC StatsService: %1$s + HandlerService/ListOutbounds در دسترس نیست؛ نشانی‌های outbound آشکار نشدند. + شمارنده‌های StatsService: %1$s   …%1$d outbound دیگر   …%1$d outbound دیگر diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4e6f0ce..b9dbf98 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -84,6 +84,9 @@ 8.8.8.8, 8.8.4.4 隐私模式 IP 显示为 185.22.*.* + 应用图标 + 经典 + 新版 主题 浅色 深色 @@ -496,6 +499,9 @@ (需要确认是否存在绕过) Xray gRPC API:未发现 Xray gRPC API:%1$s + Xray gRPC StatsService:%1$s + HandlerService/ListOutbounds 不可用;未暴露出站地址。 + StatsService 计数器:%1$s   …还有 %1$d 个 outbound diff --git a/app/src/test/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatterTest.kt b/app/src/test/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatterTest.kt index 33337e9..9343d85 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatterTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/DebugDiagnosticsFormatterTest.kt @@ -5,6 +5,8 @@ import com.notcvnt.rknhardering.checker.CheckSettings import com.notcvnt.rknhardering.checker.DebugStepTiming import com.notcvnt.rknhardering.checker.IndirectCheckPerformanceDiagnostics import com.notcvnt.rknhardering.checker.IndirectCheckPerformanceRegistry +import com.notcvnt.rknhardering.checker.LocationSignalsDiagnostics +import com.notcvnt.rknhardering.checker.LocationSignalsDiagnosticsRegistry import com.notcvnt.rknhardering.checker.StunScopeTiming import com.notcvnt.rknhardering.model.ActiveVpnApp import com.notcvnt.rknhardering.model.BypassResult @@ -203,6 +205,75 @@ class DebugDiagnosticsFormatterTest { assertFalse(report.contains("203.0.113.64")) } + @Test + fun `formatter prints location diagnostics outside findings`() { + val emptyCategory = CategoryResult(name = "empty", detected = false, findings = emptyList()) + val location = LocationSignalsDiagnosticsRegistry.attach( + CategoryResult( + name = "Location", + detected = false, + findings = listOf(Finding("Cell lookup candidates: 1")), + ), + LocationSignalsDiagnostics( + fineLocationPermissionGranted = true, + nearbyWifiPermissionGranted = true, + locationServicesEnabled = true, + telephonyRadioAccessAvailable = true, + wifiFeatureAvailable = true, + networkRequestsEnabled = true, + cellRawInfoCount = 2, + cellRawInfoTypes = listOf("nr"), + cellCandidateRadios = listOf("nr"), + beaconDbCellCandidatesUsedCount = 0, + beaconDbUnsupportedCellRadios = listOf("nr"), + beaconDbWifiCandidatesUsedCount = 0, + wifiAccessPointCandidatesCount = 1, + wifiCachedScanCandidatesCount = 1, + wifiFreshScanCandidatesCount = null, + wifiConnectedCandidateAvailable = false, + bssidSource = "single Wi-Fi scan candidate", + bssidUnavailableReason = "connected Wi-Fi info is redacted by Android", + ), + ) + val result = CheckResult( + geoIp = emptyCategory, + ipComparison = IpComparisonResult( + detected = false, + summary = "", + ruGroup = IpCheckerGroupResult(title = "RU", detected = false, statusLabel = "", summary = "", responses = emptyList()), + nonRuGroup = IpCheckerGroupResult(title = "NON_RU", detected = false, statusLabel = "", summary = "", responses = emptyList()), + ), + directSigns = emptyCategory, + indirectSigns = emptyCategory, + locationSignals = location, + bypassResult = BypassResult( + proxyEndpoint = null, + directIp = null, + proxyIp = null, + vpnNetworkIp = null, + underlyingIp = null, + xrayApiScanResult = null, + proxyChecks = emptyList(), + findings = emptyList(), + detected = false, + ), + verdict = Verdict.NOT_DETECTED, + ) + + val report = DebugDiagnosticsFormatter.format( + result = result, + settings = CheckSettings(), + privacyMode = true, + ) + + assertTrue(report.contains("[locationSignals.debug]")) + assertTrue(report.contains("cellRawInfoCount: 2")) + assertTrue(report.contains("cellRawInfoTypes: nr")) + assertTrue(report.contains("beaconDbUnsupportedCellRadios: nr")) + assertTrue(report.contains("wifiFreshScanCandidatesCount: ")) + assertFalse(report.contains("- description=Cell raw info:")) + } + @Test fun `formatter includes detailed errors evidence and network sources`() { val result = CheckResult( diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/BeaconDbClientTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/BeaconDbClientTest.kt index 9142662..ac222f9 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/BeaconDbClientTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/BeaconDbClientTest.kt @@ -84,7 +84,10 @@ class BeaconDbClientTest { val result = client.lookup(emptyList(), listOf(wifi())) assertFalse(called) - assertEquals("BeaconDB: insufficient radio data", result.summary) + assertEquals( + "BeaconDB: insufficient radio data (need >=2 Wi-Fi APs or >=1 supported cell tower)", + result.summary, + ) } @Test @@ -105,13 +108,31 @@ class BeaconDbClientTest { val client = BeaconDbClient(countryResolver = { _, _ -> null }) val body = client.buildRequestBody( - cells = listOf(cell(radio = "nr")), + cells = listOf(cell(radio = "cdma")), wifiAccessPoints = emptyList(), ) assertTrue(body.contains("\"cellTowers\":[]")) } + @Test + fun `nr radio is excluded from beacon db payload diagnostics`() { + val client = BeaconDbClient(countryResolver = { _, _ -> null }) + + val body = client.buildRequestBody( + cells = listOf(cell(radio = "nr", cellId = null, newRadioCellId = 1234567890)), + wifiAccessPoints = emptyList(), + ) + val diagnostics = client.inputDiagnostics( + cells = listOf(cell(radio = "nr", cellId = null, newRadioCellId = 1234567890)), + wifiAccessPoints = emptyList(), + ) + + assertTrue(body.contains("\"cellTowers\":[]")) + assertEquals(0, diagnostics.supportedCellCount) + assertEquals(listOf("nr"), diagnostics.unsupportedCellRadios) + } + @Test fun `lookup surfaces rate limiting`() = runBlocking { val client = BeaconDbClient( @@ -158,12 +179,17 @@ class BeaconDbClientTest { assertEquals(2, "\"macAddress\":".toRegex().findAll(capturedRequest).count()) } - private fun cell(radio: String = "lte"): CellLookupCandidate = CellLookupCandidate( + private fun cell( + radio: String = "lte", + cellId: Long? = 67890, + newRadioCellId: Long? = null, + ): CellLookupCandidate = CellLookupCandidate( radio = radio, mcc = "250", mnc = "1", areaCode = 12345, - cellId = 67890, + cellId = cellId, + newRadioCellId = newRadioCellId, registered = true, signalStrength = -75, ) diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/LocationSignalsCheckerTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/LocationSignalsCheckerTest.kt index 719d6e6..d04612d 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/LocationSignalsCheckerTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/LocationSignalsCheckerTest.kt @@ -30,6 +30,7 @@ class LocationSignalsCheckerTest { ), ) + assertTrue(result.detected) assertTrue(result.needsReview) assertTrue( result.evidence.any { @@ -49,6 +50,7 @@ class LocationSignalsCheckerTest { ), ) + assertFalse(result.detected) assertTrue(result.needsReview) assertTrue( result.evidence.any { @@ -57,6 +59,29 @@ class LocationSignalsCheckerTest { ) } + @Test + fun `mcc 310 non roaming network is detected as location signal`() { + val result = LocationSignalsChecker.evaluate( + snapshot( + networkMcc = "310", + networkCountryIso = "us", + networkOperatorName = "T-Mobile", + simCards = listOf(sim(simMcc = "310", simCountryIso = "us", operatorName = "T-Mobile", isRoaming = false)), + ), + ) + + assertTrue(result.detected) + assertTrue(result.needsReview) + assertTrue(result.findings.any { it.description == "Network MCC 310 (US) is not Russia" }) + assertTrue( + result.evidence.any { + it.source == EvidenceSource.LOCATION_SIGNALS && + it.detected && + it.confidence == EvidenceConfidence.MEDIUM + }, + ) + } + @Test fun `plmn fields are marked as informational`() { val result = LocationSignalsChecker.evaluate(snapshot()) @@ -104,6 +129,29 @@ class LocationSignalsCheckerTest { assertTrue(result.findings.any { it.description.contains("base station identifiers are unavailable") }) } + @Test + fun `cell lookup diagnostics expose raw radios and beacon db eligibility`() { + val result = LocationSignalsChecker.evaluate( + snapshot( + cellLookupPermissionGranted = true, + cellCandidatesCount = 1, + cellRawInfoCount = 1, + cellRawInfoTypes = listOf("nr"), + cellCandidateRadios = listOf("nr"), + beaconDbCellCandidatesUsedCount = 0, + beaconDbUnsupportedCellRadios = listOf("nr"), + ), + ) + val diagnostics = LocationSignalsDiagnosticsRegistry.find(result) + + assertTrue(result.findings.none { it.description.startsWith("Cell raw info:") }) + assertEquals(1, diagnostics?.cellRawInfoCount) + assertEquals(listOf("nr"), diagnostics?.cellRawInfoTypes) + assertEquals(listOf("nr"), diagnostics?.cellCandidateRadios) + assertEquals(0, diagnostics?.beaconDbCellCandidatesUsedCount) + assertEquals(listOf("nr"), diagnostics?.beaconDbUnsupportedCellRadios) + } + @Test fun `wifi permission absence is reported explicitly`() { val result = LocationSignalsChecker.evaluate(snapshot(wifiPermissionGranted = false)) @@ -124,6 +172,69 @@ class LocationSignalsCheckerTest { assertTrue(result.findings.any { it.description == "Wi-Fi scan candidates: 4" }) } + @Test + fun `wifi diagnostics expose scan sources and beacon db minimum`() { + val result = LocationSignalsChecker.evaluate( + snapshot( + wifiPermissionGranted = true, + wifiAccessPointCandidatesCount = 1, + wifiCachedScanCandidatesCount = 1, + wifiFreshScanCandidatesCount = null, + wifiConnectedCandidateAvailable = false, + beaconDbWifiCandidatesUsedCount = 0, + ), + ) + val diagnostics = LocationSignalsDiagnosticsRegistry.find(result) + + assertTrue(result.findings.none { it.description.startsWith("Wi-Fi scan sources:") }) + assertEquals(1, diagnostics?.wifiCachedScanCandidatesCount) + assertEquals(null, diagnostics?.wifiFreshScanCandidatesCount) + assertEquals(false, diagnostics?.wifiConnectedCandidateAvailable) + assertEquals(0, diagnostics?.beaconDbWifiCandidatesUsedCount) + } + + @Test + fun `location services disabled is reported for radio lookups`() { + val result = LocationSignalsChecker.evaluate( + snapshot( + cellLookupPermissionGranted = true, + wifiPermissionGranted = true, + locationServicesEnabled = false, + ), + ) + + assertTrue(result.findings.any { it.description == "Location services: disabled" }) + assertTrue(result.findings.any { it.description == "Cell lookup: system location is disabled" }) + assertTrue(result.findings.any { it.description == "Wi-Fi scan: system location is disabled" }) + assertTrue(result.findings.any { it.description == "BSSID: system location is disabled" }) + assertFalse(result.findings.any { it.description == "Cell lookup: base station identifiers are unavailable" }) + } + + @Test + fun `missing telephony radio access is reported explicitly`() { + val result = LocationSignalsChecker.evaluate( + snapshot( + cellLookupPermissionGranted = true, + telephonyRadioAccessAvailable = false, + ), + ) + + assertTrue(result.findings.any { it.description == "Cell lookup: telephony radio access is unavailable" }) + } + + @Test + fun `missing wifi feature is reported explicitly`() { + val result = LocationSignalsChecker.evaluate( + snapshot( + wifiPermissionGranted = true, + wifiFeatureAvailable = false, + ), + ) + + assertTrue(result.findings.any { it.description == "Wi-Fi scan: Wi-Fi feature is unavailable" }) + assertTrue(result.findings.any { it.description == "BSSID: Wi-Fi feature is unavailable" }) + } + @Test fun `ru cell lookup adds russian markers`() { val result = LocationSignalsChecker.evaluate( @@ -160,10 +271,12 @@ class LocationSignalsCheckerTest { snapshot( wifiPermissionGranted = true, bssid = "AA:BB:CC:DD:EE:FF", + bssidSource = "connected Wi-Fi info", ), ) assertTrue(result.findings.any { it.description.contains("AA:BB:CC:DD:EE:FF") }) + assertEquals("connected Wi-Fi info", LocationSignalsDiagnosticsRegistry.find(result)?.bssidSource) } @Test @@ -172,10 +285,40 @@ class LocationSignalsCheckerTest { snapshot( wifiPermissionGranted = true, bssid = "02:00:00:00:00:00", + bssidUnavailableReason = "connected Wi-Fi info is redacted by Android", ), ) assertTrue(result.findings.any { it.description == "BSSID: unavailable" }) + assertEquals( + "connected Wi-Fi info is redacted by Android", + LocationSignalsDiagnosticsRegistry.find(result)?.bssidUnavailableReason, + ) + } + + @Test + fun `wifi merge keeps cached candidates when fresh scan is empty`() { + val cached = listOf(wifi("aa:bb:cc:dd:ee:01", signalStrength = -60)) + + val merged = LocationSignalsChecker.mergeWifiCandidates( + cached = cached, + refreshed = emptyList(), + connected = null, + ) + + assertEquals(cached, merged) + } + + @Test + fun `wifi merge includes connection and keeps strongest duplicate`() { + val merged = LocationSignalsChecker.mergeWifiCandidates( + cached = listOf(wifi("aa:bb:cc:dd:ee:01", signalStrength = -80)), + refreshed = listOf(wifi("aa:bb:cc:dd:ee:01", signalStrength = -55)), + connected = wifi("aa:bb:cc:dd:ee:02", signalStrength = -50), + ) + + assertEquals(listOf("aa:bb:cc:dd:ee:02", "aa:bb:cc:dd:ee:01"), merged.map { it.macAddress }) + assertEquals(-55, merged.first { it.macAddress == "aa:bb:cc:dd:ee:01" }.signalStrength) } @Test @@ -238,6 +381,7 @@ class LocationSignalsCheckerTest { ), ) + assertTrue(result.detected) assertTrue(result.needsReview) assertTrue( result.evidence.any { @@ -260,6 +404,7 @@ class LocationSignalsCheckerTest { ), ) + assertFalse(result.detected) assertTrue(result.needsReview) assertTrue( result.evidence.any { @@ -279,6 +424,7 @@ class LocationSignalsCheckerTest { ), ) + assertTrue(result.detected) assertTrue(result.needsReview) assertFalse(result.findings.any { it.description.startsWith("SIM[") }) assertTrue( @@ -316,6 +462,22 @@ class LocationSignalsCheckerTest { bssid: String? = null, cellLookupPermissionGranted: Boolean = false, wifiPermissionGranted: Boolean = false, + locationServicesEnabled: Boolean = true, + telephonyRadioAccessAvailable: Boolean = true, + wifiFeatureAvailable: Boolean = true, + nearbyWifiPermissionGranted: Boolean = true, + networkRequestsEnabled: Boolean = true, + cellRawInfoCount: Int = 0, + cellRawInfoTypes: List = emptyList(), + cellCandidateRadios: List = emptyList(), + beaconDbCellCandidatesUsedCount: Int = 0, + beaconDbUnsupportedCellRadios: List = emptyList(), + beaconDbWifiCandidatesUsedCount: Int = 0, + wifiCachedScanCandidatesCount: Int = 0, + wifiFreshScanCandidatesCount: Int? = null, + wifiConnectedCandidateAvailable: Boolean = false, + bssidSource: String? = null, + bssidUnavailableReason: String? = null, ): LocationSignalsChecker.LocationSnapshot { return LocationSignalsChecker.LocationSnapshot( networkMcc = networkMcc, @@ -329,6 +491,31 @@ class LocationSignalsCheckerTest { bssid = bssid, cellLookupPermissionGranted = cellLookupPermissionGranted, wifiPermissionGranted = wifiPermissionGranted, + locationServicesEnabled = locationServicesEnabled, + telephonyRadioAccessAvailable = telephonyRadioAccessAvailable, + wifiFeatureAvailable = wifiFeatureAvailable, + nearbyWifiPermissionGranted = nearbyWifiPermissionGranted, + networkRequestsEnabled = networkRequestsEnabled, + cellRawInfoCount = cellRawInfoCount, + cellRawInfoTypes = cellRawInfoTypes, + cellCandidateRadios = cellCandidateRadios, + beaconDbCellCandidatesUsedCount = beaconDbCellCandidatesUsedCount, + beaconDbUnsupportedCellRadios = beaconDbUnsupportedCellRadios, + beaconDbWifiCandidatesUsedCount = beaconDbWifiCandidatesUsedCount, + wifiCachedScanCandidatesCount = wifiCachedScanCandidatesCount, + wifiFreshScanCandidatesCount = wifiFreshScanCandidatesCount, + wifiConnectedCandidateAvailable = wifiConnectedCandidateAvailable, + bssidSource = bssidSource, + bssidUnavailableReason = bssidUnavailableReason, ) } + + private fun wifi( + mac: String, + signalStrength: Int, + ) = WifiLookupCandidate( + macAddress = mac, + frequency = 2412, + signalStrength = signalStrength, + ) } diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt index 9143540..f343117 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt @@ -243,6 +243,34 @@ class VerdictEngineTest { assertEquals(Verdict.NEEDS_REVIEW, verdict) } + @Test + fun `R6 detected location signal alone promotes to needs review`() { + val verdict = VerdictEngine.evaluate( + geoIp = category(), + directSigns = category(), + indirectSigns = category(), + locationSignals = category( + evidence = listOf(evidence(EvidenceSource.LOCATION_SIGNALS, EvidenceConfidence.MEDIUM)), + ), + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + ) + assertEquals(Verdict.NEEDS_REVIEW, verdict) + } + + @Test + fun `R6 review only location signal does not promote verdict`() { + val verdict = VerdictEngine.evaluate( + geoIp = category(), + directSigns = category(), + indirectSigns = category(), + locationSignals = category(needsReview = true), + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + ) + assertEquals(Verdict.NOT_DETECTED, verdict) + } + @Test fun `R7 empty input yields not detected`() { val verdict = VerdictEngine.evaluate( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a901a4..9c9ae20 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ grpc = "1.64.0" protobuf = "3.25.5" protobufPlugin = "0.9.6" okhttp = "4.12.0" -robolectric = "4.14.1" +robolectric = "4.16.1" androidxTestCore = "1.6.1" [libraries]