mirror of
https://github.com/xtclovver/RKNHardering.git
synced 2026-07-09 17:19:25 +00:00
fix: Location Services now works as intended
This commit is contained in:
parent
e7d76f9b4b
commit
350eabb9d6
13 changed files with 820 additions and 44 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}$")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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">  …%1$d outbound دیگر</item>
|
||||
<item quantity="other">  …%1$d outbound دیگر</item>
|
||||
|
|
|
|||
|
|
@ -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">  …还有 %1$d 个 outbound</item>
|
||||
</plurals>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue