fix: Location Services now works as intended

This commit is contained in:
xtclovver 2026-05-08 01:05:13 +03:00
parent e7d76f9b4b
commit 350eabb9d6
13 changed files with 820 additions and 44 deletions

View file

@ -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<org.gradle.api.tasks.testing.Test>().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<org.gradle.api.tasks.testing.Test>().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)
}

View file

@ -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() ?: "<timeout>"}")
builder.appendLine("wifiConnectedCandidateAvailable: ${diagnostics.wifiConnectedCandidateAvailable}")
builder.appendLine("bssidSource: ${diagnostics.bssidSource ?: "<none>"}")
builder.appendLine("bssidUnavailableReason: ${diagnostics.bssidUnavailableReason ?: "<none>"}")
}
private fun formatStringList(values: List<String>): String {
return values.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: "<none>"
}
private fun appendIpComparison(
builder: StringBuilder,
ipComparison: IpComparisonResult,

View file

@ -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<String>,
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<CellLookupCandidate>,
wifiAccessPoints: List<WifiLookupCandidate>,
): 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<CellLookupCandidate>,
wifiAccessPoints: List<WifiLookupCandidate>,
): 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<CellLookupCandidate>,
wifiAccessPoints: List<WifiLookupCandidate>,
): 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

View file

@ -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<CellLookupCandidate> = emptyList(),
val rawInfoCount: Int = 0,
val rawInfoTypes: List<String> = emptyList(),
val candidateRadios: List<String> = emptyList(),
)
private data class WifiCollectionResult(
val candidates: List<WifiLookupCandidate> = 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<String> = emptyList(),
val cellCandidateRadios: List<String> = emptyList(),
val beaconDbCellCandidatesUsedCount: Int = 0,
val beaconDbUnsupportedCellRadios: List<String> = 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<SimCardInfo> {
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<CellLookupCandidate> {
simCards: List<SimCardInfo>,
): 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<CellLookupCandidate> { 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<CellInfo> {
return (requestFreshCellInfo(context, tm) + getCachedCellInfo(tm))
.distinctBy { it.toString() }
}
@Suppress("MissingPermission")
@ -250,6 +375,10 @@ object LocationSignalsChecker {
override fun onCellInfo(cellInfo: MutableList<CellInfo>) {
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<WifiLookupCandidate> {
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<CellLookupCandidate>): List<String> {
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<WifiLookupCandidate>,
): 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<WifiLookupCandidate>,
refreshed: List<WifiLookupCandidate>,
connected: WifiLookupCandidate?,
): List<WifiLookupCandidate> {
return (cached + refreshed + listOfNotNull(connected))
.groupBy { it.macAddress }
.values
.mapNotNull { candidates ->
candidates.maxWithOrNull(
compareBy<WifiLookupCandidate> { 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}$")

View file

@ -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<String>,
val cellCandidateRadios: List<String>,
val beaconDbCellCandidatesUsedCount: Int,
val beaconDbUnsupportedCellRadios: List<String>,
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<CategoryResult, LocationSignalsDiagnostics>())
fun attach(category: CategoryResult, diagnostics: LocationSignalsDiagnostics): CategoryResult {
diagnosticsByCategory[category] = diagnostics
return category
}
fun find(category: CategoryResult): LocationSignalsDiagnostics? {
return diagnosticsByCategory[category]
}
}

View file

@ -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 ||

View file

@ -84,6 +84,9 @@
<string name="settings_dns_bootstrap_hint">8.8.8.8, 8.8.4.4</string>
<string name="settings_privacy_mode">حالت حریم خصوصی</string>
<string name="settings_privacy_mode_desc">IP به شکل 185.22.*.* نمایش داده می‌شود</string>
<string name="settings_icon_style">نماد برنامه</string>
<string name="settings_icon_style_classic">کلاسیک</string>
<string name="settings_icon_style_new">جدید</string>
<string name="settings_theme">تم</string>
<string name="settings_theme_light">روشن</string>
<string name="settings_theme_dark">تیره</string>
@ -497,6 +500,9 @@
<string name="checker_bypass_open_proxy_review_suffix"> (نیازمند تأیید دور زدن)</string>
<string name="checker_bypass_no_xray">Xray gRPC API: پیدا نشد</string>
<string name="checker_bypass_xray_api">Xray gRPC API: %1$s</string>
<string name="checker_bypass_xray_api_stats_only">Xray gRPC StatsService: %1$s</string>
<string name="checker_bypass_xray_handler_unavailable">HandlerService/ListOutbounds در دسترس نیست؛ نشانی‌های outbound آشکار نشدند.</string>
<string name="checker_bypass_xray_stats_sample">شمارنده‌های StatsService: %1$s</string>
<plurals name="checker_bypass_extra_outbounds">
<item quantity="one">&#160;&#160;&#8230;%1$d outbound دیگر</item>
<item quantity="other">&#160;&#160;&#8230;%1$d outbound دیگر</item>

View file

@ -84,6 +84,9 @@
<string name="settings_dns_bootstrap_hint">8.8.8.8, 8.8.4.4</string>
<string name="settings_privacy_mode">隐私模式</string>
<string name="settings_privacy_mode_desc">IP 显示为 185.22.*.*</string>
<string name="settings_icon_style">应用图标</string>
<string name="settings_icon_style_classic">经典</string>
<string name="settings_icon_style_new">新版</string>
<string name="settings_theme">主题</string>
<string name="settings_theme_light">浅色</string>
<string name="settings_theme_dark">深色</string>
@ -496,6 +499,9 @@
<string name="checker_bypass_open_proxy_review_suffix">(需要确认是否存在绕过)</string>
<string name="checker_bypass_no_xray">Xray gRPC API未发现</string>
<string name="checker_bypass_xray_api">Xray gRPC API%1$s</string>
<string name="checker_bypass_xray_api_stats_only">Xray gRPC StatsService%1$s</string>
<string name="checker_bypass_xray_handler_unavailable">HandlerService/ListOutbounds 不可用;未暴露出站地址。</string>
<string name="checker_bypass_xray_stats_sample">StatsService 计数器:%1$s</string>
<plurals name="checker_bypass_extra_outbounds">
<item quantity="other">&#160;&#160;&#8230;还有 %1$d 个 outbound</item>
</plurals>

View file

@ -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: <timeout>"))
assertFalse(report.contains("- description=Cell raw info:"))
}
@Test
fun `formatter includes detailed errors evidence and network sources`() {
val result = CheckResult(

View file

@ -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,
)

View file

@ -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<String> = emptyList(),
cellCandidateRadios: List<String> = emptyList(),
beaconDbCellCandidatesUsedCount: Int = 0,
beaconDbUnsupportedCellRadios: List<String> = 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,
)
}

View file

@ -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(

View file

@ -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]