Compare commits

..

7 commits
v2.9.0 ... main

Author SHA1 Message Date
xtclovver
46fc12181a fix: IMCP now is ignoring spoofing adresses (#82)
Some checks are pending
CI / build (push) Waiting to run
2026-07-10 21:56:27 +02:00
xtclovver
fa7c2fbc72 chore: bump to 2.10.0
Some checks are pending
CI / build (push) Waiting to run
2026-07-10 18:16:06 +02:00
xtclovver
0d24988aed fix: false positive, modem service routes (ccmni0, 26.0.0.0/8) are detected as "VPN server IP leak"(#80) and another small bag with ipv6/ipv4 adresses 2026-07-10 18:03:03 +02:00
xtclovver
640e445a92 feat: selecting verbose level of checks in UI. 2026-07-10 14:27:38 +02:00
AR34
e5faf94563
Da (#81)
Some checks are pending
CI / build (push) Waiting to run
* Add Detect VPN from native

* Add some methods & fix docs

* Sonar fixes

* fix: устранить ложные нативные VPN-сигналы

---------

Co-authored-by: xtclovver <notcvnt@gmail.com>
2026-07-10 03:26:07 +03:00
xtclovver
219518c53f fix(checker): не считать kernel-route таблицы local утечкой VPN (#78)
Some checks failed
CI / build (push) Has been cancelled
evaluateHostRoutes выдавал ложное срабатывание "Host route ... (VPN
server IP leak)" на адресе собственного транспортного интерфейса.

netlinkRouteDump делает RTM_GETROUTE с NLM_F_DUMP без указания таблицы,
поэтому в дамп попадают записи таблицы local (table 255), которые ядро
автоматически создаёт для каждого настроенного адреса интерфейса:

  local 12.233.114.164 dev ccmni1 table local proto kernel scope host
      src 12.233.114.164

Такая запись имеет prefixLen=32, type=local, scope=host, dst==prefsrc.
Если адрес интерфейса попадает в публично-маршрутизируемый диапазон
(например 12.0.0.0/8 у сотовых модемов ccmni MediaTek), все условия
фильтра проходили и checker рапортовал утечку.

Добавлен isKernelManagedLocalRoute: исключает type local/broadcast/
anycast/multicast, scope host и dst==prefsrc. Настоящая утечка VPN —
unicast-маршрут к чужому публичному IP (dst!=prefsrc) в main/policy
таблицах — детектируется как прежде.
2026-06-12 14:43:29 +03:00
xtclovver
52b4550427
chore(quality): SonarCloud triage — safe fixes + UI tile bugfix (#77)
Some checks are pending
CI / build (push) Waiting to run
* fix(ui): wire IP-consensus result into its category tile

The IpConsensusReady handler only marked the stage completed and never
called updateTileFromIpConsensus(), unlike every sibling updateTileFrom*
branch. As a result the IP-consensus tile (CATEGORY_IPS) never reflected
detected/review/clean signals. Call the already-written updater with the
event payload.

* chore(quality): low-risk SonarCloud code-smell cleanups

Behavior-preserving fixes for SonarCloud findings, verified by build +
unit tests. No detector signal, threshold, port, or verdict logic changed.

Kotlin:
- extract duplicated string literals to consts (S1192): "*.*.*.*",
  "SHA-512", "meduza.io", "<none>", sing-box core type
- if-throw -> require()/check() for validation (S6532): Ed25519 decode,
  GeoIp HTTP status
- empty catch blocks documented (S108); collapsible if merged (S1066)
- isExpanded() -> val property (S6512) with call-site update
- drop unused lambda parameter (S1172)

C++ (native_signs_probe.cpp, native_curl_probe.cpp):
- read-only netlink/diag pointers made pointer-to-const (S5350)
- split multi-variable declarations (S1659)
- sizeof/sizeof -> std::size for JNI method table (S7127)
- ipv6 prefix loop converted to range-for (S5566)
- progress-callback state pointer made const (S5350)

Riskier findings (cognitive complexity, too-many-params, deep nesting,
flag-guarded null ops that the Kotlin compiler cannot smart-cast) were
left in place. Genuine false positives (C++20/23 suggestions under a
C++17 toolchain, JNI function-pointer decay, intentional reinterpret_cast
in syscall code, sentinel IPs) were marked False Positive / Accepted in
SonarCloud rather than changed.
2026-06-12 02:24:56 +03:00
73 changed files with 6755 additions and 179 deletions

3
.gitignore vendored
View file

@ -23,4 +23,5 @@ release.jks
!CONTRIBUTING.md
!app/src/test/resources/export/golden/*.md
/.codex
.marketplace-keys/
.marketplace-keys/
app-release/

114
README.md
View file

@ -528,7 +528,7 @@ TLS-шаг использует trust-all X.509 manager, потому что ц
#### 12.2 Host-route /32 эвристика
В таблице маршрутов (`NETLINK`) ищется не-default маршрут с префиксом `/32` (IPv4) или `/128` (IPv6) к публичному маршрутизируемому адресу через физический интерфейс (`wlan0`, `rmnet`, `eth0`). Это классический хост-маршрут VPN-клиента к IP своего сервера в обход туннеля — утечка реального IP VPN-сервера. Итог: `detected = true` (`EvidenceSource.NATIVE_ROUTE`).
В таблице маршрутов (`NETLINK`) ищется не-default маршрут с префиксом `/32` (IPv4) или `/128` (IPv6) к публичному маршрутизируемому адресу через физический интерфейс (`wlan0`, `rmnet`, `eth0`). Это классический хост-маршрут VPN-клиента к IP своего сервера в обход туннеля — утечка реального IP VPN-сервера. Записи таблицы `local` и созданные ядром link-маршруты на модемных интерфейсах (`rmnet*`, `ccmni*`, `pdp*`, `seth*`) исключаются: операторы используют их для служебной адресации независимо от VPN. Статические глобальные host-route на этих интерфейсах остаются кандидатами. Итог: `detected = true` (`EvidenceSource.NATIVE_ROUTE`).
#### 12.3 Детектор эмулятора
@ -546,6 +546,110 @@ JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kerne
Любой из сигналов даёт `needsReview = true` (`EvidenceSource.SANDBOX_ISOLATION`), **никогда** `detected`.
#### 12.5 VPN-сигналы (`evaluateVpnSignals`)
Комплексная проверка VPN через нативные JNI-вызовы. Все проверки работают на **нерутованных** устройствах — при отсутствии прав (SELinux/capabilities) проверка помечается как `unavailable` и не крашится.
**Свойства и файлы (`nativeDetectVpnProperties`):**
| Проверка | Что ищем | Источник |
|----------|----------|----------|
| DNS-свойства | `net.dns1-4`, `net.vpn.dns1-2`, `dhcp.tun0.dns1-2` | `__system_property_get` |
| VPN-свойства | `net.vpn.default_iface`, `vpn.enable`, `net.tun0.dns1-2`, `net.ppp0.dns1-2` | `__system_property_get` |
| vpnhide файлы | `/data/local/vpnhide`, `/data/adb/vpnhide`, `/data/local/bypass` и др. | `access(F_OK)` |
| LSPosed/Xposed | `/data/adb/lspd`, `/data/adb/modules/lsposed`, `/data/adb/ksu/modules/lsposed` | `access(F_OK)` |
| Hook-свойства | `persist.sys.lspd`, `persist.sys.lsposed`, `ro.lsposed.hidden` | `__system_property_get` |
Высокая уверенность: `vpn_prop`, `vpnhide`, `hook_prop`. Средняя: остальные.
**Утечки через /proc (`nativeDetectVpnLeaks`):**
| Проверка | Что ищем | Источник |
|----------|----------|----------|
| TCP VPN-порты | Соединения на портах 443, 1194, 51820, 8443, 1723, 500, 4500 | `/proc/net/tcp[6]` |
| UDP VPN-порты | Сокеты на портах 51820 (WireGuard), 1194 (OpenVPN), 500, 4500 | `/proc/net/udp[6]` |
| if_inet6 | Интерфейсы tun/wg/ppp/tap в `/proc/net/if_inet6` | `/proc/net/if_inet6` |
| Route VPN | Маршруты через tun/wg/ppp/tap | `/proc/net/route` |
| FIB trie | `/32 host` записи (не LOCAL) — хост-маршруты VPN | `/proc/net/fib_trie` |
Высокая уверенность: `udp_vpn_port`, `route_vpn_iface`, `arp_vpn_iface`, `inet6_vpn_iface`.
**Продвинутые проверки (`nativeDetectVpnAdvanced`):**
| Проверка | Что ищем | Источник |
|----------|----------|----------|
| ARP VPN | Записи на tun/wg/ppp интерфейсах | `/proc/net/arp` |
| Sysctl | `rp_filter=0`, `ip_forward=1`, `forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` и др. |
| ESTABLISHED VPN | Соединения к приватным IP на VPN-портах | `/proc/net/tcp` |
**Нетрадиционные syscall-проверки (`nativeDetectVpnSyscalls`):**
Проверки через прямые netlink-запросы и пробные соединения. При отсутствии прав возвращают `unavailable`:
| Проверка | Метод | Что обнаруживает |
|----------|-------|-------------------|
| RTM_GETRULE | Netlink dump роутинг-правил | VPN policy routing rules |
| RTM_GETQDISC | Netlink dump queueing discipline | VPN qdisc туннели |
| RTM_GETNEIGH | Netlink dump таблицы соседей | Скрытые MAC-адреса (нулевые LLADDR) |
| TCP_INFO MSS | Connect к 8.8.8.8:443, чтение `snd_mss` | Снижение MSS (признак туннеля) |
| SO_BINDTODEVICE | Пробный bind на несуществующий интерфейс | Перехват setsockopt VPN-хуком |
| Loopback port bind | Попытка занять порты 51820/1194/443/8443 | Конфликты портов (VPN listener) |
| BPF OBJ GET | Попытка открыть `/sys/fs/bpf/` карты | Доступ к BPF-картам netd |
| IP_RECVERR | Пробный setsockopt IP_RECVERR | Перехват IP_RECVERR VPN-хуком |
Высокая уверенность: `vpn_policy_rules`, `hidden_mac_neighbors`, `tcp_mss_low`, `loopback_port_conflict`, `bpf_map_accessible`.
#### 12.6 Deep VPN Detector (`VpnNativeDetectorChecker`)
Новые детекты вынесены в отдельную секцию внутри категории Native и сгруппированы по 4 подкатегориям. Данные приходят из нового JNI-метода `nativeDetectVpnDetector(cancellationSignal)`, строки имеют префикс `vdet|`.
**Прямые признаки (Direct signs) — `EvidenceSource.NATIVE_INTERFACE`:**
| Проверка (kind) | Что ищем | Источник |
|-----------------|----------|----------|
| `sysfs_vpn_leak` | Утечка tun/wg/ppp/xfrm через sysfs | `/sys/class/net`, `/sys/devices/virtual/net`, `/proc/sys/net/ipv4|6/conf|neigh` |
| `getifaddrs_vpn` | VPN-интерфейсы в списке `getifaddrs()` | `getifaddrs()` |
| `sysclassnet_vpn` | VPN-интерфейсы в `/sys/class/net` | `stat("/sys/class/net/<if>")` |
| `rtm_getlink_vpn` | VPN-интерфейсы через netlink RTM_GETLINK | Netlink `RTM_GETLINK` dump |
| `proc_if_inet6_vpn` | VPN-интерфейсы в `/proc/net/if_inet6` | `/proc/net/if_inet6` |
| `proc_ipv6_route_vpn` | VPN-маршруты в `/proc/net/ipv6_route` | `/proc/net/ipv6_route` |
| `proc_net_dev_vpn` | VPN-трафик (RX/TX) в `/proc/net/dev` | `/proc/net/dev` |
| `ifindexname_vpn` | VPN-интерфейсы через `if_indextoname()` | `if_indextoname()` перебор ifindex |
| `vpn_policy_rules_netlink` | VPN policy routing rules (table 100200, oif=tun) | Netlink `RTM_GETRULE` dump |
**Сетевые признаки (Network signs) — `EvidenceSource.NATIVE_SOCKET`:**
| Проверка (kind) | Что ищем | Источник |
|-----------------|----------|----------|
| `fib_trie_denied` | `/proc/net/fib_trie` недоступен (SELinux EACCES) | `fopen("/proc/net/fib_trie")` |
| `inet_diag_denied` | inet_diag netlink запрещён (SELinux) | `socket(NETLINK_SOCK_DIAG)` |
| `bindtodevice_leak` | `SO_BINDTODEVICE` к tun + подтверждение `getsockopt` | `setsockopt(SO_BINDTODEVICE)` |
| `getsockname_leak` | `getsockname()` возвращает приватный VPN-IP | `getsockname()` на UDP-сокете |
| `udp_port_conflict_physical` | Конфликт UDP-портов (500/4500/1194/1701/51820) на физическом IP | `bind()` на физический IP |
| `route_count` | Число маршрутов и уникальных интерфейсов | Netlink `RTM_GETROUTE` dump |
| `trim_oracle` | Расхождение bind-probe vs RTM_GETLINK по числу iface | `if_indextoname()` vs `RTM_GETLINK` |
**Косвенные признаки (Indirect signs) — `EvidenceSource.NATIVE_ROUTE`:**
| Проверка (kind) | Что ищем | Источник |
|-----------------|----------|----------|
| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` |
| `udp_pmtu_ok` / `udp_pmtu_fail` | Успех/неудача отправки 1500 байт по UDP | `sendto()` 1500 байт |
| `normal_pmtu` | Path MTU основного физического интерфейса | `fetchMtu()` через `getifaddrs()` |
| `timing_oracle` | ARM CNTVCT циклы для `sendto()` (мин/макс/сред) | `mrs cntvct_el0` (aarch64) |
| `backpressure` | Пропускная способность при 50000 UDP-пакетах с отменой скана | Неблокирующий `sendto()` burst + проверка cancellation каждые 64 пакета |
| `gso_failed` / `gso_send_failed` / `gso_ok` | Диагностика поддержки UDP GSO; результат не является признаком VPN | `UDP_SEGMENT=1200` + отправка 4800 байт |
| `hw_timestamp` | Аппаратный таймстампинг (`SIOCSHWTSTAMP`, `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` |
**Пробы окружения (Environment probes) — `EvidenceSource.NATIVE_INTERFACE`:**
| Проверка (kind) | Что ищем | Источник |
|-----------------|----------|----------|
| `traceroute_denied` | Traceroute-проба (TTL=1 UDP) заблокирована | `setsockopt(IP_TTL=1)` + `sendto()` |
Высокая уверенность (→ `detected = true`): `sysfs_vpn_leak`, `getifaddrs_vpn`, `sysclassnet_vpn`, `rtm_getlink_vpn`, `proc_if_inet6_vpn`, `proc_ipv6_route_vpn`, `proc_net_dev_vpn`, `ifindexname_vpn`, `vpn_policy_rules_netlink`, `bindtodevice_leak`, `getsockname_leak`, `udp_port_conflict_physical`. Сырые измерения и GSO-результаты информационные; остальные аномалии → `needsReview`.
---
## Вердикт (`VerdictEngine`)
@ -571,7 +675,7 @@ JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kerne
- `geoHit` = `GeoIP.outsideRu == true` (кроме роуминга)
- `directHit` = detected-evidence из `DIRECT_NETWORK_CAPABILITIES` или `SYSTEM_PROXY`
- `indirectHit` = detected-evidence из `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH`
- `indirectHit` = detected-evidence из `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH` либо high-confidence `NATIVE_SOCKET`
| Geo | Direct | Indirect | Вердикт |
|-----|--------|----------|---------|
@ -612,6 +716,12 @@ JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kerne
./gradlew assembleDebug
```
## Режимы подробности результатов
В «Настройки → Внешний вид» доступны три режима. «Простой» показывает понятные итоги без низкоуровневых строк, «Обычный» сохраняет стандартное представление, а «Расширенный» добавляет свёрнутые технические данные к карточкам. Выбор фиксируется при запуске проверки, поэтому изменение настройки применяется только к следующему запуску и не меняет уже выполняющуюся или завершённую проверку. Режим не влияет на проверки, вердикт, таймауты или сетевое поведение.
Технический снимок создаётся только для проверки, запущенной в расширенном режиме. Он хранится только в памяти процесса, очищается при отмене или новой проверке и не добавляется в JSON/Markdown-экспорт. Одна запись ограничена 64 КиБ, весь запуск — 512 КиБ; интерфейс явно отмечает обрезанные записи. Заголовки авторизации и cookie, пароли, токены, UUID, ключи, URI userinfo и чувствительные query-параметры удаляются до сохранения. В приватном режиме дополнительно маскируются все IPv4/IPv6-адреса. Полные ответы Clash `/configs`, `/connections`, `/proxies`, Xray UUID/public keys, BSSID и идентификаторы сот не сохраняются.
---
## Благодарности

View file

@ -29,8 +29,8 @@ android {
applicationId = "com.notcvnt.rknhardering"
minSdk = 26
targetSdk = 36
versionCode = 20900
versionName = "2.9.0"
versionCode = 21000
versionName = "2.10.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
androidResources.localeFilters += listOf("en", "ru", "fa", "zh-rCN")

View file

@ -166,7 +166,7 @@ int ProgressCallback(
curl_off_t /* dlnow */,
curl_off_t /* ultotal */,
curl_off_t /* ulnow */) {
auto* state = static_cast<NativeCurlCancellationState*>(clientp);
const auto* state = static_cast<const NativeCurlCancellationState*>(clientp);
return state != nullptr && state->cancelled.load() ? 1 : 0;
}

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ import com.notcvnt.rknhardering.checker.CheckSettings
import com.notcvnt.rknhardering.checker.CheckUpdate
import com.notcvnt.rknhardering.checker.VpnCheckRunner
import com.notcvnt.rknhardering.customcheck.CustomCheckRunner
import com.notcvnt.rknhardering.diagnostics.DiagnosticTraceCollector
import com.notcvnt.rknhardering.model.CheckResult
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@ -14,9 +15,17 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
sealed interface ScanEvent {
data class Started(val settings: CheckSettings, val privacyMode: Boolean) : ScanEvent
data class Started(
val settings: CheckSettings,
val privacyMode: Boolean,
val resultDisplayMode: ResultDisplayMode = ResultDisplayMode.NORMAL,
) : ScanEvent
data class Update(val update: CheckUpdate) : ScanEvent
data class Completed(val result: CheckResult, val privacyMode: Boolean) : ScanEvent
data class Completed(
val result: CheckResult,
val privacyMode: Boolean,
val resultDisplayMode: ResultDisplayMode = ResultDisplayMode.NORMAL,
) : ScanEvent
data object Cancelled : ScanEvent
}
@ -46,14 +55,32 @@ class CheckViewModel(app: Application) : AndroidViewModel(app) {
private var completedDiagnosticsConsumed = false
fun startScan(settings: CheckSettings, privacyMode: Boolean) {
val resultDisplayMode = ResultDisplayMode.fromPrefs(AppUiSettings.prefs(getApplication()))
startScan(settings, privacyMode, resultDisplayMode)
}
fun startScan(
settings: CheckSettings,
privacyMode: Boolean,
resultDisplayMode: ResultDisplayMode,
) {
if (scanJob?.isActive == true && activeExecutionContext?.cancellationSignal?.isCancelled() == false) return
val scanId = nextScanId++
val executionContext = ScanExecutionContext(scanId = scanId)
val diagnosticCollector = if (resultDisplayMode == ResultDisplayMode.ADVANCED) {
DiagnosticTraceCollector(privacyMode)
} else {
null
}
val executionContext = ScanExecutionContext(
scanId = scanId,
resultDisplayMode = resultDisplayMode,
diagnosticCollector = diagnosticCollector,
)
activeScanId = scanId
activeExecutionContext = executionContext
resetCompletedDiagnosticsRetention()
replaceScanEvents(scanId, ScanEvent.Started(settings, privacyMode))
replaceScanEvents(scanId, ScanEvent.Started(settings, privacyMode, resultDisplayMode))
_isRunning.value = true
lateinit var launchedJob: Job
@ -82,7 +109,7 @@ class CheckViewModel(app: Application) : AndroidViewModel(app) {
}
}
if (isCurrentScan(scanId, executionContext)) {
val enrichedResult = try {
val resultWithProfile = try {
val app: Application = getApplication()
val customEnabled = AppUiSettings.prefs(app)
.getBoolean(SettingsPrefs.PREF_CUSTOM_CHECKS_ENABLED, false)
@ -94,9 +121,17 @@ class CheckViewModel(app: Application) : AndroidViewModel(app) {
} catch (_: Exception) {
result
}
appendScanEvent(scanId, ScanEvent.Completed(enrichedResult, privacyMode))
val enrichedResult = if (resultWithProfile.diagnosticSnapshot == null) {
resultWithProfile.copy(
diagnosticSnapshot = executionContext.diagnosticCollector?.snapshot(),
)
} else {
resultWithProfile
}
appendScanEvent(scanId, ScanEvent.Completed(enrichedResult, privacyMode, resultDisplayMode))
}
} catch (e: kotlinx.coroutines.CancellationException) {
executionContext.diagnosticCollector?.clear()
if (isCurrentScan(scanId, executionContext)) {
appendScanEvent(scanId, ScanEvent.Cancelled)
}
@ -124,6 +159,7 @@ class CheckViewModel(app: Application) : AndroidViewModel(app) {
activeExecutionContext = null
activeScanId = null
_isRunning.value = false
executionContext.diagnosticCollector?.clear()
executionContext.cancellationSignal.cancel()
scanJob?.cancel()
}

View file

@ -11,6 +11,7 @@ import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
@ -53,11 +54,15 @@ import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.ObservedIp
import com.notcvnt.rknhardering.model.StunProbeGroupResult
import com.notcvnt.rknhardering.model.VerdictRuleCode
import com.notcvnt.rknhardering.export.CompletedExportSnapshot
import com.notcvnt.rknhardering.export.createCompletedExportSnapshot
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.ui.main.CategoryTiles
import com.notcvnt.rknhardering.ui.main.MainExportController
import com.notcvnt.rknhardering.ui.main.SimpleCardModel
import com.notcvnt.rknhardering.ui.main.SimpleResultModels
import com.notcvnt.rknhardering.ui.main.SimpleResultStatus
import com.notcvnt.rknhardering.ui.main.TileSpec
import com.notcvnt.rknhardering.ui.main.render.BypassRenderer
import com.notcvnt.rknhardering.ui.main.render.CallTransportRenderer
@ -68,6 +73,8 @@ import com.notcvnt.rknhardering.ui.main.render.FindingViewFactory
import com.notcvnt.rknhardering.ui.main.render.IpChannelsRenderer
import com.notcvnt.rknhardering.ui.main.render.IpComparisonRenderer
import com.notcvnt.rknhardering.ui.main.render.MainRenderEnvironment
import com.notcvnt.rknhardering.ui.main.render.SimpleResultRenderer
import com.notcvnt.rknhardering.ui.main.render.TechnicalDataRenderer
import com.notcvnt.rknhardering.ui.main.render.VerdictRenderer
import com.notcvnt.rknhardering.ui.main.render.VerdictViews
import kotlinx.coroutines.Job
@ -199,6 +206,7 @@ class MainActivity : AppCompatActivity() {
private var userTouchScrollInProgress = false
private var isAutoScrollInProgress = false
private var activeCheckPrivacyMode = false
private var activeResultDisplayMode = ResultDisplayMode.NORMAL
private var activeCheckSettings: CheckSettings? = null
private var retainedDiagnosticsSnapshot: RetainedDiagnosticsSnapshot? = null
private var completedExportSnapshot: CompletedExportSnapshot? = null
@ -267,6 +275,8 @@ class MainActivity : AppCompatActivity() {
private val ipChannelsRenderer by lazy { IpChannelsRenderer(renderEnv) }
private val domainReachabilityRenderer by lazy { DomainReachabilityRenderer(renderEnv) }
private val verdictRenderer by lazy { VerdictRenderer(renderEnv, findingViews) }
private val simpleResultRenderer by lazy { SimpleResultRenderer(renderEnv) }
private val technicalDataRenderer by lazy { TechnicalDataRenderer(renderEnv) }
override fun onCreate(savedInstanceState: Bundle?) {
AppUiSettings.applySavedTheme(this)
@ -652,7 +662,8 @@ class MainActivity : AppCompatActivity() {
private fun onRunCheckClicked() {
if (viewModel.isRunning.value) return
runCheck()
val resultDisplayMode = ResultDisplayMode.fromPrefs(prefs)
runCheck(resultDisplayMode)
}
private fun isDebugClipboardExportEnabled(): Boolean {
@ -749,7 +760,7 @@ class MainActivity : AppCompatActivity() {
Toast.makeText(this, R.string.main_diagnostics_copied, Toast.LENGTH_SHORT).show()
}
private fun runCheck() {
private fun runCheck(resultDisplayMode: ResultDisplayMode) {
val splitTunnelEnabled = prefs.getBoolean(SettingsActivity.PREF_SPLIT_TUNNEL_ENABLED, true)
val proxyScanEnabled = prefs.getBoolean(SettingsActivity.PREF_PROXY_SCAN_ENABLED, true)
val proxyAuthProbeEnabled = prefs.getBoolean(SettingsActivity.PREF_PROXY_AUTH_PROBE_ENABLED, false)
@ -803,7 +814,7 @@ class MainActivity : AppCompatActivity() {
val activeProfile = if (customEnabled) com.notcvnt.rknhardering.customcheck.CustomCheckRunner.getActiveProfile(this) else null
val effectiveSettings = activeProfile?.let { com.notcvnt.rknhardering.customcheck.CustomCheckRunner.toCheckSettings(it, baseSettings) } ?: baseSettings
viewModel.startScan(effectiveSettings, privacyMode)
viewModel.startScan(effectiveSettings, privacyMode, resultDisplayMode)
}
private fun observeScanEvents() {
@ -825,6 +836,7 @@ class MainActivity : AppCompatActivity() {
prepareCheckSessionUi(
firstEvent.settings,
firstEvent.privacyMode,
firstEvent.resultDisplayMode,
)
processedEventCount = 1
while (processedEventCount < events.size) {
@ -850,7 +862,18 @@ class MainActivity : AppCompatActivity() {
}
private fun prepareCheckSessionUi(settings: CheckSettings, privacyMode: Boolean) {
prepareCheckSessionUi(settings, privacyMode, ResultDisplayMode.NORMAL)
}
private fun prepareCheckSessionUi(
settings: CheckSettings,
privacyMode: Boolean,
resultDisplayMode: ResultDisplayMode,
) {
simpleResultRenderer.clear()
technicalDataRenderer.clear()
activeCheckPrivacyMode = privacyMode
activeResultDisplayMode = resultDisplayMode
activeCheckSettings = settings
retainedDiagnosticsSnapshot = null
completedExportSnapshot = null
@ -864,12 +887,18 @@ class MainActivity : AppCompatActivity() {
resetBypassProgress()
clearStageContent()
resetAllTiles()
applyResultDisplayTitles()
applyTileVisibilityFromSettings(settings)
if (settings.callTransportProbeEnabled) {
setTileStatus(CATEGORY_STN, TILE_STATUS_NEUTRAL, getString(R.string.tile_hint_loading))
}
bindVerdictHeroRunning()
showAllLoadingCardsNow(settings)
if (activeResultDisplayMode == ResultDisplayMode.SIMPLE) {
tiles.filterValues { it.card.isVisible }.keys.forEach { id ->
renderSimpleCard(id, SimpleCardModel(SimpleResultStatus.RUNNING))
}
}
updateResultActionButtonsVisibility()
}
@ -892,8 +921,12 @@ class MainActivity : AppCompatActivity() {
is ScanEvent.Started -> Unit
is ScanEvent.Update -> {
handleCheckUpdate(event.update, animate = animate)
if (activeResultDisplayMode == ResultDisplayMode.SIMPLE) {
renderSimpleUpdate(event.update)
}
}
is ScanEvent.Completed -> {
activeResultDisplayMode = event.resultDisplayMode
retainedDiagnosticsSnapshot = if (viewModel.canRetainCompletedDiagnostics()) {
retainCompletedDiagnosticsSnapshot(
result = event.result,
@ -911,11 +944,17 @@ class MainActivity : AppCompatActivity() {
refreshCompletedCategoryViews(event.result, event.privacyMode)
displayVerdict(event.result, event.privacyMode)
bindVerdictHero(event.result)
when (activeResultDisplayMode) {
ResultDisplayMode.SIMPLE -> renderSimpleCompletedResult(event.result)
ResultDisplayMode.ADVANCED -> renderTechnicalData(event.result)
ResultDisplayMode.NORMAL -> Unit
}
if (animate) animateContentReveal(verdictHero)
stopLoadingStatusAnimation()
updateResultActionButtonsVisibility()
}
is ScanEvent.Cancelled -> {
technicalDataRenderer.clear()
activeCheckSettings = null
resetBypassProgress()
statusBypass.text = getString(R.string.main_status_cancelled)
@ -940,6 +979,167 @@ class MainActivity : AppCompatActivity() {
}
}
private fun applyResultDisplayTitles() {
CategoryTiles.ALL.forEach { spec ->
tiles[spec.id]?.title?.setText(
if (activeResultDisplayMode == ResultDisplayMode.SIMPLE) {
simpleTitleForCategory(spec.id)
} else {
spec.titleRes
},
)
}
}
private fun renderSimpleUpdate(update: CheckUpdate) {
when (update) {
is CheckUpdate.GeoIpReady -> renderSimpleCard(CATEGORY_GEO, SimpleResultModels.category(update.result))
is CheckUpdate.IpComparisonReady -> renderSimpleCard(CATEGORY_IPC, SimpleResultModels.ipComparison(update.result))
is CheckUpdate.CdnPullingReady -> renderSimpleCard(CATEGORY_CDN, SimpleResultModels.cdn(update.result))
is CheckUpdate.IcmpSpoofingReady -> renderSimpleCard(CATEGORY_ICM, SimpleResultModels.category(update.result))
is CheckUpdate.RttTriangulationReady -> renderSimpleCard(
CATEGORY_RTT,
SimpleResultModels.category(update.result, extraInformation = true),
)
is CheckUpdate.DirectSignsReady -> renderSimpleCard(CATEGORY_DIR, SimpleResultModels.category(update.result))
is CheckUpdate.IndirectSignsReady -> {
renderSimpleCard(CATEGORY_IND, SimpleResultModels.category(update.result))
if (tiles[CATEGORY_STN]?.card?.isVisible == true) {
renderSimpleCard(
CATEGORY_STN,
SimpleResultModels.callTransport(update.result.callTransportLeaks, update.result.stunProbeGroups),
)
}
}
is CheckUpdate.LocationSignalsReady -> renderSimpleCard(CATEGORY_LOC, SimpleResultModels.category(update.result))
is CheckUpdate.NativeSignsReady -> renderSimpleCard(CATEGORY_NAT, SimpleResultModels.category(update.result))
is CheckUpdate.BypassReady -> renderSimpleCard(CATEGORY_BYP, SimpleResultModels.bypass(update.result))
is CheckUpdate.DomainReachabilityReady -> renderSimpleCard(
CATEGORY_REA,
SimpleResultModels.domainReachability(update.result),
)
is CheckUpdate.BypassProgress,
is CheckUpdate.IpConsensusReady,
is CheckUpdate.VerdictReady,
-> Unit
}
}
private fun renderSimpleCompletedResult(result: CheckResult) {
renderSimpleCard(CATEGORY_GEO, SimpleResultModels.category(result.geoIp))
renderSimpleCard(CATEGORY_IPC, SimpleResultModels.ipComparison(result.ipComparison))
renderSimpleCard(CATEGORY_CDN, SimpleResultModels.cdn(result.cdnPulling))
renderSimpleCard(CATEGORY_DIR, SimpleResultModels.category(result.directSigns))
renderSimpleCard(CATEGORY_IND, SimpleResultModels.category(result.indirectSigns))
renderSimpleCard(CATEGORY_NAT, SimpleResultModels.category(result.nativeSigns))
renderSimpleCard(
CATEGORY_STN,
SimpleResultModels.callTransport(
result.indirectSigns.callTransportLeaks,
result.indirectSigns.stunProbeGroups,
),
)
renderSimpleCard(CATEGORY_ICM, SimpleResultModels.category(result.icmpSpoofing))
renderSimpleCard(
CATEGORY_RTT,
SimpleResultModels.category(result.rttTriangulation, extraInformation = true),
)
renderSimpleCard(CATEGORY_LOC, SimpleResultModels.category(result.locationSignals))
renderSimpleCard(CATEGORY_BYP, SimpleResultModels.bypass(result.bypassResult))
renderSimpleCard(CATEGORY_REA, SimpleResultModels.domainReachability(result.domainReachability))
verdictSubtitle.text = simpleVerdictExplanation(result.verdictDecision.rule)
textVerdictExplanation.text = simpleVerdictExplanation(result.verdictDecision.rule)
}
private fun renderSimpleCard(id: String, model: SimpleCardModel) {
val holder = tiles[id] ?: return
simpleResultRenderer.render(holder.body as ViewGroup, simpleCheckDescription(id), model)
setTileStatus(id, tileStatus(model.status), simpleStatusText(model.status))
}
private fun renderTechnicalData(result: CheckResult) {
technicalDataRenderer.clear()
val snapshot = result.diagnosticSnapshot
val entries = snapshot?.entries.orEmpty()
tiles.values.filter { it.card.isVisible }.forEach { holder ->
technicalDataRenderer.render(
holder.body as ViewGroup,
entries.filter { it.category == holder.id },
runTruncated = snapshot?.truncated == true,
)
}
}
private fun tileStatus(status: SimpleResultStatus): Int = when (status) {
SimpleResultStatus.RUNNING,
SimpleResultStatus.DISABLED,
-> TILE_STATUS_NEUTRAL
SimpleResultStatus.CLEAN -> TILE_STATUS_CLEAN
SimpleResultStatus.REVIEW -> TILE_STATUS_REVIEW
SimpleResultStatus.DETECTED -> TILE_STATUS_DETECTED
SimpleResultStatus.ERROR -> TILE_STATUS_ERROR
}
private fun simpleStatusText(status: SimpleResultStatus): String = getString(
when (status) {
SimpleResultStatus.RUNNING -> R.string.simple_result_running
SimpleResultStatus.CLEAN -> R.string.simple_result_clean
SimpleResultStatus.REVIEW -> R.string.simple_result_review
SimpleResultStatus.DETECTED -> R.string.simple_result_detected
SimpleResultStatus.ERROR -> R.string.simple_result_error
SimpleResultStatus.DISABLED -> R.string.simple_result_disabled
},
)
private fun simpleTitleForCategory(id: String): Int = when (id) {
CATEGORY_GEO -> R.string.simple_title_geo
CATEGORY_IPC -> R.string.simple_title_ip_comparison
CATEGORY_CDN -> R.string.simple_title_cdn
CATEGORY_DIR -> R.string.simple_title_direct
CATEGORY_IND -> R.string.simple_title_indirect
CATEGORY_NAT -> R.string.simple_title_native
CATEGORY_STN -> R.string.simple_title_call_transport
CATEGORY_ICM -> R.string.simple_title_icmp
CATEGORY_RTT -> R.string.simple_title_rtt
CATEGORY_LOC -> R.string.simple_title_location
CATEGORY_BYP -> R.string.simple_title_bypass
CATEGORY_REA -> R.string.simple_title_reachability
else -> R.string.section_check_details
}
private fun simpleCheckDescription(id: String): String = getString(
when (id) {
CATEGORY_GEO -> R.string.simple_checked_geo
CATEGORY_IPC -> R.string.simple_checked_ip_comparison
CATEGORY_CDN -> R.string.simple_checked_cdn
CATEGORY_DIR -> R.string.simple_checked_direct
CATEGORY_IND -> R.string.simple_checked_indirect
CATEGORY_NAT -> R.string.simple_checked_native
CATEGORY_STN -> R.string.simple_checked_call_transport
CATEGORY_ICM -> R.string.simple_checked_icmp
CATEGORY_RTT -> R.string.simple_checked_rtt
CATEGORY_LOC -> R.string.simple_checked_location
CATEGORY_BYP -> R.string.simple_checked_bypass
CATEGORY_REA -> R.string.simple_checked_reachability
else -> R.string.section_check_details
},
)
private fun simpleVerdictExplanation(rule: VerdictRuleCode): String = getString(
when (rule) {
VerdictRuleCode.R1_HARD_BYPASS -> R.string.simple_verdict_hard_bypass
VerdictRuleCode.R3_PROBE_TARGET_DIVERGENCE,
VerdictRuleCode.R3_PROBE_TARGET_DIRECT_DIVERGENCE,
VerdictRuleCode.R3_CROSS_CHANNEL_MISMATCH,
-> R.string.simple_verdict_address_conflict
VerdictRuleCode.R4_LOCATION_GEO_CONFLICT -> R.string.simple_verdict_location_conflict
VerdictRuleCode.R4_HOSTING_REVIEW -> R.string.simple_verdict_hosting_review
VerdictRuleCode.R5_MATRIX -> R.string.simple_verdict_combined_signals
VerdictRuleCode.R6_FALLBACK -> R.string.simple_verdict_review_signals
VerdictRuleCode.UNSPECIFIED -> R.string.simple_verdict_general
},
)
private fun refreshCompletedCategoryViews(result: CheckResult, privacyMode: Boolean) {
if (cardCdnPulling.isVisible || result.cdnPulling != CdnPullingResult.empty()) {
displayCdnPulling(result.cdnPulling, privacyMode)
@ -1209,6 +1409,7 @@ class MainActivity : AppCompatActivity() {
}
is CheckUpdate.IpConsensusReady -> {
markStageCompleted(RunningStage.IP_CONSENSUS)
updateTileFromIpConsensus(update.result)
}
is CheckUpdate.DomainReachabilityReady -> {
markStageCompleted(RunningStage.DOMAIN_REACHABILITY)

View file

@ -0,0 +1,18 @@
package com.notcvnt.rknhardering
import android.content.SharedPreferences
enum class ResultDisplayMode(val prefValue: String) {
SIMPLE("simple"),
NORMAL("normal"),
ADVANCED("advanced"),
;
companion object {
fun fromPref(value: String?): ResultDisplayMode =
entries.firstOrNull { it.prefValue == value } ?: NORMAL
fun fromPrefs(prefs: SharedPreferences): ResultDisplayMode =
fromPref(prefs.getString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, null))
}
}

View file

@ -1,5 +1,6 @@
package com.notcvnt.rknhardering
import com.notcvnt.rknhardering.diagnostics.DiagnosticTraceCollector
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.asContextElement
import java.net.DatagramSocket
@ -14,6 +15,8 @@ private val scanExecutionContextThreadLocal = ThreadLocal<ScanExecutionContext?>
data class ScanExecutionContext(
val scanId: Long = 0L,
val cancellationSignal: ScanCancellationSignal = ScanCancellationSignal(),
val resultDisplayMode: ResultDisplayMode = ResultDisplayMode.NORMAL,
val diagnosticCollector: DiagnosticTraceCollector? = null,
) {
fun asCoroutineContext(): CoroutineContext = scanExecutionContextThreadLocal.asContextElement(this)

View file

@ -3,6 +3,7 @@ package com.notcvnt.rknhardering
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import com.google.android.material.chip.ChipGroup
@ -11,6 +12,8 @@ import com.google.android.material.snackbar.Snackbar
internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_appearance) {
private lateinit var prefs: SharedPreferences
private lateinit var chipGroupResultDisplayMode: ChipGroup
private lateinit var textResultDisplayModeDescription: TextView
private lateinit var chipGroupTheme: ChipGroup
private lateinit var chipGroupIconStyle: ChipGroup
private lateinit var chipGroupLanguage: ChipGroup
@ -18,6 +21,8 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
prefs = AppUiSettings.prefs(requireContext())
chipGroupResultDisplayMode = view.findViewById(R.id.chipGroupResultDisplayMode)
textResultDisplayModeDescription = view.findViewById(R.id.textResultDisplayModeDescription)
chipGroupTheme = view.findViewById(R.id.chipGroupTheme)
chipGroupIconStyle = view.findViewById(R.id.chipGroupIconStyle)
chipGroupLanguage = view.findViewById(R.id.chipGroupLanguage)
@ -26,6 +31,16 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
}
private fun loadSettings() {
val resultDisplayMode = ResultDisplayMode.fromPrefs(prefs)
chipGroupResultDisplayMode.check(
when (resultDisplayMode) {
ResultDisplayMode.SIMPLE -> R.id.chipResultDisplaySimple
ResultDisplayMode.NORMAL -> R.id.chipResultDisplayNormal
ResultDisplayMode.ADVANCED -> R.id.chipResultDisplayAdvanced
},
)
updateResultDisplayModeDescription(resultDisplayMode)
val theme = prefs.getString(SettingsPrefs.PREF_THEME, "system") ?: "system"
chipGroupTheme.check(
when (theme) {
@ -53,6 +68,17 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
}
private fun setupListeners(view: View) {
chipGroupResultDisplayMode.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener
val mode = when (checkedIds.first()) {
R.id.chipResultDisplaySimple -> ResultDisplayMode.SIMPLE
R.id.chipResultDisplayAdvanced -> ResultDisplayMode.ADVANCED
else -> ResultDisplayMode.NORMAL
}
prefs.edit { putString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, mode.prefValue) }
updateResultDisplayModeDescription(mode)
}
chipGroupTheme.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener
val value = when (checkedIds.first()) {
@ -86,6 +112,16 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
}
}
private fun updateResultDisplayModeDescription(mode: ResultDisplayMode) {
textResultDisplayModeDescription.setText(
when (mode) {
ResultDisplayMode.SIMPLE -> R.string.settings_result_display_mode_simple_desc
ResultDisplayMode.NORMAL -> R.string.settings_result_display_mode_normal_desc
ResultDisplayMode.ADVANCED -> R.string.settings_result_display_mode_advanced_desc
},
)
}
private fun applyIconStyle(view: View, isClassic: Boolean) {
val rawMode = prefs.getString(SettingsPrefs.PREF_COLOR_VISION_MODE, null)
val mode = ColorVisionMode.fromPref(rawMode)

View file

@ -141,6 +141,11 @@ internal class SettingsCategoriesFragment : Fragment(R.layout.fragment_settings_
}
private fun appearanceValue(): String {
val resultDisplayMode = when (ResultDisplayMode.fromPrefs(prefs)) {
ResultDisplayMode.SIMPLE -> getString(R.string.settings_result_display_mode_simple)
ResultDisplayMode.NORMAL -> getString(R.string.settings_result_display_mode_normal)
ResultDisplayMode.ADVANCED -> getString(R.string.settings_result_display_mode_advanced)
}
val theme = when (prefs.getString(SettingsPrefs.PREF_THEME, "system")) {
"light" -> getString(R.string.settings_theme_light)
"dark" -> getString(R.string.settings_theme_dark)
@ -153,7 +158,7 @@ internal class SettingsCategoriesFragment : Fragment(R.layout.fragment_settings_
"zh-CN" -> "ZH"
else -> currentLocaleCode()
}
return getString(R.string.settings_value_appearance_format, theme, language)
return getString(R.string.settings_value_appearance_format, theme, language, resultDisplayMode)
}
private fun colorVisionValue(): String {

View file

@ -162,7 +162,7 @@ internal class SettingsCustomCheckEditorFragment :
// Override expansion listener to track openSectionIds
sectionView.findViewById<View>(R.id.sectionHeader).setOnClickListener {
val nowOpen = !controller.isExpanded()
val nowOpen = !controller.isExpanded
controller.setExpanded(nowOpen, animate = true)
if (nowOpen) openSectionIds.add(id) else openSectionIds.remove(id)
}
@ -225,7 +225,7 @@ internal class SettingsCustomCheckEditorFragment :
labelHintRes = R.string.settings_custom_check_description_field,
extraInputHintRes = R.string.settings_custom_check_check_type_field,
extraSwitchTextRes = null,
testAction = { domain, checkType, _ ->
testAction = { domain, _, _ ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
val result = runCatching {
val client = okhttp3.OkHttpClient.Builder()

View file

@ -24,6 +24,7 @@ internal object SettingsPrefs {
const val PREF_DNS_RESOLVER_DOH_URL = "pref_dns_resolver_doh_url"
const val PREF_DNS_RESOLVER_DOH_BOOTSTRAP = "pref_dns_resolver_doh_bootstrap"
const val PREF_PRIVACY_MODE = "pref_privacy_mode"
const val PREF_RESULT_DISPLAY_MODE = "pref_result_display_mode"
const val PREF_THEME = "pref_theme"
const val PREF_LANGUAGE = "pref_language"
const val PREF_COLOR_VISION_MODE = "pref_color_vision_mode"

View file

@ -31,9 +31,10 @@ object CdnPullingChecker {
private const val MAX_FETCH_ATTEMPTS = 1
private const val RETRY_DELAY_MS = 250L
private const val MEDUZA_IO = "meduza.io"
private val ACTIONABLE_TARGETS = setOf(
"redirector.googlevideo.com",
"meduza.io",
MEDUZA_IO,
)
internal data class EndpointSpec(
@ -71,7 +72,7 @@ object CdnPullingChecker {
kind = CdnPullingClient.TargetKind.CLOUDFLARE_TRACE,
),
EndpointSpec(
label = "meduza.io",
label = MEDUZA_IO,
url = "https://meduza.io/cdn-cgi/trace",
kind = CdnPullingClient.TargetKind.CLOUDFLARE_TRACE,
),
@ -92,7 +93,7 @@ object CdnPullingChecker {
val activeEndpoints = buildList {
if (config.builtinTargetsEnabled) {
for (endpoint in ENDPOINTS) {
if (endpoint.label == "meduza.io" && !config.meduzaEnabled) continue
if (endpoint.label == MEDUZA_IO && !config.meduzaEnabled) continue
if (endpoint.label == "rutracker.org" && !config.rutrackerEnabled) continue
add(endpoint)
}

View file

@ -580,9 +580,7 @@ object GeoIpChecker {
config = resolverConfig,
cancellationSignal = executionContext.cancellationSignal,
)
if (response.code !in 200..299) {
throw IllegalStateException("HTTP ${response.code}")
}
check(response.code in 200..299) { "HTTP ${response.code}" }
return JSONObject(response.body)
}

View file

@ -14,6 +14,7 @@ import com.notcvnt.rknhardering.network.ResolverNetworkStack
import com.notcvnt.rknhardering.probe.SystemPingProber
import java.io.IOException
import java.net.Inet4Address
import java.net.InetAddress
import java.util.Locale
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -47,8 +48,8 @@ object IcmpSpoofingChecker {
config = resolverConfig,
cancellationSignal = ScanExecutionContext.currentOrDefault().cancellationSignal,
)
val ipv4 = addresses.firstOrNull { it is Inet4Address }
?: throw IOException("No IPv4 address resolved for $host")
val ipv4 = addresses.filterIsInstance<Inet4Address>().firstOrNull(::isUsablePublicIpv4)
?: throw IOException("No usable public IPv4 address resolved for $host")
ipv4.hostAddress ?: throw IOException("Resolved IPv4 address is empty for $host")
},
val ping: suspend (String, Int, Int) -> SystemPingProber.PingResult = { address, count, timeoutSeconds ->
@ -94,6 +95,14 @@ object IcmpSpoofingChecker {
effectiveTargets.map { target ->
async {
val address = dependencies.resolveIpv4(target.host, resolverConfig)
val parsedAddress = if (DnsResolverConfig.isValidIpLiteral(address)) {
runCatching { InetAddress.getByName(address) }.getOrNull() as? Inet4Address
} else {
null
}
if (parsedAddress == null || !isUsablePublicIpv4(parsedAddress)) {
throw IOException("No usable public IPv4 address resolved for ${target.host}")
}
val pingResult = dependencies.ping(address, pingCount, timeoutSeconds)
TargetOutcome(target = target, address = address, ping = pingResult)
}
@ -264,4 +273,31 @@ object IcmpSpoofingChecker {
private fun formatRtt(value: Double?): String {
return value?.let { String.format(Locale.US, "%.1f ms", it) } ?: "n/a"
}
internal fun isUsablePublicIpv4(address: Inet4Address): Boolean {
if (
address.isAnyLocalAddress ||
address.isLoopbackAddress ||
address.isLinkLocalAddress ||
address.isSiteLocalAddress ||
address.isMulticastAddress
) {
return false
}
val bytes = address.address.map { it.toInt() and 0xff }
val first = bytes[0]
val second = bytes[1]
val third = bytes[2]
return when {
first == 0 -> false
first == 100 && second in 64..127 -> false
first == 192 && second == 0 && (third == 0 || third == 2) -> false
first == 198 && second in 18..19 -> false
first == 198 && second == 51 && third == 100 -> false
first == 203 && second == 0 && third == 113 -> false
first >= 240 -> false
else -> true
}
}
}

View file

@ -3,6 +3,7 @@ package com.notcvnt.rknhardering.checker
import android.content.Context
import android.os.Build
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.model.ActiveVpnApp
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
@ -11,6 +12,7 @@ import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.vpn.VpnAppCatalog
import com.notcvnt.rknhardering.vpn.VpnAppMetadataScanner
import com.notcvnt.rknhardering.vpn.VpnDumpsysParser
import kotlin.concurrent.thread
internal fun checkDumpsysVpn(
context: Context,
@ -20,9 +22,7 @@ internal fun checkDumpsysVpn(
): SignalOutcome {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return SignalOutcome()
return try {
val process = Runtime.getRuntime().exec(arrayOf("dumpsys", "vpn_management"))
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
val output = executeDumpsys(listOf("dumpsys", "vpn_management"))
if (VpnDumpsysParser.isUnavailable(output)) {
findings.add(Finding(context.getString(R.string.checker_indirect_dumpsys_vpn_unavailable)))
@ -108,9 +108,7 @@ internal fun checkDumpsysVpnService(
activeApps: MutableList<ActiveVpnApp>,
): SignalOutcome {
return try {
val process = Runtime.getRuntime().exec(arrayOf("dumpsys", "activity", "services", "android.net.VpnService"))
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
val output = executeDumpsys(listOf("dumpsys", "activity", "services", "android.net.VpnService"))
if (VpnDumpsysParser.isUnavailable(output)) {
findings.add(Finding(context.getString(R.string.checker_indirect_dumpsys_service_unavailable)))
@ -192,3 +190,55 @@ internal fun checkDumpsysVpnService(
SignalOutcome()
}
}
private fun executeDumpsys(command: List<String>): String {
val executionContext = ScanExecutionContext.currentOrDefault()
val startedAt = System.nanoTime()
val process = ProcessBuilder(command).start()
val registration = executionContext.cancellationSignal.register {
runCatching { process.destroyForcibly() }
}
return try {
var stdout = ""
var stderr = ""
val stdoutReader = thread(name = "rkn-dumpsys-stdout") {
stdout = runCatching { process.inputStream.bufferedReader().use { it.readText() } }.getOrDefault("")
}
val stderrReader = thread(name = "rkn-dumpsys-stderr") {
stderr = runCatching { process.errorStream.bufferedReader().use { it.readText() } }.getOrDefault("")
}
val exitCode = process.waitFor()
stdoutReader.join()
stderrReader.join()
executionContext.throwIfCancelled()
executionContext.diagnosticCollector?.record(
category = "ind",
source = "dumpsys",
target = command.drop(1).joinToString(" "),
status = "exit $exitCode",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = buildString {
appendLine("command=${command.joinToString(" ")}")
appendLine("exitCode=$exitCode")
appendLine("stdout:")
appendLine(stdout)
appendLine("stderr:")
append(stderr)
},
)
stdout
} catch (error: Exception) {
executionContext.diagnosticCollector?.record(
category = "ind",
source = "dumpsys",
target = command.drop(1).joinToString(" "),
status = "error",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = error.message.orEmpty(),
)
throw error
} finally {
registration.dispose()
runCatching { process.destroy() }
}
}

View file

@ -2,6 +2,7 @@ package com.notcvnt.rknhardering.checker
import android.content.Context
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.rethrowIfCancellation
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
@ -38,6 +39,7 @@ object NativeSignsChecker {
)
private const val ARPHRD_TUNTAP = 65534
private const val RTPROT_KERNEL = 2
private val HIGH_CONFIDENCE_ROOT_MOUNT_MARKERS = setOf(
"magisk",
@ -153,6 +155,31 @@ object NativeSignsChecker {
evidence += isolationOutcome.evidence
needsReview = needsReview || isolationOutcome.needsReview
val vpnProps = runCatching { NativeInterfaceProbe.collectVpnPropertyFindings() }.getOrDefault(emptyList())
val vpnLeaks = runCatching { NativeInterfaceProbe.collectVpnLeakFindings() }.getOrDefault(emptyList())
val vpnAdvanced = runCatching { NativeInterfaceProbe.collectVpnAdvancedFindings() }.getOrDefault(emptyList())
val vpnSyscalls = runCatching { NativeInterfaceProbe.collectVpnSyscallFindings() }.getOrDefault(emptyList())
val vpnOutcome = evaluateVpnSignals(context, vpnProps, vpnLeaks, vpnAdvanced, vpnSyscalls)
findings += vpnOutcome.findings
evidence += vpnOutcome.evidence
detected = detected || vpnOutcome.detected
needsReview = needsReview || vpnOutcome.needsReview
// Deep VPN detector (ported from reference APK) — kept as its own
// sub-section inside the Native category for clarity.
val detectorOutcome = try {
VpnNativeDetectorChecker.check(context)
} catch (error: Throwable) {
rethrowIfCancellation(error)
null
}
if (detectorOutcome != null) {
findings += detectorOutcome.findings
evidence += detectorOutcome.evidence
detected = detected || detectorOutcome.detected
needsReview = needsReview || detectorOutcome.needsReview
}
if (findings.isEmpty()) {
findings += Finding(
description = context.getString(R.string.checker_native_no_anomalies),
@ -492,6 +519,7 @@ object NativeSignsChecker {
!route.isDefault &&
(route.prefixLen == 32 || route.prefixLen == 128) &&
route.destination != null &&
!isKernelManagedLocalRoute(route) &&
isPublicRoutableAddress(route.destination) &&
NetworkInterfacePatterns.isStandardInterface(route.interfaceName)
}
@ -520,6 +548,37 @@ object NativeSignsChecker {
return PartialOutcome(findings, evidence, detected = detected)
}
/**
* Excludes kernel-managed entries that are not VPN host-route leaks:
* - every entry from the kernel `local` table (255), even if a vendor reports
* incomplete type/scope metadata
* - `type=local` (the interface's own address) / `broadcast` / `anycast` / `multicast`
* - `scope=host` (the route never leaves the box)
* - `dst == prefsrc` (route destination equals the interface's own source address)
* - kernel-created link routes on cellular modem interfaces; carriers use these
* for service addressing and they exist independently of a VPN (issue #80)
* A genuine VPN server host-route leak is a `unicast` route to a *foreign* public IP
* (dst != prefsrc) in the main/policy tables.
*/
internal fun isKernelManagedLocalRoute(route: NativeRouteEntry): Boolean {
if (route.table == 255) return true
when (route.type?.lowercase()) {
"local", "broadcast", "anycast", "multicast" -> return true
}
if (route.scope?.lowercase() == "host") return true
val dst = route.destination
val src = route.prefSrc
if (dst != null && src != null && dst == src) return true
if (
route.protocol == RTPROT_KERNEL &&
route.scope?.lowercase() == "link" &&
NetworkInterfacePatterns.isCellularModemInterface(route.interfaceName)
) {
return true
}
return false
}
internal fun isPublicRoutableAddress(addr: String): Boolean {
if (!com.notcvnt.rknhardering.customcheck.UrlSanitizer.isPublicAddress(addr)) return false
// UrlSanitizer does not cover IPv6 ULA fc00::/7 — add it here
@ -916,11 +975,163 @@ object NativeSignsChecker {
}.getOrDefault(false)
}
internal fun evaluateVpnSignals(
context: Context,
props: List<com.notcvnt.rknhardering.probe.NativeVpnPropertyFinding>,
leaks: List<com.notcvnt.rknhardering.probe.NativeVpnLeakFinding>,
advanced: List<com.notcvnt.rknhardering.probe.NativeVpnAdvancedFinding>,
syscalls: List<com.notcvnt.rknhardering.probe.NativeVpnSyscallFinding>,
): PartialOutcome {
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
var detected = false
var needsReview = false
val propsByKind = props.groupBy { it.kind }
val leaksByKind = leaks.groupBy { it.kind }
val advancedByKind = advanced.groupBy { it.kind }
val syscallsByKind = syscalls.filter { !it.kind.startsWith("unavailable") }.groupBy { it.kind }
val allKinds = listOf(
"vpn_prop", "dns_prop", "vpn_file", "vpnhide", "lsposed", "hook_prop",
"tcp_vpn_port", "udp_vpn_port", "inet6_vpn_iface", "route_vpn_iface",
"arp_vpn_iface", "sysctl_forwarding", "sysctl_rp_filter", "established_vpn",
"vpn_policy_rules", "vpn_qdisc", "hidden_mac_neighbors", "tcp_mss_low",
"so_bindtodevice", "loopback_port_conflict", "bpf_map_accessible", "ip_recverr",
)
val unavailableSyscalls = syscalls.filter { it.kind.startsWith("unavailable") }
for (kind in allKinds) {
val items = propsByKind[kind]
?: leaksByKind[kind]
?: advancedByKind[kind]
?: syscallsByKind[kind]
val isHigh = kind == "vpn_prop" || kind == "vpnhide" || kind == "hook_prop" ||
kind == "udp_vpn_port" || kind == "route_vpn_iface" ||
kind == "arp_vpn_iface" || kind == "inet6_vpn_iface" ||
kind == "vpn_policy_rules" || kind == "hidden_mac_neighbors" ||
kind == "tcp_mss_low" || kind == "loopback_port_conflict" ||
kind == "bpf_map_accessible"
val source = when {
kind.contains("hook") || kind.contains("lsposed") || kind.contains("vpnhide") -> EvidenceSource.NATIVE_HOOK_MARKERS
kind.contains("route") || kind.contains("policy") || kind.contains("sysctl") ||
kind.contains("qdisc") -> EvidenceSource.NATIVE_ROUTE
kind.contains("inet6") || kind.contains("neigh") || kind.contains("mac") ||
kind.contains("arp") -> EvidenceSource.NATIVE_INTERFACE
else -> EvidenceSource.NATIVE_SOCKET
}
if (items.isNullOrEmpty()) {
findings += Finding(
description = context.getString(R.string.checker_native_vpn_signal, "${context.getString(getVpnDescResId(kind))}${context.getString(R.string.vpn_status_not_found)}"),
detected = false,
isInformational = true,
source = source,
confidence = EvidenceConfidence.LOW,
)
} else {
for (item in items) {
val detail = when (item) {
is com.notcvnt.rknhardering.probe.NativeVpnPropertyFinding ->
if (item.value != null) "${item.prop}=${item.value}" else (item.prop ?: item.kind)
is com.notcvnt.rknhardering.probe.NativeVpnLeakFinding ->
if (item.count > 0) "${item.detail} (×${item.count})" else (item.detail ?: item.kind)
is com.notcvnt.rknhardering.probe.NativeVpnAdvancedFinding ->
if (item.count > 0) "${item.detail} (×${item.count})" else (item.detail ?: item.kind)
is com.notcvnt.rknhardering.probe.NativeVpnSyscallFinding ->
item.detail ?: item.kind
else -> kind
}
findings += Finding(
description = context.getString(R.string.checker_native_vpn_signal, describeVpnFinding(context, kind, detail)),
detected = isHigh,
needsReview = !isHigh,
source = source,
confidence = if (isHigh) EvidenceConfidence.HIGH else EvidenceConfidence.MEDIUM,
)
evidence += EvidenceItem(source = source, detected = true, confidence = if (isHigh) EvidenceConfidence.HIGH else EvidenceConfidence.MEDIUM, description = describeVpnFinding(context, kind, detail))
detected = detected || isHigh
needsReview = needsReview || !isHigh
}
}
}
for (item in unavailableSyscalls) {
val reason = item.detail ?: item.kind.removePrefix("unavailable|")
findings += Finding(
description = context.getString(R.string.checker_native_vpn_signal, "${reason}${context.getString(R.string.vpn_status_unavailable)}"),
detected = false,
isInformational = true,
source = EvidenceSource.NATIVE_SOCKET,
confidence = EvidenceConfidence.LOW,
)
}
return PartialOutcome(findings, evidence, detected = detected, needsReview = needsReview)
}
private fun getVpnDescResId(kind: String): Int = when (kind) {
"vpn_prop" -> R.string.vpn_desc_prop
"vpnhide" -> R.string.vpn_desc_vpnhide
"hook_prop" -> R.string.vpn_desc_hook
"dns_prop" -> R.string.vpn_desc_dns
"vpn_file" -> R.string.vpn_desc_file
"lsposed" -> R.string.vpn_desc_lsposed
"tcp_vpn_port" -> R.string.vpn_desc_tcp_port
"udp_vpn_port" -> R.string.vpn_desc_udp_port
"inet6_vpn_iface" -> R.string.vpn_desc_inet6
"route_vpn_iface" -> R.string.vpn_desc_route
"arp_vpn_iface" -> R.string.vpn_desc_arp
"sysctl_forwarding" -> R.string.vpn_desc_forwarding
"sysctl_rp_filter" -> R.string.vpn_desc_rpfilter
"established_vpn" -> R.string.vpn_desc_established
"vpn_policy_rules" -> R.string.vpn_desc_policy_rules
"vpn_qdisc" -> R.string.vpn_desc_qdisc
"hidden_mac_neighbors" -> R.string.vpn_desc_hidden_mac
"tcp_mss_low" -> R.string.vpn_desc_mss
"so_bindtodevice" -> R.string.vpn_desc_bindtodevice
"loopback_port_conflict" -> R.string.vpn_desc_port_conflict
"bpf_map_accessible" -> R.string.vpn_desc_bpf
"ip_recverr" -> R.string.vpn_desc_recverr
else -> R.string.checker_native_vpn_signal
}
internal fun containsHighConfidenceRootMountMarker(detail: String): Boolean {
val normalized = detail.lowercase()
return HIGH_CONFIDENCE_ROOT_MOUNT_MARKERS.any { marker ->
normalized.contains(marker)
}
}
}
private fun describeVpnFinding(context: Context, kind: String, detail: String): String {
val resId = when (kind) {
"vpn_prop" -> R.string.vpn_desc_prop
"vpnhide" -> R.string.vpn_desc_vpnhide
"hook_prop" -> R.string.vpn_desc_hook
"dns_prop" -> R.string.vpn_desc_dns
"vpn_file" -> R.string.vpn_desc_file
"lsposed" -> R.string.vpn_desc_lsposed
"tcp_vpn_port" -> R.string.vpn_desc_tcp_port
"udp_vpn_port" -> R.string.vpn_desc_udp_port
"inet6_vpn_iface" -> R.string.vpn_desc_inet6
"route_vpn_iface" -> R.string.vpn_desc_route
"arp_vpn_iface" -> R.string.vpn_desc_arp
"sysctl_forwarding" -> R.string.vpn_desc_forwarding
"sysctl_rp_filter" -> R.string.vpn_desc_rpfilter
"established_vpn" -> R.string.vpn_desc_established
"vpn_policy_rules" -> R.string.vpn_desc_policy_rules
"vpn_qdisc" -> R.string.vpn_desc_qdisc
"hidden_mac_neighbors" -> R.string.vpn_desc_hidden_mac
"tcp_mss_low" -> R.string.vpn_desc_mss
"so_bindtodevice" -> R.string.vpn_desc_bindtodevice
"loopback_port_conflict" -> R.string.vpn_desc_port_conflict
"bpf_map_accessible" -> R.string.vpn_desc_bpf
"ip_recverr" -> R.string.vpn_desc_recverr
else -> return "$kind: $detail"
}
return "$detail${context.getString(resId)}"
}
}

View file

@ -4,9 +4,13 @@ import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.CallTransportNetworkPath
import com.notcvnt.rknhardering.model.CallTransportStatus
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.model.VerdictDecision
import com.notcvnt.rknhardering.model.VerdictParticipant
import com.notcvnt.rknhardering.model.VerdictRuleCode
object VerdictEngine {
@ -53,10 +57,39 @@ object VerdictEngine {
nativeSigns: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()),
icmpSpoofing: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()),
geoCheckAvailable: Boolean = true,
): Verdict {
): Verdict = evaluateDetailed(
geoIp = geoIp,
directSigns = directSigns,
indirectSigns = indirectSigns,
locationSignals = locationSignals,
bypassResult = bypassResult,
ipConsensus = ipConsensus,
nativeSigns = nativeSigns,
icmpSpoofing = icmpSpoofing,
geoCheckAvailable = geoCheckAvailable,
).verdict
fun evaluateDetailed(
geoIp: CategoryResult,
directSigns: CategoryResult,
indirectSigns: CategoryResult,
locationSignals: CategoryResult,
bypassResult: BypassResult,
ipConsensus: IpConsensusResult,
nativeSigns: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()),
icmpSpoofing: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()),
geoCheckAvailable: Boolean = true,
): VerdictDecision {
// R1
if (bypassResult.evidence.any { it.detected && it.source in HARD_DETECT_BYPASS }) {
return Verdict.DETECTED
val hardBypassSources = bypassResult.evidence
.filter { it.detected && it.source in HARD_DETECT_BYPASS }
.mapTo(linkedSetOf()) { it.source }
if (hardBypassSources.isNotEmpty()) {
return decision(
Verdict.DETECTED,
VerdictRuleCode.R1_HARD_BYPASS,
participant("hard_bypass", hardBypassSources),
)
}
val geoAxis = ipConsensus.foreignIps.isNotEmpty() ||
@ -64,13 +97,28 @@ object VerdictEngine {
ipConsensus.warpLikeIndicator
// R3
if (ipConsensus.probeTargetDivergence && geoAxis) {
return Verdict.DETECTED
return decision(
Verdict.DETECTED,
VerdictRuleCode.R3_PROBE_TARGET_DIVERGENCE,
participant("probe_target_divergence"),
participant("geo_axis"),
)
}
if (ipConsensus.probeTargetDirectDivergence && geoAxis) {
return Verdict.DETECTED
return decision(
Verdict.DETECTED,
VerdictRuleCode.R3_PROBE_TARGET_DIRECT_DIVERGENCE,
participant("probe_target_direct_divergence"),
participant("geo_axis"),
)
}
if (ipConsensus.crossChannelMismatch && geoAxis) {
return Verdict.DETECTED
return decision(
Verdict.DETECTED,
VerdictRuleCode.R3_CROSS_CHANNEL_MISMATCH,
participant("cross_channel_mismatch"),
participant("geo_axis"),
)
}
// R4
@ -92,21 +140,36 @@ object VerdictEngine {
ipConsensus.probeTargetDivergence ||
ipConsensus.probeTargetDirectDivergence
if (locationConfirmsRussia && geo?.outsideRu == true && !expectedRoamingExit) {
return Verdict.DETECTED
return decision(
Verdict.DETECTED,
VerdictRuleCode.R4_LOCATION_GEO_CONFLICT,
participant("location_confirms_russia", setOf(EvidenceSource.LOCATION_SIGNALS)),
participant("geo_outside_russia", setOf(EvidenceSource.GEO_IP)),
)
}
if (locationConfirmsRussia &&
(geo?.hosting == true || geo?.proxyDb == true) &&
geo.outsideRu != true &&
!anyOtherSignal
) {
return Verdict.NEEDS_REVIEW
return decision(
Verdict.NEEDS_REVIEW,
VerdictRuleCode.R4_HOSTING_REVIEW,
participant("location_confirms_russia", setOf(EvidenceSource.LOCATION_SIGNALS)),
participant("hosting_or_proxy_database", setOf(EvidenceSource.GEO_IP)),
)
}
// R5 — 3-bit matrix (geo x direct x indirect)
val geoHit = geo?.outsideRu == true && !expectedRoamingExit
val directHit = directSigns.evidence.any { it.detected && it.source in MATRIX_DIRECT_SOURCES }
val indirectHit = indirectSigns.evidence.any { it.detected && it.source in MATRIX_INDIRECT_SOURCES } ||
nativeSigns.evidence.any { it.detected && it.source in MATRIX_INDIRECT_SOURCES }
nativeSigns.evidence.any {
it.detected && (
it.source in MATRIX_INDIRECT_SOURCES ||
(it.source == EvidenceSource.NATIVE_SOCKET && it.confidence == EvidenceConfidence.HIGH)
)
}
val matrix = when {
!geoHit && !directHit && !indirectHit -> Verdict.NOT_DETECTED
!geoHit && directHit && !indirectHit -> Verdict.NOT_DETECTED
@ -125,7 +188,12 @@ object VerdictEngine {
it.status == CallTransportStatus.NEEDS_REVIEW &&
it.networkPath != CallTransportNetworkPath.LOCAL_PROXY
}
val nativeReviewHit = nativeSigns.evidence.any { it.detected && it.source in NATIVE_REVIEW_SOURCES }
val nativeReviewHit = nativeSigns.evidence.any {
it.detected && (
it.source in NATIVE_REVIEW_SOURCES ||
(it.source == EvidenceSource.NATIVE_SOCKET && it.confidence == EvidenceConfidence.HIGH)
)
}
val tunProbeReview = directSigns.evidence.any {
it.source == EvidenceSource.TUN_ACTIVE_PROBE && !it.detected
}
@ -144,9 +212,53 @@ object VerdictEngine {
tunProbeReview
)
) {
return Verdict.NEEDS_REVIEW
val participants = buildList {
if (bypassResult.needsReview) add(participant("bypass_needs_review", bypassResult.evidence.mapTo(linkedSetOf()) { it.source }))
if (directSigns.needsReview) add(participant("direct_needs_review", directSigns.evidence.mapTo(linkedSetOf()) { it.source }))
if (indirectSigns.needsReview) add(participant("indirect_needs_review", indirectSigns.evidence.mapTo(linkedSetOf()) { it.source }))
if (locationSignalHit) add(participant("location_signal", setOf(EvidenceSource.LOCATION_SIGNALS)))
if (hasActionableCallTransportLeak) add(participant("call_transport_leak"))
if (icmpSpoofing.needsReview) add(participant("icmp_needs_review", setOf(EvidenceSource.ICMP_SPOOFING)))
if (nativeReviewHit) add(participant("native_needs_review", nativeSigns.evidence.mapTo(linkedSetOf()) { it.source }))
if (ipConsensus.needsReview) add(participant("ip_consensus_needs_review"))
if (ipConsensus.channelConflict.isNotEmpty()) add(participant("ip_channel_conflict"))
if (ipConsensus.probeTargetDivergence) add(participant("probe_target_divergence"))
if (tunProbeReview) add(participant("tun_probe_review", setOf(EvidenceSource.TUN_ACTIVE_PROBE)))
}
return decision(Verdict.NEEDS_REVIEW, VerdictRuleCode.R6_FALLBACK, *participants.toTypedArray())
}
return matrix
val matrixSources = buildSet {
if (directHit) addAll(directSigns.evidence.filter { it.detected && it.source in MATRIX_DIRECT_SOURCES }.map { it.source })
if (indirectHit) {
addAll(indirectSigns.evidence.filter { it.detected && it.source in MATRIX_INDIRECT_SOURCES }.map { it.source })
addAll(nativeSigns.evidence.filter { it.detected }.map { it.source })
}
if (geoHit) add(EvidenceSource.GEO_IP)
}
return decision(
matrix,
VerdictRuleCode.R5_MATRIX,
participant("geo=$geoHit"),
participant("direct=$directHit"),
participant("indirect=$indirectHit", matrixSources),
participant("geo_available=$geoAxisAvailable"),
participant("expected_roaming_exit=$expectedRoamingExit"),
)
}
private fun participant(
factor: String,
sources: Set<EvidenceSource> = emptySet(),
): VerdictParticipant = VerdictParticipant(factor = factor, evidenceSources = sources)
private fun decision(
verdict: Verdict,
rule: VerdictRuleCode,
vararg participants: VerdictParticipant,
): VerdictDecision = VerdictDecision(
verdict = verdict,
rule = rule,
participants = participants.toList(),
)
}

View file

@ -25,6 +25,7 @@ import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.CdnPullingResult
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.CheckResult
import com.notcvnt.rknhardering.diagnostics.DiagnosticResultRecorder
import com.notcvnt.rknhardering.model.DomainReachabilityResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceSource
@ -669,7 +670,7 @@ object VpnCheckRunner {
onUpdate?.invoke(CheckUpdate.IpConsensusReady(ipConsensus))
executionContext.throwIfCancelled()
val verdict = VerdictEngine.evaluate(
val verdictDecision = VerdictEngine.evaluateDetailed(
geoIp = geoIp,
directSigns = directSigns,
indirectSigns = indirectSigns,
@ -680,11 +681,50 @@ object VpnCheckRunner {
icmpSpoofing = icmpSpoofing,
geoCheckAvailable = settings.networkRequestsEnabled,
)
val verdict = verdictDecision.verdict
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.VerdictReady(verdict))
val operatorWhitelistResult = operatorWhitelistDeferred?.await()
val diagnosticCollector = executionContext.diagnosticCollector
DiagnosticResultRecorder.recordCategory(diagnosticCollector, "geo", "GeoIpChecker", geoIp)
DiagnosticResultRecorder.recordIpComparison(diagnosticCollector, ipComparison)
DiagnosticResultRecorder.recordCdn(diagnosticCollector, cdnPulling)
DiagnosticResultRecorder.recordCategory(diagnosticCollector, "dir", "DirectSignsChecker", directSigns)
DiagnosticResultRecorder.recordCategory(diagnosticCollector, "ind", "IndirectSignsChecker", indirectSigns)
DiagnosticResultRecorder.recordCategory(diagnosticCollector, "loc", "LocationSignalsChecker", locationSignals)
DiagnosticResultRecorder.recordCategory(diagnosticCollector, "nat", "NativeSignsChecker", nativeSigns)
DiagnosticResultRecorder.recordCategory(diagnosticCollector, "icmp", "IcmpSpoofingChecker", icmpSpoofing)
DiagnosticResultRecorder.recordCategory(diagnosticCollector, "rtt", "RttTriangulationChecker", rttTriangulation)
DiagnosticResultRecorder.recordBypass(diagnosticCollector, bypassResult)
tunProbeResult?.let { probe ->
diagnosticCollector?.record(
category = "byp",
source = "UnderlyingNetworkProber",
status = when {
probe.underlyingReachable -> "reachable"
probe.vpnError != null || probe.underlyingError != null -> "error"
else -> "completed"
},
body = buildString {
appendLine("vpnActive=${probe.vpnActive}")
appendLine("activeNetworkIsVpn=${probe.activeNetworkIsVpn}")
appendLine("underlyingReachable=${probe.underlyingReachable}")
appendLine("dnsPathMismatch=${probe.dnsPathMismatch}")
appendLine("vpnError=${probe.vpnError}")
appendLine("underlyingError=${probe.underlyingError}")
appendLine("ruTarget=${probe.ruTarget}")
appendLine("nonRuTarget=${probe.nonRuTarget}")
appendLine("tunProbeDiagnostics=${probe.tunProbeDiagnostics}")
},
)
}
DiagnosticResultRecorder.recordDomainReachability(diagnosticCollector, domainReachability)
DiagnosticResultRecorder.recordConsensus(diagnosticCollector, ipConsensus)
DiagnosticResultRecorder.recordVerdict(diagnosticCollector, verdictDecision)
val diagnosticSnapshot = diagnosticCollector?.snapshot()
CheckResult(
geoIp = geoIp,
ipComparison = ipComparison,
@ -701,6 +741,8 @@ object VpnCheckRunner {
ipConsensus = ipConsensus,
operatorWhitelistProbe = operatorWhitelistResult,
domainReachability = domainReachability,
verdictDecision = verdictDecision,
diagnosticSnapshot = diagnosticSnapshot,
)
}
}

View file

@ -0,0 +1,190 @@
package com.notcvnt.rknhardering.checker
import android.content.Context
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.probe.NativeSignsBridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Deep VPN detector ported from the reference [dev.soranerai.vpndetector] APK.
* Lives in its own UI category, separate from the legacy [NativeSignsChecker].
* Every native line is prefixed with "vdet|" and has the shape "vdet|<kind>|<detail>".
*/
object VpnNativeDetectorChecker {
private val NETWORK_KINDS = setOf(
"fib_trie_denied", "inet_diag_denied", "bindtodevice_leak",
"getsockname_leak", "udp_port_conflict_physical", "vpn_qdisc",
"bpf_map_accessible", "route_count", "trim_oracle",
)
private val INDIRECT_KINDS = setOf(
"pmtu_mss_combined", "udp_pmtu_ok", "udp_pmtu_fail", "normal_pmtu",
"timing_oracle", "backpressure", "gso_failed", "gso_send_failed",
"gso_ok", "hw_timestamp",
)
private val HIGH_CONFIDENCE = setOf(
"sysfs_vpn_leak", "getifaddrs_vpn", "sysclassnet_vpn", "rtm_getlink_vpn",
"proc_if_inet6_vpn", "proc_ipv6_route_vpn", "proc_net_dev_vpn",
"ifindexname_vpn", "vpn_policy_rules_netlink",
"bindtodevice_leak", "getsockname_leak", "udp_port_conflict_physical",
)
private val INFORMATIONAL_KINDS = setOf(
"route_count", "pmtu_mss_combined", "udp_pmtu_ok", "normal_pmtu",
"timing_oracle", "backpressure", "gso_failed", "gso_send_failed",
"gso_ok", "hw_timestamp",
)
private fun libraryUnavailableResult(context: Context): CategoryResult {
val loadError = NativeSignsBridge.lastLoadErrorMessage()
val description = if (loadError != null) {
context.getString(R.string.checker_vpn_detector_unavailable_with_reason, loadError)
} else {
context.getString(R.string.checker_vpn_detector_unavailable)
}
return CategoryResult(
name = context.getString(R.string.checker_vpn_detector_category_name),
detected = false,
findings = listOf(Finding(description = description, isInformational = true)),
needsReview = false,
evidence = emptyList(),
)
}
private fun mapSource(kind: String): EvidenceSource = when (kind) {
in NETWORK_KINDS -> EvidenceSource.NATIVE_SOCKET
in INDIRECT_KINDS -> EvidenceSource.NATIVE_ROUTE
else -> EvidenceSource.NATIVE_INTERFACE
}
private fun evaluateItem(
context: Context,
item: ParsedRow,
findings: MutableList<Finding>,
evidence: MutableList<EvidenceItem>,
) {
val description = describe(context, item.kind, item.detail)
val source = mapSource(item.kind)
if (item.kind in INFORMATIONAL_KINDS) {
findings += Finding(
description = description,
isInformational = true,
source = source,
confidence = EvidenceConfidence.LOW,
)
return
}
val isHigh = item.kind in HIGH_CONFIDENCE
val confidence = if (isHigh) EvidenceConfidence.HIGH else EvidenceConfidence.MEDIUM
findings += Finding(
description = description,
detected = isHigh,
needsReview = !isHigh,
source = source,
confidence = confidence,
)
evidence += EvidenceItem(
source = source,
detected = true,
confidence = confidence,
description = description,
)
}
suspend fun check(context: Context): CategoryResult = withContext(Dispatchers.IO) {
val executionContext = ScanExecutionContext.currentOrDefault()
executionContext.throwIfCancelled()
NativeSignsBridge.initIfNeeded()
if (!NativeSignsBridge.isLibraryLoaded()) return@withContext libraryUnavailableResult(context)
val rows = runCatching {
NativeSignsBridge.detectVpnDetector(executionContext.cancellationSignal)
}.getOrDefault(emptyArray())
executionContext.throwIfCancelled()
val parsed = rows.mapNotNull { parseRow(it) }
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
for (item in parsed) {
evaluateItem(context, item, findings, evidence)
}
if (findings.isEmpty()) {
findings += Finding(
description = context.getString(R.string.checker_vpn_detector_no_anomalies),
isInformational = true,
)
}
CategoryResult(
name = context.getString(R.string.checker_vpn_detector_category_name),
detected = findings.any { it.detected },
findings = findings,
needsReview = findings.any { it.needsReview },
evidence = evidence,
)
}
private data class ParsedRow(val kind: String, val detail: String)
private fun parseRow(row: String): ParsedRow? {
if (!row.startsWith("vdet|")) return null
val body = row.removePrefix("vdet|")
val sep = body.indexOf('|')
if (sep <= 0) return null
val kind = body.substring(0, sep)
val detail = body.substring(sep + 1).takeIf { it.isNotBlank() } ?: return null
return ParsedRow(kind, detail)
}
private fun describe(context: Context, kind: String, detail: String): String {
val resId = when (kind) {
"sysfs_vpn_leak" -> R.string.vpn_desc_sysfs_leak
"getifaddrs_vpn" -> R.string.vpn_desc_getifaddrs
"sysclassnet_vpn" -> R.string.vpn_desc_sysclassnet
"rtm_getlink_vpn" -> R.string.vpn_desc_rtm_getlink
"proc_if_inet6_vpn" -> R.string.vpn_desc_proc_if_inet6
"proc_ipv6_route_vpn" -> R.string.vpn_desc_proc_ipv6_route
"proc_net_dev_vpn" -> R.string.vpn_desc_proc_net_dev
"ifindexname_vpn" -> R.string.vpn_desc_ifindexname
"vpn_policy_rules_netlink" -> R.string.vpn_desc_policy_rules_netlink
"fib_trie_denied" -> R.string.vpn_desc_fib_trie
"inet_diag_denied" -> R.string.vpn_desc_inet_diag
"bindtodevice_leak" -> R.string.vpn_desc_bindtodevice_leak
"getsockname_leak" -> R.string.vpn_desc_getsockname
"udp_port_conflict_physical" -> R.string.vpn_desc_udp_port_physical
"vpn_qdisc" -> R.string.vpn_desc_qdisc
"bpf_map_accessible" -> R.string.vpn_desc_bpf
"route_count" -> R.string.vpn_desc_route_count
"trim_oracle" -> R.string.vpn_desc_trim_oracle
"pmtu_mss_combined" -> R.string.vpn_desc_pmtu_mss
"udp_pmtu_ok" -> R.string.vpn_desc_udp_pmtu_ok
"udp_pmtu_fail" -> R.string.vpn_desc_udp_pmtu_fail
"normal_pmtu" -> R.string.vpn_desc_normal_pmtu
"timing_oracle" -> R.string.vpn_desc_timing_oracle
"backpressure" -> R.string.vpn_desc_backpressure
"gso_failed" -> R.string.vpn_desc_gso_failed
"gso_send_failed" -> R.string.vpn_desc_gso_send_failed
"gso_ok" -> R.string.vpn_desc_gso_ok
"hw_timestamp" -> R.string.vpn_desc_hw_timestamp
"traceroute_denied" -> R.string.vpn_desc_traceroute
else -> null
}
return if (resId != null) {
"$detail${context.getString(resId)}"
} else {
"$kind: $detail"
}
}
}

View file

@ -8,6 +8,8 @@ import java.security.MessageDigest
// for the offline signing CLI under :app:src/test (host-only path).
object Ed25519 {
private const val SHA_512 = "SHA-512"
private val P = BigInteger.ONE.shiftLeft(255).subtract(BigInteger.valueOf(19))
private val L = BigInteger("7237005577332262213973186563042994240857116359379907606001950938285454250989")
private val D = BigInteger("-121665").mod(P)
@ -26,7 +28,7 @@ object Ed25519 {
val s = decodeScalarLE(signature.copyOfRange(32, 64))
if (s >= L) return false
val rPoint = runCatching { decodePoint(r) }.getOrNull() ?: return false
val md = MessageDigest.getInstance("SHA-512")
val md = MessageDigest.getInstance(SHA_512)
md.update(r)
md.update(publicKey)
md.update(message)
@ -38,7 +40,7 @@ object Ed25519 {
fun sign(privateKey: ByteArray, publicKey: ByteArray, message: ByteArray): ByteArray {
require(privateKey.size == 32 && publicKey.size == 32)
val md = MessageDigest.getInstance("SHA-512")
val md = MessageDigest.getInstance(SHA_512)
val h = md.digest(privateKey)
val a = clampScalar(h.copyOfRange(0, 32))
val prefix = h.copyOfRange(32, 64)
@ -55,7 +57,7 @@ object Ed25519 {
fun derivePublicKey(privateKey: ByteArray): ByteArray {
require(privateKey.size == 32)
val h = MessageDigest.getInstance("SHA-512").digest(privateKey)
val h = MessageDigest.getInstance(SHA_512).digest(privateKey)
val a = clampScalar(h.copyOfRange(0, 32))
return encodePoint(scalarMul(B, a))
}
@ -92,9 +94,7 @@ object Ed25519 {
if (x.multiply(x).subtract(xx).mod(P) != BigInteger.ZERO) {
x = x.multiply(I).mod(P)
}
if (x.multiply(x).subtract(xx).mod(P) != BigInteger.ZERO) {
throw IllegalArgumentException("no sqrt")
}
require(x.multiply(x).subtract(xx).mod(P) == BigInteger.ZERO) { "no sqrt" }
if ((x.testBit(0)) != sign) x = P.subtract(x).mod(P)
return x
}
@ -105,10 +105,10 @@ object Ed25519 {
val sign = (raw[31].toInt() and 0x80) != 0
raw[31] = (raw[31].toInt() and 0x7F).toByte()
val y = decodeScalarLE(raw)
if (y >= P) throw IllegalArgumentException("y out of range")
require(y < P) { "y out of range" }
val x = recoverX(y, sign)
val p = Point(x, y)
if (!onCurve(p)) throw IllegalArgumentException("not on curve")
require(onCurve(p)) { "not on curve" }
return p
}

View file

@ -83,9 +83,9 @@ object EndpointResponseMapper {
fun autoDetectResponseType(rawResponse: String): ResponseType {
val trimmed = rawResponse.trim()
// 1. Try JSON object
try { JSONObject(trimmed); return ResponseType.JSON } catch (_: Exception) { }
try { JSONObject(trimmed); return ResponseType.JSON } catch (_: Exception) { /* not a JSON object, try next format */ }
// 2. Try JSON array
try { JSONArray(trimmed); return ResponseType.JSON } catch (_: Exception) { }
try { JSONArray(trimmed); return ResponseType.JSON } catch (_: Exception) { /* not a JSON array, try next format */ }
// 3. key=value lines
val lines = trimmed.lines().filter { it.isNotBlank() }
val kvPattern = Regex("""^\w+=.+$""")
@ -180,9 +180,12 @@ object EndpointResponseMapper {
if (countryCodePath == null && key in setOf("country_code", "cc", "countrycode", "country") && COUNTRY_CODE_REGEX.matches(strVal)) {
countryCodePath = path
}
if (countryNamePath == null && key in setOf("country", "country_name", "countryname")) {
// only if it doesn't look like a 2-letter code itself
if (!COUNTRY_CODE_REGEX.matches(strVal)) countryNamePath = path
// only if it doesn't look like a 2-letter code itself
if (countryNamePath == null &&
key in setOf("country", "country_name", "countryname") &&
!COUNTRY_CODE_REGEX.matches(strVal)
) {
countryNamePath = path
}
if (ispPath == null && key in setOf("isp", "provider")) {
ispPath = path

View file

@ -80,7 +80,7 @@ internal class CheckerSectionController(
updateNoteVisibility()
}
fun isExpanded(): Boolean = expanded
val isExpanded: Boolean get() = expanded
fun setEnabled(value: Boolean, propagate: Boolean) {
enabled = value

View file

@ -0,0 +1,242 @@
package com.notcvnt.rknhardering.diagnostics
import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.CdnPullingResult
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.DomainReachabilityResult
import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.VerdictDecision
object DiagnosticResultRecorder {
fun recordCategory(
collector: DiagnosticTraceCollector?,
category: String,
source: String,
result: CategoryResult,
) {
collector ?: return
collector.record(
category = category,
source = source,
status = status(result.detected, result.needsReview, result.hasError),
body = buildString {
appendLine("name=${result.name}")
appendLine("detected=${result.detected}")
appendLine("needsReview=${result.needsReview}")
appendLine("hasError=${result.hasError}")
result.geoFacts?.let { appendLine("geoFacts=$it") }
result.locationFacts?.let { appendLine("locationFacts=$it") }
result.evidence.forEachIndexed { index, item ->
appendLine(
"evidence[$index]=source:${item.source},detected:${item.detected}," +
"confidence:${item.confidence},description:${item.description}",
)
}
result.findings.forEachIndexed { index, finding ->
appendLine(
"finding[$index]=detected:${finding.detected},needsReview:${finding.needsReview}," +
"informational:${finding.isInformational},error:${finding.isError}," +
"source:${finding.source},description:${finding.description}",
)
}
result.stunProbeGroups.flatMap { it.results }.forEachIndexed { index, item ->
appendLine(
"stun[$index]=host:${item.host},port:${item.port},scope:${item.scope}," +
"mappedIpv4:${item.mappedIpv4},mappedIpv6:${item.mappedIpv6},error:${item.error}",
)
}
result.callTransportLeaks.forEachIndexed { index, item ->
appendLine(
"callTransport[$index]=service:${item.service},kind:${item.probeKind}," +
"path:${item.networkPath},status:${item.status},target:${item.targetHost}:${item.targetPort}," +
"resolved:${item.resolvedIps},mapped:${item.mappedIp},public:${item.observedPublicIp}," +
"summary:${item.summary}",
)
}
},
)
result.geoIpResponses.forEach { response ->
collector.record(
category = category,
source = "${response.provider} HTTP",
status = if (response.error == null) "completed" else "error",
body = buildString {
appendLine("ip=${response.ip}")
appendLine("error=${response.error}")
append(response.rawBody.orEmpty())
},
)
}
}
fun recordIpComparison(
collector: DiagnosticTraceCollector?,
result: IpComparisonResult,
) {
collector ?: return
collector.record(
category = "ipc",
source = "IpComparisonChecker",
status = status(result.detected, result.needsReview, result.hasError),
body = buildString {
appendLine("summary=${result.summary}")
(result.ruGroup.responses + result.nonRuGroup.responses).forEachIndexed { index, response ->
appendLine(
"response[$index]=label:${response.label},url:${response.url},scope:${response.scope}," +
"ip:${response.ip},ipv4Records:${response.ipv4Records}," +
"ipv6Records:${response.ipv6Records},error:${response.error}",
)
}
},
)
}
fun recordCdn(collector: DiagnosticTraceCollector?, result: CdnPullingResult) {
collector ?: return
collector.record(
category = "cdn",
source = "CdnPullingChecker",
status = status(result.detected, result.needsReview, result.hasError),
body = "summary=${result.summary}",
)
result.responses.forEach { response ->
collector.record(
category = "cdn",
source = response.targetLabel,
target = response.url,
status = if (response.error == null) "completed" else "error",
body = buildString {
appendLine("ip=${response.ip}")
appendLine("ipv4=${response.ipv4}")
appendLine("ipv6=${response.ipv6}")
appendLine("importantFields=${response.importantFields}")
appendLine("error=${response.error}")
append(response.rawBody.orEmpty())
},
)
}
}
fun recordBypass(collector: DiagnosticTraceCollector?, result: BypassResult) {
collector ?: return
collector.record(
category = "byp",
source = "BypassChecker",
status = status(result.detected, result.needsReview, result.hasError),
body = buildString {
appendLine("proxyEndpoint=${result.proxyEndpoint}")
appendLine("directIp=${result.directIp}")
appendLine("proxyIp=${result.proxyIp}")
appendLine("vpnNetworkIp=${result.vpnNetworkIp}")
appendLine("underlyingIp=${result.underlyingIp}")
appendLine("proxyChecks=${result.proxyChecks}")
result.evidence.forEachIndexed { index, item ->
appendLine("evidence[$index]=source:${item.source},detected:${item.detected},confidence:${item.confidence},description:${item.description}")
}
result.findings.forEachIndexed { index, item ->
appendLine("finding[$index]=detected:${item.detected},review:${item.needsReview},error:${item.isError},description:${item.description}")
}
},
)
result.xrayApiScanResult?.let { xray ->
collector.record(
category = "byp",
source = "Xray API",
target = "${xray.endpoint.host}:${xray.endpoint.port}",
status = if (xray.handlerAvailable) "available" else "unavailable",
body = buildString {
appendLine("outboundCount=${xray.outbounds.size}")
xray.outbounds.forEachIndexed { index, outbound ->
appendLine(
"outbound[$index]=tag:${outbound.tag},protocol:${outbound.protocolName}," +
"address:${outbound.address},port:${outbound.port},sni:${outbound.sni}," +
"uuidPresent:${!outbound.uuid.isNullOrBlank()}," +
"publicKeyPresent:${!outbound.publicKey.isNullOrBlank()}," +
"senderType:${outbound.senderSettingsType},proxyType:${outbound.proxySettingsType}",
)
}
},
)
}
result.clashApiScanResult?.let { clash ->
collector.record(
category = "byp",
source = "Clash API",
target = "${clash.endpoint.host}:${clash.endpoint.port}",
status = if (clash.configAvailable) "available" else "unavailable",
body = buildString {
appendLine("configAvailable=${clash.configAvailable}")
appendLine("destinationIpCount=${clash.leakedDestIps.size}")
appendLine("proxyNodeCount=${clash.proxyNodes.size}")
},
)
}
}
fun recordDomainReachability(
collector: DiagnosticTraceCollector?,
result: DomainReachabilityResult,
) {
collector ?: return
result.responses.forEach { response ->
collector.record(
category = "rea",
source = "DomainReachabilityChecker",
target = response.domain,
status = if (response.matchesExpectation) "expected" else "mismatch",
body = buildString {
appendLine("label=${response.label}")
appendLine("dns=${response.dnsStatus},error=${response.dnsError},resolved=${response.resolvedIps}")
appendLine("tcp=${response.tcpStatus},error=${response.tcpError}")
appendLine("tls=${response.tlsStatus},error=${response.tlsError}")
appendLine(
"expectedDns=${response.expectedDnsAvailable}," +
"expectedTcp=${response.expectedTcpAvailable},expectedTls=${response.expectedTlsAvailable}",
)
},
)
}
}
fun recordConsensus(collector: DiagnosticTraceCollector?, result: IpConsensusResult) {
collector?.record(
category = "ipc",
source = "IpConsensusBuilder",
status = if (result.needsReview) "review" else "completed",
body = buildString {
appendLine("observedIps=${result.observedIps}")
appendLine("unparsedIps=${result.unparsedIps}")
appendLine("channelIps=${result.channelIps}")
appendLine("channelConflict=${result.channelConflict}")
appendLine("crossChannelMismatch=${result.crossChannelMismatch}")
appendLine("foreignIps=${result.foreignIps}")
appendLine("geoCountryMismatch=${result.geoCountryMismatch}")
appendLine("warpLikeIndicator=${result.warpLikeIndicator}")
appendLine("probeTargetDivergence=${result.probeTargetDivergence}")
appendLine("probeTargetDirectDivergence=${result.probeTargetDirectDivergence}")
},
)
}
fun recordVerdict(collector: DiagnosticTraceCollector?, decision: VerdictDecision) {
collector?.record(
category = "geo",
source = "VerdictEngine",
status = decision.verdict.name,
body = buildString {
appendLine("rule=${decision.ruleCode}")
decision.participants.forEachIndexed { index, participant ->
appendLine("participant[$index]=factor:${participant.factor},sources:${participant.evidenceSources}")
}
},
)
}
private fun status(detected: Boolean, review: Boolean, error: Boolean): String = when {
error -> "error"
detected -> "detected"
review -> "review"
else -> "clean"
}
}

View file

@ -0,0 +1,179 @@
package com.notcvnt.rknhardering.diagnostics
import java.nio.charset.StandardCharsets
data class DiagnosticEntry(
val category: String,
val source: String,
val target: String?,
val status: String,
val durationMs: Long?,
val body: String,
val storedBytes: Int,
val originalBytes: Int,
val truncated: Boolean,
) {
override fun toString(): String =
"DiagnosticEntry(category=$category, source=$source, target=$target, status=$status, " +
"durationMs=$durationMs, storedBytes=$storedBytes, originalBytes=$originalBytes, truncated=$truncated)"
}
data class DiagnosticSnapshot(
val entries: List<DiagnosticEntry>,
val storedBytes: Int,
val truncated: Boolean,
) {
companion object {
val EMPTY = DiagnosticSnapshot(emptyList(), storedBytes = 0, truncated = false)
}
}
class DiagnosticTraceCollector(
private val privacyMode: Boolean,
private val entryLimitBytes: Int = DEFAULT_ENTRY_LIMIT_BYTES,
private val runLimitBytes: Int = DEFAULT_RUN_LIMIT_BYTES,
) {
private val lock = Any()
private val entries = mutableListOf<DiagnosticEntry>()
private var storedBytes = 0
private var closed = false
private var runTruncated = false
fun record(
category: String,
source: String,
target: String? = null,
status: String,
durationMs: Long? = null,
body: String = "",
) {
val safeCategory = DiagnosticSanitizer.sanitize(category, privacyMode)
val safeSource = DiagnosticSanitizer.sanitize(source, privacyMode)
val safeTarget = target?.let { DiagnosticSanitizer.sanitize(it, privacyMode) }
val safeStatus = DiagnosticSanitizer.sanitize(status, privacyMode)
val safeBody = DiagnosticSanitizer.sanitize(body, privacyMode)
val metadataBytes = listOfNotNull(safeCategory, safeSource, safeTarget, safeStatus)
.sumOf { it.toByteArray(StandardCharsets.UTF_8).size }
val originalBodyBytes = safeBody.toByteArray(StandardCharsets.UTF_8).size
val originalBytes = metadataBytes + originalBodyBytes
synchronized(lock) {
if (closed) return
val available = (runLimitBytes - storedBytes).coerceAtLeast(0)
val allowed = minOf(entryLimitBytes.coerceAtLeast(0), available)
if (allowed < metadataBytes) {
runTruncated = true
return
}
val storedBody = truncateUtf8(safeBody, allowed - metadataBytes)
val bodyBytes = storedBody.toByteArray(StandardCharsets.UTF_8).size
val entryBytes = metadataBytes + bodyBytes
val truncated = bodyBytes < originalBodyBytes
storedBytes += entryBytes
runTruncated = runTruncated || truncated
entries += DiagnosticEntry(
category = safeCategory,
source = safeSource,
target = safeTarget,
status = safeStatus,
durationMs = durationMs?.coerceAtLeast(0),
body = storedBody,
storedBytes = entryBytes,
originalBytes = originalBytes,
truncated = truncated,
)
}
}
fun snapshot(): DiagnosticSnapshot = synchronized(lock) {
closed = true
DiagnosticSnapshot(
entries = entries.sortedWith(
compareBy<DiagnosticEntry>(
DiagnosticEntry::category,
DiagnosticEntry::source,
{ it.target.orEmpty() },
DiagnosticEntry::status,
DiagnosticEntry::body,
),
),
storedBytes = storedBytes,
truncated = runTruncated,
)
}
fun clear() = synchronized(lock) {
entries.clear()
storedBytes = 0
runTruncated = false
closed = true
}
companion object {
const val DEFAULT_ENTRY_LIMIT_BYTES = 64 * 1024
const val DEFAULT_RUN_LIMIT_BYTES = 512 * 1024
private fun truncateUtf8(value: String, maxBytes: Int): String {
if (maxBytes <= 0 || value.isEmpty()) return ""
if (value.toByteArray(StandardCharsets.UTF_8).size <= maxBytes) return value
var used = 0
var index = 0
while (index < value.length) {
val codePoint = value.codePointAt(index)
val charCount = Character.charCount(codePoint)
val byteCount = String(Character.toChars(codePoint))
.toByteArray(StandardCharsets.UTF_8)
.size
if (used + byteCount > maxBytes) break
used += byteCount
index += charCount
}
return value.substring(0, index)
}
}
}
object DiagnosticSanitizer {
private const val REDACTED = "[REDACTED]"
private const val IPV4_REDACTED = "[IPv4 redacted]"
private const val IPV6_REDACTED = "[IPv6 redacted]"
private val sensitiveHeader = Regex(
"(?im)^(\\s*)(authorization|proxy-authorization|cookie|set-cookie)(\\s*:\\s*).*$",
)
private val bearerOrBasic = Regex("(?i)\\b(Bearer|Basic)\\s+[A-Za-z0-9._~+/=-]+")
private val sensitiveQuery = Regex(
"(?i)([?&](?:token|key|api[_-]?key|password|secret|auth|authorization|signature|session|cookie)=)[^&#\\s]+",
)
private val sensitiveQuotedAssignment = Regex(
"(?i)([\\\"']?(?:authorization|proxy[_-]?authorization|cookie|set[_-]?cookie|password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|key|secret|private[_-]?key|public[_-]?key|uuid|bssid|cell[_-]?(?:id|identity))[\\\"']?\\s*[:=]\\s*)([\\\"'])(.*?)\\2",
)
private val sensitiveAssignment = Regex(
"(?i)([\\\"']?(?:authorization|proxy[_-]?authorization|cookie|set[_-]?cookie|password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|key|secret|private[_-]?key|public[_-]?key|uuid|bssid|cell[_-]?(?:id|identity))[\\\"']?\\s*[:=]\\s*)([\\\"']?)[^,;\\s&}\\\"']+([\\\"']?)",
)
private val uriUserInfo = Regex("(?i)([a-z][a-z0-9+.-]*://)[^/@\\s]+@")
private val uuid = Regex("(?i)\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b")
private val pem = Regex("(?s)-----BEGIN [^-]+-----.*?-----END [^-]+-----")
private val macAddress = Regex("(?i)\\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\\b")
private val ipv4 = Regex("(?<![A-Za-z0-9])(?:\\d{1,3}\\.){3}\\d{1,3}(?![A-Za-z0-9])")
private val ipv6 = Regex("(?<![A-Za-z0-9])(?:[0-9A-Fa-f]{0,4}:){2,}[0-9A-Fa-f]{0,4}(?![A-Za-z0-9])")
fun sanitize(value: String, privacyMode: Boolean): String {
var safe = sensitiveHeader.replace(value) { match ->
"${match.groupValues[1]}${match.groupValues[2]}${match.groupValues[3]}$REDACTED"
}
safe = bearerOrBasic.replace(safe) { "${it.groupValues[1]} $REDACTED" }
safe = sensitiveQuery.replace(safe) { "${it.groupValues[1]}$REDACTED" }
safe = sensitiveQuotedAssignment.replace(safe) { "${it.groupValues[1]}$REDACTED" }
safe = sensitiveAssignment.replace(safe) { "${it.groupValues[1]}$REDACTED" }
safe = uriUserInfo.replace(safe) { "${it.groupValues[1]}$REDACTED@" }
safe = uuid.replace(safe, REDACTED)
safe = pem.replace(safe, REDACTED)
safe = macAddress.replace(safe, REDACTED)
if (privacyMode) {
safe = ipv4.replace(safe, IPV4_REDACTED)
safe = ipv6.replace(safe, IPV6_REDACTED)
}
return safe
}
}

View file

@ -287,6 +287,31 @@ enum class Verdict {
DETECTED,
}
enum class VerdictRuleCode(val code: String) {
R1_HARD_BYPASS("R1"),
R3_PROBE_TARGET_DIVERGENCE("R3"),
R3_PROBE_TARGET_DIRECT_DIVERGENCE("R3"),
R3_CROSS_CHANNEL_MISMATCH("R3"),
R4_LOCATION_GEO_CONFLICT("R4"),
R4_HOSTING_REVIEW("R4"),
R5_MATRIX("R5"),
R6_FALLBACK("R6"),
UNSPECIFIED("unknown"),
}
data class VerdictParticipant(
val factor: String,
val evidenceSources: Set<EvidenceSource> = emptySet(),
)
data class VerdictDecision(
val verdict: Verdict,
val rule: VerdictRuleCode,
val participants: List<VerdictParticipant>,
) {
val ruleCode: String get() = rule.code
}
data class BypassResult(
val proxyEndpoint: ProxyEndpoint?,
val proxyOwner: LocalProxyOwner? = null,
@ -472,4 +497,10 @@ data class CheckResult(
val customProfileId: String? = null,
val customProfileName: String? = null,
val domainReachability: DomainReachabilityResult = DomainReachabilityResult.empty(),
val verdictDecision: VerdictDecision = VerdictDecision(
verdict = verdict,
rule = VerdictRuleCode.UNSPECIFIED,
participants = emptyList(),
),
val diagnosticSnapshot: com.notcvnt.rknhardering.diagnostics.DiagnosticSnapshot? = null,
)

View file

@ -17,6 +17,14 @@ object NetworkInterfacePatterns {
val IPSEC_INTERFACE_PATTERN: Regex = Regex("^(ipsec|xfrm).*")
private val CELLULAR_MODEM_INTERFACES: List<Regex> = listOf(
Regex("^rmnet.*"),
Regex("^ccmni.*"),
Regex("^ccemni.*"),
Regex("^pdp.*"),
Regex("^seth.*"),
)
val STANDARD_INTERFACES: List<Regex> = listOf(
Regex("^wlan.*"),
Regex("^rmnet.*"),
@ -58,4 +66,10 @@ object NetworkInterfacePatterns {
if (canonical.isNullOrBlank()) return false
return STANDARD_INTERFACES.any { it.matches(canonical) }
}
fun isCellularModemInterface(name: String?): Boolean {
val canonical = NetworkInterfaceNameNormalizer.canonicalName(name)
if (canonical.isNullOrBlank()) return false
return CELLULAR_MODEM_INTERFACES.any { it.matches(canonical) }
}
}

View file

@ -68,19 +68,13 @@ internal object ResolverSocketBinder {
private fun bindSocketToDevice(socket: Socket, interfaceName: String) {
bindSocketToDeviceOverride?.invoke(socket, interfaceName) ?: ParcelFileDescriptor.fromSocket(socket).use { pfd ->
bindFileDescriptorToDevice(
pfd.fileDescriptor,
interfaceName,
)
bindFileDescriptorToDevice(pfd.fileDescriptor, interfaceName)
}
}
private fun bindDatagramToDevice(socket: DatagramSocket, interfaceName: String) {
bindDatagramToDeviceOverride?.invoke(socket, interfaceName) ?: ParcelFileDescriptor.fromDatagramSocket(socket).use { pfd ->
bindFileDescriptorToDevice(
pfd.fileDescriptor,
interfaceName,
)
bindFileDescriptorToDevice(pfd.fileDescriptor, interfaceName)
}
}

View file

@ -73,8 +73,33 @@ object ResolverNetworkStack {
cancellationSignal: ScanCancellationSignal? = null,
): List<InetAddress> {
literalToInetAddress(hostname)?.let { return listOf(it) }
val startedAt = System.nanoTime()
val executionContext = cancellationSignal?.let { signal ->
ScanExecutionContext.currentOrDefault().takeIf { it.cancellationSignal === signal }
} ?: ScanExecutionContext.currentOrDefault()
cancellationSignal?.throwIfCancelled()
return dns(config, binding, cancellationSignal).lookup(hostname)
return try {
dns(config, binding, cancellationSignal).lookup(hostname).also { addresses ->
executionContext.diagnosticCollector?.record(
category = "ind",
source = "DNS",
target = hostname,
status = "resolved",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = addresses.mapNotNull { it.hostAddress }.distinct().joinToString("\n"),
)
}
} catch (error: Exception) {
executionContext.diagnosticCollector?.record(
category = "ind",
source = "DNS",
target = hostname,
status = "error",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = error.message.orEmpty(),
)
throw error
}
}
internal fun resetForTests() {
@ -118,7 +143,47 @@ object ResolverNetworkStack {
nativeCurlRetryCount = nativeCurlRetryCount,
cancellationSignal = cancellationSignal,
)
return executeWithFallback(request)
val startedAt = System.nanoTime()
return try {
executeWithFallback(request).also { response ->
currentExecutionContext(request).diagnosticCollector?.record(
category = diagnosticCategoryForUrl(url),
source = "${method.uppercase()} HTTP",
target = url,
status = "HTTP ${response.code}",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = response.body,
)
}
} catch (error: Exception) {
currentExecutionContext(request).diagnosticCollector?.record(
category = diagnosticCategoryForUrl(url),
source = "${method.uppercase()} HTTP",
target = url,
status = "error",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = error.message.orEmpty(),
)
throw error
}
}
private fun diagnosticCategoryForUrl(url: String): String {
val normalized = url.lowercase()
return when {
normalized.contains("googlevideo.com") ||
normalized.contains("cloudflare.com") ||
normalized.contains("one.one.one.one") ||
normalized.contains("rutracker.org") ||
normalized.contains("meduza.io") -> "cdn"
normalized.contains("ifconfig") ||
normalized.contains("ipify") ||
normalized.contains("ip.sb") ||
normalized.contains("checkip.amazonaws") ||
normalized.contains("ip.mail.ru") ||
normalized.contains("internet.yandex") -> "ipc"
else -> "geo"
}
}
private fun executeWithFallback(request: ResolverHttpRequest): ResolverHttpResponse {

View file

@ -56,9 +56,21 @@ class ClashApiScanner(
private suspend fun probeApi(host: String, port: Int): ClashApiScanResult? {
probeApiOverride?.let { return it(host, port) }
val executionContext = ScanExecutionContext.currentOrDefault()
val startedAt = System.nanoTime()
val client = clients.getValue(host)
val configs = client.fetchConfigs(port)
if (!ClashApiClient.isConfigResponseAlive(configs)) return null
if (!ClashApiClient.isConfigResponseAlive(configs)) {
executionContext.diagnosticCollector?.record(
category = "byp",
source = "Clash API",
target = "$host:$port",
status = "unavailable",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = "configAvailable=false",
)
return null
}
val connections = client.fetchConnections(port)
val proxies = client.fetchProxies(port)
return ClashApiScanResult(
@ -66,7 +78,21 @@ class ClashApiScanner(
leakedDestIps = ClashApiClient.parseConnectionsDestinationIps(connections),
proxyNodes = ClashApiClient.parseProxyNodes(proxies),
configAvailable = true,
)
).also { result ->
// Never retain the full /configs, /connections or /proxies bodies.
executionContext.diagnosticCollector?.record(
category = "byp",
source = "Clash API",
target = "$host:$port",
status = "available",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = buildString {
appendLine("configAvailable=true")
appendLine("destinationIpCount=${result.leakedDestIps.size}")
appendLine("proxyNodeCount=${result.proxyNodes.size}")
},
)
}
}
private fun isTcpPortOpen(host: String, port: Int): Boolean {

View file

@ -45,6 +45,7 @@ object MtProtoProber {
readTimeoutMs: Int = 3000,
executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(),
): ProbeResult = withContext(Dispatchers.IO) {
val startedAt = System.nanoTime()
for (target in TELEGRAM_DC_TARGETS) {
val reachable = trySocks5Connect(
proxyHost = proxyHost,
@ -56,10 +57,31 @@ object MtProtoProber {
executionContext = executionContext,
)
if (reachable) {
return@withContext ProbeResult(reachable = true, targetAddress = target)
return@withContext ProbeResult(reachable = true, targetAddress = target).also { result ->
recordDiagnostic(executionContext, proxyHost, proxyPort, startedAt, result)
}
}
}
ProbeResult(reachable = false, targetAddress = null)
ProbeResult(reachable = false, targetAddress = null).also { result ->
recordDiagnostic(executionContext, proxyHost, proxyPort, startedAt, result)
}
}
private fun recordDiagnostic(
executionContext: ScanExecutionContext,
proxyHost: String,
proxyPort: Int,
startedAt: Long,
result: ProbeResult,
) {
executionContext.diagnosticCollector?.record(
category = "byp",
source = "MTProto proxy probe",
target = "$proxyHost:$proxyPort",
status = if (result.reachable) "reachable" else "unreachable",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = "target=${result.targetAddress}",
)
}
/**

View file

@ -42,6 +42,7 @@ data class NativeRouteEntry(
val type: String? = null,
val table: Int? = null,
val prefixLen: Int? = null,
val protocol: Int? = null,
) {
enum class RouteSource { PROC, NETLINK }
}
@ -69,7 +70,32 @@ data class NativeEmulatorFinding(
val detail: String?,
)
data class NativeVpnPropertyFinding(
val kind: String,
val prop: String?,
val value: String?,
)
data class NativeVpnLeakFinding(
val kind: String,
val detail: String?,
val count: Int = 0,
)
data class NativeVpnAdvancedFinding(
val kind: String,
val detail: String?,
val count: Int = 0,
)
data class NativeVpnSyscallFinding(
val kind: String,
val detail: String?,
)
object NativeInterfaceProbe {
private const val IPV4_ADDRESS_FAMILY = 2
private const val IPV6_ADDRESS_FAMILY = 10
private const val IPV4_DEFAULT_DESTINATION = "00000000"
private const val IPV6_DEFAULT_DESTINATION = "00000000000000000000000000000000"
private const val IPV6_DEFAULT_PREFIX_LENGTH = "00"
@ -181,13 +207,14 @@ object NativeInterfaceProbe {
var scope: String? = null
var type: String? = null
var table: Int? = null
var protocol: Int? = null
for (token in tokens) {
val eq = token.indexOf('=')
if (eq <= 0) continue
val key = token.substring(0, eq)
val value = token.substring(eq + 1)
when (key) {
"family" -> family = value.toIntOrNull() ?: 0
"family" -> family = parseNetlinkFamily(value)
"dst" -> {
val slash = value.indexOf('/')
if (slash > 0) {
@ -205,6 +232,7 @@ object NativeInterfaceProbe {
"scope" -> scope = value
"type" -> type = value
"table" -> table = value.toIntOrNull()
"proto" -> protocol = value.toIntOrNull()
}
}
val iface = dev ?: oif?.let { "if$it" } ?: continue
@ -229,12 +257,19 @@ object NativeInterfaceProbe {
type = type,
table = table,
prefixLen = prefixLen,
protocol = protocol,
),
)
}
return result
}
private fun parseNetlinkFamily(value: String): Int = when (value) {
"inet" -> IPV4_ADDRESS_FAMILY
"inet6" -> IPV6_ADDRESS_FAMILY
else -> value.toIntOrNull() ?: 0
}
fun collectNetlinkRoutes(): List<NativeRouteEntry> {
val rows = runCatching { NativeSignsBridge.netlinkRouteDump(0) }.getOrDefault(emptyArray())
return parseNetlinkRoutes(rows)
@ -383,4 +418,87 @@ object NativeInterfaceProbe {
val rows = NativeSignsBridge.detectEmulator()
return parseEmulatorFindings(rows)
}
fun parseVpnPropertyFindings(rows: Array<String>): List<NativeVpnPropertyFinding> {
return rows.mapNotNull { row ->
val sep = row.indexOf('|')
if (sep <= 0) return@mapNotNull null
val kind = row.substring(0, sep)
val rest = row.substring(sep + 1)
val eq = rest.indexOf('=')
if (eq <= 0) {
NativeVpnPropertyFinding(kind = kind, prop = rest, value = null)
} else {
NativeVpnPropertyFinding(
kind = kind,
prop = rest.substring(0, eq),
value = rest.substring(eq + 1).takeIf { it.isNotBlank() },
)
}
}
}
fun collectVpnPropertyFindings(): List<NativeVpnPropertyFinding> {
val rows = NativeSignsBridge.detectVpnProperties()
return parseVpnPropertyFindings(rows)
}
fun parseVpnLeakFindings(rows: Array<String>): List<NativeVpnLeakFinding> {
return rows.mapNotNull { row ->
val sep = row.indexOf('|')
if (sep <= 0) return@mapNotNull null
val kind = row.substring(0, sep)
val rest = row.substring(sep + 1)
val sep2 = rest.lastIndexOf('|')
if (sep2 > 0) {
val detail = rest.substring(0, sep2)
val count = rest.substring(sep2 + 1).toIntOrNull() ?: 0
NativeVpnLeakFinding(kind = kind, detail = detail, count = count)
} else {
NativeVpnLeakFinding(kind = kind, detail = rest)
}
}
}
fun collectVpnLeakFindings(): List<NativeVpnLeakFinding> {
val rows = NativeSignsBridge.detectVpnLeaks()
return parseVpnLeakFindings(rows)
}
fun parseVpnAdvancedFindings(rows: Array<String>): List<NativeVpnAdvancedFinding> {
return rows.mapNotNull { row ->
val sep = row.indexOf('|')
if (sep <= 0) return@mapNotNull null
val kind = row.substring(0, sep)
val rest = row.substring(sep + 1)
val sep2 = rest.lastIndexOf('|')
if (sep2 > 0) {
val detail = rest.substring(0, sep2)
val count = rest.substring(sep2 + 1).toIntOrNull() ?: 0
NativeVpnAdvancedFinding(kind = kind, detail = detail, count = count)
} else {
NativeVpnAdvancedFinding(kind = kind, detail = rest)
}
}
}
fun collectVpnAdvancedFindings(): List<NativeVpnAdvancedFinding> {
val rows = NativeSignsBridge.detectVpnAdvanced()
return parseVpnAdvancedFindings(rows)
}
fun parseVpnSyscallFindings(rows: Array<String>): List<NativeVpnSyscallFinding> {
return rows.mapNotNull { row ->
val sep = row.indexOf('|')
if (sep <= 0) return@mapNotNull null
val kind = row.substring(0, sep)
val detail = row.substring(sep + 1).takeIf { it.isNotBlank() }
NativeVpnSyscallFinding(kind = kind, detail = detail)
}
}
fun collectVpnSyscallFindings(): List<NativeVpnSyscallFinding> {
val rows = NativeSignsBridge.detectVpnSyscalls()
return parseVpnSyscallFindings(rows)
}
}

View file

@ -1,5 +1,8 @@
package com.notcvnt.rknhardering.probe
import com.notcvnt.rknhardering.ScanCancellationSignal
import com.notcvnt.rknhardering.ScanExecutionContext
object NativeSignsBridge {
private const val LIBRARY_NAME = "native_signs_probe"
@ -39,6 +42,21 @@ object NativeSignsBridge {
@Volatile
internal var detectEmulatorOverride: (() -> Array<String>)? = null
@Volatile
internal var detectVpnPropertiesOverride: (() -> Array<String>)? = null
@Volatile
internal var detectVpnLeaksOverride: (() -> Array<String>)? = null
@Volatile
internal var detectVpnAdvancedOverride: (() -> Array<String>)? = null
@Volatile
internal var detectVpnSyscallsOverride: (() -> Array<String>)? = null
@Volatile
internal var detectVpnDetectorOverride: ((ScanCancellationSignal?) -> Array<String>)? = null
@Volatile
private var initialized = false
@ -76,69 +94,159 @@ object NativeSignsBridge {
}
fun getIfAddrs(): Array<String> {
getIfAddrsOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeGetIfAddrs() }.getOrDefault(emptyArray())
return traceArray("getifaddrs") {
getIfAddrsOverride?.invoke()
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeGetIfAddrs() }.getOrDefault(emptyArray())
}
}
fun ifNameToIndex(name: String): Int {
ifNameToIndexOverride?.let { return it.invoke(name) }
if (!isLibraryLoaded() || name.isBlank()) return 0
return runCatching { nativeIfNameToIndex(name) }.getOrDefault(0)
return traceValue("if_nametoindex", name, {
ifNameToIndexOverride?.invoke(name)
?: if (!isLibraryLoaded() || name.isBlank()) 0 else runCatching { nativeIfNameToIndex(name) }.getOrDefault(0)
}) { it.toString() }
}
fun readProcFile(path: String, maxBytes: Int = 262_144): String? {
readProcFileOverride?.let { return it.invoke(path, maxBytes) }
if (!isLibraryLoaded()) return null
return runCatching { nativeReadProcFile(path, maxBytes) }.getOrNull()
return traceValue("readProcFile", path, {
val override = readProcFileOverride
if (override != null) {
override.invoke(path, maxBytes)
} else if (!isLibraryLoaded()) {
null
} else {
runCatching { nativeReadProcFile(path, maxBytes) }.getOrNull()
}
}) { it.orEmpty() }
}
fun readSelfMapsSummary(): Array<String> {
readSelfMapsSummaryOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeReadSelfMapsSummary() }.getOrDefault(emptyArray())
return traceArray("readSelfMapsSummary") {
readSelfMapsSummaryOverride?.invoke()
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeReadSelfMapsSummary() }.getOrDefault(emptyArray())
}
}
fun probeFeatureFlags(): Array<String> {
probeFeatureFlagsOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeProbeFeatureFlags() }.getOrDefault(emptyArray())
return traceArray("probeFeatureFlags") {
probeFeatureFlagsOverride?.invoke()
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeProbeFeatureFlags() }.getOrDefault(emptyArray())
}
}
fun libraryIntegrity(): Array<String> {
libraryIntegrityOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeLibraryIntegrity() }.getOrDefault(emptyArray())
return traceArray("libraryIntegrity") {
libraryIntegrityOverride?.invoke()
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeLibraryIntegrity() }.getOrDefault(emptyArray())
}
}
fun interfaceDump(): Array<String> {
interfaceDumpOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeInterfaceDump() }.getOrDefault(emptyArray())
return traceArray("interfaceDump") {
interfaceDumpOverride?.invoke()
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeInterfaceDump() }.getOrDefault(emptyArray())
}
}
fun netlinkRouteDump(family: Int = 0): Array<String> {
netlinkRouteDumpOverride?.let { return it.invoke(family) }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeNetlinkRouteDump(family) }.getOrDefault(emptyArray())
return traceArray("netlinkRouteDump", "family=$family") {
netlinkRouteDumpOverride?.invoke(family)
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeNetlinkRouteDump(family) }.getOrDefault(emptyArray())
}
}
fun netlinkSockDiag(family: Int, protocol: Int): Array<String> {
netlinkSockDiagOverride?.let { return it.invoke(family, protocol) }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeNetlinkSockDiag(family, protocol) }.getOrDefault(emptyArray())
return traceArray("netlinkSockDiag", "family=$family,protocol=$protocol") {
netlinkSockDiagOverride?.invoke(family, protocol)
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeNetlinkSockDiag(family, protocol) }.getOrDefault(emptyArray())
}
}
fun detectRoot(): Array<String> {
detectRootOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectRoot() }.getOrDefault(emptyArray())
return traceArray("detectRoot") {
detectRootOverride?.invoke()
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectRoot() }.getOrDefault(emptyArray())
}
}
fun detectEmulator(): Array<String> {
detectEmulatorOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectEmulator() }.getOrDefault(emptyArray())
return traceArray("detectEmulator") {
detectEmulatorOverride?.invoke()
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectEmulator() }.getOrDefault(emptyArray())
}
}
fun detectVpnProperties(): Array<String> {
return traceArray("detectVpnProperties") {
detectVpnPropertiesOverride?.invoke()
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnProperties() }.getOrDefault(emptyArray())
}
}
fun detectVpnLeaks(): Array<String> {
return traceArray("detectVpnLeaks") {
detectVpnLeaksOverride?.invoke()
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnLeaks() }.getOrDefault(emptyArray())
}
}
fun detectVpnAdvanced(): Array<String> {
return traceArray("detectVpnAdvanced") {
detectVpnAdvancedOverride?.invoke()
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnAdvanced() }.getOrDefault(emptyArray())
}
}
fun detectVpnSyscalls(): Array<String> {
return traceArray("detectVpnSyscalls") {
detectVpnSyscallsOverride?.invoke()
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnSyscalls() }.getOrDefault(emptyArray())
}
}
fun detectVpnDetector(cancellationSignal: ScanCancellationSignal? = null): Array<String> {
return traceArray("detectVpnDetector") {
detectVpnDetectorOverride?.invoke(cancellationSignal)
?: if (!isLibraryLoaded()) emptyArray() else runCatching { nativeDetectVpnDetector(cancellationSignal) }.getOrDefault(emptyArray())
}
}
private fun traceArray(
source: String,
target: String? = null,
block: () -> Array<String>,
): Array<String> = traceValue(source, target, block) { it.joinToString("\n") }
private fun <T> traceValue(
source: String,
target: String? = null,
block: () -> T,
render: (T) -> String,
): T {
val executionContext = ScanExecutionContext.currentOrDefault()
val startedAt = System.nanoTime()
return try {
block().also { result ->
executionContext.diagnosticCollector?.record(
category = "nat",
source = source,
target = target,
status = "completed",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = render(result),
)
}
} catch (error: Throwable) {
executionContext.diagnosticCollector?.record(
category = "nat",
source = source,
target = target,
status = "error",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = error.message.orEmpty(),
)
throw error
}
}
internal fun resetForTests() {
@ -154,6 +262,11 @@ object NativeSignsBridge {
netlinkSockDiagOverride = null
detectRootOverride = null
detectEmulatorOverride = null
detectVpnPropertiesOverride = null
detectVpnLeaksOverride = null
detectVpnAdvancedOverride = null
detectVpnSyscallsOverride = null
detectVpnDetectorOverride = null
initialized = false
libraryLoaded = false
lastLoadError = null
@ -170,4 +283,9 @@ object NativeSignsBridge {
private external fun nativeNetlinkSockDiag(family: Int, protocol: Int): Array<String>
private external fun nativeDetectRoot(): Array<String>
private external fun nativeDetectEmulator(): Array<String>
private external fun nativeDetectVpnProperties(): Array<String>
private external fun nativeDetectVpnLeaks(): Array<String>
private external fun nativeDetectVpnAdvanced(): Array<String>
private external fun nativeDetectVpnSyscalls(): Array<String>
private external fun nativeDetectVpnDetector(cancellationSignal: ScanCancellationSignal?): Array<String>
}

View file

@ -44,6 +44,7 @@ object StunBindingClient {
timeoutMs: Int = 3_000,
executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(),
): Result<BindingResult> {
val startedAt = System.nanoTime()
return runCatching {
executionContext.throwIfCancelled()
val resolvedAddresses = ResolverNetworkStack.lookup(
@ -75,6 +76,8 @@ object StunBindingClient {
}
}
throw lastError ?: IOException("No STUN response from $host:$port")
}.also { result ->
recordDiagnostic(executionContext, host, port, startedAt, listOf(result))
}
}
@ -86,6 +89,7 @@ object StunBindingClient {
timeoutMs: Int = 3_000,
executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(),
): DualStackBindingResult {
val startedAt = System.nanoTime()
executionContext.throwIfCancelled()
val allAddresses = runCatching {
ResolverNetworkStack.lookup(
@ -94,7 +98,17 @@ object StunBindingClient {
binding = binding,
cancellationSignal = executionContext.cancellationSignal,
).distinctBy { it.hostAddress }
}.getOrElse { return DualStackBindingResult(null, null) }
}.getOrElse { error ->
executionContext.diagnosticCollector?.record(
category = "stn",
source = "STUN",
target = "$host:$port",
status = "error",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = error.message.orEmpty(),
)
return DualStackBindingResult(null, null)
}
val ipv4Addresses = allAddresses.filterIsInstance<Inet4Address>()
val ipv6Addresses = allAddresses.filterIsInstance<Inet6Address>()
@ -126,7 +140,15 @@ object StunBindingClient {
return DualStackBindingResult(
ipv4Result = probeFamily(ipv4Addresses),
ipv6Result = probeFamily(ipv6Addresses),
)
).also { result ->
recordDiagnostic(
executionContext,
host,
port,
startedAt,
listOfNotNull(result.ipv4Result, result.ipv6Result),
)
}
}
internal fun probeWithDatagramExchange(
@ -136,6 +158,7 @@ object StunBindingClient {
exchange: (ByteArray) -> Socks5UdpAssociateClient.UdpDatagram,
executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(),
): Result<BindingResult> {
val startedAt = System.nanoTime()
return runCatching {
executionContext.throwIfCancelled()
val transactionId = Random.nextBytes(12)
@ -156,9 +179,40 @@ object StunBindingClient {
if (error is Exception) {
rethrowIfCancellation(error, executionContext)
}
}.also { result ->
recordDiagnostic(executionContext, host, port, startedAt, listOf(result))
}
}
private fun recordDiagnostic(
executionContext: ScanExecutionContext,
host: String,
port: Int,
startedAt: Long,
results: List<Result<BindingResult>>,
) {
executionContext.diagnosticCollector?.record(
category = "stn",
source = "STUN",
target = "$host:$port",
status = when {
results.isEmpty() -> "unavailable"
results.any { it.isSuccess } -> "completed"
else -> "error"
},
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = results.joinToString("\n") { result ->
result.fold(
onSuccess = { value ->
"resolved=${value.resolvedIps},remote=${value.remoteIp}:${value.remotePort}," +
"mapped=${value.mappedIp}:${value.mappedPort}"
},
onFailure = { error -> "error=${error.message}" },
)
},
)
}
private fun sendBindingRequest(
host: String,
port: Int,

View file

@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import kotlin.math.roundToInt
import kotlin.concurrent.thread
object SystemPingProber {
private const val DEFAULT_COUNT = 3
@ -20,6 +21,7 @@ object SystemPingProber {
internal data class CommandResult(
val exitCode: Int,
val output: String,
val stderr: String = "",
)
data class PingResult(
@ -66,7 +68,23 @@ object SystemPingProber {
for (command in commands) {
executionContext.throwIfCancelled()
try {
val startedAt = System.nanoTime()
val commandResult = runCommand(command, executionContext)
executionContext.diagnosticCollector?.record(
category = "icmp",
source = "ping",
target = address,
status = "exit ${commandResult.exitCode}",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = buildString {
appendLine("command=${command.joinToString(" ")}")
appendLine("exitCode=${commandResult.exitCode}")
appendLine("stdout:")
appendLine(commandResult.output)
appendLine("stderr:")
append(commandResult.stderr)
},
)
return@withContext try {
parse(address, commandResult)
} catch (error: IOException) {
@ -131,20 +149,27 @@ object SystemPingProber {
): CommandResult {
runCommandOverride?.let { return it(command) }
val process = ProcessBuilder(command)
.redirectErrorStream(true)
.start()
val process = ProcessBuilder(command).start()
val registration = executionContext.cancellationSignal.register {
runCatching { process.destroyForcibly() }
}
try {
val output = process.inputStream.bufferedReader().use { it.readText() }
executionContext.throwIfCancelled()
var output = ""
var stderr = ""
val stdoutReader = thread(name = "rkn-ping-stdout") {
output = runCatching { process.inputStream.bufferedReader().use { it.readText() } }.getOrDefault("")
}
val stderrReader = thread(name = "rkn-ping-stderr") {
stderr = runCatching { process.errorStream.bufferedReader().use { it.readText() } }.getOrDefault("")
}
val exitCode = process.waitFor()
stdoutReader.join()
stderrReader.join()
executionContext.throwIfCancelled()
return CommandResult(
exitCode = exitCode,
output = output,
stderr = stderr,
)
} finally {
registration.dispose()

View file

@ -13,6 +13,8 @@ import java.util.Locale
object TunProbeDiagnosticsFormatter {
private const val NONE = "<none>"
fun format(
diagnostics: TunProbeDiagnostics,
settings: CheckSettings,
@ -109,8 +111,8 @@ object TunProbeDiagnosticsFormatter {
builder.appendLine("available: true")
builder.appendLine("interfaceName: ${path.interfaceName ?: "<missing>"}")
builder.appendLine("selectedMode: ${path.selectedMode?.name ?: "NONE"}")
builder.appendLine("selectedIp: ${path.selectedIp?.let(::maskIp) ?: "<none>"}")
builder.appendLine("selectedError: ${path.selectedError?.let(::maskIpsInText) ?: "<none>"}")
builder.appendLine("selectedIp: ${path.selectedIp?.let(::maskIp) ?: NONE}")
builder.appendLine("selectedError: ${path.selectedError?.let(::maskIpsInText) ?: NONE}")
builder.appendLine("dnsPathMismatch: ${path.dnsPathMismatch}")
appendAttempt(builder, "strict", path.strict)
appendAttempt(builder, "curl-compatible", path.curlCompatible)
@ -122,8 +124,8 @@ object TunProbeDiagnosticsFormatter {
attempt: TunProbeAttemptDiagnostics,
) {
builder.appendLine("$label.status: ${attempt.status}")
builder.appendLine("$label.ip: ${attempt.ip?.let(::maskIp) ?: "<none>"}")
builder.appendLine("$label.error: ${attempt.error?.let(::maskIpsInText) ?: "<none>"}")
builder.appendLine("$label.ip: ${attempt.ip?.let(::maskIp) ?: NONE}")
builder.appendLine("$label.error: ${attempt.error?.let(::maskIpsInText) ?: NONE}")
appendTransportDiagnostics(builder, label, attempt.transportDiagnostics)
builder.appendLine("$label.endpoints:")
if (attempt.endpointAttempts.isEmpty()) {
@ -138,9 +140,9 @@ object TunProbeDiagnosticsFormatter {
private fun formatEndpointAttempt(attempt: TunEndpointAttempt): String {
val result = when (attempt.status) {
PublicIpProbeStatus.SUCCEEDED -> "ip=${attempt.ip?.let(::maskIp) ?: "<none>"}"
PublicIpProbeStatus.SUCCEEDED -> "ip=${attempt.ip?.let(::maskIp) ?: NONE}"
PublicIpProbeStatus.FAILED,
PublicIpProbeStatus.SKIPPED -> "error=${attempt.error?.let(::maskIpsInText) ?: "<none>"}"
PublicIpProbeStatus.SKIPPED -> "error=${attempt.error?.let(::maskIpsInText) ?: NONE}"
}
return "${attempt.endpoint} [${attempt.familyHint}] -> ${attempt.status} ($result)"
}
@ -191,14 +193,14 @@ object TunProbeDiagnosticsFormatter {
DnsResolverMode.DIRECT -> {
val servers = settings.resolverConfig.effectiveDirectServers()
.joinToString(", ") { maskIp(it) }
.ifBlank { "<none>" }
.ifBlank { NONE }
"DIRECT preset=${settings.resolverConfig.preset.name} servers=$servers"
}
DnsResolverMode.DOH -> {
val bootstrap = settings.resolverConfig.effectiveDohBootstrapHosts()
.joinToString(", ") { maskIp(it) }
.ifBlank { "<none>" }
"DOH preset=${settings.resolverConfig.preset.name} url=${settings.resolverConfig.effectiveDohUrl() ?: "<none>"} bootstrap=$bootstrap"
.ifBlank { NONE }
"DOH preset=${settings.resolverConfig.preset.name} url=${settings.resolverConfig.effectiveDohUrl() ?: NONE} bootstrap=$bootstrap"
}
}
}

View file

@ -26,6 +26,7 @@ class XrayApiClient(
deadlineMs: Long = 600,
executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(),
): Result<XrayApiScanResult> = withContext(Dispatchers.IO) {
val startedAt = System.nanoTime()
val channel = OkHttpChannelBuilder.forAddress(host, port)
.usePlaintext()
.build()
@ -69,15 +70,40 @@ class XrayApiClient(
)
}
Result.success(
XrayApiScanResult(
endpoint = XrayApiEndpoint(host = host, port = port),
outbounds = outbounds,
handlerAvailable = true,
),
val scanResult = XrayApiScanResult(
endpoint = XrayApiEndpoint(host = host, port = port),
outbounds = outbounds,
handlerAvailable = true,
)
executionContext.diagnosticCollector?.record(
category = "byp",
source = "Xray gRPC API",
target = "$host:$port",
status = "available",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = buildString {
appendLine("outboundCount=${outbounds.size}")
outbounds.forEachIndexed { index, outbound ->
appendLine(
"outbound[$index]=tag:${outbound.tag},protocol:${outbound.protocolName}," +
"address:${outbound.address},port:${outbound.port},sni:${outbound.sni}," +
"uuidPresent:${!outbound.uuid.isNullOrBlank()}," +
"publicKeyPresent:${!outbound.publicKey.isNullOrBlank()}",
)
}
},
)
Result.success(scanResult)
} catch (e: Exception) {
rethrowIfCancellation(e, executionContext)
executionContext.diagnosticCollector?.record(
category = "byp",
source = "Xray gRPC API",
target = "$host:$port",
status = "error",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = e.message.orEmpty(),
)
Result.failure(e)
} finally {
registration.dispose()
@ -91,6 +117,7 @@ class XrayApiClient(
deadlineMs: Long = 600,
executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(),
): Result<XrayStatsSummary> = withContext(Dispatchers.IO) {
val startedAt = System.nanoTime()
val channel = OkHttpChannelBuilder.forAddress(host, port)
.usePlaintext()
.build()
@ -108,18 +135,33 @@ class XrayApiClient(
stub.queryStats(QueryStatsRequest.getDefaultInstance())
}
Result.success(
XrayStatsSummary(
statCount = response.statCount,
sampleNames = response.statList
.asSequence()
.mapNotNull { stat -> stat.name.takeIf { it.isNotBlank() } }
.take(8)
.toList(),
),
val summary = XrayStatsSummary(
statCount = response.statCount,
sampleNames = response.statList
.asSequence()
.mapNotNull { stat -> stat.name.takeIf { it.isNotBlank() } }
.take(8)
.toList(),
)
executionContext.diagnosticCollector?.record(
category = "byp",
source = "Xray stats API",
target = "$host:$port",
status = "available",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = "statCount=${summary.statCount}",
)
Result.success(summary)
} catch (e: Exception) {
rethrowIfCancellation(e, executionContext)
executionContext.diagnosticCollector?.record(
category = "byp",
source = "Xray stats API",
target = "$host:$port",
status = "error",
durationMs = (System.nanoTime() - startedAt) / 1_000_000,
body = e.message.orEmpty(),
)
Result.failure(e)
} finally {
registration.dispose()

View file

@ -0,0 +1,385 @@
package com.notcvnt.rknhardering.ui.main
import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.CallTransportLeakResult
import com.notcvnt.rknhardering.model.CallTransportStatus
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.CdnPullingResult
import com.notcvnt.rknhardering.model.DomainReachabilityResult
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.LocalProxyCheckStatus
import com.notcvnt.rknhardering.model.StunProbeGroupResult
internal enum class SimpleResultStatus {
RUNNING,
CLEAN,
REVIEW,
DETECTED,
ERROR,
DISABLED,
}
internal enum class SimpleSignalArea {
PUBLIC_ADDRESS,
DEVICE_NETWORK,
LOCAL_APP,
LOCAL_PROXY,
NETWORK_ROUTE,
CALL_ROUTE,
LOCATION,
REMOTE_SITE,
DEVICE_ENVIRONMENT,
MULTIPLE,
NONE,
}
internal enum class SimpleResultCause {
IP_RU_SERVICES_DISAGREE,
IP_NON_RU_SERVICES_DISAGREE,
IP_GROUPS_DISAGREE,
IP_FAMILIES_DIFFER,
IP_PARTIAL_RESPONSE,
IP_UNAVAILABLE,
CDN_RESPONSES_DIFFER,
CDN_PARTIAL_RESPONSE,
PUBLIC_IP_LOCATION,
VPN_NETWORK_STATE,
ACTIVE_VPN_APP,
SYSTEM_PROXY,
LOCAL_PROXY,
VPN_INTERFACE,
VPN_ROUTE,
DNS_REDIRECTION,
TRAFFIC_BYPASS,
PROXY_AUTH_REQUIRED,
LOCATION_CONFLICT,
ICMP_RESPONSE_MISMATCH,
RTT_ROUTE_PATTERN,
TELEGRAM_CALL_PATH,
WHATSAPP_CALL_PATH,
CALL_CHECK_UNAVAILABLE,
DOMAIN_DNS_MISMATCH,
DOMAIN_TCP_MISMATCH,
DOMAIN_TLS_MISMATCH,
PUBLIC_DATA_UNAVAILABLE,
NETWORK_DATA_UNAVAILABLE,
REMOTE_DATA_UNAVAILABLE,
DEVICE_DATA_UNAVAILABLE,
DEVICE_ENVIRONMENT,
}
internal data class SimpleCardModel(
val status: SimpleResultStatus,
val area: SimpleSignalArea = SimpleSignalArea.NONE,
val extraInformation: Boolean = false,
val causes: List<SimpleResultCause> = emptyList(),
)
internal object SimpleResultModels {
fun category(result: CategoryResult, extraInformation: Boolean = false): SimpleCardModel {
val status = status(result.detected, result.needsReview, result.hasError)
val hasDiagnosticAppSignal = result.matchedApps.isNotEmpty() ||
result.evidence.any {
it.source == EvidenceSource.INSTALLED_APP ||
it.source == EvidenceSource.VPN_SERVICE_DECLARATION
}
val signalSources = buildSet {
result.evidence.filter { it.detected }.mapTo(this) { it.source }
result.findings
.filter { it.detected || it.needsReview }
.mapNotNullTo(this) { it.source }
if (result.activeApps.isNotEmpty()) add(EvidenceSource.ACTIVE_VPN)
}
val areaSources = buildSet {
addAll(signalSources)
result.findings.filter { it.isError }.mapNotNullTo(this) { it.source }
if (result.matchedApps.isNotEmpty()) add(EvidenceSource.INSTALLED_APP)
}
return SimpleCardModel(
status = status,
area = signalArea(areaSources),
extraInformation = extraInformation || hasDiagnosticAppSignal,
causes = if (status == SimpleResultStatus.CLEAN) {
emptyList()
} else {
buildList {
result.findings.filter { it.isError }.mapNotNullTo(this) { errorCause(it.source) }
signalSources.mapNotNullTo(this) { signalCause(it) }
}.distinct().sortedBy { it.ordinal }.take(MAX_CAUSES)
},
)
}
fun ipComparison(result: IpComparisonResult): SimpleCardModel {
val successfulCount = result.ruGroup.responses.count { it.ip != null } +
result.nonRuGroup.responses.count { it.ip != null }
val ruIp = result.ruGroup.canonicalIp
val nonRuIp = result.nonRuGroup.canonicalIp
val causes = buildList {
if (result.ruGroup.detected) add(SimpleResultCause.IP_RU_SERVICES_DISAGREE)
if (result.nonRuGroup.detected) add(SimpleResultCause.IP_NON_RU_SERVICES_DISAGREE)
if (ruIp != null && nonRuIp != null) {
if ((ruIp.contains(':')) != (nonRuIp.contains(':'))) {
add(SimpleResultCause.IP_FAMILIES_DIFFER)
} else if (ruIp != nonRuIp) {
add(SimpleResultCause.IP_GROUPS_DISAGREE)
}
}
if (successfulCount == 0 && (result.hasError || result.needsReview) && isEmpty()) {
add(SimpleResultCause.IP_UNAVAILABLE)
} else if (result.needsReview && isEmpty()) {
add(SimpleResultCause.IP_PARTIAL_RESPONSE)
}
}
return SimpleCardModel(
status = status(result.detected, result.needsReview, result.hasError),
area = SimpleSignalArea.PUBLIC_ADDRESS,
causes = causes.distinct().take(MAX_CAUSES),
)
}
fun cdn(result: CdnPullingResult): SimpleCardModel {
val cause = when {
result.hasError && result.responses.none { it.ip != null || it.ipv4 != null || it.ipv6 != null } ->
SimpleResultCause.REMOTE_DATA_UNAVAILABLE
result.detected -> SimpleResultCause.CDN_RESPONSES_DIFFER
result.needsReview -> SimpleResultCause.CDN_PARTIAL_RESPONSE
else -> null
}
return SimpleCardModel(
status = status(result.detected, result.needsReview, result.hasError),
area = SimpleSignalArea.REMOTE_SITE,
extraInformation = true,
causes = listOfNotNull(cause),
)
}
fun bypass(result: BypassResult): SimpleCardModel {
val sources = buildSet {
result.evidence.filter { it.detected }.mapTo(this) { it.source }
result.findings.filter { it.detected || it.needsReview }.mapNotNullTo(this) { it.source }
}
val causes = buildList {
result.findings.filter { it.isError }.mapNotNullTo(this) { errorCause(it.source) }
sources.mapNotNullTo(this) { signalCause(it) }
result.proxyChecks.forEach { check ->
when (check.status) {
LocalProxyCheckStatus.CONFIRMED_BYPASS -> add(SimpleResultCause.TRAFFIC_BYPASS)
LocalProxyCheckStatus.AUTH_REQUIRED -> add(SimpleResultCause.PROXY_AUTH_REQUIRED)
LocalProxyCheckStatus.PROXY_IP_UNAVAILABLE,
LocalProxyCheckStatus.DIRECT_IP_UNAVAILABLE,
-> add(SimpleResultCause.PUBLIC_DATA_UNAVAILABLE)
LocalProxyCheckStatus.SAME_IP -> Unit
}
}
}.distinct().sortedBy { it.ordinal }.take(MAX_CAUSES)
return SimpleCardModel(
status = status(result.detected, result.needsReview, result.hasError),
area = signalArea(sources),
causes = causes,
)
}
fun callTransport(
leaks: List<CallTransportLeakResult>,
stunGroups: List<StunProbeGroupResult>,
): SimpleCardModel {
val status = when {
leaks.any { it.status == CallTransportStatus.ERROR } -> SimpleResultStatus.ERROR
leaks.any { it.status == CallTransportStatus.NEEDS_REVIEW } -> SimpleResultStatus.REVIEW
leaks.isEmpty() && stunGroups.isEmpty() -> SimpleResultStatus.ERROR
else -> SimpleResultStatus.CLEAN
}
val causes = when {
status == SimpleResultStatus.ERROR -> listOf(SimpleResultCause.CALL_CHECK_UNAVAILABLE)
status == SimpleResultStatus.REVIEW -> leaks
.filter { it.status == CallTransportStatus.NEEDS_REVIEW }
.map {
when (it.service) {
com.notcvnt.rknhardering.model.CallTransportService.TELEGRAM ->
SimpleResultCause.TELEGRAM_CALL_PATH
com.notcvnt.rknhardering.model.CallTransportService.WHATSAPP ->
SimpleResultCause.WHATSAPP_CALL_PATH
}
}
.distinct()
.take(MAX_CAUSES)
else -> emptyList()
}
return SimpleCardModel(status, SimpleSignalArea.CALL_ROUTE, causes = causes)
}
fun domainReachability(result: DomainReachabilityResult): SimpleCardModel {
val status = when {
result.isEmpty -> SimpleResultStatus.ERROR
result.responses.any { !it.matchesExpectation } -> SimpleResultStatus.DETECTED
else -> SimpleResultStatus.CLEAN
}
val causes = if (result.isEmpty) {
listOf(SimpleResultCause.REMOTE_DATA_UNAVAILABLE)
} else {
buildList {
result.responses.filter { !it.matchesExpectation }.forEach { response ->
if ((response.dnsStatus == com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.OK) !=
response.expectedDnsAvailable
) {
add(SimpleResultCause.DOMAIN_DNS_MISMATCH)
}
if (response.dnsStatus != com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.FAILED &&
(response.tcpStatus == com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.OK) !=
response.expectedTcpAvailable
) {
add(SimpleResultCause.DOMAIN_TCP_MISMATCH)
}
if (response.tcpStatus == com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.OK &&
(response.tlsStatus == com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.OK) !=
response.expectedTlsAvailable
) {
add(SimpleResultCause.DOMAIN_TLS_MISMATCH)
}
}
}.distinct().take(MAX_CAUSES)
}
return SimpleCardModel(
status = status,
area = SimpleSignalArea.REMOTE_SITE,
extraInformation = true,
causes = causes,
)
}
private fun status(detected: Boolean, needsReview: Boolean, hasError: Boolean): SimpleResultStatus =
when {
hasError -> SimpleResultStatus.ERROR
detected -> SimpleResultStatus.DETECTED
needsReview -> SimpleResultStatus.REVIEW
else -> SimpleResultStatus.CLEAN
}
private fun signalArea(sources: Set<EvidenceSource>): SimpleSignalArea {
val areas = sources.mapTo(linkedSetOf()) { source ->
when (source) {
EvidenceSource.GEO_IP -> SimpleSignalArea.PUBLIC_ADDRESS
EvidenceSource.INSTALLED_APP,
EvidenceSource.VPN_SERVICE_DECLARATION,
EvidenceSource.ACTIVE_VPN,
-> SimpleSignalArea.LOCAL_APP
EvidenceSource.LOCAL_PROXY,
EvidenceSource.XRAY_API,
EvidenceSource.CLASH_API,
EvidenceSource.PROXY_AUTH_BYPASS,
EvidenceSource.PROXY_TECHNICAL_SIGNAL,
-> SimpleSignalArea.LOCAL_PROXY
EvidenceSource.ROUTING,
EvidenceSource.DNS,
EvidenceSource.NETWORK_INTERFACE,
EvidenceSource.SPLIT_TUNNEL_BYPASS,
EvidenceSource.VPN_GATEWAY_LEAK,
EvidenceSource.VPN_NETWORK_BINDING,
EvidenceSource.TUN_ACTIVE_PROBE,
EvidenceSource.DIRECT_NETWORK_CAPABILITIES,
EvidenceSource.INDIRECT_NETWORK_CAPABILITIES,
EvidenceSource.SYSTEM_PROXY,
-> SimpleSignalArea.NETWORK_ROUTE
EvidenceSource.TELEGRAM_CALL_TRANSPORT,
EvidenceSource.WHATSAPP_CALL_TRANSPORT,
EvidenceSource.STUN_PROBE,
-> SimpleSignalArea.CALL_ROUTE
EvidenceSource.LOCATION_SIGNALS,
EvidenceSource.HOME_ROUTED_ROAMING,
-> SimpleSignalArea.LOCATION
EvidenceSource.NATIVE_INTERFACE,
EvidenceSource.NATIVE_ROUTE,
EvidenceSource.NATIVE_SOCKET,
EvidenceSource.NATIVE_HOOK_MARKERS,
EvidenceSource.NATIVE_JVM_MISMATCH,
EvidenceSource.NATIVE_LIBRARY_INTEGRITY,
EvidenceSource.NATIVE_ROOT_DETECTION,
EvidenceSource.NATIVE_EMULATOR,
EvidenceSource.SANDBOX_ISOLATION,
EvidenceSource.DUMPSYS,
-> SimpleSignalArea.DEVICE_ENVIRONMENT
EvidenceSource.ICMP_SPOOFING,
EvidenceSource.RTT_TRIANGULATION,
-> SimpleSignalArea.REMOTE_SITE
}
}
return when (areas.size) {
0 -> SimpleSignalArea.NONE
1 -> areas.first()
else -> SimpleSignalArea.MULTIPLE
}
}
private fun signalCause(source: EvidenceSource): SimpleResultCause? = when (source) {
EvidenceSource.GEO_IP -> SimpleResultCause.PUBLIC_IP_LOCATION
EvidenceSource.DIRECT_NETWORK_CAPABILITIES,
EvidenceSource.INDIRECT_NETWORK_CAPABILITIES,
EvidenceSource.DUMPSYS,
-> SimpleResultCause.VPN_NETWORK_STATE
EvidenceSource.ACTIVE_VPN -> SimpleResultCause.ACTIVE_VPN_APP
EvidenceSource.SYSTEM_PROXY -> SimpleResultCause.SYSTEM_PROXY
EvidenceSource.LOCAL_PROXY,
EvidenceSource.XRAY_API,
EvidenceSource.CLASH_API,
EvidenceSource.PROXY_TECHNICAL_SIGNAL,
-> SimpleResultCause.LOCAL_PROXY
EvidenceSource.PROXY_AUTH_BYPASS -> SimpleResultCause.PROXY_AUTH_REQUIRED
EvidenceSource.NETWORK_INTERFACE,
EvidenceSource.NATIVE_INTERFACE,
-> SimpleResultCause.VPN_INTERFACE
EvidenceSource.ROUTING,
EvidenceSource.NATIVE_ROUTE,
-> SimpleResultCause.VPN_ROUTE
EvidenceSource.DNS -> SimpleResultCause.DNS_REDIRECTION
EvidenceSource.SPLIT_TUNNEL_BYPASS,
EvidenceSource.VPN_GATEWAY_LEAK,
EvidenceSource.VPN_NETWORK_BINDING,
EvidenceSource.TUN_ACTIVE_PROBE,
-> SimpleResultCause.TRAFFIC_BYPASS
EvidenceSource.LOCATION_SIGNALS,
EvidenceSource.HOME_ROUTED_ROAMING,
-> SimpleResultCause.LOCATION_CONFLICT
EvidenceSource.ICMP_SPOOFING -> SimpleResultCause.ICMP_RESPONSE_MISMATCH
EvidenceSource.RTT_TRIANGULATION -> SimpleResultCause.RTT_ROUTE_PATTERN
EvidenceSource.TELEGRAM_CALL_TRANSPORT -> SimpleResultCause.TELEGRAM_CALL_PATH
EvidenceSource.WHATSAPP_CALL_TRANSPORT -> SimpleResultCause.WHATSAPP_CALL_PATH
EvidenceSource.STUN_PROBE -> SimpleResultCause.CALL_CHECK_UNAVAILABLE
EvidenceSource.NATIVE_SOCKET,
EvidenceSource.NATIVE_HOOK_MARKERS,
EvidenceSource.NATIVE_JVM_MISMATCH,
EvidenceSource.NATIVE_LIBRARY_INTEGRITY,
EvidenceSource.NATIVE_ROOT_DETECTION,
EvidenceSource.NATIVE_EMULATOR,
EvidenceSource.SANDBOX_ISOLATION,
-> SimpleResultCause.DEVICE_ENVIRONMENT
EvidenceSource.INSTALLED_APP,
EvidenceSource.VPN_SERVICE_DECLARATION,
-> null
}
private fun errorCause(source: EvidenceSource?): SimpleResultCause = when (source) {
EvidenceSource.GEO_IP -> SimpleResultCause.PUBLIC_DATA_UNAVAILABLE
EvidenceSource.ICMP_SPOOFING,
EvidenceSource.RTT_TRIANGULATION,
EvidenceSource.TELEGRAM_CALL_TRANSPORT,
EvidenceSource.WHATSAPP_CALL_TRANSPORT,
EvidenceSource.STUN_PROBE,
-> SimpleResultCause.REMOTE_DATA_UNAVAILABLE
EvidenceSource.NATIVE_INTERFACE,
EvidenceSource.NATIVE_ROUTE,
EvidenceSource.NATIVE_SOCKET,
EvidenceSource.NATIVE_HOOK_MARKERS,
EvidenceSource.NATIVE_JVM_MISMATCH,
EvidenceSource.NATIVE_LIBRARY_INTEGRITY,
EvidenceSource.NATIVE_ROOT_DETECTION,
EvidenceSource.NATIVE_EMULATOR,
EvidenceSource.SANDBOX_ISOLATION,
-> SimpleResultCause.DEVICE_DATA_UNAVAILABLE
null -> SimpleResultCause.NETWORK_DATA_UNAVAILABLE
else -> SimpleResultCause.NETWORK_DATA_UNAVAILABLE
}
private const val MAX_CAUSES = 3
}

View file

@ -0,0 +1,168 @@
package com.notcvnt.rknhardering.ui.main.render
import android.graphics.Typeface
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.ui.main.SimpleCardModel
import com.notcvnt.rknhardering.ui.main.SimpleResultCause
import com.notcvnt.rknhardering.ui.main.SimpleResultStatus
import com.notcvnt.rknhardering.ui.main.SimpleSignalArea
import java.util.IdentityHashMap
internal class SimpleResultRenderer(
private val env: MainRenderEnvironment,
) {
private data class RenderedCard(
val root: View,
val originalVisibility: IdentityHashMap<View, Int>,
)
private val renderedCards = mutableMapOf<ViewGroup, RenderedCard>()
fun render(body: ViewGroup, whatWasChecked: String, model: SimpleCardModel) {
clear(body)
val originalVisibility = IdentityHashMap<View, Int>()
repeat(body.childCount) { index ->
body.getChildAt(index).let { child ->
originalVisibility[child] = child.visibility
child.visibility = View.GONE
}
}
val root = LinearLayout(env.context).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, 10.dp, 0, 0)
addView(label(R.string.simple_result_checked_label))
addView(value(whatWasChecked))
addView(label(R.string.simple_result_outcome_label, topMargin = 10.dp))
addView(value(statusText(model.status), Typeface.BOLD))
addView(label(R.string.simple_result_explanation_label, topMargin = 10.dp))
addView(value(explanationText(model)))
if (model.extraInformation) {
addView(value(env.context.getString(R.string.simple_result_extra_information)).apply {
setPadding(0, 10.dp, 0, 0)
})
}
}
body.addView(root)
renderedCards[body] = RenderedCard(root, originalVisibility)
}
fun clear() {
renderedCards.keys.toList().forEach(::clear)
}
private fun clear(body: ViewGroup) {
val rendered = renderedCards.remove(body) ?: return
body.removeView(rendered.root)
rendered.originalVisibility.forEach { (view, visibility) ->
if (view.parent === body) view.visibility = visibility
}
}
private fun label(textRes: Int, topMargin: Int = 0): TextView = TextView(env.context).apply {
setText(textRes)
setTextColor(env.onSurfaceVariantColor())
textSize = 12f
typeface = Typeface.DEFAULT_BOLD
if (topMargin > 0) setPadding(0, topMargin, 0, 0)
}
private fun value(value: String, style: Int = Typeface.NORMAL): TextView = TextView(env.context).apply {
text = value
setTextColor(env.onSurfaceColor())
textSize = 14f
typeface = Typeface.create(Typeface.DEFAULT, style)
setLineSpacing(0f, 1.08f)
}
private fun statusText(status: SimpleResultStatus): String = env.context.getString(
when (status) {
SimpleResultStatus.RUNNING -> R.string.simple_result_running
SimpleResultStatus.CLEAN -> R.string.simple_result_clean
SimpleResultStatus.REVIEW -> R.string.simple_result_review
SimpleResultStatus.DETECTED -> R.string.simple_result_detected
SimpleResultStatus.ERROR -> R.string.simple_result_error
SimpleResultStatus.DISABLED -> R.string.simple_result_disabled
},
)
private fun explanationText(model: SimpleCardModel): String {
if (model.status == SimpleResultStatus.RUNNING) {
return env.context.getString(R.string.simple_result_explanation_running)
}
if (model.status == SimpleResultStatus.CLEAN) {
return env.context.getString(R.string.simple_result_explanation_clean)
}
if (model.causes.isNotEmpty()) {
val explanations = model.causes.map { env.context.getString(causeText(it)) }
return if (explanations.size == 1) {
explanations.single()
} else {
explanations.joinToString(separator = "\n") { "\u2022 $it" }
}
}
if (model.status == SimpleResultStatus.ERROR) {
return env.context.getString(R.string.simple_result_explanation_error)
}
if (model.status == SimpleResultStatus.DISABLED) {
return env.context.getString(R.string.simple_result_disabled)
}
return env.context.getString(
when (model.area) {
SimpleSignalArea.PUBLIC_ADDRESS -> R.string.simple_result_area_public_address
SimpleSignalArea.DEVICE_NETWORK -> R.string.simple_result_area_device_network
SimpleSignalArea.LOCAL_APP -> R.string.simple_result_area_local_app
SimpleSignalArea.LOCAL_PROXY -> R.string.simple_result_area_local_proxy
SimpleSignalArea.NETWORK_ROUTE -> R.string.simple_result_area_network_route
SimpleSignalArea.CALL_ROUTE -> R.string.simple_result_area_call_route
SimpleSignalArea.LOCATION -> R.string.simple_result_area_location
SimpleSignalArea.REMOTE_SITE -> R.string.simple_result_area_remote_site
SimpleSignalArea.DEVICE_ENVIRONMENT -> R.string.simple_result_area_device_environment
SimpleSignalArea.MULTIPLE -> R.string.simple_result_area_multiple
SimpleSignalArea.NONE -> R.string.simple_result_explanation_review
},
)
}
private fun causeText(cause: SimpleResultCause): Int = when (cause) {
SimpleResultCause.IP_RU_SERVICES_DISAGREE -> R.string.simple_cause_ip_ru_services_disagree
SimpleResultCause.IP_NON_RU_SERVICES_DISAGREE -> R.string.simple_cause_ip_non_ru_services_disagree
SimpleResultCause.IP_GROUPS_DISAGREE -> R.string.simple_cause_ip_groups_disagree
SimpleResultCause.IP_FAMILIES_DIFFER -> R.string.simple_cause_ip_families_differ
SimpleResultCause.IP_PARTIAL_RESPONSE -> R.string.simple_cause_ip_partial_response
SimpleResultCause.IP_UNAVAILABLE -> R.string.simple_cause_ip_unavailable
SimpleResultCause.CDN_RESPONSES_DIFFER -> R.string.simple_cause_cdn_responses_differ
SimpleResultCause.CDN_PARTIAL_RESPONSE -> R.string.simple_cause_cdn_partial_response
SimpleResultCause.PUBLIC_IP_LOCATION -> R.string.simple_cause_public_ip_location
SimpleResultCause.VPN_NETWORK_STATE -> R.string.simple_cause_vpn_network_state
SimpleResultCause.ACTIVE_VPN_APP -> R.string.simple_cause_active_vpn_app
SimpleResultCause.SYSTEM_PROXY -> R.string.simple_cause_system_proxy
SimpleResultCause.LOCAL_PROXY -> R.string.simple_cause_local_proxy
SimpleResultCause.VPN_INTERFACE -> R.string.simple_cause_vpn_interface
SimpleResultCause.VPN_ROUTE -> R.string.simple_cause_vpn_route
SimpleResultCause.DNS_REDIRECTION -> R.string.simple_cause_dns_redirection
SimpleResultCause.TRAFFIC_BYPASS -> R.string.simple_cause_traffic_bypass
SimpleResultCause.PROXY_AUTH_REQUIRED -> R.string.simple_cause_proxy_auth_required
SimpleResultCause.LOCATION_CONFLICT -> R.string.simple_cause_location_conflict
SimpleResultCause.ICMP_RESPONSE_MISMATCH -> R.string.simple_cause_icmp_response_mismatch
SimpleResultCause.RTT_ROUTE_PATTERN -> R.string.simple_cause_rtt_route_pattern
SimpleResultCause.TELEGRAM_CALL_PATH -> R.string.simple_cause_telegram_call_path
SimpleResultCause.WHATSAPP_CALL_PATH -> R.string.simple_cause_whatsapp_call_path
SimpleResultCause.CALL_CHECK_UNAVAILABLE -> R.string.simple_cause_call_check_unavailable
SimpleResultCause.DOMAIN_DNS_MISMATCH -> R.string.simple_cause_domain_dns_mismatch
SimpleResultCause.DOMAIN_TCP_MISMATCH -> R.string.simple_cause_domain_tcp_mismatch
SimpleResultCause.DOMAIN_TLS_MISMATCH -> R.string.simple_cause_domain_tls_mismatch
SimpleResultCause.PUBLIC_DATA_UNAVAILABLE -> R.string.simple_cause_public_data_unavailable
SimpleResultCause.NETWORK_DATA_UNAVAILABLE -> R.string.simple_cause_network_data_unavailable
SimpleResultCause.REMOTE_DATA_UNAVAILABLE -> R.string.simple_cause_remote_data_unavailable
SimpleResultCause.DEVICE_DATA_UNAVAILABLE -> R.string.simple_cause_device_data_unavailable
SimpleResultCause.DEVICE_ENVIRONMENT -> R.string.simple_cause_device_environment
}
private val Int.dp: Int
get() = (this * env.context.resources.displayMetrics.density).toInt()
}

View file

@ -0,0 +1,157 @@
package com.notcvnt.rknhardering.ui.main.render
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Typeface
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import com.google.android.material.button.MaterialButton
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.diagnostics.DiagnosticEntry
import java.util.Locale
internal class TechnicalDataRenderer(
private val env: MainRenderEnvironment,
) {
private val roots = mutableListOf<View>()
fun render(body: ViewGroup, entries: List<DiagnosticEntry>, runTruncated: Boolean = false) {
val content = LinearLayout(env.context).apply {
orientation = LinearLayout.VERTICAL
visibility = View.GONE
}
val toggle = MaterialButton(
env.context,
null,
com.google.android.material.R.attr.materialButtonOutlinedStyle,
).apply {
setText(R.string.technical_data_title)
gravity = Gravity.START or Gravity.CENTER_VERTICAL
contentDescription = env.context.getString(R.string.technical_data_expand_content_description)
setOnClickListener {
val expanding = content.visibility != View.VISIBLE
if (expanding && content.childCount == 0) {
buildContent(content, entries, runTruncated)
}
content.visibility = if (expanding) View.VISIBLE else View.GONE
this.contentDescription = env.context.getString(
if (expanding) {
R.string.technical_data_collapse_content_description
} else {
R.string.technical_data_expand_content_description
},
)
}
}
val root = LinearLayout(env.context).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, 12.dp, 0, 0)
addView(toggle, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
addView(content, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
}
body.addView(root)
roots += root
}
fun clear() {
roots.forEach { root -> (root.parent as? ViewGroup)?.removeView(root) }
roots.clear()
}
private fun buildContent(
container: LinearLayout,
entries: List<DiagnosticEntry>,
runTruncated: Boolean,
) {
if (runTruncated) {
container.addView(text(env.context.getString(R.string.technical_data_run_truncated), bold = true))
}
if (entries.isEmpty()) {
container.addView(text(env.context.getString(R.string.technical_data_empty)))
return
}
entries.forEach { entry -> container.addView(entryView(entry)) }
}
private fun entryView(entry: DiagnosticEntry): View {
val metadata = buildMetadata(entry)
val copiedBlock = buildString {
appendLine(metadata)
append(entry.body)
}
return LinearLayout(env.context).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, 10.dp, 0, 8.dp)
addView(text(entry.source, bold = true))
addView(text(metadata, secondary = true))
addView(TextView(env.context).apply {
text = entry.body.ifBlank { env.context.getString(R.string.technical_data_empty_body) }
typeface = Typeface.MONOSPACE
textSize = 12f
setTextColor(env.onSurfaceColor())
setTextIsSelectable(true)
layoutDirection = View.LAYOUT_DIRECTION_LTR
textDirection = View.TEXT_DIRECTION_LTR
gravity = Gravity.START
setHorizontallyScrolling(false)
setPadding(0, 8.dp, 0, 4.dp)
})
addView(MaterialButton(env.context).apply {
setText(R.string.technical_data_copy_block)
contentDescription = env.context.getString(
R.string.technical_data_copy_block_content_description,
entry.source,
)
setOnClickListener { copyBlock(copiedBlock) }
})
}
}
private fun buildMetadata(entry: DiagnosticEntry): String = buildString {
append(env.context.getString(R.string.technical_data_source, entry.source))
entry.target?.takeIf { it.isNotBlank() }?.let {
appendLine()
append(env.context.getString(R.string.technical_data_target, it))
}
appendLine()
append(env.context.getString(R.string.technical_data_status, entry.status))
entry.durationMs?.let {
appendLine()
append(env.context.getString(R.string.technical_data_duration, it))
}
appendLine()
append(env.context.getString(R.string.technical_data_size, formatBytes(entry.storedBytes)))
if (entry.truncated) {
appendLine()
append(env.context.getString(R.string.technical_data_truncated, formatBytes(entry.originalBytes)))
}
}
private fun copyBlock(value: String) {
val clipboard = env.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(env.context.getString(R.string.technical_data_title), value))
Toast.makeText(env.context, R.string.technical_data_copied, Toast.LENGTH_SHORT).show()
}
private fun formatBytes(bytes: Int): String = when {
bytes < 1024 -> env.context.getString(R.string.technical_data_bytes, bytes)
else -> String.format(Locale.ROOT, "%.1f KiB", bytes / 1024.0)
}
private fun text(value: String, bold: Boolean = false, secondary: Boolean = false): TextView =
TextView(env.context).apply {
text = value
textSize = if (bold) 14f else 12f
typeface = if (bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
setTextColor(if (secondary) env.onSurfaceVariantColor() else env.onSurfaceColor())
setLineSpacing(0f, 1.06f)
}
private val Int.dp: Int
get() = (this * env.context.resources.displayMetrics.density).toInt()
}

View file

@ -7,19 +7,21 @@ import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
private const val IP_MASK_PLACEHOLDER = "*.*.*.*"
fun maskIp(ip: String): String {
val normalized = ip.trim()
val parsed = when {
normalized.contains('.') -> {
val parts = normalized.split('.')
if (parts.size != 4 || parts.any { (it.toIntOrNull() ?: -1) !in 0..255 }) {
return "*.*.*.*"
return IP_MASK_PLACEHOLDER
}
runCatching { InetAddress.getByName(normalized) }.getOrNull()
}
normalized.contains(':') -> runCatching { InetAddress.getByName(normalized) }.getOrNull()
else -> null
} ?: return "*.*.*.*"
} ?: return IP_MASK_PLACEHOLDER
return when (parsed) {
is Inet4Address -> {
if (parsed.isSiteLocalAddress || parsed.isLoopbackAddress || parsed.isLinkLocalAddress) {
@ -41,7 +43,7 @@ fun maskIp(ip: String): String {
}
}
}
else -> "*.*.*.*"
else -> IP_MASK_PLACEHOLDER
}
}

View file

@ -46,7 +46,7 @@ object VpnAppClassifier {
)
private val coreTypeMarkers = listOf(
CORE_TYPE_SING_BOX to listOf("sing-box", "singbox"),
CORE_TYPE_SING_BOX to listOf(CORE_TYPE_SING_BOX, "singbox"),
CORE_TYPE_XRAY_V2RAY to listOf("xray", "v2ray", "v2fly"),
CORE_TYPE_CLASH_MIHOMO to listOf("mihomo", "clash"),
CORE_TYPE_WIREGUARD to listOf("wireguard"),

View file

@ -11,6 +11,71 @@
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorSurfaceContainer"
app:strokeColor="?attr/colorOutlineVariant"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_result_display_mode"
android:textColor="?android:attr/textColorPrimary"
android:textSize="15sp"
android:textStyle="bold" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroupResultDisplayMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:selectionRequired="true"
app:singleSelection="true">
<com.google.android.material.chip.Chip
android:id="@+id/chipResultDisplaySimple"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/settings_result_display_mode_simple_content_description"
android:text="@string/settings_result_display_mode_simple" />
<com.google.android.material.chip.Chip
android:id="@+id/chipResultDisplayNormal"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/settings_result_display_mode_normal_content_description"
android:text="@string/settings_result_display_mode_normal" />
<com.google.android.material.chip.Chip
android:id="@+id/chipResultDisplayAdvanced"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/settings_result_display_mode_advanced_content_description"
android:text="@string/settings_result_display_mode_advanced" />
</com.google.android.material.chip.ChipGroup>
<TextView
android:id="@+id/textResultDisplayModeDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="?android:attr/textColorSecondary"
android:textSize="13sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View file

@ -95,6 +95,16 @@
<string name="settings_theme_light">روشن</string>
<string name="settings_theme_dark">تیره</string>
<string name="settings_theme_system">سیستمی</string>
<string name="settings_result_display_mode">جزئیات نتایج</string>
<string name="settings_result_display_mode_simple">ساده</string>
<string name="settings_result_display_mode_normal">معمولی</string>
<string name="settings_result_display_mode_advanced">پیشرفته</string>
<string name="settings_result_display_mode_simple_desc">خلاصه‌های ساده و قابل فهم، بدون جزئیات فنی. برای بررسی بعدی اعمال می‌شود.</string>
<string name="settings_result_display_mode_normal_desc">نمای استاندارد نتایج و جزئیات. برای بررسی بعدی اعمال می‌شود.</string>
<string name="settings_result_display_mode_advanced_desc">نتایج استاندارد همراه با داده‌های فنی بازشدنی. برای بررسی بعدی اعمال می‌شود.</string>
<string name="settings_result_display_mode_simple_content_description">حالت نمایش ساده نتایج</string>
<string name="settings_result_display_mode_normal_content_description">حالت نمایش معمولی نتایج</string>
<string name="settings_result_display_mode_advanced_content_description">حالت نمایش پیشرفته نتایج</string>
<string name="settings_language">زبان</string>
<string name="settings_language_system">سیستمی</string>
<string name="settings_language_en">English</string>
@ -591,7 +601,7 @@
<string name="settings_value_network_all">همه درخواست‌ها</string>
<string name="settings_value_network_disabled">غیرفعال</string>
<string name="settings_value_privacy_masking">پنهان‌سازی IP</string>
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s</string>
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s \u00b7 %3$s</string>
<!-- NativeSignsChecker -->
<string name="checker_native_category_name">نشانه‌های بومی β</string>
@ -639,6 +649,64 @@
<string name="checker_native_isolation_clone">برنامه در یک محفظه شبیه‌سازی/dual-app اجرا می‌شود (کاربر %1$d)؛ VPN در پروفایل اصلی قابل تشخیص نیست</string>
<string name="checker_native_isolation_work_profile">برنامه در یک پروفایل کاری مدیریت‌شده اجرا می‌شود؛ VPN در پروفایل شخصی بدون روت قابل تشخیص نیست</string>
<string name="checker_native_vpn_signal">سیگنال VPN: %1$s</string>
<string name="vpn_desc_prop">ویژگی سیستمی VPN شناسایی شد</string>
<string name="vpn_desc_vpnhide">فایل مخفی‌سازی VPN یافت شد</string>
<string name="vpn_desc_hook">ویژگی فریمورک hook شناسایی شد</string>
<string name="vpn_desc_dns">ویژگی DNS به VPN اشاره می‌کند</string>
<string name="vpn_desc_file">فایل ابزار VPN یافت شد</string>
<string name="vpn_desc_lsposed">محیط LSPosed/Xposed شناسایی شد</string>
<string name="vpn_desc_tcp_port">اتصال TCP روی پورت VPN</string>
<string name="vpn_desc_udp_port">سوکت UDP روی پورت VPN</string>
<string name="vpn_desc_inet6">رابط VPN IPv6 در /proc</string>
<string name="vpn_desc_route">مسیر از طریق رابط VPN</string>
<string name="vpn_desc_arp">رکورد ARP روی رابط VPN</string>
<string name="vpn_desc_forwarding">Forwarding IP فعال است</string>
<string name="vpn_desc_rpfilter">فیلتر reverse path غیرفعال است</string>
<string name="vpn_desc_established">اتصال برقرار به IP خصوصی روی پورت VPN</string>
<string name="vpn_desc_policy_rules">قوانین مسیریابی VPN یافت شد</string>
<string name="vpn_desc_qdisc"> queue discipline VPN شناسایی شد</string>
<string name="vpn_desc_hidden_mac">آدرس‌های MAC پنهان در جدول همسایه</string>
<string name="vpn_desc_mss">MSS به طور مشکوکی پایین (احتمالاً تونل)</string>
<string name="vpn_desc_bindtodevice">setsockopt رهگیری شد (hook VPN)</string>
<string name="vpn_desc_port_conflict">تداخل پورت روی loopback (listener VPN)</string>
<string name="vpn_desc_bpf">دسترسی به نقشه‌های BPF netd</string>
<string name="vpn_desc_recverr">IP_RECVERR رهگیری شد (hook VPN)</string>
<string name="vpn_desc_sysfs_leak">رابط VPN از طریق sysfs نشت کرد</string>
<string name="vpn_desc_getifaddrs">رابط VPN در getifaddrs()</string>
<string name="vpn_desc_sysclassnet">رابط VPN در /sys/class/net</string>
<string name="vpn_desc_rtm_getlink">رابط VPN در RTM_GETLINK</string>
<string name="vpn_desc_proc_if_inet6">رابط VPN IPv6 در /proc/net/if_inet6</string>
<string name="vpn_desc_proc_ipv6_route">مسیر VPN IPv6 در /proc/net/ipv6_route</string>
<string name="vpn_desc_proc_net_dev">ترافیک VPN در /proc/net/dev</string>
<string name="vpn_desc_fib_trie">دسترسی به /proc/net/fib_trie مسدود شد (SELinux)</string>
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE رابط VPN را نشت داد</string>
<string name="vpn_desc_inet_diag">inet_diag netlink مسدود شد (SELinux)</string>
<string name="vpn_desc_getsockname">getsockname() IP VPN را نشت داد</string>
<string name="vpn_desc_policy_rules_netlink">قوانین مسیریابی VPN از طریق netlink</string>
<string name="vpn_desc_route_count">شمارش مسیرها و رابط‌های ناشناس</string>
<string name="vpn_desc_udp_port_physical">تداخل پورت UDP روی رابط فیزیکی</string>
<string name="vpn_desc_trim_oracle">عدم تطابق bind probe و RTM_GETLINK</string>
<string name="vpn_desc_ifindexname">رابط VPN از طریق if_indextoname</string>
<string name="vpn_desc_pmtu_mss">تحلیل UDP PMTU و TCP MSS</string>
<string name="vpn_desc_udp_pmtu_ok">ارسال UDP موفق (بدون مشکل PMTU)</string>
<string name="vpn_desc_udp_pmtu_fail">ارسال UDP ناموفق (مشکل PMTU)</string>
<string name="vpn_desc_normal_pmtu">تشخیص path MTU عادی</string>
<string name="vpn_desc_traceroute">تست traceroute (ICMP/TTL)</string>
<string name="vpn_desc_timing_oracle">ARM CNTVCT timing oracle</string>
<string name="vpn_desc_backpressure">Backpressure تحت بار مداوم</string>
<string name="vpn_desc_gso_failed">پشتیبانی GSO در دسترس نیست (فقط تشخیصی)</string>
<string name="vpn_desc_gso_send_failed">ارسال GSO ناموفق بود (فقط تشخیصی)</string>
<string name="vpn_desc_gso_ok">GSO پشتیبانی می‌شود (رابط عادی)</string>
<string name="vpn_desc_hw_timestamp">بررسی تایم‌استمپینگ سخت‌افزاری</string>
<string name="vpn_status_not_found">یافت نشد</string>
<string name="vpn_status_unavailable">غیرقابل دسترس (بدون مجوز)</string>
<string name="checker_vpn_detector_category_name">تشخیص‌دهنده VPN (عمیق)</string>
<string name="checker_vpn_detector_unavailable">کتابخانه تشخیص‌دهنده VPN بارگذاری نشد</string>
<string name="checker_vpn_detector_unavailable_with_reason">کتابخانه تشخیص‌دهنده VPN بارگذاری نشد: %1$s</string>
<string name="checker_vpn_detector_no_anomalies">نشانه عمیق VPN یافت نشد</string>
<!-- App update checker -->
<string name="update_dialog_title">به‌روزرسانی موجود است</string>
<string name="update_dialog_message">نسخه نصب‌شده: %1$s\nآخرین نسخه: %2$s</string>
@ -932,4 +1000,111 @@
<string name="export_duration_unit_ms">میلی‌ثانیه</string>
<string name="export_available">در دسترس</string>
<string name="export_unavailable">در دسترس نیست</string>
<string name="simple_result_checked_label">چه چیزی بررسی شد</string>
<string name="simple_result_outcome_label">نتیجه</string>
<string name="simple_result_explanation_label">معنای این نتیجه</string>
<string name="simple_result_running">در حال بررسی</string>
<string name="simple_result_clean">مشکلی پیدا نشد</string>
<string name="simple_result_review">کمی تردید وجود دارد</string>
<string name="simple_result_detected">یک مشکل پیدا شد</string>
<string name="simple_result_error">بررسی ممکن نشد</string>
<string name="simple_result_disabled">این بررسی غیرفعال است</string>
<string name="simple_result_explanation_running">نتیجه پس از پایان این بخش نمایش داده می‌شود.</string>
<string name="simple_result_explanation_clean">نشانه‌های بررسی‌شده عادی به نظر می‌رسند.</string>
<string name="simple_result_explanation_error">این بخش اطلاعات کافی برنگرداند. بررسی‌های دیگر همچنان می‌توانند کامل شوند.</string>
<string name="simple_result_explanation_review">یک نشانه غیرعادی پیدا شد، اما به‌تنهایی برای تأیید مشکل کافی نیست.</string>
<string name="simple_result_area_public_address">نشانه غیرعادی در اطلاعات نشانی عمومی شبکه دیده شد.</string>
<string name="simple_result_area_device_network">نشانه غیرعادی در وضعیت شبکه دستگاه دیده شد.</string>
<string name="simple_result_area_local_app">نشانه غیرعادی به یک برنامه شبکه نصب‌شده یا فعال مربوط بود.</string>
<string name="simple_result_area_local_proxy">نشانه غیرعادی در سرویس محلی تغییر مسیر ترافیک دیده شد.</string>
<string name="simple_result_area_network_route">نشانه غیرعادی در مسیر ترافیک شبکه دیده شد.</string>
<string name="simple_result_area_call_route">نشانه غیرعادی در مسیر شبکه تماس‌ها دیده شد.</string>
<string name="simple_result_area_location">نشانه غیرعادی هنگام مقایسه اطلاعات شبکه و مکان دیده شد.</string>
<string name="simple_result_area_remote_site">نشانه غیرعادی هنگام ارتباط با یک سرویس دوردست دیده شد.</string>
<string name="simple_result_area_device_environment">نشانه غیرعادی در محیط سیستمی دستگاه دیده شد.</string>
<string name="simple_result_area_multiple">نشانه‌های مرتبط در چند بخش بررسی شبکه دیده شدند.</string>
<string name="simple_result_extra_information">اطلاعات تکمیلی: این کارت به‌تنهایی رأی نهایی را تغییر نمی‌دهد.</string>
<string name="simple_title_geo">مکان ظاهری اتصال</string>
<string name="simple_title_ip_comparison">یکسان بودن نشانی نزد سرویس‌ها</string>
<string name="simple_title_cdn">پاسخ سایت‌های دوردست</string>
<string name="simple_title_direct">تنظیمات آشکار شبکه</string>
<string name="simple_title_indirect">نشانه‌های دیگر شبکه</string>
<string name="simple_title_native">نشانه‌های شبکه در سطح دستگاه</string>
<string name="simple_title_call_transport">مسیر ترافیک تماس</string>
<string name="simple_title_icmp">سازگاری پاسخ‌های شبکه</string>
<string name="simple_title_rtt">مقایسه تأخیر اتصال</string>
<string name="simple_title_location">هماهنگی شبکه و مکان</string>
<string name="simple_title_bypass">خروج ترافیک از مسیری دیگر</string>
<string name="simple_title_reachability">دسترسی به سایت‌های انتخاب‌شده</string>
<string name="simple_checked_geo">کشور و شبکه‌ای که از نشانی عمومی اتصال دیده می‌شود.</string>
<string name="simple_checked_ip_comparison">این‌که سرویس‌های مختلف نشانی عمومی سازگاری گزارش می‌کنند.</string>
<string name="simple_checked_cdn">این‌که سرویس‌های دوردست انتخاب‌شده اطلاعات مورد انتظار را برمی‌گردانند.</string>
<string name="simple_checked_direct">تنظیمات شبکه‌ای که تغییر مسیر ترافیک را مستقیماً گزارش می‌کنند.</string>
<string name="simple_checked_indirect">نشانه‌های پشتیبان در برنامه‌ها، رابط‌ها و پیکربندی شبکه.</string>
<string name="simple_checked_native">نشانه‌های سطح دستگاه که پنهان‌کردن آن‌ها برای برنامه‌ها دشوارتر است.</string>
<string name="simple_checked_call_transport">این‌که ترافیک تماس از مسیر شبکه غیرمنتظره‌ای عبور می‌کند.</string>
<string name="simple_checked_icmp">این‌که پاسخ‌های شبکه با سرویس مقصد سازگار هستند.</string>
<string name="simple_checked_rtt">این‌که تأخیرهای اتصال به‌شکل غیرعادی متفاوت هستند.</string>
<string name="simple_checked_location">این‌که اطلاعات شبکه محلی و مکان با هم سازگار هستند.</string>
<string name="simple_checked_bypass">این‌که ترافیک می‌تواند خارج از مسیر محافظت‌شده مورد انتظار خارج شود.</string>
<string name="simple_checked_reachability">این‌که سایت‌های انتخاب‌شده در این شبکه مطابق انتظار رفتار می‌کنند.</string>
<string name="simple_verdict_hard_bypass">یک نشانه مستقیم نشان داد که ترافیک می‌تواند از مسیر غیرمنتظره‌ای عبور کند.</string>
<string name="simple_verdict_address_conflict">چند مشاهده نشانی به‌گونه‌ای ناسازگارند که به تغییر مسیر ترافیک اشاره می‌کند.</string>
<string name="simple_verdict_location_conflict">مکان اتصال با مکان شبکه محلی سازگار نیست.</string>
<string name="simple_verdict_hosting_review">نشانی شبیه اتصال میزبانی‌شده یا واسطه‌ای است، اما نشانه‌های دیگر قطعی نیستند.</string>
<string name="simple_verdict_combined_signals">رأی از ترکیب مکان اتصال و نشانه‌های شبکه دستگاه به‌دست آمده است.</string>
<string name="simple_verdict_review_signals">یک یا چند نشانه پشتیبان نیاز به توجه دارند، اما به‌تنهایی مشکل را ثابت نمی‌کنند.</string>
<string name="simple_verdict_general">رأی بر پایه نتایج ساخت‌یافته بررسی‌های کامل‌شده است.</string>
<string name="technical_data_title">داده‌های فنی</string>
<string name="technical_data_expand_content_description">باز کردن داده‌های فنی</string>
<string name="technical_data_collapse_content_description">بستن داده‌های فنی</string>
<string name="technical_data_empty">برای این کارت داده فنی ثبت نشده است.</string>
<string name="technical_data_empty_body">بدنه‌ای ثبت نشده است.</string>
<string name="technical_data_source">منبع: %1$s</string>
<string name="technical_data_target">هدف: %1$s</string>
<string name="technical_data_status">وضعیت: %1$s</string>
<string name="technical_data_duration">مدت: %1$d میلی‌ثانیه</string>
<string name="technical_data_size">اندازه ذخیره‌شده: %1$s</string>
<string name="technical_data_truncated">کوتاه شده (اندازه اصلی: %1$s)</string>
<string name="technical_data_run_truncated">کوتاه شده: سقف کلی ۵۱۲ KiB برای این بررسی پر شد.</string>
<string name="technical_data_bytes">%1$d بایت</string>
<string name="technical_data_copy_block">کپی بلوک</string>
<string name="technical_data_copy_block_content_description">کپی داده‌های فنی از %1$s</string>
<string name="technical_data_copied">داده‌های فنی کپی شد</string>
<string name="simple_cause_ip_ru_services_disagree">سرویس‌های روسی تشخیص IP نشانی‌های عمومی متفاوتی گزارش کردند.</string>
<string name="simple_cause_ip_non_ru_services_disagree">سرویس‌های بین‌المللی تشخیص IP نشانی‌های عمومی متفاوتی گزارش کردند.</string>
<string name="simple_cause_ip_groups_disagree">سرویس‌های روسی و بین‌المللی نشانی‌های عمومی متفاوتی گزارش کردند.</string>
<string name="simple_cause_ip_families_differ">سرویس‌ها خانواده‌های متفاوت نشانی را برگرداندند، بنابراین پاسخ‌ها مستقیماً قابل مقایسه نیستند.</string>
<string name="simple_cause_ip_partial_response">بعضی سرویس‌های تشخیص نشانی عمومی پاسخ ندادند و مقایسه ناقص است.</string>
<string name="simple_cause_ip_unavailable">هیچ سرویس تشخیص نشانی عمومی پاسخ قابل استفاده‌ای نداد.</string>
<string name="simple_cause_cdn_responses_differ">سایت‌های آزمایش‌شده از طریق نشانی‌های عمومی متفاوتی باز شدند.</string>
<string name="simple_cause_cdn_partial_response">بعضی سایت‌های آزمایش‌شده برای مقایسه کامل داده کافی ندادند.</string>
<string name="simple_cause_public_ip_location">اطلاعات کشور یا مالک شبکه نشانی عمومی به VPN، پراکسی، میزبانی یا کشوری غیرمنتظره اشاره دارد.</string>
<string name="simple_cause_vpn_network_state">اندروید گزارش می‌دهد که شبکه فعال از VPN عبور می‌کند.</string>
<string name="simple_cause_active_vpn_app">یک برنامه اکنون اتصال VPN را برقرار نگه داشته است.</string>
<string name="simple_cause_system_proxy">تنظیم سراسری هدایت ترافیک در اندروید فعال است.</string>
<string name="simple_cause_local_proxy">یک سرویس محلی که می‌تواند ترافیک شبکه را هدایت کند روی دستگاه پاسخ می‌دهد.</string>
<string name="simple_cause_vpn_interface">یک رابط شبکه ویژه ترافیک تونلی فعال است.</string>
<string name="simple_cause_vpn_route">جدول مسیریابی دستگاه ترافیک را از تونل یا دروازه‌ای غیرمنتظره عبور می‌دهد.</string>
<string name="simple_cause_dns_redirection">درخواست‌های نام دامنه از مسیر شبکه متفاوت یا هدایت‌شده‌ای پردازش می‌شوند.</string>
<string name="simple_cause_traffic_bypass">ترافیک خارج از VPN از مسیر شبکه یا نشانی عمومی متفاوتی استفاده کرد.</string>
<string name="simple_cause_proxy_auth_required">پراکسی محلی احراز هویت خواست و هدف آن قابل تأیید نبود.</string>
<string name="simple_cause_location_conflict">داده‌های سیم‌کارت، شبکه همراه و مکان نشانی عمومی مسیر یکسانی را نشان نمی‌دهند.</string>
<string name="simple_cause_icmp_response_mismatch">پاسخ بسته آزمایشی با بسته ارسال‌شده مطابقت نداشت.</string>
<string name="simple_cause_rtt_route_pattern">زمان پاسخ نشان می‌دهد برخی مقصدها شاید از مسیر شبکه متفاوتی استفاده کنند.</string>
<string name="simple_cause_telegram_call_path">بررسی تماس تلگرام مسیر شبکه غیرعادی یا ناقصی پیدا کرد.</string>
<string name="simple_cause_whatsapp_call_path">بررسی تماس واتس‌اپ مسیر شبکه غیرعادی یا ناقصی پیدا کرد.</string>
<string name="simple_cause_call_check_unavailable">سرویس‌های بررسی تماس داده کافی برای بررسی مسیر ندادند.</string>
<string name="simple_cause_domain_dns_mismatch">نام سایت برخلاف نتیجه مورد انتظار حل شد.</string>
<string name="simple_cause_domain_tcp_mismatch">اتصال به سایت برخلاف نتیجه مورد انتظار برقرار شد.</string>
<string name="simple_cause_domain_tls_mismatch">اتصال امن برخلاف نتیجه مورد انتظار کامل شد.</string>
<string name="simple_cause_public_data_unavailable">سرویس‌های تشخیص نشانی عمومی داده کافی برنگرداندند.</string>
<string name="simple_cause_network_data_unavailable">اندروید برای تکمیل این بررسی داده کافی از وضعیت شبکه نداد.</string>
<string name="simple_cause_remote_data_unavailable">سرویس‌های آزمایشی راه دور برای تکمیل این بررسی داده کافی ندادند.</string>
<string name="simple_cause_device_data_unavailable">اندروید برای تکمیل این بررسی داده سیستمی کافی از شبکه نداد.</string>
<string name="simple_cause_device_environment">در محیط دستگاه تغییر یا محدودیتی وجود دارد که می‌تواند بررسی‌های شبکه را تغییر دهد.</string>
</resources>

View file

@ -95,6 +95,16 @@
<string name="settings_theme_light">Светлая</string>
<string name="settings_theme_dark">Тёмная</string>
<string name="settings_theme_system">Системная</string>
<string name="settings_result_display_mode">Подробность результатов</string>
<string name="settings_result_display_mode_simple">Простой</string>
<string name="settings_result_display_mode_normal">Обычный</string>
<string name="settings_result_display_mode_advanced">Расширенный</string>
<string name="settings_result_display_mode_simple_desc">Понятные краткие итоги без технических подробностей. Применится к следующей проверке.</string>
<string name="settings_result_display_mode_normal_desc">Стандартный вид и состав результатов. Применится к следующей проверке.</string>
<string name="settings_result_display_mode_advanced_desc">Обычные результаты с раскрываемыми техническими данными. Применится к следующей проверке.</string>
<string name="settings_result_display_mode_simple_content_description">Простой режим отображения результатов</string>
<string name="settings_result_display_mode_normal_content_description">Обычный режим отображения результатов</string>
<string name="settings_result_display_mode_advanced_content_description">Расширенный режим отображения результатов</string>
<string name="settings_language">Язык</string>
<string name="settings_language_system">Системный</string>
<string name="settings_language_en">English</string>
@ -581,7 +591,7 @@
<string name="settings_value_network_all">Все запросы</string>
<string name="settings_value_network_disabled">Отключено</string>
<string name="settings_value_privacy_masking">Маскировка IP</string>
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s</string>
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s \u00b7 %3$s</string>
<!-- NativeSignsChecker -->
<string name="checker_native_category_name">Нативные признаки β</string>
@ -660,6 +670,65 @@
<string name="checker_native_isolation_clone">Приложение работает в клонированном/dual-app контейнере (пользователь %1$d); VPN в основном профиле не обнаруживается</string>
<string name="checker_native_isolation_work_profile">Приложение работает в управляемом рабочем профиле; VPN в личном профиле не обнаруживается без root</string>
<!-- NativeSignsChecker: VPN сигналы -->
<string name="checker_native_vpn_signal">VPN сигнал: %1$s</string>
<string name="vpn_desc_prop">Обнаружены VPN-свойства системы</string>
<string name="vpn_desc_vpnhide">Файл сокрытия VPN</string>
<string name="vpn_desc_hook">Свойства хук-фреймворков</string>
<string name="vpn_desc_dns">DNS-свойства указывают на VPN</string>
<string name="vpn_desc_file">Файлы VPN-инструментов</string>
<string name="vpn_desc_lsposed">Обнаружена LSPosed/Xposed среда</string>
<string name="vpn_desc_tcp_port">TCP-соединение на VPN-порту</string>
<string name="vpn_desc_udp_port">UDP-сокет на VPN-порту</string>
<string name="vpn_desc_inet6">IPv6-интерфейс VPN в /proc</string>
<string name="vpn_desc_route">Маршрут через VPN-интерфейс</string>
<string name="vpn_desc_arp">ARP-запись VPN-интерфейса</string>
<string name="vpn_desc_forwarding">IP-форвардинг включён</string>
<string name="vpn_desc_rpfilter">Reverse path filter отключен</string>
<string name="vpn_desc_established">Установленное соединение к приватному IP через VPN-порт</string>
<string name="vpn_desc_policy_rules">Найдены VPN-правила роутинга</string>
<string name="vpn_desc_qdisc">Обнаружена VPN queue discipline</string>
<string name="vpn_desc_hidden_mac">Скрытые MAC-адреса в таблице соседей</string>
<string name="vpn_desc_mss">Подозрительно низкий MSS (возможно туннель)</string>
<string name="vpn_desc_bindtodevice">Перехват setsockopt (VPN-хук)</string>
<string name="vpn_desc_port_conflict">Конфликт портов на loopback (VPN listener)</string>
<string name="vpn_desc_bpf">Доступ к BPF-картам netd</string>
<string name="vpn_desc_recverr">Перехват IP_RECVERR (VPN-хук)</string>
<string name="vpn_desc_sysfs_leak">VPN-интерфейс утёк через sysfs</string>
<string name="vpn_desc_getifaddrs">VPN-интерфейс в getifaddrs()</string>
<string name="vpn_desc_sysclassnet">VPN-интерфейс в /sys/class/net</string>
<string name="vpn_desc_rtm_getlink">VPN-интерфейс в RTM_GETLINK</string>
<string name="vpn_desc_proc_if_inet6">VPN IPv6-интерфейс в /proc/net/if_inet6</string>
<string name="vpn_desc_proc_ipv6_route">VPN IPv6-маршрут в /proc/net/ipv6_route</string>
<string name="vpn_desc_proc_net_dev">VPN-трафик в /proc/net/dev</string>
<string name="vpn_desc_fib_trie">Доступ к /proc/net/fib_trie запрещён (SELinux)</string>
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE утек интерфейс VPN</string>
<string name="vpn_desc_inet_diag">inet_diag netlink запрещён (SELinux)</string>
<string name="vpn_desc_getsockname">getsockname() утек IP VPN</string>
<string name="vpn_desc_policy_rules_netlink">VPN-правила роутинга через netlink</string>
<string name="vpn_desc_route_count">Подсчёт маршрутов и анонимных интерфейсов</string>
<string name="vpn_desc_udp_port_physical">Конфликт UDP-порта на физическом интерфейсе</string>
<string name="vpn_desc_trim_oracle">Расхождение bind probe и RTM_GETLINK</string>
<string name="vpn_desc_ifindexname">VPN-интерфейс через if_indextoname</string>
<string name="vpn_desc_pmtu_mss">Анализ UDP PMTU и TCP MSS</string>
<string name="vpn_desc_udp_pmtu_ok">UDP-отправка успешна (нет PMTU-проблемы)</string>
<string name="vpn_desc_udp_pmtu_fail">UDP-отправка не удалась (PMTU-проблема)</string>
<string name="vpn_desc_normal_pmtu">Обнаружение нормального path MTU</string>
<string name="vpn_desc_traceroute">Traceroute-проба (ICMP/TTL)</string>
<string name="vpn_desc_timing_oracle">ARM CNTVCT timing oracle</string>
<string name="vpn_desc_backpressure">Backpressure при постоянной нагрузке</string>
<string name="vpn_desc_gso_failed">Поддержка GSO недоступна (только диагностика)</string>
<string name="vpn_desc_gso_send_failed">GSO-отправка не удалась (только диагностика)</string>
<string name="vpn_desc_gso_ok">GSO поддерживается (нормальный интерфейс)</string>
<string name="vpn_desc_hw_timestamp">Проверка аппаратного таймстампинга</string>
<string name="vpn_status_not_found">не обнаружено</string>
<string name="vpn_status_unavailable">недоступно (нет прав)</string>
<string name="checker_vpn_detector_category_name">VPN Детектор (глубокий)</string>
<string name="checker_vpn_detector_unavailable">Библиотека VPN-детектора не загружена</string>
<string name="checker_vpn_detector_unavailable_with_reason">Библиотека VPN-детектора не загружена: %1$s</string>
<string name="checker_vpn_detector_no_anomalies">Глубоких признаков VPN не обнаружено</string>
<!-- App update checker -->
<string name="update_dialog_title">Доступно обновление</string>
<string name="update_dialog_message">Установленная версия: %1$s\nАктуальная версия: %2$s</string>
@ -953,4 +1022,111 @@
<string name="export_duration_unit_ms">мс</string>
<string name="export_available">доступен</string>
<string name="export_unavailable">недоступен</string>
<string name="simple_result_checked_label">Что проверялось</string>
<string name="simple_result_outcome_label">Результат</string>
<string name="simple_result_explanation_label">Что это значит</string>
<string name="simple_result_running">Идёт проверка</string>
<string name="simple_result_clean">Проблем не найдено</string>
<string name="simple_result_review">Есть сомнение</string>
<string name="simple_result_detected">Обнаружена проблема</string>
<string name="simple_result_error">Не удалось проверить</string>
<string name="simple_result_disabled">Эта проверка отключена</string>
<string name="simple_result_explanation_running">Результат появится после завершения этой части проверки.</string>
<string name="simple_result_explanation_clean">Проверенные признаки выглядят обычно.</string>
<string name="simple_result_explanation_error">Эта часть не дала достаточно данных. Остальные проверки могут продолжиться.</string>
<string name="simple_result_explanation_review">Найден необычный признак, но его недостаточно, чтобы подтвердить проблему.</string>
<string name="simple_result_area_public_address">Необычный признак найден в данных о публичном сетевом адресе.</string>
<string name="simple_result_area_device_network">Необычный признак найден в состоянии сети устройства.</string>
<string name="simple_result_area_local_app">Необычный признак связан с установленным или активным сетевым приложением.</string>
<string name="simple_result_area_local_proxy">Необычный признак найден в локальной службе перенаправления трафика.</string>
<string name="simple_result_area_network_route">Необычный признак найден на пути сетевого трафика.</string>
<string name="simple_result_area_call_route">Необычный признак найден на сетевом пути звонков.</string>
<string name="simple_result_area_location">Необычный признак найден при сравнении данных сети и местоположения.</string>
<string name="simple_result_area_remote_site">Необычный признак найден при обращении к удалённому сервису.</string>
<string name="simple_result_area_device_environment">Необычный признак найден в системном окружении устройства.</string>
<string name="simple_result_area_multiple">Связанные признаки найдены в нескольких частях проверки сети.</string>
<string name="simple_result_extra_information">Дополнительная информация: эта карточка сама по себе не меняет итоговый вердикт.</string>
<string name="simple_title_geo">Где находится подключение</string>
<string name="simple_title_ip_comparison">Одинаковый ли адрес видят сервисы</string>
<string name="simple_title_cdn">Как отвечают удалённые сайты</string>
<string name="simple_title_direct">Видимые настройки сети</string>
<string name="simple_title_indirect">Другие сетевые признаки</string>
<string name="simple_title_native">Системные сетевые признаки</string>
<string name="simple_title_call_transport">Путь трафика звонков</string>
<string name="simple_title_icmp">Согласованность сетевых ответов</string>
<string name="simple_title_rtt">Сравнение задержек подключения</string>
<string name="simple_title_location">Совпадение сети и местоположения</string>
<string name="simple_title_bypass">Выход трафика другим путём</string>
<string name="simple_title_reachability">Доступность выбранных сайтов</string>
<string name="simple_checked_geo">Страна и сеть, которые видны по публичному адресу подключения.</string>
<string name="simple_checked_ip_comparison">Показывают ли разные сервисы согласованный публичный адрес.</string>
<string name="simple_checked_cdn">Возвращают ли выбранные удалённые сервисы ожидаемые данные.</string>
<string name="simple_checked_direct">Настройки сети, прямо сообщающие о перенаправлении трафика.</string>
<string name="simple_checked_indirect">Дополнительные признаки в приложениях, интерфейсах и настройках сети.</string>
<string name="simple_checked_native">Системные признаки, которые приложениям сложнее скрыть.</string>
<string name="simple_checked_call_transport">Идёт ли трафик звонков по неожиданному сетевому пути.</string>
<string name="simple_checked_icmp">Согласуются ли сетевые ответы с сервисом, к которому обращается приложение.</string>
<string name="simple_checked_rtt">Отличаются ли задержки подключения необычным образом.</string>
<string name="simple_checked_location">Согласуются ли данные локальной сети и местоположения.</string>
<string name="simple_checked_bypass">Может ли трафик выйти вне ожидаемого защищённого пути.</string>
<string name="simple_checked_reachability">Ведут ли себя выбранные сайты ожидаемо в этой сети.</string>
<string name="simple_verdict_hard_bypass">Прямой признак показал, что трафик может идти неожиданным путём.</string>
<string name="simple_verdict_address_conflict">Несколько наблюдений адреса расходятся так, что это указывает на перенаправление трафика.</string>
<string name="simple_verdict_location_conflict">Местоположение подключения противоречит местоположению локальной сети.</string>
<string name="simple_verdict_hosting_review">Адрес похож на серверное или пересылаемое подключение, но остальные признаки не дают уверенного ответа.</string>
<string name="simple_verdict_combined_signals">Вердикт основан на сочетании местоположения подключения и сетевых признаков устройства.</string>
<string name="simple_verdict_review_signals">Один или несколько дополнительных признаков требуют внимания, но сами по себе не подтверждают проблему.</string>
<string name="simple_verdict_general">Вердикт основан на структурированных результатах завершённых проверок.</string>
<string name="technical_data_title">Технические данные</string>
<string name="technical_data_expand_content_description">Развернуть технические данные</string>
<string name="technical_data_collapse_content_description">Свернуть технические данные</string>
<string name="technical_data_empty">Для этой карточки технические записи не собраны.</string>
<string name="technical_data_empty_body">Тело ответа не сохранено.</string>
<string name="technical_data_source">Источник: %1$s</string>
<string name="technical_data_target">Цель: %1$s</string>
<string name="technical_data_status">Статус: %1$s</string>
<string name="technical_data_duration">Длительность: %1$d мс</string>
<string name="technical_data_size">Сохранённый размер: %1$s</string>
<string name="technical_data_truncated">Обрезано (исходный размер: %1$s)</string>
<string name="technical_data_run_truncated">Обрезано: достигнут общий лимит 512 КиБ на проверку.</string>
<string name="technical_data_bytes">%1$d Б</string>
<string name="technical_data_copy_block">Копировать блок</string>
<string name="technical_data_copy_block_content_description">Копировать технические данные из %1$s</string>
<string name="technical_data_copied">Технические данные скопированы</string>
<string name="simple_cause_ip_ru_services_disagree">Российские сервисы определения IP сообщили разные публичные адреса.</string>
<string name="simple_cause_ip_non_ru_services_disagree">Зарубежные сервисы определения IP сообщили разные публичные адреса.</string>
<string name="simple_cause_ip_groups_disagree">Российские и зарубежные сервисы сообщили разные публичные адреса.</string>
<string name="simple_cause_ip_families_differ">Сервисы вернули адреса разных семейств, поэтому ответы нельзя сравнить напрямую.</string>
<string name="simple_cause_ip_partial_response">Часть сервисов определения публичного адреса не ответила, поэтому сравнение неполное.</string>
<string name="simple_cause_ip_unavailable">Ни один сервис определения публичного адреса не вернул пригодный ответ.</string>
<string name="simple_cause_cdn_responses_differ">Проверенные сайты открылись через разные публичные адреса.</string>
<string name="simple_cause_cdn_partial_response">Часть проверенных сайтов не вернула достаточно данных для полного сравнения.</string>
<string name="simple_cause_public_ip_location">Данные о стране или владельце публичного адреса указывают на VPN, прокси, хостинг либо неожиданную страну.</string>
<string name="simple_cause_vpn_network_state">Android сообщает, что активная сеть работает через VPN.</string>
<string name="simple_cause_active_vpn_app">Одно из приложений сейчас поддерживает VPN-подключение.</string>
<string name="simple_cause_system_proxy">В Android включена системная настройка перенаправления интернет-трафика.</string>
<string name="simple_cause_local_proxy">На устройстве отвечает локальная служба, способная перенаправлять сетевой трафик.</string>
<string name="simple_cause_vpn_interface">Активен сетевой интерфейс, созданный для туннельного трафика.</string>
<string name="simple_cause_vpn_route">Таблица маршрутов устройства направляет трафик через туннель или неожиданный шлюз.</string>
<string name="simple_cause_dns_redirection">Запросы доменных имён обрабатываются через другой или перенаправленный сетевой путь.</string>
<string name="simple_cause_traffic_bypass">Трафик вне VPN прошёл по другому сетевому пути или получил другой публичный адрес.</string>
<string name="simple_cause_proxy_auth_required">Локальный прокси запросил вход, поэтому подтвердить его назначение не удалось.</string>
<string name="simple_cause_location_conflict">Данные SIM-карты, мобильной сети и страны публичного адреса описывают разные маршруты.</string>
<string name="simple_cause_icmp_response_mismatch">На тестовый пакет пришёл ответ, который не совпадает с отправленным пакетом.</string>
<string name="simple_cause_rtt_route_pattern">Время ответа показывает, что часть адресов может использовать другой сетевой маршрут.</string>
<string name="simple_cause_telegram_call_path">Проверка связи для звонков Telegram обнаружила необычный или неполный сетевой путь.</string>
<string name="simple_cause_whatsapp_call_path">Проверка связи для звонков WhatsApp обнаружила необычный или неполный сетевой путь.</string>
<string name="simple_cause_call_check_unavailable">Сервисы проверки связи для звонков не вернули достаточно данных о маршруте.</string>
<string name="simple_cause_domain_dns_mismatch">Имя сайта разрешилось не так, как ожидалось.</string>
<string name="simple_cause_domain_tcp_mismatch">Соединение с сайтом установилось не так, как ожидалось.</string>
<string name="simple_cause_domain_tls_mismatch">Защищённое соединение завершилось не так, как ожидалось.</string>
<string name="simple_cause_public_data_unavailable">Сервисы определения публичного адреса не вернули достаточно данных.</string>
<string name="simple_cause_network_data_unavailable">Android не предоставил достаточно данных о состоянии сети для этой проверки.</string>
<string name="simple_cause_remote_data_unavailable">Удалённые тестовые сервисы не вернули достаточно данных для этой проверки.</string>
<string name="simple_cause_device_data_unavailable">Android не предоставил достаточно системных данных о сети для этой проверки.</string>
<string name="simple_cause_device_environment">В среде устройства есть изменения или ограничения, способные повлиять на сетевые проверки.</string>
</resources>

View file

@ -95,6 +95,16 @@
<string name="settings_theme_light">浅色</string>
<string name="settings_theme_dark">深色</string>
<string name="settings_theme_system">跟随系统</string>
<string name="settings_result_display_mode">结果详细程度</string>
<string name="settings_result_display_mode_simple">简洁</string>
<string name="settings_result_display_mode_normal">普通</string>
<string name="settings_result_display_mode_advanced">高级</string>
<string name="settings_result_display_mode_simple_desc">使用易懂的摘要,不显示技术细节。将在下次检查时应用。</string>
<string name="settings_result_display_mode_normal_desc">使用标准的结果视图和详细信息。将在下次检查时应用。</string>
<string name="settings_result_display_mode_advanced_desc">显示标准结果和可展开的技术数据。将在下次检查时应用。</string>
<string name="settings_result_display_mode_simple_content_description">简洁结果显示模式</string>
<string name="settings_result_display_mode_normal_content_description">普通结果显示模式</string>
<string name="settings_result_display_mode_advanced_content_description">高级结果显示模式</string>
<string name="settings_language">语言</string>
<string name="settings_language_system">系统</string>
<string name="settings_language_en">English</string>
@ -589,7 +599,7 @@
<string name="settings_value_network_all">所有请求</string>
<string name="settings_value_network_disabled">已禁用</string>
<string name="settings_value_privacy_masking">IP 掩码</string>
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s</string>
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s \u00b7 %3$s</string>
<!-- NativeSignsChecker -->
<string name="checker_native_category_name">原生检查 β</string>
@ -637,6 +647,64 @@
<string name="checker_native_isolation_clone">应用运行在克隆/双开容器中(用户 %1$d无法检测主资料中的 VPN</string>
<string name="checker_native_isolation_work_profile">应用运行在受管理的工作资料中;无法在无 root 的情况下检测个人资料中的 VPN</string>
<string name="checker_native_vpn_signal">VPN 信号:%1$s</string>
<string name="vpn_desc_prop">检测到 VPN 系统属性</string>
<string name="vpn_desc_vpnhide">发现 VPN 隐藏文件</string>
<string name="vpn_desc_hook">检测到 hook 框架属性</string>
<string name="vpn_desc_dns">DNS 属性指向 VPN</string>
<string name="vpn_desc_file">发现 VPN 工具文件</string>
<string name="vpn_desc_lsposed">检测到 LSPosed/Xposed 环境</string>
<string name="vpn_desc_tcp_port">VPN 端口上的 TCP 连接</string>
<string name="vpn_desc_udp_port">VPN 端口上的 UDP 套接字</string>
<string name="vpn_desc_inet6">/proc 中的 IPv6 VPN 接口</string>
<string name="vpn_desc_route">通过 VPN 接口的路由</string>
<string name="vpn_desc_arp">VPN 接口上的 ARP 条目</string>
<string name="vpn_desc_forwarding">IP 转发已启用</string>
<string name="vpn_desc_rpfilter">反向路径过滤已禁用</string>
<string name="vpn_desc_established">到私有 IP 的 VPN 端口已建立连接</string>
<string name="vpn_desc_policy_rules">发现 VPN 路由规则</string>
<string name="vpn_desc_qdisc">检测到 VPN 队列规则</string>
<string name="vpn_desc_hidden_mac">邻居表中隐藏的 MAC 地址</string>
<string name="vpn_desc_mss">异常低的 MSS可能是隧道</string>
<string name="vpn_desc_bindtodevice">setsockopt 被拦截VPN hook</string>
<string name="vpn_desc_port_conflict">loopback 端口冲突VPN 监听器)</string>
<string name="vpn_desc_bpf">访问 netd BPF 映射</string>
<string name="vpn_desc_recverr">IP_RECVERR 被拦截VPN hook</string>
<string name="vpn_desc_sysfs_leak">VPN 接口通过 sysfs 泄露</string>
<string name="vpn_desc_getifaddrs">getifaddrs() 中的 VPN 接口</string>
<string name="vpn_desc_sysclassnet">/sys/class/net 中的 VPN 接口</string>
<string name="vpn_desc_rtm_getlink">RTM_GETLINK 中的 VPN 接口</string>
<string name="vpn_desc_proc_if_inet6">/proc/net/if_inet6 中的 VPN IPv6 接口</string>
<string name="vpn_desc_proc_ipv6_route">/proc/net/ipv6_route 中的 VPN IPv6 路由</string>
<string name="vpn_desc_proc_net_dev">/proc/net/dev 中的 VPN 流量</string>
<string name="vpn_desc_fib_trie">/proc/net/fib_trie 访问被拒绝 (SELinux)</string>
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE 泄露 VPN 接口</string>
<string name="vpn_desc_inet_diag">inet_diag netlink 被拒绝 (SELinux)</string>
<string name="vpn_desc_getsockname">getsockname() 泄露 VPN IP</string>
<string name="vpn_desc_policy_rules_netlink">通过 netlink 的 VPN 策略规则</string>
<string name="vpn_desc_route_count">路由计数和匿名接口</string>
<string name="vpn_desc_udp_port_physical">物理接口上的 UDP 端口冲突</string>
<string name="vpn_desc_trim_oracle">bind probe 与 RTM_GETLINK 不符</string>
<string name="vpn_desc_ifindexname">通过 if_indextoname 暴露的 VPN 接口</string>
<string name="vpn_desc_pmtu_mss">UDP PMTU 和 TCP MSS 分析</string>
<string name="vpn_desc_udp_pmtu_ok">UDP 发送成功(无 PMTU 瓶颈)</string>
<string name="vpn_desc_udp_pmtu_fail">UDP 发送失败PMTU 问题)</string>
<string name="vpn_desc_normal_pmtu">正常路径 MTU 检测</string>
<string name="vpn_desc_traceroute">Traceroute 探测 (ICMP/TTL)</string>
<string name="vpn_desc_timing_oracle">ARM CNTVCT 计时神谕</string>
<string name="vpn_desc_backpressure">持续突发下的背压</string>
<string name="vpn_desc_gso_failed">GSO 支持不可用(仅诊断)</string>
<string name="vpn_desc_gso_send_failed">GSO 发送失败(仅诊断)</string>
<string name="vpn_desc_gso_ok">支持 GSO正常接口</string>
<string name="vpn_desc_hw_timestamp">硬件时间戳检查</string>
<string name="vpn_status_not_found">未发现</string>
<string name="vpn_status_unavailable">不可用(无权限)</string>
<string name="checker_vpn_detector_category_name">VPN 检测器(深度)</string>
<string name="checker_vpn_detector_unavailable">VPN 检测器库未加载</string>
<string name="checker_vpn_detector_unavailable_with_reason">VPN 检测器库未加载:%1$s</string>
<string name="checker_vpn_detector_no_anomalies">未检测到深度 VPN 迹象</string>
<!-- App update checker -->
<string name="update_dialog_title">有更新可用</string>
<string name="update_dialog_message">已安装版本:%1$s\n最新版本%2$s</string>
@ -930,4 +998,111 @@
<string name="export_duration_unit_ms">毫秒</string>
<string name="export_available">可用</string>
<string name="export_unavailable">不可用</string>
<string name="simple_result_checked_label">检查内容</string>
<string name="simple_result_outcome_label">结果</string>
<string name="simple_result_explanation_label">结果含义</string>
<string name="simple_result_running">正在检查</string>
<string name="simple_result_clean">未发现问题</string>
<string name="simple_result_review">存在疑点</string>
<string name="simple_result_detected">发现问题</string>
<string name="simple_result_error">无法检查</string>
<string name="simple_result_disabled">此检查已关闭</string>
<string name="simple_result_explanation_running">这部分检查完成后会显示结果。</string>
<string name="simple_result_explanation_clean">已检查的信号看起来正常。</string>
<string name="simple_result_explanation_error">这部分未返回足够的信息,其他检查仍可继续完成。</string>
<string name="simple_result_explanation_review">检查发现异常信号,但仅凭这一项不足以确认问题。</string>
<string name="simple_result_area_public_address">异常信号出现在公网地址信息中。</string>
<string name="simple_result_area_device_network">异常信号出现在设备网络状态中。</string>
<string name="simple_result_area_local_app">异常信号与已安装或正在运行的网络应用有关。</string>
<string name="simple_result_area_local_proxy">异常信号出现在本地流量转发服务中。</string>
<string name="simple_result_area_network_route">异常信号出现在网络流量路径中。</string>
<string name="simple_result_area_call_route">异常信号出现在通话使用的网络路径中。</string>
<string name="simple_result_area_location">比较网络与位置信息时发现异常信号。</string>
<string name="simple_result_area_remote_site">连接远程服务时发现异常信号。</string>
<string name="simple_result_area_device_environment">异常信号出现在设备系统环境中。</string>
<string name="simple_result_area_multiple">网络检查的多个部分出现了相关信号。</string>
<string name="simple_result_extra_information">补充信息:此卡片本身不会改变最终结论。</string>
<string name="simple_title_geo">连接显示的位置</string>
<string name="simple_title_ip_comparison">各服务看到的地址是否一致</string>
<string name="simple_title_cdn">远程网站如何响应</string>
<string name="simple_title_direct">可见的网络设置</string>
<string name="simple_title_indirect">其他网络信号</string>
<string name="simple_title_native">设备级网络信号</string>
<string name="simple_title_call_transport">通话流量路径</string>
<string name="simple_title_icmp">网络响应一致性</string>
<string name="simple_title_rtt">连接延迟对比</string>
<string name="simple_title_location">网络与位置是否一致</string>
<string name="simple_title_bypass">流量是否从其他路径离开</string>
<string name="simple_title_reachability">所选网站是否可访问</string>
<string name="simple_checked_geo">公网连接地址所显示的国家和网络。</string>
<string name="simple_checked_ip_comparison">不同服务是否报告一致的公网地址。</string>
<string name="simple_checked_cdn">所选远程服务是否返回预期信息。</string>
<string name="simple_checked_direct">直接表明流量被转发的网络设置。</string>
<string name="simple_checked_indirect">应用、接口和网络配置中的辅助信号。</string>
<string name="simple_checked_native">应用较难隐藏的设备级信号。</string>
<string name="simple_checked_call_transport">通话流量是否经过意外的网络路径。</string>
<string name="simple_checked_icmp">网络响应是否与所联系的服务一致。</string>
<string name="simple_checked_rtt">连接延迟是否以异常方式出现差异。</string>
<string name="simple_checked_location">本地网络与位置信息是否一致。</string>
<string name="simple_checked_bypass">流量是否能离开预期的受保护路径。</string>
<string name="simple_checked_reachability">所选网站在当前网络上是否按预期工作。</string>
<string name="simple_verdict_hard_bypass">直接信号表明流量可以经过意外路径。</string>
<string name="simple_verdict_address_conflict">多项地址观察存在冲突,表明流量可能被转发。</string>
<string name="simple_verdict_location_conflict">连接位置与本地网络位置冲突。</string>
<string name="simple_verdict_hosting_review">该地址类似托管或中转连接,但其他信号尚无定论。</string>
<string name="simple_verdict_combined_signals">结论基于连接位置与设备网络信号的组合。</string>
<string name="simple_verdict_review_signals">一个或多个辅助信号需要关注,但单独不足以证明问题。</string>
<string name="simple_verdict_general">结论基于已完成检查的结构化结果。</string>
<string name="technical_data_title">技术数据</string>
<string name="technical_data_expand_content_description">展开技术数据</string>
<string name="technical_data_collapse_content_description">收起技术数据</string>
<string name="technical_data_empty">此卡片没有记录技术条目。</string>
<string name="technical_data_empty_body">未记录正文。</string>
<string name="technical_data_source">来源:%1$s</string>
<string name="technical_data_target">目标:%1$s</string>
<string name="technical_data_status">状态:%1$s</string>
<string name="technical_data_duration">耗时:%1$d 毫秒</string>
<string name="technical_data_size">保存大小:%1$s</string>
<string name="technical_data_truncated">已截断(原始大小:%1$s</string>
<string name="technical_data_run_truncated">已截断:本次检查已达到 512 KiB 总上限。</string>
<string name="technical_data_bytes">%1$d 字节</string>
<string name="technical_data_copy_block">复制区块</string>
<string name="technical_data_copy_block_content_description">复制来自 %1$s 的技术数据</string>
<string name="technical_data_copied">已复制技术数据</string>
<string name="simple_cause_ip_ru_services_disagree">俄罗斯 IP 查询服务报告了不同的公网地址。</string>
<string name="simple_cause_ip_non_ru_services_disagree">国际 IP 查询服务报告了不同的公网地址。</string>
<string name="simple_cause_ip_groups_disagree">俄罗斯和国际服务报告了不同的公网地址。</string>
<string name="simple_cause_ip_families_differ">服务返回了不同的地址类型,因此无法直接比较这些结果。</string>
<string name="simple_cause_ip_partial_response">部分公网地址查询服务没有响应,因此比较结果不完整。</string>
<string name="simple_cause_ip_unavailable">所有公网地址查询服务都没有返回可用结果。</string>
<string name="simple_cause_cdn_responses_differ">受测网站通过不同的公网地址访问。</string>
<string name="simple_cause_cdn_partial_response">部分受测网站没有返回足够数据,无法完成比较。</string>
<string name="simple_cause_public_ip_location">公网地址的国家或网络归属信息指向 VPN、代理、托管网络或意外的国家。</string>
<string name="simple_cause_vpn_network_state">Android 报告当前网络通过 VPN 传输。</string>
<string name="simple_cause_active_vpn_app">某个应用当前正在维持 VPN 连接。</string>
<string name="simple_cause_system_proxy">Android 已启用全局流量转发设置。</string>
<string name="simple_cause_local_proxy">设备上的本地流量转发服务正在响应。</string>
<string name="simple_cause_vpn_interface">用于隧道流量的网络接口处于活动状态。</string>
<string name="simple_cause_vpn_route">设备路由表将流量发送到隧道或意外的网关。</string>
<string name="simple_cause_dns_redirection">域名请求通过不同或重定向的网络路径处理。</string>
<string name="simple_cause_traffic_bypass">VPN 外的流量使用了不同的网络路径或公网地址。</string>
<string name="simple_cause_proxy_auth_required">本地代理要求身份验证,因此无法确认其用途。</string>
<string name="simple_cause_location_conflict">SIM 卡、移动网络和公网地址位置数据描述了不同的路径。</string>
<string name="simple_cause_icmp_response_mismatch">测试数据包收到的响应与发出的数据包不一致。</string>
<string name="simple_cause_rtt_route_pattern">响应时间表明部分目标可能使用了不同的网络路径。</string>
<string name="simple_cause_telegram_call_path">Telegram 通话连接检查发现了异常或不完整的网络路径。</string>
<string name="simple_cause_whatsapp_call_path">WhatsApp 通话连接检查发现了异常或不完整的网络路径。</string>
<string name="simple_cause_call_check_unavailable">通话连接服务没有返回足够数据来检查路径。</string>
<string name="simple_cause_domain_dns_mismatch">网站名称的解析结果与预期不同。</string>
<string name="simple_cause_domain_tcp_mismatch">与网站建立连接的结果与预期不同。</string>
<string name="simple_cause_domain_tls_mismatch">安全连接的完成结果与预期不同。</string>
<string name="simple_cause_public_data_unavailable">公网地址查询服务没有返回足够数据。</string>
<string name="simple_cause_network_data_unavailable">Android 没有提供足够的网络状态数据来完成此检查。</string>
<string name="simple_cause_remote_data_unavailable">远程测试服务没有返回足够数据来完成此检查。</string>
<string name="simple_cause_device_data_unavailable">Android 没有提供足够的设备级网络数据来完成此检查。</string>
<string name="simple_cause_device_environment">设备环境中存在可能影响网络检查的修改或限制。</string>
</resources>

View file

@ -94,6 +94,16 @@
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_theme_system">System</string>
<string name="settings_result_display_mode">Result details</string>
<string name="settings_result_display_mode_simple">Simple</string>
<string name="settings_result_display_mode_normal">Normal</string>
<string name="settings_result_display_mode_advanced">Advanced</string>
<string name="settings_result_display_mode_simple_desc">Plain-language summaries without technical details. Applies to the next check.</string>
<string name="settings_result_display_mode_normal_desc">The standard result view and details. Applies to the next check.</string>
<string name="settings_result_display_mode_advanced_desc">Standard results with expandable technical data. Applies to the next check.</string>
<string name="settings_result_display_mode_simple_content_description">Simple result display mode</string>
<string name="settings_result_display_mode_normal_content_description">Normal result display mode</string>
<string name="settings_result_display_mode_advanced_content_description">Advanced result display mode</string>
<string name="settings_language">Language</string>
<string name="settings_language_system">System</string>
<string name="settings_language_en">English</string>
@ -577,7 +587,7 @@
<string name="settings_value_network_all">All requests</string>
<string name="settings_value_network_disabled">Disabled</string>
<string name="settings_value_privacy_masking">IP masking</string>
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s</string>
<string name="settings_value_appearance_format">%1$s \u00b7 %2$s \u00b7 %3$s</string>
<!-- NativeSignsChecker -->
<string name="checker_native_category_name">Native signs β</string>
@ -665,6 +675,65 @@
<string name="checker_native_isolation_clone">App runs in a cloned/dual-app container (user %1$d); a VPN in the primary profile is not observable</string>
<string name="checker_native_isolation_work_profile">App runs in a managed work profile; a VPN in the personal profile is not observable without root</string>
<!-- NativeSignsChecker: VPN signals -->
<string name="checker_native_vpn_signal">VPN signal: %1$s</string>
<string name="vpn_desc_prop">VPN system property detected</string>
<string name="vpn_desc_vpnhide">VPN hide file found</string>
<string name="vpn_desc_hook">Hook framework property detected</string>
<string name="vpn_desc_dns">DNS property points to VPN</string>
<string name="vpn_desc_file">VPN tool file found</string>
<string name="vpn_desc_lsposed">LSPosed/Xposed environment detected</string>
<string name="vpn_desc_tcp_port">TCP connection on VPN port</string>
<string name="vpn_desc_udp_port">UDP socket on VPN port</string>
<string name="vpn_desc_inet6">IPv6 VPN interface in /proc</string>
<string name="vpn_desc_route">Route via VPN interface</string>
<string name="vpn_desc_arp">ARP entry on VPN interface</string>
<string name="vpn_desc_forwarding">IP forwarding is enabled</string>
<string name="vpn_desc_rpfilter">Reverse path filter disabled</string>
<string name="vpn_desc_established">Established connection to private IP on VPN port</string>
<string name="vpn_desc_policy_rules">VPN routing rules found</string>
<string name="vpn_desc_qdisc">VPN queue discipline detected</string>
<string name="vpn_desc_hidden_mac">Hidden MAC addresses in neighbor table</string>
<string name="vpn_desc_mss">Suspiciously low MSS (possible tunnel)</string>
<string name="vpn_desc_bindtodevice">setsockopt intercepted (VPN hook)</string>
<string name="vpn_desc_port_conflict">Port conflict on loopback (VPN listener)</string>
<string name="vpn_desc_bpf">Access to netd BPF maps</string>
<string name="vpn_desc_recverr">IP_RECVERR intercepted (VPN hook)</string>
<string name="vpn_desc_sysfs_leak">VPN interface leaked via sysfs</string>
<string name="vpn_desc_getifaddrs">VPN interface in getifaddrs()</string>
<string name="vpn_desc_sysclassnet">VPN interface in /sys/class/net</string>
<string name="vpn_desc_rtm_getlink">VPN interface in RTM_GETLINK</string>
<string name="vpn_desc_proc_if_inet6">VPN IPv6 interface in /proc/net/if_inet6</string>
<string name="vpn_desc_proc_ipv6_route">VPN IPv6 route in /proc/net/ipv6_route</string>
<string name="vpn_desc_proc_net_dev">VPN traffic in /proc/net/dev</string>
<string name="vpn_desc_fib_trie">/proc/net/fib_trie access denied (SELinux)</string>
<string name="vpn_desc_bindtodevice_leak">SO_BINDTODEVICE leaked VPN interface</string>
<string name="vpn_desc_inet_diag">inet_diag netlink denied (SELinux)</string>
<string name="vpn_desc_getsockname">getsockname() leaked VPN IP</string>
<string name="vpn_desc_policy_rules_netlink">VPN policy rules via netlink</string>
<string name="vpn_desc_route_count">Route count and anonymous interfaces</string>
<string name="vpn_desc_udp_port_physical">UDP port conflict on physical interface</string>
<string name="vpn_desc_trim_oracle">Bind probe vs RTM_GETLINK discrepancy</string>
<string name="vpn_desc_ifindexname">VPN interface exposed via if_indextoname</string>
<string name="vpn_desc_pmtu_mss">UDP PMTU and TCP MSS analysis</string>
<string name="vpn_desc_udp_pmtu_ok">UDP send succeeded (no PMTU bottleneck)</string>
<string name="vpn_desc_udp_pmtu_fail">UDP send failed (PMTU issue)</string>
<string name="vpn_desc_normal_pmtu">Normal path MTU detection</string>
<string name="vpn_desc_traceroute">Traceroute probe (ICMP/TTL)</string>
<string name="vpn_desc_timing_oracle">ARM CNTVCT timing oracle</string>
<string name="vpn_desc_backpressure">Backpressure under sustained burst</string>
<string name="vpn_desc_gso_failed">GSO support unavailable (diagnostic only)</string>
<string name="vpn_desc_gso_send_failed">GSO send failed (diagnostic only)</string>
<string name="vpn_desc_gso_ok">GSO supported (normal interface)</string>
<string name="vpn_desc_hw_timestamp">Hardware timestamping check</string>
<string name="vpn_status_not_found">not found</string>
<string name="vpn_status_unavailable">unavailable (no permission)</string>
<string name="checker_vpn_detector_category_name">VPN Detector (deep)</string>
<string name="checker_vpn_detector_unavailable">VPN detector library not loaded</string>
<string name="checker_vpn_detector_unavailable_with_reason">VPN detector library not loaded: %1$s</string>
<string name="checker_vpn_detector_no_anomalies">No deep VPN signs detected</string>
<!-- App update checker -->
<string name="update_dialog_title">Update available</string>
<string name="update_dialog_message">Installed version: %1$s\nLatest version: %2$s</string>
@ -965,4 +1034,111 @@
<string name="export_duration_unit_ms">ms</string>
<string name="export_available">available</string>
<string name="export_unavailable">unavailable</string>
<string name="simple_result_checked_label">What was checked</string>
<string name="simple_result_outcome_label">Result</string>
<string name="simple_result_explanation_label">What this means</string>
<string name="simple_result_running">Checking now</string>
<string name="simple_result_clean">No problems found</string>
<string name="simple_result_review">There is some uncertainty</string>
<string name="simple_result_detected">A problem was found</string>
<string name="simple_result_error">Could not check</string>
<string name="simple_result_disabled">This check is turned off</string>
<string name="simple_result_explanation_running">The result will appear when this part of the check finishes.</string>
<string name="simple_result_explanation_clean">The checked signs looked normal.</string>
<string name="simple_result_explanation_error">This part did not return enough information. Other checks can still finish.</string>
<string name="simple_result_explanation_review">The check found an unusual sign, but it is not enough on its own to confirm a problem.</string>
<string name="simple_result_area_public_address">The unusual sign appeared in the public network address information.</string>
<string name="simple_result_area_device_network">The unusual sign appeared in the device network state.</string>
<string name="simple_result_area_local_app">The unusual sign was linked to an installed or active network app.</string>
<string name="simple_result_area_local_proxy">The unusual sign appeared in a local traffic redirection service.</string>
<string name="simple_result_area_network_route">The unusual sign appeared in the path used by network traffic.</string>
<string name="simple_result_area_call_route">The unusual sign appeared in the network path used for calls.</string>
<string name="simple_result_area_location">The unusual sign appeared when network and location information were compared.</string>
<string name="simple_result_area_remote_site">The unusual sign appeared while contacting a remote service.</string>
<string name="simple_result_area_device_environment">The unusual sign appeared in the device system environment.</string>
<string name="simple_result_area_multiple">Related signs appeared in more than one part of the network check.</string>
<string name="simple_result_extra_information">Additional information: this card does not change the final verdict by itself.</string>
<string name="simple_title_geo">Where the connection appears to be</string>
<string name="simple_title_ip_comparison">Whether services see the same address</string>
<string name="simple_title_cdn">How remote sites answer</string>
<string name="simple_title_direct">Visible network settings</string>
<string name="simple_title_indirect">Other network signs</string>
<string name="simple_title_native">Device-level network signs</string>
<string name="simple_title_call_transport">Call traffic path</string>
<string name="simple_title_icmp">Network reply consistency</string>
<string name="simple_title_rtt">Connection delay comparison</string>
<string name="simple_title_location">Network and location match</string>
<string name="simple_title_bypass">Traffic leaving by another path</string>
<string name="simple_title_reachability">Whether selected sites are reachable</string>
<string name="simple_checked_geo">The country and network shown by the public connection address.</string>
<string name="simple_checked_ip_comparison">Whether different services report a consistent public address.</string>
<string name="simple_checked_cdn">Whether selected remote services return the expected information.</string>
<string name="simple_checked_direct">Network settings that directly report traffic redirection.</string>
<string name="simple_checked_indirect">Supporting signs in apps, interfaces and network configuration.</string>
<string name="simple_checked_native">Device-level signs that are harder for apps to hide.</string>
<string name="simple_checked_call_transport">Whether call traffic follows an unexpected network path.</string>
<string name="simple_checked_icmp">Whether network replies look consistent with the contacted service.</string>
<string name="simple_checked_rtt">Whether connection delays differ in an unusual way.</string>
<string name="simple_checked_location">Whether local network and location information agree.</string>
<string name="simple_checked_bypass">Whether traffic can leave outside the expected protected path.</string>
<string name="simple_checked_reachability">Whether selected sites behave as expected on this network.</string>
<string name="simple_verdict_hard_bypass">A direct sign showed that traffic can use an unexpected path.</string>
<string name="simple_verdict_address_conflict">Several address observations disagree in a way that points to traffic redirection.</string>
<string name="simple_verdict_location_conflict">The connection location conflicts with the local network location.</string>
<string name="simple_verdict_hosting_review">The address resembles a hosted or relayed connection, but the other signs are not conclusive.</string>
<string name="simple_verdict_combined_signals">The verdict is based on the combined connection location and device network signs.</string>
<string name="simple_verdict_review_signals">One or more supporting signs need attention, but they do not prove a problem by themselves.</string>
<string name="simple_verdict_general">The verdict is based on the structured results of the completed checks.</string>
<string name="technical_data_title">Technical data</string>
<string name="technical_data_expand_content_description">Expand technical data</string>
<string name="technical_data_collapse_content_description">Collapse technical data</string>
<string name="technical_data_empty">No technical entries were recorded for this card.</string>
<string name="technical_data_empty_body">No body was recorded.</string>
<string name="technical_data_source">Source: %1$s</string>
<string name="technical_data_target">Target: %1$s</string>
<string name="technical_data_status">Status: %1$s</string>
<string name="technical_data_duration">Duration: %1$d ms</string>
<string name="technical_data_size">Stored size: %1$s</string>
<string name="technical_data_truncated">Truncated (original size: %1$s)</string>
<string name="technical_data_run_truncated">Truncated: the scan-wide 512 KiB limit was reached.</string>
<string name="technical_data_bytes">%1$d B</string>
<string name="technical_data_copy_block">Copy block</string>
<string name="technical_data_copy_block_content_description">Copy technical data from %1$s</string>
<string name="technical_data_copied">Technical data copied</string>
<string name="simple_cause_ip_ru_services_disagree">Russian IP services reported different public addresses.</string>
<string name="simple_cause_ip_non_ru_services_disagree">International IP services reported different public addresses.</string>
<string name="simple_cause_ip_groups_disagree">Russian and international services reported different public addresses.</string>
<string name="simple_cause_ip_families_differ">The services returned different address families, so the answers cannot be compared directly.</string>
<string name="simple_cause_ip_partial_response">Some public-address services did not answer, so the comparison is incomplete.</string>
<string name="simple_cause_ip_unavailable">No public-address service returned a usable answer.</string>
<string name="simple_cause_cdn_responses_differ">The tested sites were reached through different public addresses.</string>
<string name="simple_cause_cdn_partial_response">Some tested sites did not return enough data for a complete comparison.</string>
<string name="simple_cause_public_ip_location">Public-address location or network ownership data indicates a VPN, proxy, hosting network, or an unexpected country.</string>
<string name="simple_cause_vpn_network_state">Android reports that the active network is carried through a VPN.</string>
<string name="simple_cause_active_vpn_app">An application is currently maintaining the VPN connection.</string>
<string name="simple_cause_system_proxy">Android has a system-wide traffic redirection setting enabled.</string>
<string name="simple_cause_local_proxy">A local service that can redirect network traffic is responding on the device.</string>
<string name="simple_cause_vpn_interface">A network interface created for tunneled traffic is active.</string>
<string name="simple_cause_vpn_route">The device routing table sends traffic through a tunnel or an unexpected gateway.</string>
<string name="simple_cause_dns_redirection">Domain-name requests are handled through a different or redirected network path.</string>
<string name="simple_cause_traffic_bypass">Traffic sent outside the VPN used a different network path or public address.</string>
<string name="simple_cause_proxy_auth_required">A local proxy asked for authentication, so its purpose could not be confirmed.</string>
<string name="simple_cause_location_conflict">SIM, mobile network, and public-address location data do not describe the same route.</string>
<string name="simple_cause_icmp_response_mismatch">A test packet received a response that did not match the packet that was sent.</string>
<string name="simple_cause_rtt_route_pattern">Response times indicate that some destinations may be using a different network route.</string>
<string name="simple_cause_telegram_call_path">The Telegram call connectivity check found an unusual or incomplete network path.</string>
<string name="simple_cause_whatsapp_call_path">The WhatsApp call connectivity check found an unusual or incomplete network path.</string>
<string name="simple_cause_call_check_unavailable">The call connectivity services did not return enough data to check the route.</string>
<string name="simple_cause_domain_dns_mismatch">The domain name resolved differently from the expected result.</string>
<string name="simple_cause_domain_tcp_mismatch">The connection to the service opened differently from the expected result.</string>
<string name="simple_cause_domain_tls_mismatch">The secure connection completed differently from the expected result.</string>
<string name="simple_cause_public_data_unavailable">The services used to determine the public address did not return enough data.</string>
<string name="simple_cause_network_data_unavailable">Android did not provide enough network-state data to finish this check.</string>
<string name="simple_cause_remote_data_unavailable">The remote test services did not return enough data to finish this check.</string>
<string name="simple_cause_device_data_unavailable">Android did not provide enough device-level network data to finish this check.</string>
<string name="simple_cause_device_environment">The device environment contains changes or restrictions that can alter network checks.</string>
</resources>

View file

@ -5,6 +5,10 @@ import androidx.test.core.app.ApplicationProvider
import com.notcvnt.rknhardering.export.CheckResultJsonExportFormatter
import com.notcvnt.rknhardering.export.CompletedExportSnapshot
import com.notcvnt.rknhardering.export.createCompletedExportSnapshot
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import org.json.JSONObject
@ -61,6 +65,60 @@ class CheckResultJsonExportFormatterTest {
assertFalse(outbound.has("publicKey"))
}
@Test
fun `json export includes deep native detector findings and evidence`() {
val result = exportRichCheckResult().copy(
nativeSigns = CategoryResult(
name = "Native signs",
detected = true,
findings = listOf(
Finding(
description = "backpressure: 50000 packets",
isInformational = true,
source = EvidenceSource.NATIVE_ROUTE,
confidence = EvidenceConfidence.LOW,
),
Finding(
description = "bindtodevice_leak: tun0",
detected = true,
source = EvidenceSource.NATIVE_SOCKET,
confidence = EvidenceConfidence.HIGH,
),
),
evidence = listOf(
EvidenceItem(
source = EvidenceSource.NATIVE_SOCKET,
detected = true,
confidence = EvidenceConfidence.HIGH,
description = "bindtodevice_leak: tun0",
),
),
),
)
val json = JSONObject(
CheckResultJsonExportFormatter.format(
context = context,
snapshot = createCompletedExportSnapshot(
result = result,
privacyMode = false,
finishedAtMillis = 0L,
),
appVersionName = "1.0",
buildType = "debug",
),
)
val nativeSigns = json.getJSONObject("results").getJSONObject("nativeSigns")
val findings = nativeSigns.getJSONArray("findings")
val evidence = nativeSigns.getJSONArray("evidence")
assertEquals("backpressure: 50000 packets", findings.getJSONObject(0).getString("description"))
assertTrue(findings.getJSONObject(0).getBoolean("isInformational"))
assertEquals("bindtodevice_leak: tun0", findings.getJSONObject(1).getString("description"))
assertEquals("NATIVE_SOCKET", evidence.getJSONObject(0).getString("source"))
assertEquals("bindtodevice_leak: tun0", evidence.getJSONObject(0).getString("description"))
}
@Test
fun `json export marks ip comparison and bypass errors`() {
val base = exportEmptyCheckResult()

View file

@ -5,6 +5,10 @@ import androidx.test.core.app.ApplicationProvider
import com.notcvnt.rknhardering.export.CheckResultMarkdownExportFormatter
import com.notcvnt.rknhardering.export.CompletedExportSnapshot
import com.notcvnt.rknhardering.export.createCompletedExportSnapshot
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.UnparsedIp
@ -45,6 +49,54 @@ class CheckResultMarkdownExportFormatterTest {
assertTrue(markdown.contains("## Footer"))
}
@Test
fun `markdown export includes deep native detector findings and evidence`() {
val result = exportRichCheckResult().copy(
nativeSigns = CategoryResult(
name = "Native signs",
detected = true,
findings = listOf(
Finding(
description = "backpressure: 50000 packets",
isInformational = true,
source = EvidenceSource.NATIVE_ROUTE,
confidence = EvidenceConfidence.LOW,
),
Finding(
description = "bindtodevice_leak: tun0",
detected = true,
source = EvidenceSource.NATIVE_SOCKET,
confidence = EvidenceConfidence.HIGH,
),
),
evidence = listOf(
EvidenceItem(
source = EvidenceSource.NATIVE_SOCKET,
detected = true,
confidence = EvidenceConfidence.HIGH,
description = "bindtodevice_leak: tun0",
),
),
),
)
val markdown = CheckResultMarkdownExportFormatter.format(
context = context,
snapshot = createCompletedExportSnapshot(
result = result,
privacyMode = false,
finishedAtMillis = 0L,
),
appVersionName = "1.0",
buildType = "debug",
)
assertTrue(markdown.contains("backpressure: 50000 packets | informational=true"))
assertTrue(markdown.contains("bindtodevice_leak: tun0 | detected=true | source=NATIVE_SOCKET"))
assertTrue(markdown.contains("source=NATIVE_SOCKET | detected=true | confidence=HIGH"))
assertTrue(markdown.contains("description=bindtodevice_leak: tun0"))
}
@Test
fun `markdown export marks ip comparison and bypass errors`() {
val base = exportEmptyCheckResult()

View file

@ -13,6 +13,7 @@ import com.notcvnt.rknhardering.model.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.Verdict
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
@ -98,6 +99,63 @@ class CheckViewModelTest {
assertTrue(events[1] is ScanEvent.Cancelled)
}
@Test
fun `scan timeline keeps captured display mode and next scan uses changed preference`() {
val firstScanMayComplete = CompletableDeferred<Unit>()
val capturedContexts = mutableListOf<ScanExecutionContext>()
val app: Application = ApplicationProvider.getApplicationContext()
val prefs = AppUiSettings.prefs(app)
prefs.edit().clear().putString(
SettingsPrefs.PREF_RESULT_DISPLAY_MODE,
ResultDisplayMode.SIMPLE.prefValue,
).commit()
val viewModel = CheckViewModel(app)
CheckViewModel.runScanOverride = { _, _, executionContext, _ ->
capturedContexts += executionContext
if (capturedContexts.size == 1) firstScanMayComplete.await()
completedResult()
}
viewModel.startScan(
settings = CheckSettings(
splitTunnelEnabled = false,
networkRequestsEnabled = false,
),
privacyMode = true,
)
shadowOf(Looper.getMainLooper()).idle()
val started = viewModel.scanEvents.value.events.single() as ScanEvent.Started
assertEquals(ResultDisplayMode.SIMPLE, started.resultDisplayMode)
assertEquals(ResultDisplayMode.SIMPLE, capturedContexts.single().resultDisplayMode)
assertEquals(null, capturedContexts.single().diagnosticCollector)
prefs.edit().putString(
SettingsPrefs.PREF_RESULT_DISPLAY_MODE,
ResultDisplayMode.ADVANCED.prefValue,
).commit()
assertEquals(ResultDisplayMode.SIMPLE, started.resultDisplayMode)
firstScanMayComplete.complete(Unit)
shadowOf(Looper.getMainLooper()).idle()
val firstCompleted = viewModel.scanEvents.value.events.last() as ScanEvent.Completed
assertEquals(ResultDisplayMode.SIMPLE, firstCompleted.resultDisplayMode)
viewModel.startScan(
settings = started.settings,
privacyMode = true,
)
shadowOf(Looper.getMainLooper()).idle()
val secondEvents = viewModel.scanEvents.value.events
assertEquals(ResultDisplayMode.ADVANCED, (secondEvents.first() as ScanEvent.Started).resultDisplayMode)
assertEquals(ResultDisplayMode.ADVANCED, (secondEvents.last() as ScanEvent.Completed).resultDisplayMode)
assertEquals(ResultDisplayMode.ADVANCED, capturedContexts.last().resultDisplayMode)
assertTrue(capturedContexts.last().diagnosticCollector != null)
}
private fun category(name: String): CategoryResult = CategoryResult(
name = name,
detected = false,

View file

@ -0,0 +1,21 @@
package com.notcvnt.rknhardering
import org.junit.Assert.assertEquals
import org.junit.Test
class ResultDisplayModeTest {
@Test
fun `missing and unknown preference fall back to normal`() {
assertEquals(ResultDisplayMode.NORMAL, ResultDisplayMode.fromPref(null))
assertEquals(ResultDisplayMode.NORMAL, ResultDisplayMode.fromPref(""))
assertEquals(ResultDisplayMode.NORMAL, ResultDisplayMode.fromPref("unexpected"))
}
@Test
fun `all preference values round trip`() {
ResultDisplayMode.entries.forEach { mode ->
assertEquals(mode, ResultDisplayMode.fromPref(mode.prefValue))
}
}
}

View file

@ -0,0 +1,314 @@
package com.notcvnt.rknhardering
import android.content.Context
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import com.google.android.material.button.MaterialButton
import com.notcvnt.rknhardering.checker.CheckSettings
import com.notcvnt.rknhardering.checker.CheckUpdate
import com.notcvnt.rknhardering.diagnostics.DiagnosticEntry
import com.notcvnt.rknhardering.diagnostics.DiagnosticSnapshot
import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.CheckResult
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpCheckerResponse
import com.notcvnt.rknhardering.model.IpCheckerScope
import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.Verdict
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
class ResultDisplayModeUiTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
AppUiSettings.prefs(context).edit().clear().commit()
}
@After
fun tearDown() {
CheckViewModel.runScanOverride = null
}
@Test
fun `normal mode keeps existing technical finding presentation`() {
val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()
invokePrivate<Unit>(activity, "prepareCheckSessionUi", CheckSettings(), false, ResultDisplayMode.NORMAL)
invokePrivate<Unit>(
activity,
"applyScanEvent",
ScanEvent.Update(CheckUpdate.DirectSignsReady(technicalCategory())),
false,
)
invokePrivate<Unit>(activity, "expandCategory", "dir")
assertTrue(collectVisibleText(activity.findViewById(R.id.bodyDirect)).contains(TECHNICAL_MARKER))
}
@Test
fun `simple mode keeps enabled cards but hides technical strings`() {
val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()
invokePrivate<Unit>(activity, "prepareCheckSessionUi", CheckSettings(), false, ResultDisplayMode.SIMPLE)
invokePrivate<Unit>(
activity,
"applyScanEvent",
ScanEvent.Update(CheckUpdate.DirectSignsReady(technicalCategory())),
false,
)
invokePrivate<Unit>(activity, "expandCategory", "dir")
assertEquals(View.VISIBLE, activity.findViewById<View>(R.id.cardDirect).visibility)
val visibleText = collectVisibleText(activity.findViewById(R.id.bodyDirect))
assertFalse(visibleText.contains(TECHNICAL_MARKER))
assertTrue(visibleText.contains(activity.getString(R.string.simple_result_checked_label)))
}
@Test
fun `simple mode names the exact ip group mismatch without technical data`() {
val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()
invokePrivate<Unit>(activity, "prepareCheckSessionUi", CheckSettings(), false, ResultDisplayMode.SIMPLE)
val rawSummary = "technical summary 87.116.181.251 vs 87.116.162.101"
val ruGroup = IpCheckerGroupResult(
title = "RU",
detected = true,
statusLabel = "raw enum-like status",
summary = rawSummary,
responses = listOf(
ipResponse("87.116.181.251", IpCheckerScope.RU),
ipResponse("87.116.162.101", IpCheckerScope.RU),
),
)
val nonRuGroup = group("non-ru")
invokePrivate<Unit>(
activity,
"applyScanEvent",
ScanEvent.Update(
CheckUpdate.IpComparisonReady(
IpComparisonResult(
detected = false,
needsReview = true,
summary = rawSummary,
ruGroup = ruGroup,
nonRuGroup = nonRuGroup,
),
),
),
false,
)
invokePrivate<Unit>(activity, "expandCategory", "ipc")
val visibleText = collectVisibleText(activity.findViewById(R.id.bodyIpComparison))
assertTrue(visibleText.contains(activity.getString(R.string.simple_cause_ip_ru_services_disagree)))
assertFalse(visibleText.contains(rawSummary))
assertFalse(visibleText.contains("87.116.181.251"))
assertFalse(visibleText.contains("87.116.162.101"))
assertFalse(visibleText.contains(activity.getString(R.string.simple_result_area_public_address)))
}
@Test
fun `advanced mode creates closed lazy technical block and clears it on new scan`() {
val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()
invokePrivate<Unit>(
activity,
"applyScanEvent",
ScanEvent.Completed(checkResult(snapshot(runTruncated = true)), false, ResultDisplayMode.ADVANCED),
false,
)
invokePrivate<Unit>(activity, "expandCategory", "dir")
val body = activity.findViewById<ViewGroup>(R.id.bodyDirect)
val toggle = findMaterialButton(body, activity.getString(R.string.technical_data_title))
assertNotNull(toggle)
assertFalse(collectVisibleText(body).contains("current-run payload"))
toggle!!.performClick()
assertTrue(collectVisibleText(body).contains(activity.getString(R.string.technical_data_run_truncated)))
val raw = findTextView(body) { it.text.toString().contains("current-run payload") }
assertNotNull(raw)
assertTrue(raw!!.isTextSelectable)
assertEquals(View.TEXT_DIRECTION_LTR, raw.textDirection)
assertEquals(View.LAYOUT_DIRECTION_LTR, raw.layoutDirection)
invokePrivate<Unit>(activity, "prepareCheckSessionUi", CheckSettings(), false, ResultDisplayMode.NORMAL)
assertFalse(collectAllText(body).contains(activity.getString(R.string.technical_data_title)))
}
@Test
@Config(qualifiers = "fa-rIR")
fun `persian layout keeps raw diagnostic text ltr`() {
val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()
invokePrivate<Unit>(
activity,
"applyScanEvent",
ScanEvent.Completed(checkResult(snapshot()), false, ResultDisplayMode.ADVANCED),
false,
)
val body = activity.findViewById<ViewGroup>(R.id.bodyDirect)
findMaterialButton(body, activity.getString(R.string.technical_data_title))!!.performClick()
val raw = findTextView(body) { it.text.toString().contains("current-run payload") }!!
assertEquals(View.TEXT_DIRECTION_LTR, raw.textDirection)
assertEquals(View.LAYOUT_DIRECTION_LTR, raw.layoutDirection)
}
@Test
fun `rotation replay keeps captured mode after preference changes`() {
CheckViewModel.runScanOverride = { _, _, executionContext, _ ->
executionContext.diagnosticCollector?.record(
category = "dir",
source = "fake",
status = "ok",
body = "rotation payload",
)
checkResult()
}
val controller = Robolectric.buildActivity(MainActivity::class.java).setup()
val first = controller.get()
getPrivateField<CheckViewModel>(first, "viewModel").startScan(
CheckSettings(),
privacyMode = false,
resultDisplayMode = ResultDisplayMode.ADVANCED,
)
shadowOf(Looper.getMainLooper()).idle()
AppUiSettings.prefs(first).edit()
.putString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, ResultDisplayMode.SIMPLE.prefValue)
.commit()
controller.recreate()
shadowOf(Looper.getMainLooper()).idle()
val recreated = controller.get()
assertNotNull(
findMaterialButton(
recreated.findViewById(R.id.bodyDirect),
recreated.getString(R.string.technical_data_title),
),
)
}
private fun technicalCategory() = CategoryResult(
name = "direct",
detected = false,
findings = listOf(Finding(TECHNICAL_MARKER)),
)
private fun snapshot(runTruncated: Boolean = false) = DiagnosticSnapshot(
entries = listOf(
DiagnosticEntry(
category = "dir",
source = "fake probe",
target = "example.test",
status = "ok",
durationMs = 12,
body = "current-run payload",
storedBytes = 19,
originalBytes = 19,
truncated = false,
),
),
storedBytes = 19,
truncated = runTruncated,
)
private fun checkResult(snapshot: DiagnosticSnapshot? = null): CheckResult = CheckResult(
geoIp = category("geo"),
ipComparison = IpComparisonResult(
detected = false,
summary = "",
ruGroup = group("ru"),
nonRuGroup = group("non-ru"),
),
directSigns = category("direct"),
indirectSigns = category("indirect"),
locationSignals = category("location"),
bypassResult = BypassResult(
proxyEndpoint = null,
directIp = null,
proxyIp = null,
xrayApiScanResult = null,
findings = emptyList(),
detected = false,
),
verdict = Verdict.NOT_DETECTED,
diagnosticSnapshot = snapshot,
)
private fun category(name: String) = CategoryResult(name, detected = false, findings = emptyList())
private fun group(name: String) = IpCheckerGroupResult(
title = name,
detected = false,
statusLabel = "",
summary = "",
responses = emptyList(),
)
private fun ipResponse(ip: String, scope: IpCheckerScope) = IpCheckerResponse(
label = "service",
url = "https://example.test/ip",
scope = scope,
ip = ip,
)
private fun collectVisibleText(view: View): String {
if (view.visibility != View.VISIBLE) return ""
if (view is TextView) return view.text.toString()
if (view !is ViewGroup) return ""
return (0 until view.childCount).joinToString("\n") { collectVisibleText(view.getChildAt(it)) }
}
private fun collectAllText(view: View): String {
if (view is TextView) return view.text.toString()
if (view !is ViewGroup) return ""
return (0 until view.childCount).joinToString("\n") { collectAllText(view.getChildAt(it)) }
}
private fun findMaterialButton(root: View, text: String): MaterialButton? {
if (root is MaterialButton && root.text.toString() == text) return root
if (root !is ViewGroup) return null
return (0 until root.childCount).firstNotNullOfOrNull { findMaterialButton(root.getChildAt(it), text) }
}
private fun findTextView(root: View, predicate: (TextView) -> Boolean): TextView? {
if (root is TextView && predicate(root)) return root
if (root !is ViewGroup) return null
return (0 until root.childCount).firstNotNullOfOrNull { findTextView(root.getChildAt(it), predicate) }
}
@Suppress("UNCHECKED_CAST")
private fun <T> getPrivateField(target: Any, name: String): T {
val field = target::class.java.getDeclaredField(name)
field.isAccessible = true
return field.get(target) as T
}
@Suppress("UNCHECKED_CAST")
private fun <T> invokePrivate(target: Any, name: String, vararg args: Any?): T {
val method = target::class.java.declaredMethods.first {
it.name == name && it.parameterTypes.size == args.size
}
method.isAccessible = true
return method.invoke(target, *args) as T
}
companion object {
private const val TECHNICAL_MARKER = "enum=ROUTING command=dumpsys path=/proc/net/route"
}
}

View file

@ -61,6 +61,7 @@ class SettingsActivityTest {
putBoolean(SettingsPrefs.PREF_PRIVACY_MODE, true)
putString(SettingsPrefs.PREF_THEME, "system")
putString(SettingsPrefs.PREF_LANGUAGE, "ru")
putString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, ResultDisplayMode.ADVANCED.prefValue)
putString(SettingsPrefs.PREF_COLOR_VISION_MODE, ColorVisionMode.BLUE_YELLOW.prefValue)
}
@ -90,6 +91,7 @@ class SettingsActivityTest {
R.string.settings_value_appearance_format,
activity.getString(R.string.settings_theme_system),
"RU",
activity.getString(R.string.settings_result_display_mode_advanced),
),
rowValue(root, R.id.rowAppearance),
)
@ -100,6 +102,37 @@ class SettingsActivityTest {
assertEquals("v${BuildConfig.VERSION_NAME}", rowValue(root, R.id.rowAbout))
}
@Test
fun `appearance fragment defaults unknown result mode to normal and saves every mode`() {
AppUiSettings.prefs(context).edit {
putString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, "unexpected")
}
val activity = Robolectric.buildActivity(SettingsActivity::class.java).setup().get()
activity.supportFragmentManager.beginTransaction()
.replace(R.id.settingsFragmentContainer, SettingsAppearanceFragment())
.commitNow()
val root = activity.supportFragmentManager
.findFragmentById(R.id.settingsFragmentContainer)!!
.requireView()
val prefs = AppUiSettings.prefs(activity)
val normal = root.findViewById<Chip>(R.id.chipResultDisplayNormal)
val simple = root.findViewById<Chip>(R.id.chipResultDisplaySimple)
val advanced = root.findViewById<Chip>(R.id.chipResultDisplayAdvanced)
assertTrue(normal.isChecked)
simple.performClick()
assertEquals(ResultDisplayMode.SIMPLE.prefValue, prefs.getString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, null))
normal.performClick()
assertEquals(ResultDisplayMode.NORMAL.prefValue, prefs.getString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, null))
advanced.performClick()
assertEquals(ResultDisplayMode.ADVANCED.prefValue, prefs.getString(SettingsPrefs.PREF_RESULT_DISPLAY_MODE, null))
assertEquals(
activity.getString(R.string.settings_result_display_mode_advanced_desc),
root.findViewById<TextView>(R.id.textResultDisplayModeDescription).text.toString(),
)
}
@Test
fun `accessibility fragment saves color vision mode`() {
val activity = Robolectric.buildActivity(SettingsActivity::class.java).setup().get()

View file

@ -0,0 +1,63 @@
package com.notcvnt.rknhardering.checker
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.customcheck.DirectSignsConfig
import com.notcvnt.rknhardering.customcheck.GeoIpConfig
import com.notcvnt.rknhardering.customcheck.IndirectSignsConfig
import com.notcvnt.rknhardering.customcheck.IpComparisonConfig
import com.notcvnt.rknhardering.customcheck.LocationSignalsConfig
import com.notcvnt.rknhardering.diagnostics.DiagnosticTraceCollector
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.Finding
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AdvancedDiagnosticRunnerTest {
@After
fun tearDown() {
VpnCheckRunner.dependenciesOverride = null
}
@Test
fun `runner snapshot contains native result returned by current invocation`() = runBlocking {
val marker = "JNI current-run getifaddrs result"
VpnCheckRunner.dependenciesOverride = VpnCheckRunner.Dependencies(
tunInterfaceInfoCollector = { TunInterfaceInfo(false, null, null) },
nativeCheck = {
CategoryResult(
name = "Native",
detected = false,
findings = listOf(Finding(marker, isInformational = true)),
)
},
)
val collector = DiagnosticTraceCollector(privacyMode = false)
val context: Context = ApplicationProvider.getApplicationContext()
val result = VpnCheckRunner.run(
context = context,
settings = CheckSettings(
splitTunnelEnabled = false,
networkRequestsEnabled = false,
nativeSignsEnabled = true,
icmpSpoofingEnabled = false,
geoIp = GeoIpConfig(enabled = false),
ipComparison = IpComparisonConfig(enabled = false),
directSigns = DirectSignsConfig(enabled = false),
indirectSigns = IndirectSignsConfig(enabled = false),
locationSignals = LocationSignalsConfig(enabled = false),
),
executionContext = ScanExecutionContext(diagnosticCollector = collector),
)
val nativeEntry = result.diagnosticSnapshot!!.entries.single { it.source == "NativeSignsChecker" }
assertTrue(nativeEntry.body.contains(marker))
}
}

View file

@ -14,6 +14,8 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.IOException
import java.net.Inet4Address
import java.net.InetAddress
import kotlin.system.measureTimeMillis
@RunWith(RobolectricTestRunner::class)
@ -78,6 +80,51 @@ class IcmpSpoofingCheckerTest {
assertTrue(result.hasError)
}
@Test
fun `dns sinkhole address is not pinged or reported as spoofing`() = runBlocking {
var sinkholePingCalled = false
IcmpSpoofingChecker.dependenciesOverride = IcmpSpoofingChecker.Dependencies(
resolveIpv4 = { host, _ -> if (host == "instagram.com") "0.0.0.0" else "8.8.8.8" },
ping = { address, _, _ ->
if (address == "0.0.0.0") sinkholePingCalled = true
pingResult(received = 3, min = 0.1, avg = 0.2, max = 0.3)
},
)
val result = IcmpSpoofingChecker.check(context, DnsResolverConfig.system())
assertFalse(sinkholePingCalled)
assertFalse(result.needsReview)
assertFalse(result.evidence.any { it.source == EvidenceSource.ICMP_SPOOFING && it.detected })
assertTrue(result.hasError)
}
@Test
fun `only globally usable unicast IPv4 addresses may be pinged`() {
val rejected = listOf(
"0.0.0.0",
"0.0.0.1",
"10.0.0.1",
"100.64.0.1",
"127.0.0.1",
"169.254.0.1",
"172.16.0.1",
"192.0.0.1",
"192.0.2.1",
"192.168.0.1",
"198.18.0.1",
"198.51.100.1",
"203.0.113.1",
"224.0.0.1",
"255.255.255.255",
)
rejected.forEach { address ->
assertFalse(address, IcmpSpoofingChecker.isUsablePublicIpv4(ipv4(address)))
}
assertTrue(IcmpSpoofingChecker.isUsablePublicIpv4(ipv4("8.8.8.8")))
}
@Test
fun `icmp targets are probed in parallel`() {
IcmpSpoofingChecker.dependenciesOverride = IcmpSpoofingChecker.Dependencies(
@ -148,4 +195,8 @@ class IcmpSpoofingCheckerTest {
rawOutput = "",
)
}
private fun ipv4(address: String): Inet4Address {
return InetAddress.getByName(address) as Inet4Address
}
}

View file

@ -224,6 +224,79 @@ class NativeSignsCheckerTest {
assertFalse(outcome.detected)
}
@Test
fun `kernel local route for interface own address does not emit evidence`() {
// Reproduces issue #78: ccmni1 cellular interface with a public-range address
// (12.233.114.164/8) produces a kernel-managed /32 entry in the local table.
// dst == prefsrc, type=local, scope=host — this is the interface's own address,
// not a VPN server host-route leak.
val localOwnAddress = NativeRouteEntry(
interfaceName = "ccmni1", destinationHex = "0CE972A4", gatewayHex = "00000000",
flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK,
family = 2, destination = "12.233.114.164", prefSrc = "12.233.114.164",
scope = "host", type = "local", table = 255, prefixLen = 32,
)
val broadcastEntry = NativeRouteEntry(
interfaceName = "ccmni1", destinationHex = "0CFFFFFF", gatewayHex = "00000000",
flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK,
family = 2, destination = "12.255.255.255", scope = "link",
type = "broadcast", table = 255, prefixLen = 32,
)
val outcome = NativeSignsChecker.evaluateHostRoutes(context, listOf(localOwnAddress, broadcastEntry))
assertFalse(outcome.detected)
}
@Test
fun `carrier service host routes do not emit VPN server leak evidence`() {
val routes = listOf(
NativeRouteEntry(
interfaceName = "ccmni0", destinationHex = "1A1272F5", gatewayHex = "00000000",
flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK,
family = 2, destination = "26.18.114.245", scope = "link",
type = "unicast", table = 1008, prefixLen = 32, protocol = 2,
),
NativeRouteEntry(
interfaceName = "rmnet_data0", destinationHex = "1A1272F5", gatewayHex = "00000000",
flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK,
family = 2, destination = "26.18.114.245", scope = "link",
type = "unicast", table = 1009, prefixLen = 32, protocol = 2,
),
)
val outcome = NativeSignsChecker.evaluateHostRoutes(context, routes)
assertFalse(outcome.detected)
assertFalse(outcome.evidence.any { it.source == EvidenceSource.NATIVE_ROUTE })
}
@Test
fun `malformed local table route does not emit VPN server leak evidence`() {
val route = NativeRouteEntry(
interfaceName = "ccmni0", destinationHex = "1A1272F5", gatewayHex = "00000000",
flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK,
family = 2, destination = "26.18.114.245", scope = "link",
type = "unicast", table = 255, prefixLen = 32,
)
val outcome = NativeSignsChecker.evaluateHostRoutes(context, listOf(route))
assertFalse(outcome.detected)
}
@Test
fun `static public host route via cellular interface remains detectable`() {
val route = NativeRouteEntry(
interfaceName = "ccmni0", destinationHex = "1A1272F5", gatewayHex = "00000000",
flags = 0, isDefault = false, source = NativeRouteEntry.RouteSource.NETLINK,
family = 2, destination = "26.18.114.245", scope = "global",
type = "unicast", table = 1008, prefixLen = 32, protocol = 4,
)
val outcome = NativeSignsChecker.evaluateHostRoutes(context, listOf(route))
assertTrue(outcome.detected)
}
@Test
fun `interface with tuntap type and nonstandard name emits NATIVE_INTERFACE evidence`() {
val ifaces = listOf(

View file

@ -16,11 +16,81 @@ import com.notcvnt.rknhardering.model.GeoIpFacts
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.LocationSignalsFacts
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.model.VerdictRuleCode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class VerdictEngineTest {
@Test
fun `detailed R1 keeps legacy verdict and reports sources`() {
val argsBypass = bypass(
evidence = listOf(evidence(EvidenceSource.PROXY_AUTH_BYPASS, EvidenceConfidence.HIGH)),
)
val detailed = VerdictEngine.evaluateDetailed(
geoIp = category(),
directSigns = category(),
indirectSigns = category(),
locationSignals = category(),
bypassResult = argsBypass,
ipConsensus = IpConsensusResult.empty(),
)
assertEquals(
VerdictEngine.evaluate(
category(), category(), category(), category(), argsBypass, IpConsensusResult.empty(),
),
detailed.verdict,
)
assertEquals(VerdictRuleCode.R1_HARD_BYPASS, detailed.rule)
assertEquals(setOf(EvidenceSource.PROXY_AUTH_BYPASS), detailed.participants.single().evidenceSources)
}
@Test
fun `detailed R3 and R6 identify the deciding rule`() {
val r3 = VerdictEngine.evaluateDetailed(
geoIp = category(),
directSigns = category(),
indirectSigns = category(),
locationSignals = category(),
bypassResult = bypass(),
ipConsensus = IpConsensusResult(crossChannelMismatch = true, geoCountryMismatch = true),
)
val r6 = VerdictEngine.evaluateDetailed(
geoIp = category(),
directSigns = category(needsReview = true),
indirectSigns = category(),
locationSignals = category(),
bypassResult = bypass(),
ipConsensus = IpConsensusResult.empty(),
)
assertEquals(VerdictRuleCode.R3_CROSS_CHANNEL_MISMATCH, r3.rule)
assertEquals(Verdict.DETECTED, r3.verdict)
assertEquals(VerdictRuleCode.R6_FALLBACK, r6.rule)
assertEquals(Verdict.NEEDS_REVIEW, r6.verdict)
}
@Test
fun `detailed roaming suppression remains R5 not detected`() {
val detailed = VerdictEngine.evaluateDetailed(
geoIp = category(geoFacts = GeoIpFacts(outsideRu = true, expectedRoamingExit = true)),
directSigns = category(),
indirectSigns = category(),
locationSignals = category(
findings = listOf(Finding("network_mcc_ru:true")),
locationFacts = LocationSignalsFacts(homeRoutedRoaming = true),
),
bypassResult = bypass(),
ipConsensus = IpConsensusResult.empty(),
)
assertEquals(Verdict.NOT_DETECTED, detailed.verdict)
assertEquals(VerdictRuleCode.R5_MATRIX, detailed.rule)
assertTrue(detailed.participants.any { it.factor == "expected_roaming_exit=true" })
}
@Test
fun `R1 split tunnel evidence yields detected`() {
val verdict = VerdictEngine.evaluate(
@ -588,6 +658,57 @@ class VerdictEngineTest {
assertEquals(Verdict.NEEDS_REVIEW, verdict)
}
@Test
fun `high confidence native socket evidence confirms foreign geo`() {
val verdict = VerdictEngine.evaluate(
geoIp = category(geoFacts = GeoIpFacts(outsideRu = true, countryCode = "DE")),
directSigns = category(),
indirectSigns = category(),
locationSignals = category(),
bypassResult = bypass(),
ipConsensus = IpConsensusResult.empty(),
nativeSigns = category(
evidence = listOf(evidence(EvidenceSource.NATIVE_SOCKET, EvidenceConfidence.HIGH)),
),
)
assertEquals(Verdict.DETECTED, verdict)
}
@Test
fun `high confidence native socket evidence alone yields needs review`() {
val verdict = VerdictEngine.evaluate(
geoIp = category(),
directSigns = category(),
indirectSigns = category(),
locationSignals = category(),
bypassResult = bypass(),
ipConsensus = IpConsensusResult.empty(),
nativeSigns = category(
evidence = listOf(evidence(EvidenceSource.NATIVE_SOCKET, EvidenceConfidence.HIGH)),
),
)
assertEquals(Verdict.NEEDS_REVIEW, verdict)
}
@Test
fun `medium confidence native socket evidence does not enter verdict matrix`() {
val verdict = VerdictEngine.evaluate(
geoIp = category(),
directSigns = category(),
indirectSigns = category(),
locationSignals = category(),
bypassResult = bypass(),
ipConsensus = IpConsensusResult.empty(),
nativeSigns = category(
evidence = listOf(evidence(EvidenceSource.NATIVE_SOCKET, EvidenceConfidence.MEDIUM)),
),
)
assertEquals(Verdict.NOT_DETECTED, verdict)
}
@Test
fun `proxy assisted udp leak does not affect verdict`() {
val verdict = VerdictEngine.evaluate(

View file

@ -0,0 +1,110 @@
package com.notcvnt.rknhardering.checker
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.notcvnt.rknhardering.ScanCancellationSignal
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.probe.NativeSignsBridge
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class VpnNativeDetectorCheckerTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
NativeSignsBridge.resetForTests()
NativeSignsBridge.isLibraryLoadedOverride = { true }
}
@After
fun tearDown() {
NativeSignsBridge.resetForTests()
}
@Test
fun `neutral measurements are informational and do not create evidence`() {
NativeSignsBridge.detectVpnDetectorOverride = {
arrayOf(
"vdet|route_count|routes=12",
"vdet|pmtu_mss_combined|tcp_snd_mss=1440 tcp_rcv_mss=1440",
"vdet|udp_pmtu_ok|sent=1500 bytes",
"vdet|normal_pmtu|iface=wlan0 mtu=1500",
"vdet|timing_oracle|min=1 max=2 avg=1",
"vdet|backpressure|50000/50000 pkts sent",
"vdet|gso_ok|supported",
"vdet|hw_timestamp|unsupported",
)
}
val result = runBlocking { VpnNativeDetectorChecker.check(context) }
assertFalse(result.detected)
assertFalse(result.needsReview)
assertTrue(result.evidence.isEmpty())
assertTrue(result.findings.all { it.isInformational })
}
@Test
fun `gso failures are capability diagnostics instead of vpn evidence`() {
NativeSignsBridge.detectVpnDetectorOverride = {
arrayOf(
"vdet|gso_failed|errno=92",
"vdet|gso_send_failed|errno=90",
)
}
val result = runBlocking { VpnNativeDetectorChecker.check(context) }
assertFalse(result.detected)
assertFalse(result.needsReview)
assertTrue(result.evidence.isEmpty())
assertTrue(result.findings.all { it.isInformational })
}
@Test
fun `high confidence socket leak remains detected evidence`() {
NativeSignsBridge.detectVpnDetectorOverride = {
arrayOf("vdet|bindtodevice_leak|setsockopt(tun0) succeeded")
}
val result = runBlocking { VpnNativeDetectorChecker.check(context) }
val evidence = result.evidence.single()
assertTrue(result.detected)
assertEquals(EvidenceSource.NATIVE_SOCKET, evidence.source)
assertEquals(EvidenceConfidence.HIGH, evidence.confidence)
assertTrue(evidence.detected)
}
@Test
fun `native detector receives scan cancellation and aborts result`() {
val cancellationSignal = ScanCancellationSignal()
val executionContext = ScanExecutionContext(cancellationSignal = cancellationSignal)
NativeSignsBridge.detectVpnDetectorOverride = { receivedSignal ->
assertSame(cancellationSignal, receivedSignal)
cancellationSignal.cancel()
arrayOf("vdet|backpressure|partial")
}
assertThrows(CancellationException::class.java) {
runBlocking(executionContext.asCoroutineContext()) {
VpnNativeDetectorChecker.check(context)
}
}
}
}

View file

@ -0,0 +1,76 @@
package com.notcvnt.rknhardering.diagnostics
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.ResolverHttpResponse
import com.notcvnt.rknhardering.network.ResolverNetworkStack
import com.notcvnt.rknhardering.probe.SystemPingProber
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DiagnosticCaptureIntegrationTest {
@After
fun tearDown() {
ResolverNetworkStack.resetForTests()
SystemPingProber.runCommandOverride = null
}
@Test
fun `http captures actual status duration and used response body`() = runBlocking {
val collector = DiagnosticTraceCollector(privacyMode = false)
val executionContext = ScanExecutionContext(diagnosticCollector = collector)
ResolverNetworkStack.okHttpExecuteOverride = {
ResolverHttpResponse(207, "body-used token=do-not-store")
}
val response = kotlinx.coroutines.withContext(executionContext.asCoroutineContext()) {
ResolverNetworkStack.execute(
url = "https://example.test/value",
method = "GET",
timeoutMs = 1_000,
config = DnsResolverConfig.system(),
cancellationSignal = executionContext.cancellationSignal,
)
}
assertEquals(207, response.code)
val entry = collector.snapshot().entries.single()
assertEquals("HTTP 207", entry.status)
assertTrue(entry.durationMs != null)
assertTrue(entry.body.contains("body-used"))
assertFalse(entry.body.contains("do-not-store"))
}
@Test
fun `ping captures exact command exit stdout and stderr from current run`() = runBlocking {
val collector = DiagnosticTraceCollector(privacyMode = false)
val executionContext = ScanExecutionContext(diagnosticCollector = collector)
SystemPingProber.runCommandOverride = {
SystemPingProber.CommandResult(
exitCode = 0,
output = "3 packets transmitted, 2 received\nrtt min/avg/max = 10/20/30 ms",
stderr = "diagnostic stderr",
)
}
val result = kotlinx.coroutines.withContext(executionContext.asCoroutineContext()) {
SystemPingProber.probe("203.0.113.5", executionContext = executionContext)
}
assertEquals(2, result.received)
val entry = collector.snapshot().entries.single()
assertTrue(entry.body.contains("command=ping -4 -n -c 3 -W 4 203.0.113.5"))
assertTrue(entry.body.contains("exitCode=0"))
assertTrue(entry.body.contains("3 packets transmitted"))
assertTrue(entry.body.contains("diagnostic stderr"))
}
}

View file

@ -0,0 +1,140 @@
package com.notcvnt.rknhardering.diagnostics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class DiagnosticTraceCollectorTest {
@Test
fun `records concurrently and sorts deterministically`() {
val collector = DiagnosticTraceCollector(privacyMode = false)
val pool = Executors.newFixedThreadPool(4)
val start = CountDownLatch(1)
val done = CountDownLatch(40)
repeat(40) { index ->
pool.execute {
start.await()
collector.record(
category = if (index % 2 == 0) "b" else "a",
source = "source-${40 - index}",
status = "ok",
body = "body-$index",
)
done.countDown()
}
}
start.countDown()
assertTrue(done.await(2, TimeUnit.SECONDS))
pool.shutdownNow()
val snapshot = collector.snapshot()
assertEquals(40, snapshot.entries.size)
assertEquals(
snapshot.entries.sortedWith(
compareBy<DiagnosticEntry>(
DiagnosticEntry::category,
DiagnosticEntry::source,
{ it.target.orEmpty() },
DiagnosticEntry::status,
DiagnosticEntry::body,
),
),
snapshot.entries,
)
}
@Test
fun `enforces utf8 entry and run limits`() {
val collector = DiagnosticTraceCollector(
privacyMode = false,
entryLimitBytes = 16,
runLimitBytes = 22,
)
collector.record("a", "one", status = "ok", body = "ёжикёж")
collector.record("b", "two", status = "ok", body = "abcdef")
val snapshot = collector.snapshot()
assertEquals(22, snapshot.storedBytes)
assertTrue(snapshot.truncated)
assertEquals(18, snapshot.entries.first { it.source == "one" }.originalBytes)
assertTrue(snapshot.entries.all { it.storedBytes <= 16 })
assertTrue(snapshot.entries.all { it.truncated })
}
@Test
fun `redacts secrets before storage`() {
val collector = DiagnosticTraceCollector(privacyMode = false)
collector.record(
category = "http",
source = "client",
target = "https://alice:password@example.test/path?token=secret-token&safe=yes",
status = "Authorization: Bearer abc.def",
body = """
Cookie: session=hidden
password=hunter2
{"cookie":"SID=body-secret","authorization":"Token body-auth","key":"body-key"}
uuid=123e4567-e89b-12d3-a456-426614174000
bssid=aa:bb:cc:dd:ee:ff
""".trimIndent(),
)
val entry = collector.snapshot().entries.single()
val stored = listOf(entry.target, entry.status, entry.body).joinToString("\n")
listOf(
"alice", "password@example", "secret-token", "abc.def", "hidden", "hunter2",
"body-secret", "body-auth", "body-key", "123e4567", "aa:bb",
).forEach {
assertFalse("Leaked <$it> in <$stored>", stored.contains(it, ignoreCase = true))
}
assertTrue(stored.contains("[REDACTED]"))
}
@Test
fun `privacy mode masks public and local ip addresses in every field`() {
val collector = DiagnosticTraceCollector(privacyMode = true)
collector.record(
category = "10.0.0.1",
source = "2001:db8::1",
target = "127.0.0.1",
status = "from 192.0.2.10",
body = "local=::1 public=8.8.8.8",
)
val entry = collector.snapshot().entries.single()
val stored = listOf(entry.category, entry.source, entry.target, entry.status, entry.body).joinToString("\n")
listOf("10.0.0.1", "2001:db8::1", "127.0.0.1", "192.0.2.10", "::1", "8.8.8.8").forEach {
assertFalse(stored.contains(it))
}
assertTrue(stored.contains("redacted"))
}
@Test
fun `clear and snapshot reject later writes`() {
val collector = DiagnosticTraceCollector(privacyMode = false)
collector.record("a", "before", status = "ok", body = "kept")
collector.clear()
collector.record("b", "after", status = "ok", body = "ignored")
assertEquals(DiagnosticSnapshot.EMPTY, collector.snapshot())
}
@Test
fun `scan truncation remains visible when a later entry cannot fit`() {
val collector = DiagnosticTraceCollector(
privacyMode = false,
entryLimitBytes = 8,
runLimitBytes = 8,
)
collector.record("a", "one", status = "ok", body = "ab")
collector.record("b", "two", status = "ok", body = "c")
val snapshot = collector.snapshot()
assertEquals(1, snapshot.entries.size)
assertFalse(snapshot.entries.single().truncated)
assertTrue(snapshot.truncated)
}
}

View file

@ -30,4 +30,14 @@ class NetworkInterfacePatternsTest {
assertTrue(NetworkInterfacePatterns.isIpsecInterface("xfrm0"))
assertFalse(NetworkInterfacePatterns.isVpnInterface("xfrm0"))
}
@Test
fun `classifies cellular modem interfaces`() {
val modemNames = listOf("rmnet_data0", "ccmni0", "ccemni1", "pdp0", "seth_lte0", "v4-ccmni0")
for (name in modemNames) {
assertTrue("expected $name to be cellular", NetworkInterfacePatterns.isCellularModemInterface(name))
}
assertFalse(NetworkInterfacePatterns.isCellularModemInterface("wlan0"))
assertFalse(NetworkInterfacePatterns.isCellularModemInterface("eth0"))
}
}

View file

@ -6,8 +6,11 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.net.DatagramSocket
import java.net.Socket
class ResolverBindingTest {
@ -31,6 +34,24 @@ class ResolverBindingTest {
assertEquals(listOf("tun0"), boundInterfaces)
}
@Test
fun `socket binding infrastructure failure is propagated`() {
Socket().use { socket ->
assertThrows(RuntimeException::class.java) {
ResolverSocketBinder.bind(socket, ResolverBinding.OsDeviceBinding("missing0"))
}
}
}
@Test
fun `datagram binding infrastructure failure is propagated`() {
DatagramSocket().use { socket ->
assertThrows(RuntimeException::class.java) {
ResolverSocketBinder.bind(socket, ResolverBinding.OsDeviceBinding("missing0"))
}
}
}
@Test
fun `build client uses bind to device socket factory for os device binding`() {
val client = ResolverNetworkStack.buildClient(

View file

@ -147,13 +147,28 @@ class NativeInterfaceProbeTest {
@Test
fun `parseNetlinkRoutes preserves prefix length for host routes`() {
val rows = arrayOf(
"route|family=2|dst=203.0.113.7/32|via=192.168.1.1|dev=wlan0|oif=5",
"route|family=2|dst=203.0.113.7/32|via=192.168.1.1|dev=wlan0|oif=5|proto=2",
"route|family=2|dst=10.8.0.0/24|dev=tun0|oif=7",
)
val parsed = NativeInterfaceProbe.parseNetlinkRoutes(rows)
val hostRoute = parsed.first { it.destination == "203.0.113.7" }
assertEquals(32, hostRoute.prefixLen)
assertEquals(2, hostRoute.protocol)
val subnet = parsed.first { it.destination == "10.8.0.0" }
assertEquals(24, subnet.prefixLen)
}
@Test
fun `parseNetlinkRoutes recognizes native textual address families`() {
val rows = arrayOf(
"route|family=inet|dst=default/0|dev=rmnet_data0|oif=5|table=254|scope=global|type=unicast|proto=3",
"route|family=inet6|dst=default/0|dev=rmnet_data0|oif=5|table=254|scope=global|type=unicast|proto=3",
)
val parsed = NativeInterfaceProbe.parseNetlinkRoutes(rows)
assertEquals(listOf(2, 10), parsed.map { it.family })
assertEquals(listOf(8, 32), parsed.map { it.destinationHex.length })
assertTrue(parsed.all { it.isDefault })
}
}

View file

@ -0,0 +1,39 @@
package com.notcvnt.rknhardering.probe
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.diagnostics.DiagnosticTraceCollector
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeSignsBridgeDiagnosticTest {
@After
fun tearDown() {
NativeSignsBridge.resetForTests()
}
@Test
fun `captures raw jni and proc values from original invocation`() = runBlocking {
val interfaceRow = "tun0|7|65|AF_INET|203.0.113.8|255.255.255.0|1400|65534"
val procRoute = "Iface Destination Gateway Flags\ntun0 00000000 0100000A 0003"
NativeSignsBridge.isLibraryLoadedOverride = { true }
NativeSignsBridge.getIfAddrsOverride = { arrayOf(interfaceRow) }
NativeSignsBridge.netlinkRouteDumpOverride = { emptyArray() }
NativeSignsBridge.readProcFileOverride = { path, _ ->
procRoute.takeIf { path.endsWith("/route") }
}
val collector = DiagnosticTraceCollector(privacyMode = false)
val executionContext = ScanExecutionContext(diagnosticCollector = collector)
withContext(executionContext.asCoroutineContext()) {
NativeInterfaceProbe.collectInterfaces()
NativeInterfaceProbe.collectRoutes()
}
val snapshot = collector.snapshot()
assertTrue(snapshot.entries.single { it.source == "getifaddrs" }.body.contains(interfaceRow))
assertTrue(snapshot.entries.any { it.source == "readProcFile" && it.body.contains(procRoute) })
}
}

View file

@ -0,0 +1,237 @@
package com.notcvnt.rknhardering.ui.main
import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.CallTransportLeakResult
import com.notcvnt.rknhardering.model.CallTransportNetworkPath
import com.notcvnt.rknhardering.model.CallTransportProbeKind
import com.notcvnt.rknhardering.model.CallTransportService
import com.notcvnt.rknhardering.model.CallTransportStatus
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.CdnPullingResult
import com.notcvnt.rknhardering.model.DomainReachabilityResponse
import com.notcvnt.rknhardering.model.DomainReachabilityResult
import com.notcvnt.rknhardering.model.DomainReachabilityStepStatus
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpComparisonResult
import org.junit.Assert.assertEquals
import org.junit.Test
class SimpleResultModelsTest {
@Test
fun `category covers clean review detected and error`() {
assertEquals(SimpleResultStatus.CLEAN, SimpleResultModels.category(category()).status)
assertEquals(
SimpleResultStatus.REVIEW,
SimpleResultModels.category(category(needsReview = true)).status,
)
assertEquals(
SimpleResultStatus.DETECTED,
SimpleResultModels.category(category(detected = true)).status,
)
assertEquals(
SimpleResultStatus.ERROR,
SimpleResultModels.category(category(findings = listOf(Finding("failure", isError = true)))).status,
)
}
@Test
fun `specialized models map structured states without parsing text`() {
val emptyGroup = IpCheckerGroupResult(
title = "group",
detected = false,
statusLabel = "",
summary = "",
responses = emptyList(),
)
assertEquals(
SimpleResultStatus.REVIEW,
SimpleResultModels.ipComparison(
IpComparisonResult(
detected = false,
needsReview = true,
summary = "arbitrary technical text",
ruGroup = emptyGroup,
nonRuGroup = emptyGroup,
),
).status,
)
assertEquals(
SimpleResultStatus.DETECTED,
SimpleResultModels.cdn(CdnPullingResult(detected = true, summary = "raw enum UNKNOWN")).status,
)
assertEquals(
SimpleResultStatus.REVIEW,
SimpleResultModels.bypass(bypass(needsReview = true)).status,
)
assertEquals(
SimpleResultStatus.REVIEW,
SimpleResultModels.callTransport(
leaks = listOf(
CallTransportLeakResult(
service = CallTransportService.TELEGRAM,
probeKind = CallTransportProbeKind.DIRECT_UDP_STUN,
networkPath = CallTransportNetworkPath.ACTIVE,
status = CallTransportStatus.NEEDS_REVIEW,
summary = "ignored",
),
),
stunGroups = emptyList(),
).status,
)
}
@Test
fun `ip comparison reports the exact disagreeing group from structured state`() {
val ruGroup = IpCheckerGroupResult(
title = "ru",
detected = true,
statusLabel = "ignored",
summary = "raw summary must not be parsed",
responses = emptyList(),
)
val otherGroup = IpCheckerGroupResult(
title = "other",
detected = false,
statusLabel = "ignored",
summary = "ignored",
canonicalIp = "203.0.113.1",
responses = emptyList(),
)
val model = SimpleResultModels.ipComparison(
IpComparisonResult(
detected = false,
needsReview = true,
summary = "different wording",
ruGroup = ruGroup,
nonRuGroup = otherGroup,
),
)
assertEquals(listOf(SimpleResultCause.IP_RU_SERVICES_DISAGREE), model.causes)
}
@Test
fun `category preserves distinct structured causes in deterministic order`() {
val result = CategoryResult(
name = "direct",
detected = true,
findings = listOf(
Finding(
description = "secret path and raw error must be ignored",
detected = true,
source = EvidenceSource.ROUTING,
),
Finding(
description = "another technical sentence",
detected = true,
source = EvidenceSource.SYSTEM_PROXY,
),
Finding(
description = "duplicate",
detected = true,
source = EvidenceSource.ROUTING,
),
),
)
assertEquals(
listOf(SimpleResultCause.SYSTEM_PROXY, SimpleResultCause.VPN_ROUTE),
SimpleResultModels.category(result).causes,
)
}
@Test
fun `error cause describes unavailable data without claiming a detected signal`() {
val result = category(
findings = listOf(
Finding(
description = "raw provider error must not be parsed",
isError = true,
source = EvidenceSource.GEO_IP,
),
),
)
assertEquals(
listOf(SimpleResultCause.PUBLIC_DATA_UNAVAILABLE),
SimpleResultModels.category(result).causes,
)
}
@Test
fun `domain reachability distinguishes empty clean and mismatch`() {
assertEquals(
SimpleResultStatus.ERROR,
SimpleResultModels.domainReachability(DomainReachabilityResult.empty()).status,
)
assertEquals(
SimpleResultStatus.CLEAN,
SimpleResultModels.domainReachability(DomainReachabilityResult(listOf(domain(matches = true)))).status,
)
val mismatch = SimpleResultModels.domainReachability(
DomainReachabilityResult(listOf(domain(matches = false))),
)
assertEquals(SimpleResultStatus.DETECTED, mismatch.status)
assertEquals(listOf(SimpleResultCause.DOMAIN_TCP_MISMATCH), mismatch.causes)
}
@Test
fun `disabled remains an explicit presentation state`() {
assertEquals(SimpleResultStatus.DISABLED, SimpleCardModel(SimpleResultStatus.DISABLED).status)
}
@Test
fun `installed app signal stays clean but is marked as additional information`() {
val result = CategoryResult(
name = "direct",
detected = false,
findings = emptyList(),
evidence = listOf(
EvidenceItem(
source = EvidenceSource.INSTALLED_APP,
detected = false,
confidence = EvidenceConfidence.LOW,
description = "diagnostic only",
),
),
)
val model = SimpleResultModels.category(result)
assertEquals(SimpleResultStatus.CLEAN, model.status)
assertEquals(true, model.extraInformation)
}
private fun category(
detected: Boolean = false,
needsReview: Boolean = false,
findings: List<Finding> = emptyList(),
) = CategoryResult(
name = "test",
detected = detected,
needsReview = needsReview,
findings = findings,
)
private fun bypass(needsReview: Boolean) = BypassResult(
proxyEndpoint = null,
directIp = null,
proxyIp = null,
xrayApiScanResult = null,
findings = emptyList(),
detected = false,
needsReview = needsReview,
)
private fun domain(matches: Boolean): DomainReachabilityResponse = DomainReachabilityResponse(
domain = "example.test",
label = "Example",
dnsStatus = DomainReachabilityStepStatus.OK,
tcpStatus = if (matches) DomainReachabilityStepStatus.OK else DomainReachabilityStepStatus.FAILED,
tlsStatus = DomainReachabilityStepStatus.OK,
)
}

View file

@ -501,7 +501,7 @@ For every interface, `/sys/class/net/<name>/type` is read. A value of `65534` (`
#### 12.2 Host-route /32 heuristic
The routing table (NETLINK) is scanned for a non-default route with a `/32` prefix (IPv4) or `/128` (IPv6) to a publicly routable address via a physical interface (`wlan0`, `rmnet`, `eth0`). This is the classic VPN client host-route to the server IP that bypasses the tunnel — a leak of the real VPN server IP. Result: `detected = true` (`EvidenceSource.NATIVE_ROUTE`).
The routing table (NETLINK) is scanned for a non-default route with a `/32` prefix (IPv4) or `/128` (IPv6) to a publicly routable address via a physical interface (`wlan0`, `rmnet`, `eth0`). This is the classic VPN client host-route to the server IP that bypasses the tunnel — a leak of the real VPN server IP. Entries from the `local` table and kernel-created link routes on modem interfaces (`rmnet*`, `ccmni*`, `pdp*`, `seth*`) are excluded because carriers use them for service addressing independently of a VPN. Static global host routes on these interfaces remain candidates. Result: `detected = true` (`EvidenceSource.NATIVE_ROUTE`).
#### 12.3 Emulator detector
@ -519,6 +519,109 @@ Identifies contexts where another user's or profile's VPN is invisible to networ
Any of these signals yields `needsReview = true` (`EvidenceSource.SANDBOX_ISOLATION`), never `detected`.
#### 12.5 VPN Signals (`evaluateVpnSignals`)
Comprehensive VPN detection via native JNI calls. All checks work on **non-rooted** devices — when permissions are missing (SELinux/capabilities), the check is marked as `unavailable` and does not crash.
**Properties and files (`nativeDetectVpnProperties`):**
| Check | What we look for | Source |
|-------|------------------|--------|
| DNS properties | `net.dns1-4`, `net.vpn.dns1-2`, `dhcp.tun0.dns1-2` | `__system_property_get` |
| VPN properties | `net.vpn.default_iface`, `vpn.enable`, `net.tun0.dns1-2`, `net.ppp0.dns1-2` | `__system_property_get` |
| vpnhide files | `/data/local/vpnhide`, `/data/adb/vpnhide`, `/data/local/bypass` etc. | `access(F_OK)` |
| LSPosed/Xposed | `/data/adb/lspd`, `/data/adb/modules/lsposed`, `/data/adb/ksu/modules/lsposed` | `access(F_OK)` |
| Hook properties | `persist.sys.lspd`, `persist.sys.lsposed`, `ro.lsposed.hidden` | `__system_property_get` |
High confidence: `vpn_prop`, `vpnhide`, `hook_prop`. Medium: others.
**Leaks via /proc (`nativeDetectVpnLeaks`):**
| Check | What we look for | Source |
|-------|------------------|--------|
| TCP VPN ports | Connections on ports 443, 1194, 51820, 8443, 1723, 500, 4500 | `/proc/net/tcp[6]` |
| UDP VPN ports | Sockets on ports 51820 (WireGuard), 1194 (OpenVPN), 500, 4500 | `/proc/net/udp[6]` |
| if_inet6 | tun/wg/ppp/tap interfaces in `/proc/net/if_inet6` | `/proc/net/if_inet6` |
| Route VPN | Routes via tun/wg/ppp/tap | `/proc/net/route` |
| FIB trie | `/32 host` entries (non-LOCAL) — VPN host routes | `/proc/net/fib_trie` |
High confidence: `udp_vpn_port`, `route_vpn_iface`, `arp_vpn_iface`, `inet6_vpn_iface`.
**Advanced checks (`nativeDetectVpnAdvanced`):**
| Check | What we look for | Source |
|-------|------------------|--------|
| ARP VPN | Entries on tun/wg/ppp interfaces | `/proc/net/arp` |
| Sysctl | `rp_filter=0`, `ip_forward=1`, `forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` etc. |
| ESTABLISHED VPN | Connections to private IPs on VPN ports | `/proc/net/tcp` |
**Non-traditional syscall checks (`nativeDetectVpnSyscalls`):**
Checks via direct netlink requests and probe connections. Returns `unavailable` when permissions are missing:
| Check | Method | What it detects |
|-------|--------|-----------------|
| RTM_GETRULE | Netlink dump of routing policy rules | VPN policy routing rules |
| RTM_GETQDISC | Netlink dump of queueing disciplines | VPN qdisc tunnels |
| RTM_GETNEIGH | Netlink dump of neighbor table | Hidden MAC addresses (zero LLADDR) |
| TCP_INFO MSS | Connect to 8.8.8.8:443, read `snd_mss` | MSS reduction (tunnel indicator) |
| SO_BINDTODEVICE | Probe bind to non-existent interface | VPN hook intercepting setsockopt |
| Loopback port bind | Try to claim ports 51820/1194/443/8443 | Port conflicts (VPN listener) |
| BPF OBJ GET | Try to open `/sys/fs/bpf/` maps | Access to netd BPF maps |
| IP_RECVERR | Probe setsockopt IP_RECVERR | VPN hook intercepting IP_RECVERR |
High confidence: `vpn_policy_rules`, `hidden_mac_neighbors`, `tcp_mss_low`, `loopback_port_conflict`, `bpf_map_accessible`.
#### 12.6 Deep VPN Detector (`VpnNativeDetectorChecker`)
The new detectors live in a dedicated sub-section inside the Native category, grouped into 4 sub-categories. Data comes from the new JNI method `nativeDetectVpnDetector()`, with lines prefixed `vdet|`.
**Direct signs — `EvidenceSource.NATIVE_INTERFACE`:**
| Check (kind) | What it looks for | Source |
|--------------|-------------------|--------|
| `sysfs_vpn_leak` | tun/wg/ppp/xfrm leak via sysfs | `/sys/class/net`, `/sys/devices/virtual/net`, `/proc/sys/net/ipv4|6/conf|neigh` |
| `getifaddrs_vpn` | VPN interfaces in `getifaddrs()` list | `getifaddrs()` |
| `sysclassnet_vpn` | VPN interfaces in `/sys/class/net` | `stat("/sys/class/net/<if>")` |
| `rtm_getlink_vpn` | VPN interfaces via netlink RTM_GETLINK | Netlink `RTM_GETLINK` dump |
| `proc_if_inet6_vpn` | VPN interfaces in `/proc/net/if_inet6` | `/proc/net/if_inet6` |
| `proc_ipv6_route_vpn` | VPN routes in `/proc/net/ipv6_route` | `/proc/net/ipv6_route` |
| `proc_net_dev_vpn` | VPN traffic (RX/TX) in `/proc/net/dev` | `/proc/net/dev` |
| `ifindexname_vpn` | VPN interfaces via `if_indextoname()` | `if_indextoname()` ifindex sweep |
| `vpn_policy_rules_netlink` | VPN policy routing rules (table 100200, oif=tun) | Netlink `RTM_GETRULE` dump |
**Network signs — `EvidenceSource.NATIVE_SOCKET`:**
| Check (kind) | What it looks for | Source |
|--------------|-------------------|--------|
| `fib_trie_denied` | `/proc/net/fib_trie` unavailable (SELinux EACCES) | `fopen("/proc/net/fib_trie")` |
| `inet_diag_denied` | inet_diag netlink denied (SELinux) | `socket(NETLINK_SOCK_DIAG)` |
| `bindtodevice_leak` | `SO_BINDTODEVICE` to tun + `getsockopt` confirm | `setsockopt(SO_BINDTODEVICE)` |
| `getsockname_leak` | `getsockname()` returns private VPN IP | `getsockname()` on UDP socket |
| `udp_port_conflict_physical` | UDP port conflict (500/4500/1194/1701/51820) on physical IP | `bind()` on physical IP |
| `route_count` | Route count and unique interface count | Netlink `RTM_GETROUTE` dump |
| `trim_oracle` | bind-probe vs RTM_GETLINK iface count mismatch | `if_indextoname()` vs `RTM_GETLINK` |
**Indirect signs — `EvidenceSource.NATIVE_ROUTE`:**
| Check (kind) | What it looks for | Source |
|--------------|-------------------|--------|
| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` |
| `udp_pmtu_ok` / `udp_pmtu_fail` | Success/failure sending 1500 bytes over UDP | `sendto()` 1500 bytes |
| `normal_pmtu` | Path MTU of primary physical interface | `fetchMtu()` via `getifaddrs()` |
| `timing_oracle` | ARM CNTVCT cycles for `sendto()` (min/max/avg) | `mrs cntvct_el0` (aarch64) |
| `backpressure` | Throughput under 50000 UDP packets with scan cancellation | Non-blocking `sendto()` burst + cancellation check every 64 packets |
| `gso_failed` / `gso_send_failed` / `gso_ok` | UDP GSO capability diagnostics; the result is not VPN evidence | `UDP_SEGMENT=1200` + 4800-byte send |
| `hw_timestamp` | Hardware timestamping (`SIOCSHWTSTAMP`, `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` |
**Environment probes — `EvidenceSource.NATIVE_INTERFACE`:**
| Check (kind) | What it looks for | Source |
|--------------|-------------------|--------|
| `traceroute_denied` | Traceroute probe (TTL=1 UDP) blocked | `setsockopt(IP_TTL=1)` + `sendto()` |
High confidence (→ `detected = true`): `sysfs_vpn_leak`, `getifaddrs_vpn`, `sysclassnet_vpn`, `rtm_getlink_vpn`, `proc_if_inet6_vpn`, `proc_ipv6_route_vpn`, `proc_net_dev_vpn`, `ifindexname_vpn`, `vpn_policy_rules_netlink`, `bindtodevice_leak`, `getsockname_leak`, `udp_port_conflict_physical`. Raw measurements and GSO results are informational; other anomalies → `needsReview`.
---
## Verdict (`VerdictEngine`)
@ -544,7 +647,7 @@ The `expectedRoamingExit` flag (resolved by `HomeNetworkCatalog` from SIM MCC/MN
- `geoHit` = `GeoIP.outsideRu == true` (excluding roaming)
- `directHit` = detected evidence from `DIRECT_NETWORK_CAPABILITIES` or `SYSTEM_PROXY`
- `indirectHit` = detected evidence from `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH`
- `indirectHit` = detected evidence from `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`, `NATIVE_INTERFACE`, `NATIVE_ROUTE`, `NATIVE_JVM_MISMATCH`, or high-confidence `NATIVE_SOCKET`
| Geo | Direct | Indirect | Verdict |
|-----|--------|----------|---------|
@ -585,6 +688,12 @@ Requirements: JDK 17+, Android SDK with Build Tools for API 36.
./gradlew assembleDebug
```
## Result detail modes
Settings → Appearance provides three modes. Simple shows plain-language outcomes without low-level strings, Normal preserves the standard presentation, and Advanced adds collapsed technical data to each result card. The selection is captured when a scan starts, so changing it affects only the next scan and never redraws a running or completed scan. The mode does not change checks, verdicts, timeouts, or network behavior.
The technical snapshot is created only for scans started in Advanced mode. It exists only in process memory, is cleared on cancellation or a new scan, and is not included in JSON/Markdown exports. Each entry is limited to 64 KiB and a scan to 512 KiB; truncation is shown explicitly. Authorization and cookie headers, passwords, tokens, UUIDs, keys, URI userinfo, and sensitive query parameters are removed before storage. Privacy mode additionally masks every IPv4/IPv6 address. Full Clash `/configs`, `/connections`, `/proxies` responses, Xray UUIDs/public keys, BSSIDs, and cell identifiers are never stored.
---
## Acknowledgements

View file

@ -503,7 +503,7 @@ API زنده یا لیست غیرخالی IP مقصد، `detected = true` می
#### 12.2 هیوریستیک host-route /32
در جدول مسیریابی (`NETLINK`) به دنبال مسیر non-default با پیشوند `/32` (IPv4) یا `/128` (IPv6) به یک آدرس قابل مسیریابی عمومی از طریق اینترفیس فیزیکی (`wlan0`، `rmnet`، `eth0`) می‌گردد. این الگوی کلاسیک host-route کلاینت VPN به IP سرور خود در دور زدن تانل است — نشت IP واقعی سرور VPN. نتیجه: `detected = true` (`EvidenceSource.NATIVE_ROUTE`).
در جدول مسیریابی (`NETLINK`) به دنبال مسیر non-default با پیشوند `/32` (IPv4) یا `/128` (IPv6) به یک آدرس قابل مسیریابی عمومی از طریق اینترفیس فیزیکی (`wlan0`، `rmnet`، `eth0`) می‌گردد. این الگوی کلاسیک host-route کلاینت VPN به IP سرور خود در دور زدن تانل است — نشت IP واقعی سرور VPN. ورودی‌های جدول `local` و مسیرهای link ساخته‌شده توسط کرنل روی اینترفیس‌های مودم (`rmnet*`، `ccmni*`، `pdp*`، `seth*`) نادیده گرفته می‌شوند، زیرا اپراتورها مستقل از VPN از آن‌ها برای آدرس‌دهی سرویس استفاده می‌کنند. host-routeهای static و global روی این اینترفیس‌ها همچنان بررسی می‌شوند. نتیجه: `detected = true` (`EvidenceSource.NATIVE_ROUTE`).
#### 12.3 تشخیصگر شبیه‌ساز
@ -521,6 +521,108 @@ API زنده یا لیست غیرخالی IP مقصد، `detected = true` می
هر کدام از این سیگنال‌ها `needsReview = true` می‌دهند (`EvidenceSource.SANDBOX_ISOLATION`**هرگز** `detected`.
#### 12.5 سیگنال‌های VPN (`evaluateVpnSignals`)
تشخیص جامع VPN از طریق فراخوانی‌های JNI بومی. تمام بررسی‌ها روی دستگاه‌های **بدون روت** کار می‌کنند — وقتی مجوزها وجود نداشته باشند (SELinux/capabilities)، بررسی به عنوان `unavailable` علامت‌گذاری می‌شود و کرش نمی‌کند.
**ویژگی‌ها و فایل‌ها (`nativeDetectVpnProperties`):**
| بررسی | چه چیزی جستجو می‌شود | منبع |
|-------|---------------------|------|
| ویژگی‌های DNS | `net.dns1-4`، `net.vpn.dns1-2`، `dhcp.tun0.dns1-2` | `__system_property_get` |
| ویژگی‌های VPN | `net.vpn.default_iface`، `vpn.enable`، `net.tun0.dns1-2`، `net.ppp0.dns1-2` | `__system_property_get` |
| فایل‌های vpnhide | `/data/local/vpnhide`، `/data/adb/vpnhide`، `/data/local/bypass` و غیره | `access(F_OK)` |
| LSPosed/Xposed | `/data/adb/lspd`، `/data/adb/modules/lsposed`، `/data/adb/ksu/modules/lsposed` | `access(F_OK)` |
| ویژگی‌های Hook | `persist.sys.lspd`، `persist.sys.lsposed`، `ro.lsposed.hidden` | `__system_property_get` |
اطمینان بالا: `vpn_prop`، `vpnhide`، `hook_prop`. متوسط: بقیه.
**نشت از طریق /proc (`nativeDetectVpnLeaks`):**
| بررسی | چه چیزی جستجو می‌شود | منبع |
|-------|---------------------|------|
| پورت‌های TCP VPN | اتصالات روی پورت‌های 443، 1194، 51820، 8443، 1723، 500، 4500 | `/proc/net/tcp[6]` |
| پورت‌های UDP VPN | سوکت‌های روی پورت‌های 51820 (WireGuard)، 1194 (OpenVPN)، 500، 4500 | `/proc/net/udp[6]` |
| if_inet6 | رابط‌های tun/wg/ppp/tap در `/proc/net/if_inet6` | `/proc/net/if_inet6` |
| مسیر VPN | مسیرهای از طریق tun/wg/ppp/tap | `/proc/net/route` |
| FIB trie | رکوردهای `/32 host` (غیر LOCAL) — مسیرهای میزبان VPN | `/proc/net/fib_trie` |
اطمینان بالا: `udp_vpn_port`، `route_vpn_iface`، `arp_vpn_iface`، `inet6_vpn_iface`.
**بررسی‌های پیشرفته (`nativeDetectVpnAdvanced`):**
| بررسی | چه چیزی جستجو می‌شود | منبع |
|-------|---------------------|------|
| ARP VPN | رکوردهای روی رابط‌های tun/wg/ppp | `/proc/net/arp` |
| Sysctl | `rp_filter=0`، `ip_forward=1`، `forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` و غیره |
| ESTABLISHED VPN | اتصالات به IPهای خصوصی روی پورت‌های VPN | `/proc/net/tcp` |
**بررسی‌های syscall غیرسنتی (`nativeDetectVpnSyscalls`):**
بررسی‌ها از طریق درخواست‌های مستقیم netlink و اتصالات probe. وقتی مجوزها وجود نداشته باشند `unavailable` برمی‌گرداند:
| بررسی | روش | چه چیزی تشخیص می‌دهد |
|-------|-----|---------------------|
| RTM_GETRULE | dump قوانین مسیریابی سیاستی | قوانین مسیریابی سیاستی VPN |
| RTM_GETQDISC | dump انضباط صف | تونل‌های qdisc VPN |
| RTM_GETNEIST | dump جدول همسایه | آدرس‌های MAC پنهان (LLADDR صفر) |
| TCP_INFO MSS | اتصال به 8.8.8.8:443، خواندن `snd_mss` | کاهش MSS (شاخص تونل) |
| SO_BINDTODEVICE | probe bind به رابط غیرموجود | VPN hook intercepting setsockopt |
| اتصال پورت loopback | تلاش برای اشغال پورت‌های 51820/1194/443/8443 | تداخل پورت (listener VPN) |
| BPF OBJ GET | تلاش برای باز کردن نقشه‌های `/sys/fs/bpf/` | دسترسی به نقشه‌های BPF netd |
| IP_RECVERR | probe setsockopt IP_RECVERR | VPN hook intercepting IP_RECVERR |
اطمینان بالا: `vpn_policy_rules`، `hidden_mac_neighbors`، `tcp_mss_low`، `loopback_port_conflict`، `bpf_map_accessible`.
#### 12.6 تشخیص‌دهنده عمیق VPN (`VpnNativeDetectorChecker`)
بررسی‌های جدید در زیربخش جداگانه‌ای در دسته Native و در ۴ زیرمجموعه گروه‌بندی شده‌اند. داده‌ها از متد JNI جدید `nativeDetectVpnDetector()` با پیشوند `vdet|` می‌آید:
**نشانه‌های مستقیم (Direct signs) — `EvidenceSource.NATIVE_INTERFACE`:**
| بررسی (kind) | چه چیزی جستجو می‌شود | منبع |
|--------------|---------------------|------|
| `sysfs_vpn_leak` | نشت tun/wg/ppp/xfrm از طریق sysfs | `/sys/class/net`، `/sys/devices/virtual/net`، `/proc/sys/net/ipv4|6/conf|neigh` |
| `getifaddrs_vpn` | اینترفیس‌های VPN در لیست `getifaddrs()` | `getifaddrs()` |
| `sysclassnet_vpn` | اینترفیس‌های VPN در `/sys/class/net` | `stat("/sys/class/net/<if>")` |
| `rtm_getlink_vpn` | اینترفیس‌های VPN از طریق netlink RTM_GETLINK | Netlink `RTM_GETLINK` dump |
| `proc_if_inet6_vpn` | اینترفیس‌های VPN در `/proc/net/if_inet6` | `/proc/net/if_inet6` |
| `proc_ipv6_route_vpn` | مسیرهای VPN در `/proc/net/ipv6_route` | `/proc/net/ipv6_route` |
| `proc_net_dev_vpn` | ترافیک VPN (RX/TX) در `/proc/net/dev` | `/proc/net/dev` |
| `ifindexname_vpn` | اینترفیس‌های VPN از طریق `if_indextoname()` | پیمایش ifindex با `if_indextoname()` |
| `vpn_policy_rules_netlink` | قوانین مسیریابی VPN policy (table 100200، oif=tun) | Netlink `RTM_GETRULE` dump |
**نشانه‌های شبکه (Network signs) — `EvidenceSource.NATIVE_SOCKET`:**
| بررسی (kind) | چه چیزی جستجو می‌شود | منبع |
|--------------|---------------------|------|
| `fib_trie_denied` | `/proc/net/fib_trie` در دسترس نیست (SELinux EACCES) | `fopen("/proc/net/fib_trie")` |
| `inet_diag_denied` | inet_diag netlink مسدود شده (SELinux) | `socket(NETLINK_SOCK_DIAG)` |
| `bindtodevice_leak` | `SO_BINDTODEVICE` به tun + تایید `getsockopt` | `setsockopt(SO_BINDTODEVICE)` |
| `getsockname_leak` | `getsockname()` IP خصوصی VPN را برمی‌گرداند | `getsockname()` روی سوکت UDP |
| `udp_port_conflict_physical` | تداخل پورت UDP (500/4500/1194/1701/51820) روی IP فیزیکی | `bind()` روی IP فیزیکی |
| `route_count` | تعداد مسیرها و اینترفیس‌های یکتا | Netlink `RTM_GETROUTE` dump |
| `trim_oracle` | عدم تطابق تعداد iface بین bind-probe و RTM_GETLINK | `if_indextoname()` در مقابل `RTM_GETLINK` |
**نشانه‌های غیرمستقیم (Indirect signs) — `EvidenceSource.NATIVE_ROUTE`:**
| بررسی (kind) | چه چیزی جستجو می‌شود | منبع |
|--------------|---------------------|------|
| `pmtu_mss_combined` | UDP PMTU + TCP MSS (tcpi_snd_mss/rcv_mss) | `connect()` + `getsockopt(TCP_INFO)` |
| `udp_pmtu_ok` / `udp_pmtu_fail` | موفقیت/شکست ارسال 1500 بایت روی UDP | `sendto()` 1500 بایت |
| `normal_pmtu` | Path MTU اینترفیس فیزیکی اصلی | `fetchMtu()` از طریق `getifaddrs()` |
| `timing_oracle` | چرخه‌های ARM CNTVCT برای `sendto()` (min/max/avg) | `mrs cntvct_el0` (aarch64) |
| `backpressure` | نرخ انتقال زیر 50000 بسته UDP با پشتیبانی لغو اسکن | `sendto()` غیرمسدودکننده + بررسی لغو هر 64 بسته |
| `gso_failed` / `gso_send_failed` / `gso_ok` | تشخیص قابلیت UDP GSO؛ نتیجه مدرک VPN نیست | `UDP_SEGMENT=1200` + ارسال 4800 بایت |
| `hw_timestamp` | تایم‌استمپینگ سخت‌افزاری (`SIOCSHWTSTAMP`، `SO_TIMESTAMPING`) | `ioctl(SIOCSHWTSTAMP)` |
**پراbeهای محیطی (Environment probes) — `EvidenceSource.NATIVE_INTERFACE`:**
| بررسی (kind) | چه چیزی جستجو می‌شود | منبع |
|--------------|---------------------|------|
| `traceroute_denied` | تست traceroute (TTL=1 UDP) مسدود شده | `setsockopt(IP_TTL=1)` + `sendto()` |
اطمینان بالا (→ `detected = true`): `sysfs_vpn_leak`، `getifaddrs_vpn`، `sysclassnet_vpn`، `rtm_getlink_vpn`، `proc_if_inet6_vpn`، `proc_ipv6_route_vpn`، `proc_net_dev_vpn`، `ifindexname_vpn`، `vpn_policy_rules_netlink`، `bindtodevice_leak`، `getsockname_leak`، `udp_port_conflict_physical`. اندازه‌گیری‌های خام و نتایج GSO اطلاعاتی هستند؛ سایر ناهنجاری‌ها → `needsReview`.
---
## نتیجه نهایی (`VerdictEngine`)
@ -546,7 +648,7 @@ API زنده یا لیست غیرخالی IP مقصد، `detected = true` می
- `geoHit` = `GeoIP.outsideRu == true` (به جز رومینگ)
- `directHit` = detected-evidence از `DIRECT_NETWORK_CAPABILITIES` یا `SYSTEM_PROXY`
- `indirectHit` = detected-evidence از `INDIRECT_NETWORK_CAPABILITIES`، `ACTIVE_VPN`، `NETWORK_INTERFACE`، `ROUTING`، `DNS`، `PROXY_TECHNICAL_SIGNAL`، `NATIVE_INTERFACE`، `NATIVE_ROUTE`، `NATIVE_JVM_MISMATCH`
- `indirectHit` = detected-evidence از `INDIRECT_NETWORK_CAPABILITIES`، `ACTIVE_VPN`، `NETWORK_INTERFACE`، `ROUTING`، `DNS`، `PROXY_TECHNICAL_SIGNAL`، `NATIVE_INTERFACE`، `NATIVE_ROUTE`، `NATIVE_JVM_MISMATCH` یا `NATIVE_SOCKET` با اطمینان بالا
| Geo | Direct | Indirect | Verdict |
|-----|--------|----------|---------|
@ -587,6 +689,12 @@ API زنده یا لیست غیرخالی IP مقصد، `detected = true` می
./gradlew assembleDebug
```
## حالت‌های جزئیات نتیجه
در «تنظیمات ← ظاهر» سه حالت وجود دارد. «ساده» نتیجه را بدون متن‌های فنی سطح پایین نشان می‌دهد، «عادی» نمایش فعلی را حفظ می‌کند و «پیشرفته» داده‌های فنیِ جمع‌شده را به‌صورت بسته در هر کارت اضافه می‌کند. حالت در لحظه شروع بررسی ثابت می‌شود؛ بنابراین تغییر تنظیم فقط بر بررسی بعدی اثر دارد و بررسی در حال اجرا یا تمام‌شده را دوباره ترسیم نمی‌کند. این انتخاب بر خود بررسی‌ها، حکم نهایی، زمان‌سنج‌ها یا رفتار شبکه اثر ندارد.
تصویر فنی فقط برای بررسی‌ای ساخته می‌شود که در حالت پیشرفته آغاز شده باشد. داده فقط در حافظه فرایند می‌ماند، با لغو یا شروع بررسی تازه پاک می‌شود و وارد خروجی JSON/Markdown نمی‌شود. سقف هر ورودی 64 KiB و سقف کل بررسی 512 KiB است و برش داده صریحاً نمایش داده می‌شود. سرآیندهای مجوز و cookie، گذرواژه، توکن، UUID، کلیدها، اطلاعات کاربر URI و پارامترهای حساس query پیش از ذخیره حذف می‌شوند. در حالت حریم خصوصی همه نشانی‌های IPv4/IPv6 نیز پوشانده می‌شوند. پاسخ کامل Clash برای `/configs`، `/connections` و `/proxies`، UUID/کلید عمومی Xray، BSSID و شناسه سلول ذخیره نمی‌شود.
---
## قدردانی

View file

@ -502,7 +502,7 @@ TLS 步骤使用信任所有证书的 X.509 manager因为目标是检测 DPI
#### 12.2 host-route /32 启发式
在路由表NETLINK中查找非默认路由其目标为公开可路由地址前缀长度为 `/32`IPv4`/128`IPv6且经由物理接口`wlan0``rmnet``eth0`)。这是 VPN 客户端绕过隧道直连 VPN 服务器 IP 的经典主机路由,会泄露真实 VPN 服务器地址。结果:`detected = true``EvidenceSource.NATIVE_ROUTE`)。
在路由表NETLINK中查找非默认路由其目标为公开可路由地址前缀长度为 `/32`IPv4`/128`IPv6且经由物理接口`wlan0``rmnet``eth0`)。这是 VPN 客户端绕过隧道直连 VPN 服务器 IP 的经典主机路由,会泄露真实 VPN 服务器地址。`local` 表中的条目以及内核在调制解调器接口(`rmnet*``ccmni*``pdp*``seth*`)上创建的 link 路由会被排除,因为运营商会独立于 VPN 使用这些路由进行内部服务寻址。这些接口上的静态全局 host-route 仍会作为候选项。结果:`detected = true``EvidenceSource.NATIVE_ROUTE`)。
#### 12.3 模拟器检测器
@ -520,6 +520,109 @@ JNI 检查(`nativeDetectEmulator`QEMU 系统属性(`ro.kernel.qemu*`
满足其中任一条件均会产生 `needsReview = true``EvidenceSource.SANDBOX_ISOLATION`**不会**产生 `detected`
#### 12.5 VPN 信号 (`evaluateVpnSignals`)
通过原生 JNI 调用进行全面的 VPN 检测。所有检查均可在**未 root** 的设备上运行——当权限不足SELinux/capabilities检查标记为 `unavailable` 而不会崩溃。
**属性和文件 (`nativeDetectVpnProperties`):**
| 检查 | 查找内容 | 来源 |
|------|----------|------|
| DNS 属性 | `net.dns1-4``net.vpn.dns1-2``dhcp.tun0.dns1-2` | `__system_property_get` |
| VPN 属性 | `net.vpn.default_iface``vpn.enable``net.tun0.dns1-2``net.ppp0.dns1-2` | `__system_property_get` |
| vpnhide 文件 | `/data/local/vpnhide``/data/adb/vpnhide``/data/local/bypass` 等 | `access(F_OK)` |
| LSPosed/Xposed | `/data/adb/lspd``/data/adb/modules/lsposed``/data/adb/ksu/modules/lsposed` | `access(F_OK)` |
| Hook 属性 | `persist.sys.lspd``persist.sys.lsposed``ro.lsposed.hidden` | `__system_property_get` |
高置信度:`vpn_prop``vpnhide``hook_prop`。中等:其余。
**通过 /proc 泄漏 (`nativeDetectVpnLeaks`):**
| 检查 | 查找内容 | 来源 |
|------|----------|------|
| TCP VPN 端口 | 端口 443、1194、51820、8443、1723、500、4500 上的连接 | `/proc/net/tcp[6]` |
| UDP VPN 端口 | 端口 51820WireGuard、1194OpenVPN、500、4500 上的套接字 | `/proc/net/udp[6]` |
| if_inet6 | `/proc/net/if_inet6` 中的 tun/wg/ppp/tap 接口 | `/proc/net/if_inet6` |
| 路由 VPN | 通过 tun/wg/ppp/tap 的路由 | `/proc/net/route` |
| FIB trie | `/32 host` 条目(非 LOCAL——VPN 主机路由 | `/proc/net/fib_trie` |
高置信度:`udp_vpn_port``route_vpn_iface``arp_vpn_iface``inet6_vpn_iface`
**高级检查 (`nativeDetectVpnAdvanced`):**
| 检查 | 查找内容 | 来源 |
|------|----------|------|
| ARP VPN | tun/wg/ppp 接口上的条目 | `/proc/net/arp` |
| Sysctl | `rp_filter=0``ip_forward=1``forwarding=1` | `/proc/sys/net/ipv4/conf/*/rp_filter` 等 |
| ESTABLISHED VPN | 到私有 IP 的 VPN 端口连接 | `/proc/net/tcp` |
**非常规 syscall 检查 (`nativeDetectVpnSyscalls`):**
通过直接 netlink 请求和探测连接进行检查。权限不足时返回 `unavailable`
| 检查 | 方法 | 检测内容 |
|------|------|----------|
| RTM_GETRULE | Netlink dump 路由策略规则 | VPN 策略路由规则 |
| RTM_GETQDISC | Netlink dump 队列规则 | VPN qdisc 隧道 |
| RTM_GETNEIGH | Netlink dump 邻居表 | 隐藏的 MAC 地址(零 LLADDR |
| TCP_INFO MSS | 连接到 8.8.8.8:443读取 `snd_mss` | MSS 降低(隧道指标) |
| SO_BINDTODEVICE | 探测绑定到不存在的接口 | VPN hook 拦截 setsockopt |
| Loopback 端口绑定 | 尝试占用端口 51820/1194/443/8443 | 端口冲突VPN 监听器) |
| BPF OBJ GET | 尝试打开 `/sys/fs/bpf/` 映射 | 访问 netd BPF 映射 |
| IP_RECVERR | 探测 setsockopt IP_RECVERR | VPN hook 拦截 IP_RECVERR |
高置信度:`vpn_policy_rules``hidden_mac_neighbors``tcp_mss_low``loopback_port_conflict``bpf_map_accessible`
#### 12.6 深度 VPN 检测器 (`VpnNativeDetectorChecker`)
新检测器位于 Native 类别下的独立子章节,分为 4 个子类别。数据来自新的 JNI 方法 `nativeDetectVpnDetector()`,行前缀为 `vdet|`
**直接迹象Direct signs`EvidenceSource.NATIVE_INTERFACE`**
| 检查 (kind) | 查找内容 | 来源 |
|-------------|----------|------|
| `sysfs_vpn_leak` | 通过 sysfs 泄漏 tun/wg/ppp/xfrm | `/sys/class/net``/sys/devices/virtual/net``/proc/sys/net/ipv4|6/conf|neigh` |
| `getifaddrs_vpn` | `getifaddrs()` 列表中的 VPN 接口 | `getifaddrs()` |
| `sysclassnet_vpn` | `/sys/class/net` 中的 VPN 接口 | `stat("/sys/class/net/<if>")` |
| `rtm_getlink_vpn` | 通过 netlink RTM_GETLINK 的 VPN 接口 | Netlink `RTM_GETLINK` dump |
| `proc_if_inet6_vpn` | `/proc/net/if_inet6` 中的 VPN 接口 | `/proc/net/if_inet6` |
| `proc_ipv6_route_vpn` | `/proc/net/ipv6_route` 中的 VPN 路由 | `/proc/net/ipv6_route` |
| `proc_net_dev_vpn` | `/proc/net/dev` 中的 VPN 流量RX/TX | `/proc/net/dev` |
| `ifindexname_vpn` | 通过 `if_indextoname()` 的 VPN 接口 | `if_indextoname()` ifindex 扫描 |
| `vpn_policy_rules_netlink` | VPN policy 路由规则table 100200oif=tun | Netlink `RTM_GETRULE` dump |
**网络迹象Network signs`EvidenceSource.NATIVE_SOCKET`**
| 检查 (kind) | 查找内容 | 来源 |
|-------------|----------|------|
| `fib_trie_denied` | `/proc/net/fib_trie` 不可用SELinux EACCES | `fopen("/proc/net/fib_trie")` |
| `inet_diag_denied` | inet_diag netlink 被拒绝SELinux | `socket(NETLINK_SOCK_DIAG)` |
| `bindtodevice_leak` | `SO_BINDTODEVICE` 到 tun + `getsockopt` 确认 | `setsockopt(SO_BINDTODEVICE)` |
| `getsockname_leak` | `getsockname()` 返回私有 VPN IP | UDP 套接字上的 `getsockname()` |
| `udp_port_conflict_physical` | 物理 IP 上的 UDP 端口冲突500/4500/1194/1701/51820 | 物理 IP 上的 `bind()` |
| `route_count` | 路由数量和唯一接口数量 | Netlink `RTM_GETROUTE` dump |
| `trim_oracle` | bind-probe 与 RTM_GETLINK 接口数不匹配 | `if_indextoname()` 对比 `RTM_GETLINK` |
**间接迹象Indirect signs`EvidenceSource.NATIVE_ROUTE`**
| 检查 (kind) | 查找内容 | 来源 |
|-------------|----------|------|
| `pmtu_mss_combined` | UDP PMTU + TCP MSStcpi_snd_mss/rcv_mss | `connect()` + `getsockopt(TCP_INFO)` |
| `udp_pmtu_ok` / `udp_pmtu_fail` | 通过 UDP 发送 1500 字节成功/失败 | `sendto()` 1500 字节 |
| `normal_pmtu` | 主物理接口的路径 MTU | 通过 `getifaddrs()``fetchMtu()` |
| `timing_oracle` | `sendto()` 的 ARM CNTVCT 周期(最小/最大/平均) | `mrs cntvct_el0`aarch64 |
| `backpressure` | 50000 个 UDP 包下的吞吐量,支持扫描取消 | 非阻塞 `sendto()` 突发,每 64 个包检查一次取消状态 |
| `gso_failed` / `gso_send_failed` / `gso_ok` | UDP GSO 能力诊断;结果不是 VPN 证据 | `UDP_SEGMENT=1200` + 发送 4800 字节 |
| `hw_timestamp` | 硬件时间戳(`SIOCSHWTSTAMP``SO_TIMESTAMPING` | `ioctl(SIOCSHWTSTAMP)` |
**环境探测Environment probes`EvidenceSource.NATIVE_INTERFACE`**
| 检查 (kind) | 查找内容 | 来源 |
|-------------|----------|------|
| `traceroute_denied` | traceroute 探测TTL=1 UDP被阻止 | `setsockopt(IP_TTL=1)` + `sendto()` |
高置信度(→ `detected = true``sysfs_vpn_leak``getifaddrs_vpn``sysclassnet_vpn``rtm_getlink_vpn``proc_if_inet6_vpn``proc_ipv6_route_vpn``proc_net_dev_vpn``ifindexname_vpn``vpn_policy_rules_netlink``bindtodevice_leak``getsockname_leak``udp_port_conflict_physical`。原始测量值和 GSO 结果仅供参考;其他异常 → `needsReview`
---
## 结论 (`VerdictEngine`)
@ -545,7 +648,7 @@ JNI 检查(`nativeDetectEmulator`QEMU 系统属性(`ro.kernel.qemu*`
- `geoHit` = `GeoIP.outsideRu == true`(漫游除外)
- `directHit` = 来自 `DIRECT_NETWORK_CAPABILITIES``SYSTEM_PROXY` 的 detected evidence
- `indirectHit` = 来自 `INDIRECT_NETWORK_CAPABILITIES``ACTIVE_VPN``NETWORK_INTERFACE``ROUTING``DNS``PROXY_TECHNICAL_SIGNAL``NATIVE_INTERFACE``NATIVE_ROUTE``NATIVE_JVM_MISMATCH` 的 detected evidence
- `indirectHit` = 来自 `INDIRECT_NETWORK_CAPABILITIES``ACTIVE_VPN``NETWORK_INTERFACE``ROUTING``DNS``PROXY_TECHNICAL_SIGNAL``NATIVE_INTERFACE``NATIVE_ROUTE``NATIVE_JVM_MISMATCH` 的 detected evidence,或高置信度 `NATIVE_SOCKET`
| Geo | Direct | Indirect | Verdict |
|-----|--------|----------|---------|
@ -586,6 +689,12 @@ JNI 检查(`nativeDetectEmulator`QEMU 系统属性(`ro.kernel.qemu*`
./gradlew assembleDebug
```
## 结果详细程度
“设置 → 外观”提供三种模式。“简单”用易懂文字显示结果,不展示底层技术字符串;“普通”保留现有展示;“高级”在每张结果卡中增加默认折叠的技术数据。模式会在检查开始时固定,因此修改设置只影响下一次检查,不会重绘正在运行或已经完成的结果。该模式不改变检查、最终判定、超时或网络行为。
技术快照只为以高级模式启动的检查创建。它仅保存在进程内存中,在取消或开始新检查时清除,也不会写入 JSON/Markdown 导出。单条记录上限为 64 KiB单次检查总上限为 512 KiB截断会被明确标记。保存前会删除授权和 Cookie 请求头、密码、令牌、UUID、密钥、URI 用户信息及敏感查询参数。启用隐私模式时还会屏蔽所有 IPv4/IPv6 地址。不会保存 Clash `/configs``/connections``/proxies` 的完整响应、Xray UUID/公钥、BSSID 或蜂窝标识符。
---
## 致谢

View file

@ -0,0 +1,5 @@
1. Added a deep native VPN detector with interface, routing, socket, sysfs, /proc, netlink, PMTU/MSS and environment probes. Permission-limited probes are reported as unavailable instead of crashing.
2. Added Simple, Normal and Advanced result display modes. Advanced mode records bounded, sanitized technical diagnostics in memory and keeps them out of JSON/Markdown exports.
3. Improved verdict handling and diagnostics with new native evidence categories, structured trace capture, safer cancellation, and expanded tests.
4. Fixed false positives from carrier service routes on modem interfaces such as ccmni*, rmnet*, pdp* and seth*, while preserving static global host-route detection.
5. Improved IPv4/IPv6 address handling and the presentation of detailed and plain-language scan results.

View file

@ -0,0 +1,5 @@
1. یک آشکارساز عمیق VPN بومی با بررسی رابط‌ها، مسیرها، سوکت‌ها، sysfs، /proc، netlink، PMTU/MSS و محیط اضافه شد. بررسی‌های محدودشده به‌دلیل مجوز به‌صورت «در دسترس نیست» گزارش می‌شوند و باعث کرش نمی‌شوند.
2. سه حالت نمایش نتیجه «ساده»، «عادی» و «پیشرفته» اضافه شد. در حالت پیشرفته، داده‌های فنی محدود و پاک‌سازی‌شده فقط در حافظه نگه‌داری می‌شوند و وارد خروجی JSON/Markdown نمی‌شوند.
3. منطق حکم و عیب‌یابی بهبود یافت: دسته‌های جدید شواهد بومی، ثبت ساختاریافته ردپا، لغو امن‌تر بررسی و مجموعه تست گسترده‌تر اضافه شد.
4. مثبت کاذب ناشی از مسیرهای سرویس اپراتور روی رابط‌های مودم مانند `ccmni*`، `rmnet*`، `pdp*` و `seth*` برطرف شد؛ host-routeهای سراسری ایستا همچنان بررسی می‌شوند.
5. پردازش نشانی‌های IPv4/IPv6 و نمایش نتایج فنی و قابل‌فهم بررسی بهبود یافت.

View file

@ -0,0 +1,5 @@
1. Добавлен глубокий нативный VPN-детектор с проверками интерфейсов, маршрутов, сокетов, sysfs, /proc, netlink, PMTU/MSS и окружения. Проверки, ограниченные правами доступа, помечаются как недоступные и не приводят к сбою.
2. Добавлены три режима отображения результатов: «Простой», «Обычный» и «Расширенный». В расширенном режиме ограниченные и очищенные технические данные хранятся в памяти и не попадают в JSON/Markdown-экспорт.
3. Улучшены вердикты и диагностика: добавлены новые категории нативных свидетельств, структурированный сбор трассировки, более безопасная отмена проверки и расширенный набор тестов.
4. Исправлены ложные срабатывания на служебных маршрутах операторов мобильной связи через интерфейсы `ccmni*`, `rmnet*`, `pdp*` и `seth*`; статические глобальные host-route по-прежнему проверяются.
5. Улучшена обработка IPv4/IPv6-адресов и отображение подробных и понятных результатов проверки.

View file

@ -0,0 +1,5 @@
1. 新增深度原生 VPN 检测器检查网络接口、路由、套接字、sysfs、/proc、netlink、PMTU/MSS 和设备环境。受权限限制的检查会标记为不可用,不会导致崩溃。
2. 新增“简单”“普通”和“高级”三种结果显示模式。高级模式只在内存中保存有上限且已清理的技术诊断数据,不会写入 JSON/Markdown 导出。
3. 改进判定和诊断:新增原生证据类别、结构化诊断跟踪、更安全的取消处理以及更完整的测试。
4. 修复调制解调器接口(如 `ccmni*`、`rmnet*`、`pdp*` 和 `seth*`)上的运营商服务路由导致的误报;静态全局 host-route 仍会继续检查。
5. 改进 IPv4/IPv6 地址处理,以及详细结果和易懂结果的显示。