Compare commits

...

42 commits
v2.6.7 ... main

Author SHA1 Message Date
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
xtclovver
7fa507c60c
chore(release): v2.9.0 changelog, README sync, version bump (#76)
Подготовка релиза v2.9.0 (versionCode 20900).

- Добавлены fastlane changelog 20900.txt для en-US/ru-RU/fa/zh-CN,
  охватывающие коммиты #73/#74/#75 (6 детекторов VPN/прокси,
  детектор эмулятора/изоляции, профили маркетплейса, Ed25519-подпись
  каталога, фиксы).
- README (все 4 локали) синхронизированы с новыми детекторами:
  дерево архитектуры, паттерны интерфейсов (4.2), BypassChecker
  (Clash API + SOCKS5-auth, 6.4), NativeSignsChecker (TUN/TAP по типу,
  host-route /32, эмулятор, изоляция, 12.1-12.4), тип VPN-транспорта (3.1).
- build.gradle.kts: versionCode 20802 -> 20900, versionName 2.8.2 -> 2.9.0.
2026-06-12 00:56:49 +03:00
xtclovver
3e1a9cf6e6
feat(checker): детектирование эмулятора и изоляции песочницы (#75)
Some checks are pending
CI / build (push) Waiting to run
Приложение определяет, что запущено в эмуляторе или изолированном/
клонированном контексте (вторичный пользователь, рабочий профиль,
dual-app), и поднимает NEEDS_REVIEW. Сетевые детекторы слепы к VPN,
скрытому в другом контексте, поэтому собственная изолированная/
эмулированная среда сама по себе является поводом для ревью.

Что добавлено:
- Native JNI-проба nativeDetectEmulator (QEMU/goldfish/Genymotion/
  BlueStacks) в native_signs_probe.cpp + регистрация в kMethods.
- Bridge NativeSignsBridge.detectEmulator с тест-override.
- Парсер NativeEmulatorFinding в NativeInterfaceProbe.
- Блоки evaluateEmulator (native HIGH + Build-эвристика MEDIUM) и
  evaluateIsolation (вторичный пользователь / клон / рабочий профиль)
  в NativeSignsChecker, вшитые в check().
- Источники NATIVE_EMULATOR и SANDBOX_ISOLATION в EvidenceSource,
  заведены только в NATIVE_REVIEW_SOURCES VerdictEngine (R6): вклад
  строго NEEDS_REVIEW, никогда DETECTED.
- Локализация 5 строк (en/ru/fa/zh-rCN).
- Тесты: парсер, оба эвалуатора, два кейса R6 в VerdictEngineTest.

Контроль качества (адверсариальная проверка): убрана ложноположительная
эвристика MANUFACTURER=="unknown" (легитимные AOSP/бюджетные устройства),
удалён мёртвый path-фоллбэк в clone-детекции (userId уже выводится из
того же пути тем же regex через extractUserId).

Проверено: ./gradlew :app:testDebugUnitTest :app:lintDebug
:app:assembleDebug — BUILD SUCCESSFUL, native-либы для arm64-v8a/
armeabi-v7a/x86_64, без MissingTranslation.
2026-06-12 00:26:30 +03:00
xtclovver
653a9f6413
feat(detect): six competitor-derived VPN/proxy detectors (#74)
* feat(detect): extend VPN interface name patterns (utun/xfrm/zt/tailscale/svpn/gre/l2tp/he-ipv6)

* fix(detect): route xfrm through IPsec disambiguation, narrow l2tp pattern

* feat(detect): classify VPN transport subtype (PLATFORM/LEGACY/IKEV2) via reflection

* chore: fastline add icon and screenshots

* fix: Marketplace fix display error

* feat(detect): host-route /32 heuristic for VPN server IP leak via physical interface

* fix(detect): reuse UrlSanitizer.isPublicAddress for host-route filter, propagate needsReview

* feat(detect): SOCKS5 weak-auth brute and UDP ASSOCIATE bypass probes (loopback, opt-in)

* test(detect): cover proxy auth-probe gate, loopback guard, and join mock acceptors

* feat(detect): Clash/mihomo/sing-box REST API scanner with VPN server IP leak detection

* perf(detect): share single OkHttpClient across Clash API probes

* feat(detect): detect TUN/TAP interfaces by ARPHRD_TUNTAP type (65534) from sysfs

* refactor(detect): name ARPHRD_TUNTAP constant instead of magic 65534

* fix(detect): correct VpnTransportInfo type mapping to AOSP constants (SERVICE/PLATFORM/LEGACY/OEM)
2026-06-12 00:01:35 +03:00
xtclovver
dfb46b716c
Рефакторинг: дедупликация и декомпозиция god-классов с сохранением поведения + фикс багов/защиты у профилей (#73)
* fix: Security upgrade & bug fixes for profiles

* fix: bugs, security and warnings

* chore: README update

* test: expand vpn/ characterization coverage

Add VpnAppClassifierTest (marker priority, case sensitivity, go version
extraction) and extend VpnAppCatalogTest with catalog integrity
invariants, VpnDumpsysParserTest with parser corpus cases,
VpnApkInspectorTest with split-apk/path-edge cases,
VpnAppMetadataScannerTest with null-package/permission/format cases.
Tests pin current behavior before refactoring; no production changes.

* test: fix data race in CdnPullingCheckerTest label collection

fetchEndpoint stubs are invoked from parallel coroutines, but the tests
collected labels into a plain ArrayList, causing intermittent
ArrayIndexOutOfBoundsException. Use CopyOnWriteArrayList.

* refactor: single source of truth for interface name patterns

NetworkInterfaceNameNormalizer no longer redeclares its own base-pattern
list; it now uses NetworkInterfacePatterns.STACKED_BASE_INTERFACES, a
deliberate strict subset of STANDARD_INTERFACES (seth/dummy excluded to
keep v4- de-stacking behavior byte-identical). Pinned the divergence and
classification behavior in NetworkInterfaceNameNormalizerTest.

* refactor: extract strict IP-literal validators into probe/IpLiterals

Move the strict charset+InetAddress validators verbatim out of
CdnPullingClient; the client keeps one-line delegates so all call sites
and tests stay untouched. PublicIpClient.looksLikeIp is deliberately NOT
unified: its loose heuristic is required for mail.ru HTML extraction
(now documented). Add IpLiteralsTest pinning edge cases including the
JDK quirk where IPv4-mapped IPv6 literals parse as Inet4Address.

* refactor: deduplicate checker/ plumbing

- Share a single SignalOutcome data class instead of identical nested
  copies in DirectSignsChecker and IndirectSignsChecker.
- GeoIpChecker: extract fetchBuiltinProvider, the common
  fetch/validate/error skeleton of the five builtin providers; each
  provider keeps its own URL construction and field-path cascades.
- BypassChecker: extract startProxyScanAsync/startXrayScanAsync so
  check() is orchestration only; progress-callback wiring unchanged.

No thresholds, signals, finding order or result structures changed.
IndirectSigns/LocationSignals/CallTransport check() bodies were already
step-decomposed and are deliberately left as is.

* refactor: collapse VpnCheckRunner await/publish blocks into publishOnReady

Replace 11 identical await -> throwIfCancelled -> publish wrapper blocks
with a generic publishOnReady helper using CheckUpdate constructor
references. Same supervisorScope receiver, same null contract, same
publish order; BypassProgress, IpConsensusReady and VerdictReady paths
untouched.

* test: golden snapshots and section-parity net for export formatters

Pin the byte-exact output of all three CheckResult formatters (Markdown,
JSON, debug diagnostics) for the rich fixture, privacy mode on and off,
with UTC/Locale.US pinned and a fixed timestamp. Actual outputs are
dumped to build/export-golden-actual/ for easy regeneration after an
intentional format change. The parity test additionally asserts every
populated section appears in every formatter, guarding the three
independent traversals against silent drift.

No shared traversal/visitor is introduced on purpose: the formatters
diverge deliberately (separators, masking policy, flag emission), so
unification would change export bytes.

* test: add markdown golden files missed by the global *.md gitignore

Allow-list app/src/test/resources/export/golden/*.md so golden
regeneration keeps working.

* test: pin custom-check editor read/write semantics before decomposition

Round-trip test (non-default config in every section survives open-save
unchanged), defaults test (untouched new profile saves the exact literal
defaults from saveAndExit), and official-fork test (saving a profile
whose official flag survived the storage trust chain produces a new id,
an edited-suffix name, cleared marketplaceInfo and sourceProfileId).

* refactor: decompose custom-check editor into per-section binders

SettingsCustomCheckEditorFragment (1593 -> 422 lines) now delegates each
checker section to a SectionBinder in customcheck/ui/editor/: bind()
loads the profile slice into the body views, collect() reconstructs it
with the exact legacy defaults, summary() renders the collapsed line.
Shared plumbing extracted alongside: EndpointPills (pill rows),
InlineEndpointSection (add/edit wiring for the three
InlineEndpointEditorController-backed sections), EditorSectionBinders
(visual order). Reachability domains moved into their binder; the
generic custom-domain list, section accordion state, dependency
enforcement and the save/fork flow stay in the fragment.

Read/write semantics are pinned by SettingsCustomCheckEditorRoundTripTest
(previous commit) and were not changed. Dead makeDialogContainer/
makeDialogEdit helpers dropped.

* refactor: replace MainActivity tile id-mappers with a static tile table

ui/main/CategoryTiles declares the twelve category tiles (id, header
title/icon resources, per-tile view ids) in accordion order; MainActivity
category constants now alias the table ids and setupCategoryAccordion/
createTileHolder consume TileSpec directly. Removes the eight mechanical
when-mappers (~130 lines). Tile behavior, the tiles field and TileHolder
member names are unchanged (MainActivityUiRenderingTest reflection net
passes unmodified).

* refactor: extract scan-report export flow into MainExportController

ui/main/MainExportController owns the format/action dialogs, the two
CreateDocument launchers, clipboard copy and document writing, moved
verbatim from MainActivity. The controller is constructed as an activity
field initializer, preserving the pre-STARTED launcher registration
timing; the export snapshot and the debug-clipboard toggle stay in the
activity and are supplied via lambdas.

* fix: NoSuchElementException race in ScanCancellationSignal.cancel

callbacks.entries.toList() takes the size==1 fast path through first(),
which throws NoSuchElementException when a checker unregisters its
callback concurrently. Snapshot through the concurrent map iterator
instead; invocation-after-clear ordering and the register() double-check
contract are unchanged. Surfaced as an intermittent failure of
VpnCheckRunnerTest cancelled-execution test.

* refactor: deduplicate MainActivity loading-stage view mapping

showLoadingCardForStage and markLoadingStagesCancelled repeated the same
stage -> card/icon/status/findings/info-section bundle twice; both now
share a single categoryViewsForStage table. bindCardStoppedState mirrors
bindCardLoadingState, removing the four copies of the stopped-state
icon/status block. Per-stage behavior is unchanged.

Deliberate deviation from the original phase plan: a separate
LoadingStageCoordinator class would have needed ~10 injected callbacks
for the same effect; in-place deduplication wins on risk and size.

* fix: preserve {ip}-placeholder GeoIP provider URLs across profile round trip

UrlSanitizer.sanitizeHttpsUrl runs the URL through java.net.URI, which
throws on the illegal { } chars, so any custom GeoIP provider whose URL
embeds the documented {ip} placeholder was silently dropped on every
profile save/load. Add sanitizeGeoIpProviderUrl, which validates a copy
with {ip} replaced by a public IP literal (8.8.8.8) and returns the
original string when it passes. Wire deserializeGeoIp to it.

The {ip} substitution only exists in GeoIpChecker.fetchCustomProvider;
IpComparison/CdnPulling send URLs verbatim, so they keep the generic
sanitizer. canonicalHash semantics unchanged.

* fix: localize IP-channels markdown export header

CheckResultMarkdownExportFormatter hardcoded the Russian '## IP каналы' section header, leaking Russian into en/fa/zh exports. Use the existing R.string.ip_channels_title resource via the Context the formatter already receives. Goldens regenerated (US locale -> 'IP channels').

* fix: localize remaining Russian literals in Markdown export formatter

Replace hardcoded RU strings in CheckResultMarkdownExportFormatter with
context.getString lookups so en/fa/zh exports no longer leak Russian:
- IP-channels table column headers (Channel/Country/Sources)
- "Flags:" prefix
- operator-whitelist section (title, Detected yes/no, control suffix,
  Duration label/ms unit, available/unavailable)

Add export_* string resources to all four locales (en/ru/fa/zh-rCN) and
drop the object-level AVAILABLE/UNAVAILABLE consts. Regenerate export
golden snapshots (now render under Locale.US in English) and update the
operator-whitelist unit-test assertions accordingly.

* refactor: extract main render environment and FindingViewFactory

* refactor: extract CategoryCardRenderer from MainActivity

* refactor: extract IP-comparison and CDN-pulling renderers

* refactor: extract call-transport, bypass, IP-channels and reachability renderers

* refactor: extract VerdictRenderer and finish MainActivity render decomposition
2026-06-11 19:50:36 +03:00
AR34
f58d4f4fc5
Add links to Github & F-Droid (#71)
Some checks failed
CI / build (push) Has been cancelled
2026-06-01 19:48:39 +03:00
xtclovver
7142ea1c6c fix: bugs
Some checks failed
CI / build (push) Has been cancelled
2026-05-25 20:14:54 +03:00
xtclovver
f6e9df2a35 feat: Strengthening profile security 2026-05-25 19:18:49 +03:00
xtclovver
595e4335f6 feat: add marketplace and profiles 2026-05-25 19:04:15 +03:00
xtclovver
2832a0571e chore: add mp
Some checks failed
CI / build (push) Has been cancelled
2026-05-21 18:53:15 +03:00
xtclovver
74d256a28d refactor: restructure packages and dedup utilities
Some checks failed
CI / build (push) Has been cancelled
2026-05-19 18:53:44 +03:00
xtclovver
a830c52b8e chore: remove dead code 2026-05-19 18:53:31 +03:00
xtclovver
8aaa6b5f43 chore: warnings fix 2026-05-19 17:48:26 +03:00
AR34
2325f1d59b
Add Telegram & Fix Warings (#68) 2026-05-19 17:16:41 +03:00
xtclovver
63e9b97a36 feat: детекция белых списков оператора и per-app TUN-зонд (#61)
Some checks failed
CI / build (push) Has been cancelled
2026-05-15 03:14:32 +03:00
xtclovver
7b101d20e8 fix: errors catch fot RTT
Some checks are pending
CI / build (push) Waiting to run
2026-05-13 20:24:02 +03:00
xtclovver
98876411c6 fastline: список изменений для 2.7.1
Some checks failed
CI / build (push) Has been cancelled
2026-05-11 21:19:19 +03:00
xtclovver
ff6cb6b534 fiz: баги 2026-05-11 20:54:33 +03:00
xtclovver
e9b7bb6667 fix&feat: Error показывается в UI корректно и ложно не вызывает "Нужна проверка" (#66) 2026-05-11 16:47:02 +03:00
xtclovver
dd33d2f03f fix: Исправлено ложноположительное срабатывание при роуминге иностранной SIM в России (issue #63)
Some checks failed
CI / build (push) Has been cancelled
2026-05-08 16:32:49 +03:00
xtclovver
562b13e729 feat: больше информации в экспорте данных 2026-05-08 15:36:13 +03:00
xtclovver
04179db9a7 fix: обновить версию до 2.7.0
Some checks are pending
CI / build (push) Waiting to run
2026-05-08 02:56:13 +03:00
xtclovver
de9de97ea6 chore: fastline синхронизация на Китайском и Персидском и обновление README 2026-05-08 02:54:35 +03:00
xtclovver
7006854473 feat: Расширение провайдеров для GeoIP проверки 2026-05-08 02:52:40 +03:00
xtclovver
a9f5dacff1 feat: add to underlying check yandex endpoint (#61) 2026-05-08 02:09:50 +03:00
xtclovver
88c32fee81 fix: Verdict Engine более логично отдаёт решение 2026-05-08 01:53:26 +03:00
xtclovver
350eabb9d6 fix: Location Services now works as intended 2026-05-08 01:05:13 +03:00
xtclovver
e7d76f9b4b feat: Выбор между классической и новой иконкой приложения 2026-05-07 23:02:54 +03:00
xtclovver
e16a78fd5e feat: StatsService check 2026-05-07 23:01:37 +03:00
xtclovver
4a9ed45762 feat: редизайн иконки
Some checks are pending
CI / build (push) Waiting to run
2026-05-07 03:38:54 +03:00
xtclovver
e2d2d1fb11 feat: SNITCH проверка (β) (#49) 2026-05-07 01:21:35 +03:00
xtclovver
859f3ca515 fix: Дополнить список доменов в предупреждении 2026-05-07 00:23:46 +03:00
xtclovver
d49d391469 fix: обновить версию до 2.6.11 2026-05-06 21:28:37 +03:00
xtclovver
3c5343391f fix: disable dependenciesInfo for F-Droid reproducible build 2026-05-06 21:28:28 +03:00
xtclovver
2385802479 fix: обновить версию до 2.6.10 2026-05-06 21:10:17 +03:00
xtclovver
85845fb42e chore:remove build-id from CMakeLists 2026-05-06 21:09:48 +03:00
xtclovver
20d8255720 fix: подготовить воспроизводимый релиз 2.6.9 2026-05-06 20:02:51 +03:00
xtclovver
f69af5e6db choreL build.gradle.kts now using static build
Some checks are pending
CI / build (push) Waiting to run
2026-05-06 16:08:10 +03:00
xtclovver
efab3490a2 feat: add Fastlane metadata for F-Droid store listing
Adds title, short/full description and changelogs (20601–20608)
in en-US and ru-RU locales. Fixes "Fastlane/Triple-T is not found"
pipeline error in fdroiddata MR.
2026-05-06 14:17:51 +03:00
xtclovver
2ed250d359 fix: replace Java toolchain with sourceCompatibility for fdroid compat
Some checks failed
CI / build (push) Has been cancelled
fdroid build environment has only JDK 21, toolchain auto-provisioning
is disabled. Use explicit sourceCompatibility/targetCompatibility instead.
2026-05-01 23:54:50 +03:00
436 changed files with 36291 additions and 4402 deletions

17
.gitattributes vendored Normal file
View file

@ -0,0 +1,17 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.jar binary
*.apk binary
*.aab binary
*.so binary
*.keystore binary

View file

@ -1,7 +1,7 @@
name: CI
env:
ANDROID_BUILD_TOOLS_VERSION: "36.0.0"
ANDROID_BUILD_TOOLS_VERSION: "35.0.0"
ANDROID_CMAKE_VERSION: "3.22.1"
ANDROID_NDK_VERSION: "28.2.13676358"
@ -13,7 +13,7 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@ -21,13 +21,16 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Set up JDK 17
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '17'
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Set reproducible build timestamp
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null

View file

@ -1,7 +1,7 @@
name: Release
env:
ANDROID_BUILD_TOOLS_VERSION: "36.0.0"
ANDROID_BUILD_TOOLS_VERSION: "35.0.0"
ANDROID_CMAKE_VERSION: "3.22.1"
ANDROID_NDK_VERSION: "28.2.13676358"
@ -17,7 +17,7 @@ on:
jobs:
release:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: write
@ -50,13 +50,16 @@ jobs:
fi
echo "name=$VERSION_NAME" >> $GITHUB_OUTPUT
- name: Set up JDK 17
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '17'
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Set reproducible build timestamp
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses >/dev/null
@ -67,6 +70,15 @@ jobs:
"ndk;${ANDROID_NDK_VERSION}" \
"cmake;${ANDROID_CMAKE_VERSION}"
- name: Validate app version
run: |
VERSION_NAME=${{ steps.android_version.outputs.name }}
IFS=. read -r major minor patch <<< "$VERSION_NAME"
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
grep -q "versionName = \"$VERSION_NAME\"" app/build.gradle.kts
grep -q "versionCode = $VERSION_CODE" app/build.gradle.kts
- name: Grant execute permission for gradlew
run: chmod +x gradlew
@ -82,7 +94,6 @@ jobs:
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
./gradlew assembleRelease \
-PappVersionName=${{ steps.android_version.outputs.name }} \
-Pandroid.injected.signing.store.file=$KEYSTORE_PATH \
-Pandroid.injected.signing.store.password=$KEYSTORE_PASSWORD \
-Pandroid.injected.signing.key.alias=$KEY_ALIAS \

15
.gitignore vendored
View file

@ -6,6 +6,10 @@
/build
**/build/
/captures
/windows
linux/
*.md
*.docx
.externalNativeBuild
.cxx
.android-user-home/
@ -13,10 +17,11 @@
.kotlin/
xray-protos/bin
release.jks
ocr_methodika_vpn_proxy.md
.claude/
tag_and_push.sh
tag_and_push.ps1
*.sh
*.ps1
!CONTRIBUTING.md
!app/src/test/resources/export/golden/*.md
/.codex
Методика_выявления_признаков_использования_VPN_и_proxy_в_клиентских.md
Методика_выявления_признаков_использования_VPN_и_proxy_в_клиентских.docx
.marketplace-keys/
app-release/

View file

@ -2,9 +2,9 @@
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<SelectionState runConfigName="start">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-04-08T19:55:08.785572700Z">
<DropdownSelection timestamp="2026-05-06T21:44:57.162357200Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\usedcvnt\.android\avd\Pixel_9_Pro.avd" />

1
.idea/gradle.xml generated
View file

@ -6,6 +6,7 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />

345
README.md
View file

@ -3,11 +3,32 @@
# RKNHardering
<a href="https://matrix.to/#/%23RKN_Hardering:matrix.kangel.tech"><img src="https://img.shields.io/badge/matrix-%23000000?style=for-the-badge&logo=matrix&logoColor=white" alt="Matrix" width="200"></a>
<a href="https://t.me/RKNHardering"><img src="https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram" width="200"></a>
Android-приложение для обнаружения VPN и прокси на устройстве. Реализует методику РКН по выявлению средств обхода блокировок.
Минимальная версия Android: 8.0 (API 26).
Вы можете скачать наш проект с:
<table>
<tr>
<th align="center">GitHub</th>
<th align="center">F-Droid</th>
</tr>
<tr>
<td align="center">
<a href="https://github.com/xtclovver/RKNHardering/releases/latest">
<img src="https://github.com/machiav3lli/oandbackupx/blob/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Download from GitHub" height="75">
</a>
</td>
<td align="center">
<a href="https://f-droid.org/en/packages/com.notcvnt.rknhardering/">
<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Download from F-Droid" height="75">
</a>
</td>
</tr>
</table>
## Нужна помощь сообщества / Community Help Wanted
> **RU**
@ -25,7 +46,7 @@ Android-приложение для обнаружения VPN и прокси
- **Обход нативных проверок** (противодействие JNI-проверкам через `/proc/self/maps`, `getifaddrs()`, `dlsym`)
- **Маскировка установленных приложений** (скрытие пакетов VPN-приложений от `PackageManager`)
Если вы обладаете знаниями в этих областях, пожалуйста, откройте Issue или Pull Request, либо напишите в [чат Matrix](https://matrix.to/#/%23RKN_Hardering:matrix.kangel.tech) с описанием метода, условий применимости и ограничений. Любая информация ценна — от теоретических идей до работающих PoC.
Если вы обладаете знаниями в этих областях, пожалуйста, откройте Issue или Pull Request, либо напишите в [чат Matrix](https://matrix.to/#/%23RKN_Hardering:matrix.kangel.tech)/[Telegram](https://t.me/RKNHardering) с описанием метода, условий применимости и ограничений. Любая информация ценна — от теоретических идей до работающих PoC.
> **EN**
@ -42,26 +63,30 @@ I am looking for people willing to help collect, organize, and test information
- **Bypassing native checks** (countering JNI-based checks through `/proc/self/maps`, `getifaddrs()`, and `dlsym`)
- **Masking installed applications** (hiding VPN app packages from `PackageManager`)
If you have expertise in these areas, please open an Issue or Pull Request describing the method, the conditions under which it applies, and its limitations. Any information is valuable, from theoretical ideas to working PoCs.
If you have expertise in these areas, please open an Issue or [Сhat in Matrix](https://matrix.to/#/%23RKN_Hardering:matrix.kangel.tech)/[Telegram](https://t.me/RKNHardering), the conditions under which it applies, and its limitations. Any information is valuable, from theoretical ideas to working PoCs.
## Архитектура
Шесть независимых модулей проверки запускаются параллельно. Итоговый вердикт рассчитывается в `VerdictEngine`.
Независимые модули проверки запускаются параллельно. Итоговый вердикт рассчитывается в `VerdictEngine`.
`IpComparisonChecker` сохраняется в результат и показывается в UI как диагностический блок, но в текущей версии не участвует в `VerdictEngine`.
`IpComparisonChecker` сохраняется в результат и показывается в UI как диагностический блок. Напрямую в `VerdictEngine` не участвует, но его данные поступают в `IpConsensusBuilder`.
```text
VpnCheckRunner
├── GeoIpChecker — GeoIP + hosting/proxy-сигналы
├── IpComparisonChecker — RU/не-RU IP-чекеры (диагностика)
├── DirectSignsChecker — NetworkCapabilities, системный proxy, установленные VPN apps
├── IndirectSignsChecker — интерфейсы, маршруты, DNS, dumpsys, proxy-tech signals
├── CallTransportChecker — STUN/MTProto (утечки и доступность)
├── CdnPullingChecker — HTTPS-запросы к CDN/redirector
├── LocationSignalsChecker — MCC/SIM/cell/Wi-Fi/BeaconDB
├── BypassChecker — localhost proxy, Xray gRPC API, underlying-network leak
└── NativeSignsChecker — JNI-проверки (маршруты, интерфейсы, хуки, root)
└── VerdictEngine — логика итогового вердикта
├── GeoIpChecker — GeoIP + hosting/proxy-сигналы
├── IpComparisonChecker — RU/не-RU IP-чекеры (диагностика)
├── DirectSignsChecker — NetworkCapabilities, системный proxy, TUN-проб, установленные VPN apps
├── IndirectSignsChecker — интерфейсы, маршруты, DNS, dumpsys, proxy-tech signals
├── CallTransportChecker — STUN/MTProto (утечки и доступность)
├── CdnPullingChecker — HTTPS-запросы к CDN/redirector
├── LocationSignalsChecker — MCC/SIM/cell/Wi-Fi/BeaconDB
├── BypassChecker — localhost proxy, Xray gRPC API, Clash/sing-box REST API, SOCKS5-auth проба, underlying-network leak
├── RttTriangulationChecker — SNITCH (β): RTT-триангуляция по RU/иностранным хостам
├── IcmpSpoofingChecker — ICMP-спуфинг оператора (заблокированный хост отвечает)
├── DomainReachabilityChecker — DNS→TCP→TLS pipeline для детекта DPI-блокировки
├── NativeSignsChecker — JNI-проверки (маршруты, интерфейсы, host-route /32, TUN/TAP по типу, хуки, root, эмулятор, изоляция)
└── IpConsensusBuilder — кросс-модульный IP-консенсус
└── VerdictEngine — логика итогового вердикта
```
---
@ -95,7 +120,7 @@ VpnCheckRunner
### 2. Сравнение IP-чекеров (`IpComparisonChecker`)
Модуль сравнивает ответы RU- и не-RU публичных IP-чекеров. Это диагностический блок: он отображается в UI, но сейчас не участвует в `VerdictEngine`.
Модуль сравнивает ответы RU- и не-RU публичных IP-чекеров. Отображается в UI как диагностический блок. Напрямую в `VerdictEngine` не участвует, но его данные поступают в `IpConsensusBuilder`, результаты которого используются в R3.
Группы сервисов:
@ -129,6 +154,8 @@ API: `ConnectivityManager.getNetworkCapabilities(activeNetwork)`
`IS_VPN` и `VpnTransportInfo` проверяются через строковое представление `NetworkCapabilities`.
При наличии `VpnTransportInfo` (API 29+, через reflection `getType()`) в findings добавляется тип транспорта: `SERVICE` (VpnService-приложение), `PLATFORM` (always-on / IKEv2), `LEGACY` (legacy VPN framework) или `OEM`. Это информационное поле, оно не влияет на `detected`/`needsReview`.
#### 3.2 Системный proxy (`checkSystemProxy`)
Используются:
@ -157,13 +184,18 @@ API: `ConnectivityManager.getNetworkCapabilities(activeNetwork)`
Известные proxy-порты: `80`, `443`, `1080`, `3127`, `3128`, `4080`, `5555`, `7000`, `7044`, `8000`, `8080`, `8081`, `8082`, `8888`, `9000`, `9050`, `9051`, `9150`, `12345`, а также диапазон `16000..16100`.
#### 3.3 Установленные VPN/Proxy-приложения (`InstalledVpnAppDetector`)
#### 3.3 TUN Active Probe (`checkTunActiveProbe`)
Если при инициализации обнаружен TUN-интерфейс, `UnderlyingNetworkProber` отправляет HTTP-запросы через VPN-сеть к RU- и non-RU целям. При расхождении IP (DNS path mismatch) или если приложение явно исключено из per-app VPN (tun0 есть, но `vpnActive = false`) — `detected = true`. Этот сигнал поступает в `VerdictEngine` через `EvidenceSource.TUN_ACTIVE_PROBE`.
#### 3.4 Установленные VPN/Proxy-приложения (`InstalledVpnAppDetector`)
Модуль проверяет три источника:
- известные сигнатуры пакетов из [`VpnAppCatalog`](app/src/main/java/com/notcvnt/rknhardering/vpn/VpnAppCatalog.kt);
- приложения, которые объявляют `VpnService.SERVICE_INTERFACE` через `PackageManager.queryIntentServices`.
- в приложении есть "VPN" в названии (это конечно не на 100% дает что это впн)
- приложения, которые объявляют `VpnService.SERVICE_INTERFACE` через `PackageManager.queryIntentServices`;
- приложения с "VPN" в названии.
Это диагностические сигналы установки или декларации `VpnService`, а не подтверждение активного туннеля. Совпадения переводят категорию в `needsReview`, но сами по себе не делают `DirectSignsChecker.detected = true`.
---
@ -189,7 +221,14 @@ API: `NetworkInterface.getNetworkInterfaces()`. Проверяются акти
- `tap\d+`
- `wg\d+`
- `ppp\d+`
- `ipsec.*`
- `utun\d*` — TUN в стиле macOS/iOS
- `zt.*` — ZeroTier
- `tailscale\d*` — Tailscale
- `svpn\d*` — Pulse Secure / Ivanti
- `gre\d+` — GRE-туннели
- `l2tp\d+` — L2TP
- `he-ipv6.*` — IPv6-туннель Hurricane Electric
- `(ipsec|xfrm).*` — IPsec / ядерный XFRM
Любой активный интерфейс, попавший под эти паттерны, даёт `detected = true`.
@ -304,10 +343,11 @@ DNS оценивается вместе со snapshot underlying-сетей, е
### 6. Bypass-проверка (`BypassChecker`)
Три проверки запускаются параллельно:
Проверки запускаются параллельно:
- `ProxyScanner`
- `XrayApiScanner`
- `ClashApiScanner`
- `UnderlyingNetworkProber`
#### 6.1 Сканер прокси (`ProxyScanner` + `ProxyProber`)
@ -344,6 +384,13 @@ DNS оценивается вместе со snapshot underlying-сетей, е
- если найден `SOCKS5`, но HTTP-получение IP через него не удалось и порт не похож на Xray, запускается `MtProtoProber`;
- успешный MTProto probe добавляет информативную находку, но не влияет на итоговый verdict.
**Проба аутентификации (`ProxyProber`, опциональная).** Включается настройкой «Probe local proxy authentication» (`pref_proxy_auth_probe_enabled`, по умолчанию выключена). Применяется только к `SOCKS5`-эндпоинтам на loopback-адресах:
- перебор словаря слабых учётных данных (RFC 1929): пустая пара, `admin/admin`, `user/password`, `proxy/proxy`, `test/test` и т.д. — только если прокси требует аутентификацию;
- проба `UDP ASSOCIATE` на прокси без аутентификации.
Успешный подбор кредов или открытый `UDP ASSOCIATE` дают `detected = true` (`EvidenceSource.PROXY_AUTH_BYPASS`, входит в `HARD_DETECT_BYPASS`).
#### 6.2 Сканер Xray gRPC API (`XrayApiScanner` + `XrayApiClient`)
Сканируются `127.0.0.1` и `::1`.
@ -373,9 +420,26 @@ DNS оценивается вместе со snapshot underlying-сетей, е
Если underlying-сеть доступна при активном VPN, это трактуется как `VPN gateway leak` и даёт `detected = true`.
#### 6.4 Сканер REST API Clash/sing-box (`ClashApiScanner` + `ClashApiClient`)
Опциональная проверка, настройка «Clash/sing-box REST API scan» (`pref_clash_api_scan_enabled`, по умолчанию включена). Сканирует loopback (`127.0.0.1`, `::1`) на REST API менеджеров Clash, mihomo и sing-box.
Параметры:
- порты `9090`, `19090`, `9091`, `9097`
- TCP connect probe `200 мс`, далее connect/read `600 мс`
Логика:
- `GET /configs` — если возвращается валидный JSON, API считается живым;
- `GET /connections` — из `metadata.destinationIP` извлекаются IP VPN-серверов (до 10 уникальных);
- `GET /proxies` — собираются имена прокси-узлов.
Живой API или непустой список IP назначений дают `detected = true` (`EvidenceSource.CLASH_API`, входит в `HARD_DETECT_BYPASS`).
Итог категории:
- `detected = confirmed split tunnel || xrayApiFound || vpnGatewayLeak || vpnNetworkBinding`
- `detected = confirmed split tunnel || xrayApiFound || clashApiFound || proxyAuthBypass || vpnGatewayLeak || vpnNetworkBinding`
- `needsReview = true`, если найден открытый proxy, но подтверждения обхода нет
---
@ -388,7 +452,66 @@ DNS оценивается вместе со snapshot underlying-сетей, е
Проверяет доступность UDP/STUN (как глобальных, так и региональных), а также TCP-доступность Telegram MTProto через локальные прокси-серверы. Это позволяет выявить утечки трафика мимо стандартных туннелей или обнаружить подмену IP-адресов.
### 9. Native Signs (`NativeSignsChecker`)
### 9. SNITCH — RTT-триангуляция (`RttTriangulationChecker`) β
Отправляет ICMP ping к набору российских и иностранных хостов и сравнивает медианные задержки.
Российские цели: `yandex.ru`, `mail.ru`, `vk.com`, `sberbank.ru`, `gosuslugi.ru`.
Иностранные цели: `facebook.com`, `github.com`, `twitter.com`, `reddit.com`, `instagram.com`.
Логика:
- если медианный RTT до RU-хостов превышает порог (`80 мс`) — устройство, вероятно, не находится в РФ;
- высокий jitter (> 60 мс) снижает уверенность вывода;
- результат переводит вердикт в `NEEDS_REVIEW`, но сам по себе не даёт `DETECTED`.
Проверка опциональна и отключена по умолчанию.
---
### 10. ICMP Spoofing (`IcmpSpoofingChecker`)
Проверяет, не подменяет ли оператор ICMP-ответы на заблокированные хосты.
Цели по умолчанию:
- `instagram.com` — заблокированный хост (`BLOCKED`);
- `google.com` — контрольный хост (`CONTROL`).
Цели конфигурируемы через пользовательские проверки. Для работы нужна хотя бы одна пара BLOCKED + CONTROL.
Логика:
| Условие | Итог |
|---------|------|
| BLOCKED-хост ответил на ping | `needsReview = true` — возможен ICMP-спуфинг оператора |
| CONTROL-хост ответил с RTT < 10 мс | `needsReview = true` подозрительно низкая задержка (возможно, локальный перехват) |
| Оба условия одновременно | `needsReview = true` с усиленным сигналом |
| CONTROL-хост не ответил | результат неопределённый (inconclusive) |
| Ничего из вышеперечисленного | норма |
`IcmpSpoofingChecker.detected` всегда `false`. Результат может перевести вердикт из `NOT_DETECTED` в `NEEDS_REVIEW` через R6 в `VerdictEngine`. Включён по умолчанию. При home-routed роуминге сигналы автоматически подавляются.
---
### 11. Доступность доменов (`DomainReachabilityChecker`)
Проверяет каждый домен из пользовательского списка по цепочке DNS → TCP → TLS:
| Шаг | Детектируемая блокировка | Таймаут |
|-----|--------------------------|---------|
| DNS | NXDOMAIN, timeout | 8 с |
| TCP :443 | Connection refused, timeout | 8 с |
| TLS (SNI) | Connection reset — признак DPI | 10 с |
TLS-шаг использует trust-all X.509 manager, потому что цель — обнаружить сброс соединения DPI, а не проверить сертификат. `SSLHandshakeException` из-за невалидного сертификата трактуется как успех TLS.
Результаты не влияют на вердикт. Модуль отключён по умолчанию (`domainReachabilityEnabled = false`) и активируется только если задан непустой список доменов в настройках пользовательской проверки.
---
### 12. Native Signs (`NativeSignsChecker`)
Выполняет низкоуровневые JNI-проверки прямо из C++:
- Перечисление нативных интерфейсов и работа `getifaddrs()`
@ -399,26 +522,160 @@ DNS оценивается вместе со snapshot underlying-сетей, е
Нативные находки транслируются в `needsReview` или общие indirect-признаки.
#### 12.1 TUN/TAP по типу интерфейса
Для каждого интерфейса читается `/sys/class/net/<name>/type`. Значение `65534` (`ARPHRD_TUNTAP`) у активного интерфейса, имя которого **не** совпадает с известными VPN-паттернами, означает TUN/TAP, маскирующийся под обычный интерфейс. Итог: `detected = true` (`EvidenceSource.NATIVE_INTERFACE`).
#### 12.2 Host-route /32 эвристика
В таблице маршрутов (`NETLINK`) ищется не-default маршрут с префиксом `/32` (IPv4) или `/128` (IPv6) к публичному маршрутизируемому адресу через физический интерфейс (`wlan0`, `rmnet`, `eth0`). Это классический хост-маршрут VPN-клиента к IP своего сервера в обход туннеля — утечка реального IP VPN-сервера. Итог: `detected = true` (`EvidenceSource.NATIVE_ROUTE`).
#### 12.3 Детектор эмулятора
JNI-проверки (`nativeDetectEmulator`): QEMU system properties (`ro.kernel.qemu*`, `ro.boot.qemu`), goldfish/ranchu hardware, pipe-устройства (`/dev/qemu_pipe`, `/dev/socket/genyd` для Genymotion), goldfish-драйвер в `/proc/tty/drivers`, артефакты BlueStacks. Дополнительно — Build-эвристика (`FINGERPRINT`, `MODEL`, `HARDWARE`, `PRODUCT`, `MANUFACTURER == "Genymotion"`).
В эмуляторе сетевые тесты ненадёжны, поэтому итог — `needsReview = true` (`EvidenceSource.NATIVE_EMULATOR`), **никогда** `detected`.
#### 12.4 Детектор изоляции
Определяются контексты, в которых VPN другого пользователя/профиля невидим сетевым детекторам:
- вторичный пользователь Android (`userId > 0`, извлекается из пути `dataDir`);
- клон приложения / dual-app (`userId == 999` или диапазон `950..959` MIUI);
- рабочий профиль (`DevicePolicyManager.isProfileOwnerApp`).
Любой из сигналов даёт `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`)
`VerdictEngine` использует не все собранные блоки одинаково.
Сначала применяются безусловные правила:
**R1 — безусловный детект через bypass-evidence:**
1. `DETECTED`, если в bypass-evidence есть `SPLIT_TUNNEL_BYPASS`.
2. `DETECTED`, если найден `XRAY_API`.
3. `DETECTED`, если найден `VPN_GATEWAY_LEAK`.
4. `DETECTED`, если location-сигналы подтверждают РФ (`network_mcc_ru:true`, `cell_country_ru:true` или `location_country_ru:true`), а `GeoIP` одновременно даёт иностранный сигнал.
Если хотя бы одно detected-evidence имеет источник `SPLIT_TUNNEL_BYPASS`, `XRAY_API`, `VPN_GATEWAY_LEAK` или `VPN_NETWORK_BINDING``DETECTED`.
После этого считается матрица:
**R3 — IP-консенсус:**
- `geoMatrixHit` = иностранный GeoIP-сигнал (`geoIp.needsReview` или evidence `GEO_IP`)
- `directMatrixHit` = evidence из `DIRECT_NETWORK_CAPABILITIES` или `SYSTEM_PROXY`
- `indirectMatrixHit` = evidence из `INDIRECT_NETWORK_CAPABILITIES`, `ACTIVE_VPN`, `NETWORK_INTERFACE`, `ROUTING`, `DNS`, `PROXY_TECHNICAL_SIGNAL`
`IpConsensusBuilder` агрегирует сигналы из GeoIP, IpComparison, CDN Pulling, TUN-проба, bypass и callTransportLeaks. Если определён `geoAxis` (иностранные IP, geo-country mismatch или Warp-индикатор) и одновременно имеет место `probeTargetDivergence`, `probeTargetDirectDivergence` или `crossChannelMismatch``DETECTED`.
Комбинации:
**R4 — локация vs GeoIP:**
- Если location-сигналы подтверждают РФ (`network_mcc_ru:true`, `cell_country_ru:true` или `location_country_ru:true`), а `GeoIP` одновременно фиксирует иностранный IP (`outsideRu = true`) — `DETECTED`, кроме случая home-routed роуминга.
- Если location подтверждает РФ, а GeoIP показывает hosting/proxy без иностранного IP и нет других сигналов — `NEEDS_REVIEW`.
Флаг `expectedRoamingExit` (определяется `HomeNetworkCatalog` по MCC/MNC SIM-карты и ASN) защищает от ложных срабатываний при международном роуминге с маршрутизацией через домашнего оператора.
**R5 — трёхосевая матрица (geo × direct × indirect):**
- `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` либо high-confidence `NATIVE_SOCKET`
| Geo | Direct | Indirect | Вердикт |
|-----|--------|----------|---------|
@ -426,14 +683,28 @@ DNS оценивается вместе со snapshot underlying-сетей, е
| нет | да | нет | `NOT_DETECTED` |
| нет | нет | да | `NOT_DETECTED` |
| да | нет | нет | `NEEDS_REVIEW` |
| нет | да | да | `NEEDS_REVIEW` |
| любые остальные комбинации | | | `DETECTED` |
| нет | да | да | `NEEDS_REVIEW` (если geo доступно), иначе `DETECTED` |
| да | да | нет | `DETECTED` |
| да | нет | да | `DETECTED` |
| да | да | да | `DETECTED` |
**R6 — fallback к `NEEDS_REVIEW`:**
Если матрица дала `NOT_DETECTED`, но выполнено хотя бы одно условие — результат повышается до `NEEDS_REVIEW`:
- `bypassResult.needsReview` (открытый proxy без подтверждения обхода)
- `directSigns.needsReview` или `indirectSigns.needsReview`
- `locationSignalHit` (location.detected && !expectedRoamingExit)
- actionable leak из `CallTransportChecker` (статус `NEEDS_REVIEW` не через local proxy)
- `icmpSpoofing.needsReview`
- `NativeSignsChecker` нашёл маркеры хуков (`NATIVE_HOOK_MARKERS`) или нарушение целостности (`NATIVE_LIBRARY_INTEGRITY`)
- `ipConsensus.needsReview`, `ipConsensus.channelConflict` не пуст, `ipConsensus.probeTargetDivergence`
- `TUN_ACTIVE_PROBE` evidence с `detected = false` (tun есть, но VPN не активен для приложения)
Примечания:
- `IpComparisonChecker` сейчас не участвует в `VerdictEngine`;
- сигналы `INSTALLED_APP` и `VPN_SERVICE_DECLARATION` тоже не входят в матрицу и остаются диагностическими;
- Действенные (actionable) утечки из `CallTransportChecker` или находки требующие проверки из `NativeSignsChecker` (например, маркеры хуков) переводят вердикт из `NOT_DETECTED` в `NEEDS_REVIEW`.
- `IpComparisonChecker` через `IpConsensusBuilder` теперь косвенно участвует в R3;
- сигналы `INSTALLED_APP` и `VPN_SERVICE_DECLARATION` не входят в матрицу и остаются диагностическими;
- `DomainReachabilityChecker` не влияет на вердикт.
---

View file

@ -1,5 +1,3 @@
import org.gradle.api.GradleException
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@ -7,34 +5,19 @@ plugins {
val nativeNdkVersion = "28.2.13676358"
val nativeCmakeVersion = "3.22.1"
val appVersionName = resolveAppVersionName()
val appVersionCode = calculateAppVersionCode(appVersionName)
fun resolveAppVersionName(): String {
val rawVersion = providers.gradleProperty("appVersionName").orElse("2.0.0").get().trim()
val normalizedVersion = rawVersion.removePrefix("v")
if (!Regex("""\d+\.\d+\.\d+""").matches(normalizedVersion)) {
throw GradleException("appVersionName must match X.Y.Z, got '$rawVersion'")
}
return normalizedVersion
val robolectricSdk33RuntimeDep by configurations.creating {
isCanBeConsumed = false
isCanBeResolved = true
}
fun calculateAppVersionCode(versionName: String): Int {
val parts = versionName.split(".")
val major = parts[0].toInt()
val minor = parts[1].toInt()
val patch = parts[2].toInt()
if (minor !in 0..99 || patch !in 0..99) {
throw GradleException(
"appVersionName supports only minor and patch values from 0 to 99, got '$versionName'"
)
}
return (major * 10_000) + (minor * 100) + patch
val robolectricSdk35RuntimeDep by configurations.creating {
isCanBeConsumed = false
isCanBeResolved = true
}
val robolectricRuntimeDepsDir = layout.buildDirectory.dir("robolectric-runtime-deps")
val prepareRobolectricRuntimeDeps by tasks.registering(Copy::class) {
from(robolectricSdk33RuntimeDep)
from(robolectricSdk35RuntimeDep)
into(robolectricRuntimeDepsDir)
}
android {
@ -46,9 +29,8 @@ android {
applicationId = "com.notcvnt.rknhardering"
minSdk = 26
targetSdk = 36
versionCode = appVersionCode
versionName = appVersionName
versionCode = 20900
versionName = "2.9.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
androidResources.localeFilters += listOf("en", "ru", "fa", "zh-rCN")
@ -93,14 +75,20 @@ android {
useLegacyPackaging = false
}
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
tasks.withType<org.gradle.api.tasks.testing.Test>().configureEach {
val testHomeDir = layout.projectDirectory.dir(".test-home").asFile
val tempDir = testHomeDir.resolve("tmp")
val testHomeDir = layout.buildDirectory.dir("test-home").get().asFile
val tempDir = layout.buildDirectory.dir("test-tmp").get().asFile
dependsOn(prepareRobolectricRuntimeDeps)
doFirst {
testHomeDir.mkdirs()
@ -109,6 +97,16 @@ tasks.withType<org.gradle.api.tasks.testing.Test>().configureEach {
systemProperty("user.home", testHomeDir.absolutePath)
systemProperty("java.io.tmpdir", tempDir.absolutePath)
systemProperty("robolectric.dependency.dir", robolectricRuntimeDepsDir.get().asFile.absolutePath)
systemProperty("robolectric.offline", "true")
systemProperty("robolectric.usePreinstrumentedJars", "false")
// Forward maintainer-only -Dmarketplace.* properties to the test JVM so the
// MarketplaceSigningTool can pick them up. No effect when unset.
System.getProperties().forEach { (k, v) ->
val key = k.toString()
if (key.startsWith("marketplace.")) systemProperty(key, v.toString())
}
}
dependencies {
@ -128,6 +126,8 @@ dependencies {
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
robolectricSdk33RuntimeDep("org.robolectric:android-all:13-robolectric-9030017")
robolectricSdk35RuntimeDep("org.robolectric:android-all:15-robolectric-13954326")
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

View file

@ -56,10 +56,10 @@
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/ic_launcher_n04_original"
android:label="@string/app_name"
android:usesCleartextTraffic="false"
android:roundIcon="@mipmap/ic_launcher_round"
android:roundIcon="@mipmap/ic_launcher_n04_original_round"
android:supportsRtl="true"
android:theme="@style/Theme.RKNHardering"
android:networkSecurityConfig="@xml/network_security_config"
@ -68,12 +68,109 @@
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter android:label="@string/import_rkncheck_label">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/json" />
<data android:pathPattern=".*\\.rkncheck" />
<data android:host="*" />
</intent-filter>
</activity>
<activity-alias
android:name=".LauncherOriginal"
android:targetActivity=".MainActivity"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher_n04_original"
android:roundIcon="@mipmap/ic_launcher_n04_original_round"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</activity-alias>
<activity-alias
android:name=".LauncherProtanopia"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_n04_protanopia"
android:roundIcon="@mipmap/ic_launcher_n04_protanopia_round"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".LauncherDeuteranopia"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_n04_deuteranopia"
android:roundIcon="@mipmap/ic_launcher_n04_deuteranopia_round"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".LauncherTritanopia"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_n04_tritanopia"
android:roundIcon="@mipmap/ic_launcher_n04_tritanopia_round"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".LauncherMonochrome"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_n04_monochrome"
android:roundIcon="@mipmap/ic_launcher_n04_monochrome_round"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".LauncherClassic"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_classic"
android:roundIcon="@mipmap/ic_launcher_classic_round"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity
android:name=".SettingsActivity"
android:exported="false" />

View file

@ -0,0 +1 @@
564c6e935dc950b122b6e0323c674f2b127fe35c5c5a06d3e5d854562a9c1a0d

View file

@ -10,6 +10,17 @@ set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(REPRODUCIBLE_PATH_FLAGS
"-ffile-prefix-map=${CMAKE_SOURCE_DIR}=."
"-ffile-prefix-map=${CMAKE_BINARY_DIR}=."
"-fmacro-prefix-map=${CMAKE_SOURCE_DIR}=."
"-fmacro-prefix-map=${CMAKE_BINARY_DIR}=."
"-fdebug-prefix-map=${CMAKE_SOURCE_DIR}=."
"-fdebug-prefix-map=${CMAKE_BINARY_DIR}=."
)
add_compile_options(${REPRODUCIBLE_PATH_FLAGS})
add_link_options("-Wl,--build-id=none")
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(BUILD_STATIC_LIBS ON CACHE BOOL "" FORCE)
set(FETCHCONTENT_UPDATES_DISCONNECTED ON CACHE BOOL "" FORCE)

View file

@ -71,7 +71,7 @@ std::shared_ptr<NativeCurlCancellationState> RegisterCancellationState(const std
return nullptr;
}
auto state = std::make_shared<NativeCurlCancellationState>();
std::lock_guard<std::mutex> lock(g_request_states_mutex);
std::scoped_lock lock(g_request_states_mutex);
g_request_states[request_id] = state;
return state;
}
@ -82,7 +82,7 @@ void UnregisterCancellationState(
if (request_id.empty()) {
return;
}
std::lock_guard<std::mutex> lock(g_request_states_mutex);
std::scoped_lock lock(g_request_states_mutex);
auto it = g_request_states.find(request_id);
if (it != g_request_states.end() && it->second == state) {
g_request_states.erase(it);
@ -90,7 +90,7 @@ void UnregisterCancellationState(
}
std::shared_ptr<NativeCurlCancellationState> FindCancellationState(const std::string& request_id) {
std::lock_guard<std::mutex> lock(g_request_states_mutex);
std::scoped_lock lock(g_request_states_mutex);
auto it = g_request_states.find(request_id);
return it != g_request_states.end() ? it->second : nullptr;
}
@ -99,7 +99,7 @@ void TrackSocket(NativeCurlCancellationState* state, curl_socket_t socket_fd) {
if (state == nullptr || socket_fd == CURL_SOCKET_BAD) {
return;
}
std::lock_guard<std::mutex> lock(state->sockets_mutex);
std::scoped_lock lock(state->sockets_mutex);
state->sockets.insert(socket_fd);
}
@ -107,7 +107,7 @@ bool UntrackSocket(NativeCurlCancellationState* state, curl_socket_t socket_fd)
if (state == nullptr || socket_fd == CURL_SOCKET_BAD) {
return false;
}
std::lock_guard<std::mutex> lock(state->sockets_mutex);
std::scoped_lock lock(state->sockets_mutex);
return state->sockets.erase(socket_fd) > 0;
}
@ -118,7 +118,7 @@ void CancelTrackedSockets(const std::shared_ptr<NativeCurlCancellationState>& st
std::vector<curl_socket_t> sockets_to_close;
{
std::lock_guard<std::mutex> lock(state->sockets_mutex);
std::scoped_lock lock(state->sockets_mutex);
sockets_to_close.reserve(state->sockets.size());
for (curl_socket_t socket_fd : state->sockets) {
sockets_to_close.push_back(socket_fd);
@ -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;
}
@ -194,8 +194,7 @@ curl_socket_t OpenSocketCallback(void* clientp, curlsocktype /* purpose */, stru
}
int CloseSocketCallback(void* clientp, curl_socket_t item) {
auto* state = static_cast<NativeCurlCancellationState*>(clientp);
if (UntrackSocket(state, item)) {
if (auto* state = static_cast<NativeCurlCancellationState*>(clientp); UntrackSocket(state, item)) {
close(item);
}
return 0;
@ -334,8 +333,8 @@ CurlExecutionResult ExecuteRequest(
result.curl_code = curl_easy_perform(handle);
curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &result.http_code);
char* primary_ip = nullptr;
if (curl_easy_getinfo(handle, CURLINFO_PRIMARY_IP, &primary_ip) == CURLE_OK &&
if (char* primary_ip = nullptr;
curl_easy_getinfo(handle, CURLINFO_PRIMARY_IP, &primary_ip) == CURLE_OK &&
primary_ip != nullptr && result.resolved_addresses_csv.empty()) {
result.resolved_addresses_csv = primary_ip;
}

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
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.model.CheckResult
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@ -81,7 +82,19 @@ class CheckViewModel(app: Application) : AndroidViewModel(app) {
}
}
if (isCurrentScan(scanId, executionContext)) {
appendScanEvent(scanId, ScanEvent.Completed(result, privacyMode))
val enrichedResult = try {
val app: Application = getApplication()
val customEnabled = AppUiSettings.prefs(app)
.getBoolean(SettingsPrefs.PREF_CUSTOM_CHECKS_ENABLED, false)
val profile = if (customEnabled) CustomCheckRunner.getActiveProfile(app) else null
result.copy(
customProfileId = profile?.id,
customProfileName = profile?.name,
)
} catch (_: Exception) {
result
}
appendScanEvent(scanId, ScanEvent.Completed(enrichedResult, privacyMode))
}
} catch (e: kotlinx.coroutines.CancellationException) {
if (isCurrentScan(scanId, executionContext)) {

View file

@ -3,6 +3,7 @@ package com.notcvnt.rknhardering
import com.notcvnt.rknhardering.checker.CheckSettings
import com.notcvnt.rknhardering.checker.DebugStepTiming
import com.notcvnt.rknhardering.checker.IndirectCheckPerformanceRegistry
import com.notcvnt.rknhardering.checker.LocationSignalsDiagnosticsRegistry
import com.notcvnt.rknhardering.checker.StunScopeTiming
import com.notcvnt.rknhardering.checker.summarizeDominantDelay
import com.notcvnt.rknhardering.model.ActiveVpnApp
@ -14,6 +15,7 @@ import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.CheckResult
import com.notcvnt.rknhardering.model.EvidenceItem
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.GeoIpResponse
import com.notcvnt.rknhardering.model.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpCheckerResponse
import com.notcvnt.rknhardering.model.IpComparisonResult
@ -21,10 +23,15 @@ import com.notcvnt.rknhardering.model.LocalProxyOwner
import com.notcvnt.rknhardering.model.LocalProxyCheckResult
import com.notcvnt.rknhardering.model.MatchedVpnApp
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
import com.notcvnt.rknhardering.export.sectionStatusTag
import com.notcvnt.rknhardering.probe.NativeInterfaceProbe
import com.notcvnt.rknhardering.probe.NativeSignsBridge
import com.notcvnt.rknhardering.probe.TunProbeDiagnosticsFormatter
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.XrayApiScanResult
import com.notcvnt.rknhardering.probe.XrayOutboundSummary
import com.notcvnt.rknhardering.util.maskIp
import com.notcvnt.rknhardering.util.maskIpsInText
import java.net.NetworkInterface
import java.text.SimpleDateFormat
import java.util.Date
@ -32,6 +39,11 @@ import java.util.Locale
object DebugDiagnosticsFormatter {
private const val NONE = "<none>"
private const val NONE_DASH = "- <none>"
private const val EMPTY = "<empty>"
private const val FINDINGS_LABEL = "findings:"
fun format(
result: CheckResult,
settings: CheckSettings,
@ -61,12 +73,16 @@ object DebugDiagnosticsFormatter {
appendCategory(builder, "directSigns", result.directSigns)
appendCategory(builder, "indirectSigns", result.indirectSigns)
appendCategory(builder, "icmpSpoofing", result.icmpSpoofing)
appendCategory(builder, "rttTriangulation", result.rttTriangulation)
appendIndirectPerformance(builder, result.indirectSigns)
appendCategory(builder, "locationSignals", result.locationSignals)
appendLocationSignalsDiagnostics(builder, result.locationSignals)
appendBypass(builder, result.bypassResult)
appendCategory(builder, "nativeSigns", result.nativeSigns)
appendNativeSignsRaw(builder, privacyMode)
appendOperatorWhitelist(builder, result.operatorWhitelistProbe)
builder.appendLine()
builder.appendLine("[tunProbe]")
val tunDiagnostics = result.tunProbeDiagnostics
@ -80,6 +96,33 @@ object DebugDiagnosticsFormatter {
return builder.toString().trimEnd()
}
private fun appendOperatorWhitelist(
builder: StringBuilder,
probe: OperatorWhitelistProbeResult?,
) {
builder.appendLine()
builder.appendLine("[operatorWhitelist]")
if (probe == null) {
builder.appendLine("collected: false")
return
}
builder.appendLine("collected: true")
builder.appendLine("detected: ${probe.whitelistDetected}")
builder.appendLine("googleReachable: ${probe.googleReachable}")
builder.appendLine("appleReachable: ${probe.appleReachable}")
builder.appendLine("firefoxReachable: ${probe.firefoxReachable}")
builder.appendLine("russianControlReachable: ${probe.russianControlReachable}")
builder.appendLine("durationMs: ${probe.durationMs}")
if (probe.errors.isEmpty()) {
builder.appendLine("errors: <none>")
} else {
builder.appendLine("errors:")
probe.errors.forEach { (key, value) ->
builder.appendLine("- $key: $value")
}
}
}
private fun appendNativeSignsRaw(builder: StringBuilder, privacyMode: Boolean) {
builder.appendLine()
builder.appendLine("[nativeSigns.raw]")
@ -92,7 +135,7 @@ object DebugDiagnosticsFormatter {
builder.appendLine("-- ifconfig-like dump (getifaddrs + /sys/class/net) --")
val dump = runCatching { NativeSignsBridge.interfaceDump() }.getOrDefault(emptyArray())
if (dump.isEmpty()) {
builder.appendLine("<empty>")
builder.appendLine(EMPTY)
} else {
dump.forEach { block ->
builder.appendLine(maskInterfaceDumpBlock(block.trimEnd()))
@ -103,7 +146,7 @@ object DebugDiagnosticsFormatter {
builder.appendLine("-- getifaddrs() rows (pipe-delimited) --")
val rows = runCatching { NativeSignsBridge.getIfAddrs() }.getOrDefault(emptyArray())
if (rows.isEmpty()) {
builder.appendLine("<empty>")
builder.appendLine(EMPTY)
} else {
rows.forEach { row -> builder.appendLine(maskIfAddrsRow(row)) }
}
@ -112,7 +155,7 @@ object DebugDiagnosticsFormatter {
builder.appendLine("-- Routes via AF_NETLINK NETLINK_ROUTE RTM_GETROUTE (IPv4+IPv6) --")
val nlRoutes = runCatching { NativeSignsBridge.netlinkRouteDump(0) }.getOrDefault(emptyArray())
if (nlRoutes.isEmpty()) {
builder.appendLine("<empty>")
builder.appendLine(EMPTY)
} else {
nlRoutes.forEach { builder.appendLine(maskNetlinkPipeRow(it)) }
}
@ -121,7 +164,7 @@ object DebugDiagnosticsFormatter {
builder.appendLine("-- TCP sockets via AF_NETLINK NETLINK_SOCK_DIAG (IPv4) --")
val nlTcp4 = runCatching { NativeSignsBridge.netlinkSockDiag(2, 6) }.getOrDefault(emptyArray())
if (nlTcp4.isEmpty()) {
builder.appendLine("<empty>")
builder.appendLine(EMPTY)
} else {
nlTcp4.take(60).forEach { builder.appendLine(maskNetlinkPipeRow(it)) }
if (nlTcp4.size > 60) builder.appendLine("... (${nlTcp4.size - 60} more truncated)")
@ -131,7 +174,7 @@ object DebugDiagnosticsFormatter {
builder.appendLine("-- TCP sockets via AF_NETLINK NETLINK_SOCK_DIAG (IPv6) --")
val nlTcp6 = runCatching { NativeSignsBridge.netlinkSockDiag(10, 6) }.getOrDefault(emptyArray())
if (nlTcp6.isEmpty()) {
builder.appendLine("<empty>")
builder.appendLine(EMPTY)
} else {
nlTcp6.take(60).forEach { builder.appendLine(maskNetlinkPipeRow(it)) }
if (nlTcp6.size > 60) builder.appendLine("... (${nlTcp6.size - 60} more truncated)")
@ -141,7 +184,7 @@ object DebugDiagnosticsFormatter {
builder.appendLine("-- UDP sockets via AF_NETLINK NETLINK_SOCK_DIAG (IPv4) --")
val nlUdp4 = runCatching { NativeSignsBridge.netlinkSockDiag(2, 17) }.getOrDefault(emptyArray())
if (nlUdp4.isEmpty()) {
builder.appendLine("<empty>")
builder.appendLine(EMPTY)
} else {
nlUdp4.take(60).forEach { builder.appendLine(maskNetlinkPipeRow(it)) }
if (nlUdp4.size > 60) builder.appendLine("... (${nlUdp4.size - 60} more truncated)")
@ -168,10 +211,10 @@ object DebugDiagnosticsFormatter {
builder.appendLine("-- /proc/self/maps markers (native classification) --")
val mapsFindings = runCatching { NativeInterfaceProbe.collectMapsFindings() }.getOrDefault(emptyList())
if (mapsFindings.isEmpty()) {
builder.appendLine("<empty>")
builder.appendLine(EMPTY)
} else {
mapsFindings.forEach { f ->
builder.appendLine("kind=${f.kind} marker=${f.marker ?: "<none>"} detail=${f.detail ?: "<none>"}")
builder.appendLine("kind=${f.kind} marker=${f.marker ?: NONE} detail=${f.detail ?: NONE}")
}
}
@ -179,11 +222,11 @@ object DebugDiagnosticsFormatter {
builder.appendLine("-- libraryIntegrity (dlsym+dladdr) --")
val integrity = runCatching { NativeInterfaceProbe.collectLibraryIntegrity() }.getOrDefault(emptyList())
if (integrity.isEmpty()) {
builder.appendLine("<empty>")
builder.appendLine(EMPTY)
} else {
integrity.forEach { sym ->
builder.appendLine(
"symbol=${sym.symbol} missing=${sym.missing} addr=${sym.address ?: "<none>"} lib=${sym.library ?: "<none>"}",
"symbol=${sym.symbol} missing=${sym.missing} addr=${sym.address ?: NONE} lib=${sym.library ?: NONE}",
)
}
}
@ -192,7 +235,7 @@ object DebugDiagnosticsFormatter {
builder.appendLine("-- probeFeatureFlags --")
val flags = runCatching { NativeSignsBridge.probeFeatureFlags() }.getOrDefault(emptyArray())
if (flags.isEmpty()) {
builder.appendLine("<empty>")
builder.appendLine(EMPTY)
} else {
flags.forEach { builder.appendLine(it) }
}
@ -293,7 +336,7 @@ object DebugDiagnosticsFormatter {
private fun renderProcContent(content: String?, maxLines: Int = 0): String {
if (content == null) return "<unavailable (null)>"
if (content.isEmpty()) return "<empty>"
if (content.isEmpty()) return EMPTY
val masked = maskIpsInText(content).trimEnd()
if (maxLines <= 0) return masked
val lines = masked.lines()
@ -302,7 +345,7 @@ object DebugDiagnosticsFormatter {
}
private fun renderJvmInterfaces(privacyMode: Boolean): String {
val ifaces = NetworkInterface.getNetworkInterfaces() ?: return "<none>"
val ifaces = NetworkInterface.getNetworkInterfaces() ?: return NONE
val sb = StringBuilder()
while (ifaces.hasMoreElements()) {
val iface = ifaces.nextElement() ?: continue
@ -329,7 +372,7 @@ object DebugDiagnosticsFormatter {
sb.append(" addr ").append(rendered).append('\n')
}
}
if (sb.isEmpty()) return "<none>"
if (sb.isEmpty()) return NONE
return sb.toString().trimEnd()
}
@ -344,14 +387,15 @@ object DebugDiagnosticsFormatter {
builder.appendLine("detected: ${category.detected}")
builder.appendLine("needsReview: ${category.needsReview}")
builder.appendLine("hasError: ${category.hasError}")
builder.appendLine("status: ${sectionStatusTag(category.detected, category.needsReview, category.hasError)}")
builder.appendLine("findingsCount: ${category.findings.size}")
builder.appendLine("evidenceCount: ${category.evidence.size}")
builder.appendLine("matchedAppsCount: ${category.matchedApps.size}")
builder.appendLine("activeAppsCount: ${category.activeApps.size}")
builder.appendLine("callTransportCount: ${category.callTransportLeaks.size}")
builder.appendLine("findings:")
builder.appendLine(FINDINGS_LABEL)
if (category.findings.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
} else {
category.findings.forEach { finding ->
builder.appendLine("- ${formatFinding(finding)}")
@ -362,6 +406,9 @@ object DebugDiagnosticsFormatter {
appendNamedCollection(builder, "matchedApps", category.matchedApps, ::formatMatchedVpnApp)
appendNamedCollection(builder, "activeApps", category.activeApps, ::formatActiveVpnApp)
appendNamedCollection(builder, "callTransport", category.callTransportLeaks, ::formatCallTransportLeak)
if (key == "geoIp") {
appendNamedCollection(builder, "responses", category.geoIpResponses, ::formatGeoIpResponse)
}
}
private fun appendIndirectPerformance(
@ -378,7 +425,7 @@ object DebugDiagnosticsFormatter {
builder.appendLine("dominantDelayMs: ${dominantDelay.durationMs}")
builder.appendLine("steps:")
if (diagnostics.steps.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
} else {
diagnostics.steps.forEach { timing ->
builder.appendLine("- ${formatStepTiming(timing)}")
@ -397,7 +444,7 @@ object DebugDiagnosticsFormatter {
builder.appendLine("callTransport.noResponseStunTargets: ${callTransport.noResponseStunTargets}")
builder.appendLine("callTransport.steps:")
if (callTransport.steps.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
} else {
callTransport.steps.forEach { timing ->
builder.appendLine("- ${formatStepTiming(timing)}")
@ -405,7 +452,7 @@ object DebugDiagnosticsFormatter {
}
builder.appendLine("callTransport.stunScopes:")
if (callTransport.stunScopeTimings.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
} else {
callTransport.stunScopeTimings.forEach { timing ->
builder.appendLine("- ${formatStunScopeTiming(timing)}")
@ -413,6 +460,37 @@ object DebugDiagnosticsFormatter {
}
}
private fun appendLocationSignalsDiagnostics(
builder: StringBuilder,
category: CategoryResult,
) {
val diagnostics = LocationSignalsDiagnosticsRegistry.find(category) ?: return
builder.appendLine()
builder.appendLine("[locationSignals.debug]")
builder.appendLine("fineLocationPermissionGranted: ${diagnostics.fineLocationPermissionGranted}")
builder.appendLine("nearbyWifiPermissionGranted: ${diagnostics.nearbyWifiPermissionGranted}")
builder.appendLine("locationServicesEnabled: ${diagnostics.locationServicesEnabled}")
builder.appendLine("telephonyRadioAccessAvailable: ${diagnostics.telephonyRadioAccessAvailable}")
builder.appendLine("wifiFeatureAvailable: ${diagnostics.wifiFeatureAvailable}")
builder.appendLine("networkRequestsEnabled: ${diagnostics.networkRequestsEnabled}")
builder.appendLine("cellRawInfoCount: ${diagnostics.cellRawInfoCount}")
builder.appendLine("cellRawInfoTypes: ${formatStringList(diagnostics.cellRawInfoTypes)}")
builder.appendLine("cellCandidateRadios: ${formatStringList(diagnostics.cellCandidateRadios)}")
builder.appendLine("beaconDbCellCandidatesUsedCount: ${diagnostics.beaconDbCellCandidatesUsedCount}")
builder.appendLine("beaconDbUnsupportedCellRadios: ${formatStringList(diagnostics.beaconDbUnsupportedCellRadios)}")
builder.appendLine("beaconDbWifiCandidatesUsedCount: ${diagnostics.beaconDbWifiCandidatesUsedCount}")
builder.appendLine("wifiAccessPointCandidatesCount: ${diagnostics.wifiAccessPointCandidatesCount}")
builder.appendLine("wifiCachedScanCandidatesCount: ${diagnostics.wifiCachedScanCandidatesCount}")
builder.appendLine("wifiFreshScanCandidatesCount: ${diagnostics.wifiFreshScanCandidatesCount?.toString() ?: "<timeout>"}")
builder.appendLine("wifiConnectedCandidateAvailable: ${diagnostics.wifiConnectedCandidateAvailable}")
builder.appendLine("bssidSource: ${diagnostics.bssidSource ?: NONE}")
builder.appendLine("bssidUnavailableReason: ${diagnostics.bssidUnavailableReason ?: NONE}")
}
private fun formatStringList(values: List<String>): String {
return values.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: NONE
}
private fun appendIpComparison(
builder: StringBuilder,
ipComparison: IpComparisonResult,
@ -421,6 +499,8 @@ object DebugDiagnosticsFormatter {
builder.appendLine("[ipComparison]")
builder.appendLine("detected: ${ipComparison.detected}")
builder.appendLine("needsReview: ${ipComparison.needsReview}")
builder.appendLine("hasError: ${ipComparison.hasError}")
builder.appendLine("status: ${sectionStatusTag(ipComparison.detected, ipComparison.needsReview, ipComparison.hasError)}")
builder.appendLine("summary: ${maskIpsInText(ipComparison.summary)}")
appendIpCheckerGroup(builder, "ru", ipComparison.ruGroup)
appendIpCheckerGroup(builder, "nonRu", ipComparison.nonRuGroup)
@ -434,16 +514,18 @@ object DebugDiagnosticsFormatter {
builder.appendLine("[bypass]")
builder.appendLine("detected: ${bypass.detected}")
builder.appendLine("needsReview: ${bypass.needsReview}")
builder.appendLine("directIp: ${bypass.directIp?.let(::maskIp) ?: "<none>"}")
builder.appendLine("proxyIp: ${bypass.proxyIp?.let(::maskIp) ?: "<none>"}")
builder.appendLine("vpnNetworkIp: ${bypass.vpnNetworkIp?.let(::maskIp) ?: "<none>"}")
builder.appendLine("underlyingIp: ${bypass.underlyingIp?.let(::maskIp) ?: "<none>"}")
builder.appendLine("hasError: ${bypass.hasError}")
builder.appendLine("status: ${sectionStatusTag(bypass.detected, bypass.needsReview, bypass.hasError)}")
builder.appendLine("directIp: ${bypass.directIp?.let(::maskIp) ?: NONE}")
builder.appendLine("proxyIp: ${bypass.proxyIp?.let(::maskIp) ?: NONE}")
builder.appendLine("vpnNetworkIp: ${bypass.vpnNetworkIp?.let(::maskIp) ?: NONE}")
builder.appendLine("underlyingIp: ${bypass.underlyingIp?.let(::maskIp) ?: NONE}")
builder.appendLine("proxyEndpoint: ${formatProxyEndpoint(bypass)}")
builder.appendLine("proxyOwner: ${formatProxyOwner(bypass.proxyOwner)}")
builder.appendLine("xrayApi: ${formatXrayApiHeader(bypass.xrayApiScanResult)}")
builder.appendLine("findings:")
builder.appendLine(FINDINGS_LABEL)
if (bypass.findings.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
} else {
bypass.findings.forEach { finding ->
builder.appendLine("- ${formatFinding(finding)}")
@ -478,10 +560,11 @@ object DebugDiagnosticsFormatter {
builder.appendLine("detected: ${result.detected}")
builder.appendLine("needsReview: ${result.needsReview}")
builder.appendLine("hasError: ${result.hasError}")
builder.appendLine("status: ${sectionStatusTag(result.detected, result.needsReview, result.hasError)}")
builder.appendLine("summary: ${maskIpsInText(result.summary)}")
builder.appendLine("findings:")
builder.appendLine(FINDINGS_LABEL)
if (result.findings.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
} else {
result.findings.forEach { finding ->
builder.appendLine("- ${formatFinding(finding)}")
@ -489,7 +572,7 @@ object DebugDiagnosticsFormatter {
}
builder.appendLine("responses:")
if (result.responses.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
return
}
result.responses.forEach { response ->
@ -506,13 +589,13 @@ object DebugDiagnosticsFormatter {
builder.appendLine("resolverPreset: ${resolver.preset}")
builder.appendLine(
"resolverDirectServers: ${
resolver.effectiveDirectServers().joinToString(", ") { maskIp(it) }.ifBlank { "<none>" }
resolver.effectiveDirectServers().joinToString(", ") { maskIp(it) }.ifBlank { NONE }
}",
)
builder.appendLine("resolverDohUrl: ${resolver.effectiveDohUrl() ?: "<none>"}")
builder.appendLine("resolverDohUrl: ${resolver.effectiveDohUrl() ?: NONE}")
builder.appendLine(
"resolverDohBootstrap: ${
resolver.effectiveDohBootstrapHosts().joinToString(", ") { maskIp(it) }.ifBlank { "<none>" }
resolver.effectiveDohBootstrapHosts().joinToString(", ") { maskIp(it) }.ifBlank { NONE }
}",
)
}
@ -527,11 +610,11 @@ object DebugDiagnosticsFormatter {
builder.appendLine("$label.needsReview: ${group.needsReview}")
builder.appendLine("$label.statusLabel: ${group.statusLabel}")
builder.appendLine("$label.summary: ${maskIpsInText(group.summary)}")
builder.appendLine("$label.canonicalIp: ${group.canonicalIp?.let(::maskIp) ?: "<none>"}")
builder.appendLine("$label.canonicalIp: ${group.canonicalIp?.let(::maskIp) ?: NONE}")
builder.appendLine("$label.ignoredIpv6ErrorCount: ${group.ignoredIpv6ErrorCount}")
builder.appendLine("$label.responses:")
if (group.responses.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
return
}
group.responses.forEach { response ->
@ -547,7 +630,7 @@ object DebugDiagnosticsFormatter {
) {
builder.appendLine("$label:")
if (items.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
return
}
items.forEach { item ->
@ -596,10 +679,10 @@ object DebugDiagnosticsFormatter {
private fun formatActiveVpnApp(app: ActiveVpnApp): String {
return buildList {
add("package=${app.packageName ?: "<none>"}")
add("serviceName=${app.serviceName ?: "<none>"}")
add("family=${app.family ?: "<none>"}")
add("kind=${app.kind ?: "<none>"}")
add("package=${app.packageName ?: NONE}")
add("serviceName=${app.serviceName ?: NONE}")
add("family=${app.family ?: NONE}")
add("kind=${app.kind ?: NONE}")
add("source=${app.source}")
add("confidence=${app.confidence}")
addAll(formatTechnicalMetadata(app.technicalMetadata))
@ -630,7 +713,7 @@ object DebugDiagnosticsFormatter {
leak.targetPort?.let { add("targetPort=$it") }
add(
"resolvedIps=${
leak.resolvedIps.joinToString(", ") { maskIp(it) }.ifBlank { "<none>" }
leak.resolvedIps.joinToString(", ") { maskIp(it) }.ifBlank { NONE }
}",
)
leak.mappedIp?.let { add("mappedIp=${maskIp(it)}") }
@ -665,11 +748,21 @@ object DebugDiagnosticsFormatter {
add("type=${proxyCheck.endpoint.type}")
add("ownerStatus=${proxyCheck.ownerStatus}")
add("owner=${formatProxyOwner(proxyCheck.owner)}")
add("proxyIp=${proxyCheck.proxyIp?.let(::maskIp) ?: "<none>"}")
add("proxyIp=${proxyCheck.proxyIp?.let(::maskIp) ?: NONE}")
add("status=${proxyCheck.status}")
add("mtProtoReachable=${proxyCheck.mtProtoReachable?.toString() ?: "<not-run>"}")
add("mtProtoTarget=${proxyCheck.mtProtoTarget?.let(::maskHostPort) ?: "<none>"}")
add("summaryReason=${proxyCheck.summaryReason ?: "<none>"}")
add("mtProtoTarget=${proxyCheck.mtProtoTarget?.let(::maskHostPort) ?: NONE}")
add("summaryReason=${proxyCheck.summaryReason ?: NONE}")
}.joinToString(" ")
}
private fun formatGeoIpResponse(response: GeoIpResponse): String {
return buildList {
add("provider=${response.provider}")
add("isCustom=${response.isCustom}")
add("ip=${response.ip?.let(::maskIp) ?: NONE}")
add("error=${response.error?.let(::maskIpsInText) ?: NONE}")
add("rawBody=${formatRawBody(response.rawBody)}")
}.joinToString(" ")
}
@ -678,16 +771,16 @@ object DebugDiagnosticsFormatter {
add("label=${response.label}")
add("scope=${response.scope}")
add("url=${response.url}")
add("ip=${response.ip?.let(::maskIp) ?: "<none>"}")
add("error=${response.error?.let(::maskIpsInText) ?: "<none>"}")
add("ip=${response.ip?.let(::maskIp) ?: NONE}")
add("error=${response.error?.let(::maskIpsInText) ?: NONE}")
add(
"ipv4Records=${
response.ipv4Records.joinToString(", ") { maskIp(it) }.ifBlank { "<none>" }
response.ipv4Records.joinToString(", ") { maskIp(it) }.ifBlank { NONE }
}",
)
add(
"ipv6Records=${
response.ipv6Records.joinToString(", ") { maskIp(it) }.ifBlank { "<none>" }
response.ipv6Records.joinToString(", ") { maskIp(it) }.ifBlank { NONE }
}",
)
add("ignoredIpv6Error=${response.ignoredIpv6Error}")
@ -698,13 +791,13 @@ object DebugDiagnosticsFormatter {
return buildList {
add("target=${response.targetLabel}")
add("url=${response.url}")
add("ip=${response.ip?.let(::maskIp) ?: "<none>"}")
add("error=${response.error?.let(::maskIpsInText) ?: "<none>"}")
add("ip=${response.ip?.let(::maskIp) ?: NONE}")
add("error=${response.error?.let(::maskIpsInText) ?: NONE}")
add(
"importantFields=${
response.importantFields.entries.joinToString(", ") { entry ->
"${entry.key}=${maskIpsInText(entry.value)}"
}.ifBlank { "<none>" }
}.ifBlank { NONE }
}",
)
add("rawBody=${formatRawBody(response.rawBody)}")
@ -712,28 +805,28 @@ object DebugDiagnosticsFormatter {
}
private fun formatProxyEndpoint(bypass: BypassResult): String {
val proxyEndpoint = bypass.proxyEndpoint ?: return "<none>"
val proxyEndpoint = bypass.proxyEndpoint ?: return NONE
return "${maskHostOrIp(proxyEndpoint.host)}:${proxyEndpoint.port} (${proxyEndpoint.type})"
}
private fun formatRawBody(rawBody: String?): String {
val normalized = rawBody?.trim().orEmpty()
if (normalized.isBlank()) return "<none>"
if (normalized.isBlank()) return NONE
return maskIpsInText(normalized).replace("\n", "\\n")
}
private fun formatProxyOwner(owner: LocalProxyOwner?): String {
if (owner == null) return "<none>"
if (owner == null) return NONE
return buildList {
add("uid=${owner.uid}")
add("confidence=${owner.confidence}")
add("apps=${owner.appLabels.joinToString(", ").ifBlank { "<none>" }}")
add("packages=${owner.packageNames.joinToString(", ").ifBlank { "<none>" }}")
add("apps=${owner.appLabels.joinToString(", ").ifBlank { NONE }}")
add("packages=${owner.packageNames.joinToString(", ").ifBlank { NONE }}")
}.joinToString(" ")
}
private fun formatXrayApiHeader(scanResult: XrayApiScanResult?): String {
if (scanResult == null) return "<none>"
if (scanResult == null) return NONE
return "endpoint=${maskHostOrIp(scanResult.endpoint.host)}:${scanResult.endpoint.port} outboundCount=${scanResult.outbounds.size}"
}
@ -757,12 +850,12 @@ object DebugDiagnosticsFormatter {
private fun formatXrayOutbound(outbound: XrayOutboundSummary): String {
return buildList {
add("tag=${outbound.tag}")
add("protocol=${outbound.protocolName ?: "<none>"}")
add("address=${outbound.address?.let(::maskHostOrIp) ?: "<none>"}")
add("port=${outbound.port ?: "<none>"}")
add("sni=${outbound.sni ?: "<none>"}")
add("senderSettingsType=${outbound.senderSettingsType ?: "<none>"}")
add("proxySettingsType=${outbound.proxySettingsType ?: "<none>"}")
add("protocol=${outbound.protocolName ?: NONE}")
add("address=${outbound.address?.let(::maskHostOrIp) ?: NONE}")
add("port=${outbound.port ?: NONE}")
add("sni=${outbound.sni ?: NONE}")
add("senderSettingsType=${outbound.senderSettingsType ?: NONE}")
add("proxySettingsType=${outbound.proxySettingsType ?: NONE}")
add("uuidPresent=${!outbound.uuid.isNullOrBlank()}")
add("publicKeyPresent=${!outbound.publicKey.isNullOrBlank()}")
}.joinToString(" ")

View file

@ -0,0 +1,55 @@
package com.notcvnt.rknhardering
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
internal object LauncherIconManager {
private const val TAG = "LauncherIconManager"
internal var setComponentEnabledSettingForTests:
((PackageManager, ComponentName, Int) -> Unit)? = null
fun apply(context: Context, target: LauncherIconVariant): Boolean {
return try {
val pm = context.packageManager
val pkg = context.packageName
setStateIfNeeded(pm, ComponentName(pkg, target.aliasClass), PackageManager.COMPONENT_ENABLED_STATE_ENABLED)
LauncherIconVariant.entries
.filterNot { it == target }
.forEach { variant ->
setStateIfNeeded(
pm,
ComponentName(pkg, variant.aliasClass),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
)
}
true
} catch (e: SecurityException) {
Log.w(TAG, "Failed to toggle launcher alias", e)
false
}
}
fun current(context: Context): LauncherIconVariant {
val pm = context.packageManager
val pkg = context.packageName
return LauncherIconVariant.entries.firstOrNull { variant ->
pm.getComponentEnabledSetting(ComponentName(pkg, variant.aliasClass)) ==
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} ?: LauncherIconVariant.ORIGINAL
}
private fun setStateIfNeeded(
pm: PackageManager,
component: ComponentName,
newState: Int,
) {
if (pm.getComponentEnabledSetting(component) != newState) {
setComponentEnabledSettingForTests?.invoke(pm, component, newState)
?: pm.setComponentEnabledSetting(component, newState, PackageManager.DONT_KILL_APP)
}
}
}

View file

@ -0,0 +1,37 @@
package com.notcvnt.rknhardering
internal enum class LauncherIconVariant(
val aliasClass: String,
val prefValue: String,
) {
ORIGINAL("com.notcvnt.rknhardering.LauncherOriginal", "original"),
CLASSIC("com.notcvnt.rknhardering.LauncherClassic", "classic"),
PROTANOPIA("com.notcvnt.rknhardering.LauncherProtanopia", "protanopia"),
DEUTERANOPIA("com.notcvnt.rknhardering.LauncherDeuteranopia", "deuteranopia"),
TRITANOPIA("com.notcvnt.rknhardering.LauncherTritanopia", "tritanopia"),
MONOCHROME("com.notcvnt.rknhardering.LauncherMonochrome", "monochrome");
companion object {
fun fromCvd(
mode: ColorVisionMode,
redGreenSub: LauncherIconVariant?,
): LauncherIconVariant {
return when (mode) {
ColorVisionMode.OFF -> ORIGINAL
ColorVisionMode.RED_GREEN ->
redGreenSub?.takeIf { it == PROTANOPIA } ?: DEUTERANOPIA
ColorVisionMode.BLUE_YELLOW -> TRITANOPIA
ColorVisionMode.ACHROMATOPSIA -> MONOCHROME
}
}
fun resolve(
iconStyleClassic: Boolean,
mode: ColorVisionMode,
redGreenSub: LauncherIconVariant?,
): LauncherIconVariant {
if (iconStyleClassic) return CLASSIC
return fromCvd(mode, redGreenSub)
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,8 @@
package com.notcvnt.rknhardering
import android.app.Application
import android.content.SharedPreferences
import androidx.core.content.edit
import com.notcvnt.rknhardering.probe.NativeCurlBridge
class RknHarderingApp : Application() {
@ -9,5 +11,54 @@ class RknHarderingApp : Application() {
super.onCreate()
NativeCurlBridge.initIfNeeded(this)
AppUiSettings.applySavedTheme(this)
migrateLauncherIconIfNeeded()
}
internal fun migrateLauncherIconIfNeeded() {
val prefs = AppUiSettings.prefs(this)
if (prefs.getBoolean(SettingsPrefs.PREF_ICON_MIGRATION_DONE, false)) return
val rawMode = prefs.getString(SettingsPrefs.PREF_COLOR_VISION_MODE, null)
syncLegacyRedGreenSubVariant(prefs, rawMode)
val mode = ColorVisionMode.fromPref(rawMode)
val ok = if (mode == ColorVisionMode.OFF) {
true
} else {
LauncherIconManager.apply(
this,
LauncherIconVariant.fromCvd(mode, redGreenSubVariantForMigration(prefs, rawMode)),
)
}
if (ok) {
prefs.edit { putBoolean(SettingsPrefs.PREF_ICON_MIGRATION_DONE, true) }
}
}
private fun syncLegacyRedGreenSubVariant(prefs: SharedPreferences, rawMode: String?) {
if (rawMode != LauncherIconVariant.PROTANOPIA.prefValue) return
prefs.edit {
putBoolean(SettingsPrefs.PREF_EASTER_EGG_PROTANOPIA_UNLOCKED, true)
putString(SettingsPrefs.PREF_RED_GREEN_ICON_VARIANT, LauncherIconVariant.PROTANOPIA.prefValue)
}
}
private fun redGreenSubVariantForMigration(
prefs: SharedPreferences,
rawMode: String?,
): LauncherIconVariant {
if (rawMode == LauncherIconVariant.PROTANOPIA.prefValue) {
return LauncherIconVariant.PROTANOPIA
}
val unlocked = prefs.getBoolean(SettingsPrefs.PREF_EASTER_EGG_PROTANOPIA_UNLOCKED, false)
val storedVariant = prefs.getString(
SettingsPrefs.PREF_RED_GREEN_ICON_VARIANT,
LauncherIconVariant.DEUTERANOPIA.prefValue,
)
return if (unlocked && storedVariant == LauncherIconVariant.PROTANOPIA.prefValue) {
LauncherIconVariant.PROTANOPIA
} else {
LauncherIconVariant.DEUTERANOPIA
}
}
}

View file

@ -60,9 +60,15 @@ class ScanCancellationSignal {
fun cancel() {
if (!cancelled.compareAndSet(false, true)) return
val pending = callbacks.entries.toList()
// Snapshot via the map's weakly-consistent iterator. Collection.toList()
// takes a size==1 fast path through first(), which races with concurrent
// unregister() and can throw NoSuchElementException.
val pending = ArrayList<() -> Unit>(callbacks.size)
for (callback in callbacks.values) {
pending.add(callback)
}
callbacks.clear()
pending.forEach { (_, callback) ->
pending.forEach { callback ->
runCatching(callback)
}
}

View file

@ -20,6 +20,10 @@ internal class SettingsAboutFragment : Fragment(R.layout.fragment_settings_about
startActivity(Intent(Intent.ACTION_VIEW, getString(R.string.matrix_room_url).toUri()))
}
view.findViewById<MaterialCardView>(R.id.cardTelegram).setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, getString(R.string.telegram_group_url).toUri()))
}
view.findViewById<MaterialCardView>(R.id.cardGithub).setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, getString(R.string.github_repo_url).toUri()))
}

View file

@ -5,18 +5,23 @@ import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.core.content.edit
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import com.google.android.material.chip.ChipGroup
import com.google.android.material.snackbar.Snackbar
internal class SettingsAccessibilityFragment : Fragment(R.layout.fragment_settings_accessibility) {
private lateinit var prefs: SharedPreferences
private lateinit var chipGroupColorVision: ChipGroup
private lateinit var chipGroupRedGreenIcon: ChipGroup
private var easterEggTapCount = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
prefs = AppUiSettings.prefs(requireContext())
chipGroupColorVision = view.findViewById(R.id.chipGroupColorVision)
chipGroupRedGreenIcon = view.findViewById(R.id.chipGroupRedGreenIcon)
loadSettings(view)
setupListeners(view)
}
@ -24,7 +29,9 @@ internal class SettingsAccessibilityFragment : Fragment(R.layout.fragment_settin
private fun loadSettings(root: View) {
val mode = currentMode()
chipGroupColorVision.check(chipIdForMode(mode))
chipGroupRedGreenIcon.check(chipIdForRedGreenSubVariant(redGreenSubVariantFromPrefs()))
renderPreview(root, mode)
refreshRedGreenSubGroup(root, mode)
}
private fun setupListeners(root: View) {
@ -33,6 +40,33 @@ internal class SettingsAccessibilityFragment : Fragment(R.layout.fragment_settin
val mode = modeForChipId(checkedIds.first())
prefs.edit { putString(SettingsPrefs.PREF_COLOR_VISION_MODE, mode.prefValue) }
renderPreview(root, mode)
refreshRedGreenSubGroup(root, mode)
applyLauncherIcon(root, mode)
}
chipGroupRedGreenIcon.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener
val variant = when (checkedIds.first()) {
R.id.chipRedGreenProtanopia -> LauncherIconVariant.PROTANOPIA
else -> LauncherIconVariant.DEUTERANOPIA
}
prefs.edit { putString(SettingsPrefs.PREF_RED_GREEN_ICON_VARIANT, variant.prefValue) }
applyLauncherIcon(root, currentMode())
}
root.findViewById<View>(R.id.easterEggTapZone).setOnClickListener {
if (prefs.getBoolean(SettingsPrefs.PREF_EASTER_EGG_PROTANOPIA_UNLOCKED, false)) {
return@setOnClickListener
}
easterEggTapCount++
if (easterEggTapCount >= EASTER_EGG_TAP_TARGET) {
prefs.edit {
putBoolean(SettingsPrefs.PREF_EASTER_EGG_PROTANOPIA_UNLOCKED, true)
putString(SettingsPrefs.PREF_RED_GREEN_ICON_VARIANT, LauncherIconVariant.DEUTERANOPIA.prefValue)
}
Snackbar.make(root, R.string.settings_easter_egg_protanopia_unlocked, Snackbar.LENGTH_LONG).show()
refreshRedGreenSubGroup(root, currentMode())
}
}
}
@ -105,6 +139,32 @@ internal class SettingsAccessibilityFragment : Fragment(R.layout.fragment_settin
getString(R.string.settings_accessibility_status_preview_content, label)
}
private fun refreshRedGreenSubGroup(root: View, mode: ColorVisionMode) {
val unlocked = prefs.getBoolean(SettingsPrefs.PREF_EASTER_EGG_PROTANOPIA_UNLOCKED, false)
root.findViewById<View>(R.id.redGreenSubGroupContainer).isVisible =
mode == ColorVisionMode.RED_GREEN && unlocked
}
private fun applyLauncherIcon(root: View, mode: ColorVisionMode) {
val iconStyleClassic = prefs.getString(SettingsPrefs.PREF_ICON_STYLE, "new") == "classic"
val target = LauncherIconVariant.resolve(iconStyleClassic, mode, redGreenSubVariantFromPrefs())
val message = if (LauncherIconManager.apply(requireContext(), target)) {
R.string.settings_color_vision_icon_changed_warning
} else {
R.string.settings_color_vision_icon_change_failed
}
Snackbar.make(root, message, Snackbar.LENGTH_LONG).show()
}
private fun redGreenSubVariantFromPrefs(): LauncherIconVariant {
val unlocked = prefs.getBoolean(SettingsPrefs.PREF_EASTER_EGG_PROTANOPIA_UNLOCKED, false)
if (!unlocked) return LauncherIconVariant.DEUTERANOPIA
return when (prefs.getString(SettingsPrefs.PREF_RED_GREEN_ICON_VARIANT, LauncherIconVariant.DEUTERANOPIA.prefValue)) {
LauncherIconVariant.PROTANOPIA.prefValue -> LauncherIconVariant.PROTANOPIA
else -> LauncherIconVariant.DEUTERANOPIA
}
}
private fun chipIdForMode(mode: ColorVisionMode): Int {
return when (mode) {
ColorVisionMode.OFF -> R.id.chipColorVisionOff
@ -122,4 +182,15 @@ internal class SettingsAccessibilityFragment : Fragment(R.layout.fragment_settin
else -> ColorVisionMode.OFF
}
}
private fun chipIdForRedGreenSubVariant(variant: LauncherIconVariant): Int {
return when (variant) {
LauncherIconVariant.PROTANOPIA -> R.id.chipRedGreenProtanopia
else -> R.id.chipRedGreenDeuteranopia
}
}
private companion object {
const val EASTER_EGG_TAP_TARGET = 10
}
}

View file

@ -57,6 +57,12 @@ class SettingsActivity : AppCompatActivity() {
.commit()
}
intent.getStringExtra(EXTRA_IMPORT_RKNCHECK_URI)?.takeIf { it.isNotEmpty() }?.let { uriStr ->
val uri = android.net.Uri.parse(uriStr)
navigateTo(SettingsCustomChecksFragment.newInstanceWithImport(uri), R.string.settings_cat_custom_checks)
intent.removeExtra(EXTRA_IMPORT_RKNCHECK_URI)
}
supportFragmentManager.addOnBackStackChangedListener {
updateToolbarTitle()
}
@ -84,6 +90,8 @@ class SettingsActivity : AppCompatActivity() {
const val PREF_SPLIT_TUNNEL_ENABLED = SettingsPrefs.PREF_SPLIT_TUNNEL_ENABLED
const val PREF_PROXY_SCAN_ENABLED = SettingsPrefs.PREF_PROXY_SCAN_ENABLED
const val PREF_XRAY_API_SCAN_ENABLED = SettingsPrefs.PREF_XRAY_API_SCAN_ENABLED
const val PREF_CLASH_API_SCAN_ENABLED = SettingsPrefs.PREF_CLASH_API_SCAN_ENABLED
const val PREF_PROXY_AUTH_PROBE_ENABLED = SettingsPrefs.PREF_PROXY_AUTH_PROBE_ENABLED
const val PREF_PORT_RANGE = SettingsPrefs.PREF_PORT_RANGE
const val PREF_PORT_RANGE_START = SettingsPrefs.PREF_PORT_RANGE_START
const val PREF_PORT_RANGE_END = SettingsPrefs.PREF_PORT_RANGE_END
@ -93,6 +101,7 @@ class SettingsActivity : AppCompatActivity() {
const val PREF_CDN_PULLING_ENABLED = SettingsPrefs.PREF_CDN_PULLING_ENABLED
const val PREF_CDN_PULLING_MEDUZA_ENABLED = SettingsPrefs.PREF_CDN_PULLING_MEDUZA_ENABLED
const val PREF_CALL_TRANSPORT_PROBE_ENABLED = SettingsPrefs.PREF_CALL_TRANSPORT_PROBE_ENABLED
const val PREF_RTT_TRIANGULATION_ENABLED = SettingsPrefs.PREF_RTT_TRIANGULATION_ENABLED
const val PREF_TUN_PROBE_DEBUG_ENABLED = SettingsPrefs.PREF_TUN_PROBE_DEBUG_ENABLED
const val PREF_TUN_PROBE_MODE_OVERRIDE = SettingsPrefs.PREF_TUN_PROBE_MODE_OVERRIDE
const val PREF_DNS_RESOLVER_MODE = SettingsPrefs.PREF_DNS_RESOLVER_MODE
@ -104,7 +113,12 @@ class SettingsActivity : AppCompatActivity() {
const val PREF_THEME = SettingsPrefs.PREF_THEME
const val PREF_LANGUAGE = SettingsPrefs.PREF_LANGUAGE
const val PREF_COLOR_VISION_MODE = SettingsPrefs.PREF_COLOR_VISION_MODE
const val PREF_EASTER_EGG_PROTANOPIA_UNLOCKED = SettingsPrefs.PREF_EASTER_EGG_PROTANOPIA_UNLOCKED
const val PREF_RED_GREEN_ICON_VARIANT = SettingsPrefs.PREF_RED_GREEN_ICON_VARIANT
const val PREF_ICON_MIGRATION_DONE = SettingsPrefs.PREF_ICON_MIGRATION_DONE
const val PREF_CUSTOM_CHECKS_ENABLED = SettingsPrefs.PREF_CUSTOM_CHECKS_ENABLED
const val EXTRA_REQUEST_PERMISSIONS = "extra_request_permissions"
const val EXTRA_IMPORT_RKNCHECK_URI = "extra_import_rkncheck_uri"
fun applyTheme(theme: String) {
when (theme) {

View file

@ -6,20 +6,23 @@ import android.view.View
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import com.google.android.material.chip.ChipGroup
import com.google.android.material.snackbar.Snackbar
internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_appearance) {
private lateinit var prefs: SharedPreferences
private lateinit var chipGroupTheme: ChipGroup
private lateinit var chipGroupIconStyle: ChipGroup
private lateinit var chipGroupLanguage: ChipGroup
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
prefs = AppUiSettings.prefs(requireContext())
chipGroupTheme = view.findViewById(R.id.chipGroupTheme)
chipGroupIconStyle = view.findViewById(R.id.chipGroupIconStyle)
chipGroupLanguage = view.findViewById(R.id.chipGroupLanguage)
loadSettings()
setupListeners()
setupListeners(view)
}
private fun loadSettings() {
@ -32,6 +35,11 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
},
)
val iconStyle = prefs.getString(SettingsPrefs.PREF_ICON_STYLE, "new") ?: "new"
chipGroupIconStyle.check(
if (iconStyle == "classic") R.id.chipIconStyleClassic else R.id.chipIconStyleNew,
)
val language = prefs.getString(SettingsPrefs.PREF_LANGUAGE, "").orEmpty()
chipGroupLanguage.check(
when (language) {
@ -44,7 +52,7 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
)
}
private fun setupListeners() {
private fun setupListeners(view: View) {
chipGroupTheme.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener
val value = when (checkedIds.first()) {
@ -56,6 +64,14 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
SettingsActivity.applyTheme(value)
}
chipGroupIconStyle.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener
val isClassic = checkedIds.first() == R.id.chipIconStyleClassic
val value = if (isClassic) "classic" else "new"
prefs.edit { putString(SettingsPrefs.PREF_ICON_STYLE, value) }
applyIconStyle(view, isClassic)
}
chipGroupLanguage.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener
val value = when (checkedIds.first()) {
@ -69,4 +85,26 @@ internal class SettingsAppearanceFragment : Fragment(R.layout.fragment_settings_
AppUiSettings.applyLanguage(value)
}
}
private fun applyIconStyle(view: View, isClassic: Boolean) {
val rawMode = prefs.getString(SettingsPrefs.PREF_COLOR_VISION_MODE, null)
val mode = ColorVisionMode.fromPref(rawMode)
val redGreenSub = redGreenSubVariantFromPrefs()
val target = LauncherIconVariant.resolve(isClassic, mode, redGreenSub)
val message = if (LauncherIconManager.apply(requireContext(), target)) {
R.string.settings_color_vision_icon_changed_warning
} else {
R.string.settings_color_vision_icon_change_failed
}
Snackbar.make(view, message, Snackbar.LENGTH_LONG).show()
}
private fun redGreenSubVariantFromPrefs(): LauncherIconVariant? {
val unlocked = prefs.getBoolean(SettingsPrefs.PREF_EASTER_EGG_PROTANOPIA_UNLOCKED, false)
if (!unlocked) return null
return when (prefs.getString(SettingsPrefs.PREF_RED_GREEN_ICON_VARIANT, LauncherIconVariant.DEUTERANOPIA.prefValue)) {
LauncherIconVariant.PROTANOPIA.prefValue -> LauncherIconVariant.PROTANOPIA
else -> LauncherIconVariant.DEUTERANOPIA
}
}
}

View file

@ -7,6 +7,7 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.fragment.app.Fragment
import com.notcvnt.rknhardering.customcheck.CustomCheckRepository
import com.notcvnt.rknhardering.network.DnsResolverMode
import java.util.Locale
@ -37,6 +38,12 @@ internal class SettingsCategoriesFragment : Fragment(R.layout.fragment_settings_
title = getString(R.string.settings_cat_dns),
) { activity.navigateTo(SettingsDnsFragment(), R.string.settings_cat_dns) }
bindRow(
view, R.id.rowCustomChecks,
iconRes = R.drawable.ic_tune,
title = getString(R.string.settings_cat_custom_checks),
) { activity.navigateTo(SettingsCustomChecksFragment(), R.string.settings_cat_custom_checks) }
bindRow(
view, R.id.rowPrivacy,
iconRes = R.drawable.ic_lock,
@ -92,6 +99,7 @@ internal class SettingsCategoriesFragment : Fragment(R.layout.fragment_settings_
setRowValue(root, R.id.rowSplitTunnel, if (splitTunnelEnabled()) R.string.settings_value_on else R.string.settings_value_off)
setRowValue(root, R.id.rowNetwork, if (networkRequestsEnabled()) R.string.settings_value_network_all else R.string.settings_value_network_disabled)
setRowValue(root, R.id.rowDns, dnsValue())
setRowValue(root, R.id.rowCustomChecks, customChecksValue())
setRowValue(root, R.id.rowPrivacy, if (privacyModeEnabled()) R.string.settings_value_privacy_masking else R.string.settings_value_off)
setRowValue(root, R.id.rowAppearance, appearanceValue())
setRowValue(root, R.id.rowAccessibility, colorVisionValue())
@ -138,7 +146,7 @@ internal class SettingsCategoriesFragment : Fragment(R.layout.fragment_settings_
"dark" -> getString(R.string.settings_theme_dark)
else -> getString(R.string.settings_theme_system)
}
val language = when (val stored = prefs.getString(SettingsPrefs.PREF_LANGUAGE, "").orEmpty()) {
val language = when (prefs.getString(SettingsPrefs.PREF_LANGUAGE, "").orEmpty()) {
"en" -> "EN"
"ru" -> "RU"
"fa" -> "FA"
@ -163,6 +171,14 @@ internal class SettingsCategoriesFragment : Fragment(R.layout.fragment_settings_
}
}
private fun customChecksValue(): String {
val enabled = prefs.getBoolean(SettingsPrefs.PREF_CUSTOM_CHECKS_ENABLED, false)
if (!enabled) return getString(R.string.settings_value_off)
val activeId = CustomCheckRepository.getActiveProfileId(requireContext()) ?: return getString(R.string.settings_value_off)
val profile = CustomCheckRepository.getById(requireContext(), activeId)
return profile?.name ?: getString(R.string.settings_value_off)
}
private fun currentLocaleCode(): String {
val locale = resources.configuration.locales[0] ?: Locale.getDefault()
return locale.language.uppercase(Locale.ROOT)

View file

@ -0,0 +1,478 @@
package com.notcvnt.rknhardering
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
import com.google.android.material.chip.ChipGroup
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.textfield.TextInputEditText
import com.notcvnt.rknhardering.customcheck.ChecksConfig
import com.notcvnt.rknhardering.customcheck.CustomCheckProfile
import com.notcvnt.rknhardering.customcheck.CustomCheckRepository
import com.notcvnt.rknhardering.customcheck.CustomDomain
import com.notcvnt.rknhardering.customcheck.NetworkConfig
import com.notcvnt.rknhardering.customcheck.ui.CheckerSectionController
import com.notcvnt.rknhardering.customcheck.ui.editor.DomainReachabilitySectionBinder
import com.notcvnt.rknhardering.customcheck.ui.editor.EditorSectionBinders
import com.notcvnt.rknhardering.customcheck.ui.editor.EndpointPillData
import com.notcvnt.rknhardering.customcheck.ui.editor.EndpointPills
import com.notcvnt.rknhardering.customcheck.ui.editor.SectionBinder
internal class SettingsCustomCheckEditorFragment :
Fragment(R.layout.fragment_settings_custom_check_editor) {
private companion object {
private const val STATE_OPEN_SECTIONS = "open_sections"
private const val STATE_DISABLED_SECTIONS = "disabled_sections"
private const val ARG_PROFILE_ID = "profile_id"
}
private var profile: CustomCheckProfile = CustomCheckProfile(name = "New profile")
private val sections = mutableMapOf<String, CheckerSectionController>()
/** Generic (non-reachability) custom domains; reachability ones live in the binder. */
private val customDomains = mutableListOf<CustomDomain>()
private val openSectionIds = mutableSetOf<String>()
private val disabledSectionIds = mutableSetOf<String>()
private val binderHost = object : SectionBinder.Host {
override val lifecycleScope get() = viewLifecycleOwner.lifecycleScope
override fun string(res: Int, vararg args: Any): String = getString(res, *args)
override fun refreshSummary(sectionId: String) {
sections[sectionId]?.refreshSummary()
}
}
private lateinit var binders: EditorSectionBinders
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
arguments?.getString(ARG_PROFILE_ID)?.let { id ->
CustomCheckRepository.getById(requireContext(), id)?.let { profile = it }
}
savedInstanceState?.getStringArrayList(STATE_OPEN_SECTIONS)?.let {
openSectionIds.clear()
openSectionIds.addAll(it)
}
savedInstanceState?.getStringArrayList(STATE_DISABLED_SECTIONS)?.let {
disabledSectionIds.clear()
disabledSectionIds.addAll(it)
}
binders = EditorSectionBinders(binderHost)
bindMetadata(view)
bindCustomDomains(view)
buildAllSections(view)
bindNetworkConfig(view)
bindBottomBar(view)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putStringArrayList(STATE_OPEN_SECTIONS, ArrayList(openSectionIds))
outState.putStringArrayList(STATE_DISABLED_SECTIONS, ArrayList(disabledSectionIds))
}
// ─── Metadata ────────────────────────────────────────────────────────────
private fun bindMetadata(view: View) {
view.findViewById<TextInputEditText>(R.id.editName).setText(profile.name)
view.findViewById<TextInputEditText>(R.id.editDescription).setText(profile.description)
view.findViewById<TextInputEditText>(R.id.editAuthor).setText(profile.author)
view.findViewById<TextInputEditText>(R.id.editVersion).setText(profile.version)
}
// ─── Sections ────────────────────────────────────────────────────────────
private fun buildAllSections(view: View) {
val container = view.findViewById<LinearLayout>(R.id.sectionsContainer)
view.findViewById<TextView>(R.id.textCheckersLabel).text =
getString(R.string.editor_checkers_section_count, 12)
val cfg = profile.checksConfig
val initialDisabled: Map<String, Boolean> = mapOf(
CheckerSectionController.GEO_IP to !cfg.geoIp.enabled,
CheckerSectionController.IP_COMPARISON to !cfg.ipComparison.enabled,
CheckerSectionController.CDN_PULLING to !cfg.cdnPulling.enabled,
CheckerSectionController.DIRECT_SIGNS to !cfg.directSigns.enabled,
CheckerSectionController.INDIRECT_SIGNS to !cfg.indirectSigns.enabled,
CheckerSectionController.NATIVE_SIGNS to !cfg.nativeSigns.enabled,
CheckerSectionController.LOCATION_SIGNALS to !cfg.locationSignals.enabled,
CheckerSectionController.ICMP_SPOOFING to !cfg.icmpSpoofing.enabled,
CheckerSectionController.RTT_TRIANGULATION to !cfg.rttTriangulation.enabled,
CheckerSectionController.CALL_TRANSPORT to !cfg.callTransport.enabled,
CheckerSectionController.SPLIT_TUNNEL to !cfg.splitTunnel.enabled,
CheckerSectionController.DOMAIN_REACHABILITY to !cfg.domainReachabilityEnabled,
)
if (savedInstanceStateNotRestored()) {
disabledSectionIds.clear()
initialDisabled.filterValues { it }.keys.forEach { disabledSectionIds.add(it) }
}
binders.ordered.forEach { binder -> addSection(container, binder) }
// Apply persisted state
sections.forEach { (id, ctrl) ->
ctrl.setEnabled(id !in disabledSectionIds, propagate = false)
ctrl.setExpanded(id in openSectionIds, animate = false)
ctrl.refreshSummary()
}
}
private fun savedInstanceStateNotRestored(): Boolean =
disabledSectionIds.isEmpty() && openSectionIds.isEmpty()
private fun addSection(container: LinearLayout, binder: SectionBinder<*>) {
val inflater = LayoutInflater.from(container.context)
val sectionView = inflater.inflate(R.layout.view_checker_section, container, false)
container.addView(sectionView)
val id = binder.sectionId
val controller = CheckerSectionController(sectionView, id, container)
controller.setTitle(getString(binder.titleRes))
controller.setIcon(binder.iconRes)
controller.onMasterChanged = { enabled ->
if (enabled) {
disabledSectionIds.remove(id)
enforceDependenciesOnEnable(id)
} else {
disabledSectionIds.add(id)
enforceDependenciesOnDisable(id)
}
}
sections[id] = controller
val body = inflater.inflate(binder.bodyLayout, controller.body, false)
controller.body.addView(body)
binder.bind(body, profile)
controller.summaryProvider = { binder.summary(body) ?: "" }
// Override expansion listener to track openSectionIds
sectionView.findViewById<View>(R.id.sectionHeader).setOnClickListener {
val nowOpen = !controller.isExpanded
controller.setExpanded(nowOpen, animate = true)
if (nowOpen) openSectionIds.add(id) else openSectionIds.remove(id)
}
}
// ─── Custom domains (compact list) ───────────────────────────────────────
private fun bindCustomDomains(view: View) {
val container = view.findViewById<LinearLayout>(R.id.containerCustomDomains)
customDomains.clear()
profile.customDomains
.filterNot { DomainReachabilitySectionBinder.isReachabilityType(it.checkType) }
.forEach { customDomains.add(it) }
rebuildDomainList(container)
view.findViewById<MaterialButton>(R.id.btnAddCustomDomain).setOnClickListener {
showAddCustomDomainDialog(container)
}
}
private fun rebuildDomainList(container: LinearLayout) {
container.removeAllViews()
customDomains.forEachIndexed { idx, cd ->
val view = EndpointPills.makePillView(
container,
EndpointPillData(
name = cd.domain,
url = "${cd.checkType} · ${cd.description}",
scope = null,
onRemove = {
customDomains.removeAt(idx)
rebuildDomainList(container)
},
onEdit = {
showAddCustomDomainDialog(container, editIndex = idx)
},
),
)
container.addView(view)
}
}
private fun showAddCustomDomainDialog(container: LinearLayout, editIndex: Int = -1) {
val btnAdd = container.parent.let { (it as ViewGroup).findViewById<View>(R.id.btnAddCustomDomain) }
val slot = container.parent.let { (it as ViewGroup).findViewById<FrameLayout>(R.id.inlineEditorSlotCustomDomain) }
btnAdd.visibility = View.GONE
val existing = if (editIndex >= 0) customDomains[editIndex] else null
val initialValues = existing?.let {
com.notcvnt.rknhardering.customcheck.ui.GenericInlineEditorController.InitialValues(
url = it.domain, label = it.description, extraInput = it.checkType,
)
}
val controller = com.notcvnt.rknhardering.customcheck.ui.GenericInlineEditorController(slot, viewLifecycleOwner.lifecycleScope)
controller.show(
config = com.notcvnt.rknhardering.customcheck.ui.GenericInlineEditorController.Config(
titleRes = R.string.settings_custom_check_add_custom_domain,
urlHintRes = R.string.settings_custom_check_domain_field,
labelHintRes = R.string.settings_custom_check_description_field,
extraInputHintRes = R.string.settings_custom_check_check_type_field,
extraSwitchTextRes = null,
testAction = { domain, _, _ ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
val result = runCatching {
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
.build()
val req = okhttp3.Request.Builder().url("https://$domain").build()
client.newCall(req).execute().use { it.isSuccessful }
}.getOrNull()
if (result == true) {
true to "Reachability OK (HTTPS)"
} else {
val resolved = runCatching {
java.net.InetAddress.getByName(domain)
}.isSuccess
if (resolved) {
true to "Reachability OK (DNS only)"
} else {
false to "Reachability failed"
}
}
}
},
saveAction = { domain, label, checkType, _ ->
val ct = checkType.trim()
if (DomainReachabilitySectionBinder.isReachabilityType(ct)) {
Toast.makeText(
requireContext(),
R.string.settings_custom_check_reachability_blocked_in_generic,
Toast.LENGTH_LONG,
).show()
btnAdd.visibility = View.VISIBLE
return@Config
}
val newDomain = CustomDomain(
domain = domain,
checkType = ct,
description = label,
)
if (editIndex >= 0) {
customDomains[editIndex] = newDomain
} else {
customDomains.add(newDomain)
}
rebuildDomainList(container)
btnAdd.visibility = View.VISIBLE
}
),
onCancel = { btnAdd.visibility = View.VISIBLE },
initialValues = initialValues,
)
}
// ─── Network config ──────────────────────────────────────────────────────
private fun bindNetworkConfig(view: View) {
val nc = profile.networkConfig
view.findViewById<MaterialSwitch>(R.id.switchNetworkRequestsEnabled).isChecked = nc.networkRequestsEnabled
val modeGroup = view.findViewById<ChipGroup>(R.id.chipGroupDnsMode)
when (nc.dnsMode) {
"direct" -> modeGroup.check(R.id.chipDnsModeDirect)
"doh" -> modeGroup.check(R.id.chipDnsModeDoh)
else -> modeGroup.check(R.id.chipDnsModeSystem)
}
val presetGroup = view.findViewById<ChipGroup>(R.id.chipGroupDnsPreset)
when (nc.dnsPreset) {
"cloudflare" -> presetGroup.check(R.id.chipDnsPresetCloudflare)
"google" -> presetGroup.check(R.id.chipDnsPresetGoogle)
"yandex" -> presetGroup.check(R.id.chipDnsPresetYandex)
else -> presetGroup.check(R.id.chipDnsPresetCustom)
}
view.findViewById<TextInputEditText>(R.id.editDnsServers).setText(nc.dnsServers)
view.findViewById<TextInputEditText>(R.id.editDohUrl).setText(nc.dohUrl)
view.findViewById<TextInputEditText>(R.id.editDohBootstrap).setText(nc.dohBootstrap)
val update = {
val mode = currentDnsMode(view)
val preset = currentDnsPreset(view)
view.findViewById<LinearLayout>(R.id.layoutCustomDnsServers).visibility =
if (mode == "direct" && preset == "custom") View.VISIBLE else View.GONE
view.findViewById<LinearLayout>(R.id.layoutCustomDoh).visibility =
if (mode == "doh" && preset == "custom") View.VISIBLE else View.GONE
}
modeGroup.setOnCheckedStateChangeListener { _, _ -> update() }
presetGroup.setOnCheckedStateChangeListener { _, _ -> update() }
update()
}
private fun currentDnsMode(view: View): String = when (view.findViewById<ChipGroup>(R.id.chipGroupDnsMode).checkedChipId) {
R.id.chipDnsModeDirect -> "direct"
R.id.chipDnsModeDoh -> "doh"
else -> "system"
}
private fun currentDnsPreset(view: View): String = when (view.findViewById<ChipGroup>(R.id.chipGroupDnsPreset).checkedChipId) {
R.id.chipDnsPresetCloudflare -> "cloudflare"
R.id.chipDnsPresetGoogle -> "google"
R.id.chipDnsPresetYandex -> "yandex"
else -> "custom"
}
// ─── Dependencies between checkers ──────────────────────────────────────
// For a given checker id, the list of checkers it requires to function.
// Enabling a checker auto-enables its requirements; disabling a requirement
// cascades the disable to anything that depends on it.
private val checkerDependencies: Map<String, List<String>> = mapOf(
// CallTransport-probe runs INSIDE IndirectSignsChecker — without it, the
// probe never executes.
CheckerSectionController.CALL_TRANSPORT to listOf(CheckerSectionController.INDIRECT_SIGNS),
// RTT triangulation uses GeoIp.countryCode to determine the home country;
// falls back to SIM but the result is then mostly inconclusive.
CheckerSectionController.RTT_TRIANGULATION to listOf(CheckerSectionController.GEO_IP),
// Split-tunnel bypass detection produces raw proxy/leak signals; the verdict
// engine needs direct + indirect signs to classify them.
CheckerSectionController.SPLIT_TUNNEL to listOf(
CheckerSectionController.DIRECT_SIGNS,
CheckerSectionController.INDIRECT_SIGNS,
),
// Cross-channel IP consensus requires at least two IP sources to detect
// mismatches; pair GeoIP and IP comparison together.
CheckerSectionController.GEO_IP to listOf(CheckerSectionController.IP_COMPARISON),
CheckerSectionController.IP_COMPARISON to listOf(CheckerSectionController.GEO_IP),
// ICMP-spoofing interprets blocked-host responses in the context of the
// operator country; without GeoIP the result is uninterpretable.
CheckerSectionController.ICMP_SPOOFING to listOf(CheckerSectionController.GEO_IP),
)
private fun sectionTitle(id: String): String = sections[id]?.titleView?.text?.toString().orEmpty()
private fun enforceDependenciesOnEnable(enabledId: String) {
val required = checkerDependencies[enabledId] ?: return
required.forEach { reqId ->
val req = sections[reqId] ?: return@forEach
if (!req.isMasterEnabled()) {
req.setEnabled(true, propagate = true)
Toast.makeText(
requireContext(),
getString(
R.string.settings_custom_check_dependency_required,
sectionTitle(enabledId),
sectionTitle(reqId),
),
Toast.LENGTH_LONG,
).show()
}
}
}
private fun enforceDependenciesOnDisable(disabledId: String) {
// When a required checker is disabled, also disable any checker that depends on it.
checkerDependencies.forEach { (dependent, requirements) ->
if (disabledId in requirements) {
sections[dependent]?.let { dep ->
if (dep.isMasterEnabled()) {
dep.setEnabled(false, propagate = true)
}
}
}
}
}
// ─── Save / cancel ───────────────────────────────────────────────────────
private fun bindBottomBar(view: View) {
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
if (saveAndExit(view)) parentFragmentManager.popBackStack()
}
view.findViewById<MaterialButton>(R.id.btnCancel).setOnClickListener {
parentFragmentManager.popBackStack()
}
}
private fun <C> collect(binder: SectionBinder<C>): C {
val controller = sections[binder.sectionId]
val body = controller?.body?.getChildAt(0)
val enabled = controller?.isMasterEnabled() ?: binder.enabledFallback
return binder.collect(body, enabled)
}
private fun saveAndExit(view: View): Boolean {
val name = view.findViewById<TextInputEditText>(R.id.editName).text?.toString()?.trim().orEmpty()
if (name.isBlank()) {
Toast.makeText(requireContext(), R.string.settings_custom_check_name_required, Toast.LENGTH_SHORT).show()
return false
}
val anyChecker = sections.values.any { it.isMasterEnabled() }
if (!anyChecker) {
Toast.makeText(
requireContext(),
R.string.settings_custom_check_no_checks_enabled,
Toast.LENGTH_LONG,
).show()
return false
}
val newConfig = ChecksConfig(
geoIp = collect(binders.geoIp),
ipComparison = collect(binders.ipComparison),
cdnPulling = collect(binders.cdnPulling),
directSigns = collect(binders.directSigns),
indirectSigns = collect(binders.indirectSigns),
nativeSigns = collect(binders.nativeSigns),
locationSignals = collect(binders.locationSignals),
icmpSpoofing = collect(binders.icmpSpoofing),
rttTriangulation = collect(binders.rttTriangulation),
callTransport = collect(binders.callTransport),
splitTunnel = collect(binders.splitTunnel),
domainReachabilityEnabled = collect(binders.domainReachability),
)
val newNetwork = NetworkConfig(
networkRequestsEnabled = view.findViewById<MaterialSwitch>(R.id.switchNetworkRequestsEnabled).isChecked,
dnsMode = currentDnsMode(view),
dnsPreset = currentDnsPreset(view),
dnsServers = view.findViewById<TextInputEditText>(R.id.editDnsServers).text?.toString()?.trim().orEmpty(),
dohUrl = view.findViewById<TextInputEditText>(R.id.editDohUrl).text?.toString()?.trim().orEmpty(),
dohBootstrap = view.findViewById<TextInputEditText>(R.id.editDohBootstrap).text?.toString()?.trim().orEmpty(),
)
val isOfficialOrVerified = profile.marketplaceInfo?.official == true ||
profile.marketplaceInfo?.verified == true
val editedSuffix = getString(R.string.profile_name_edited_suffix)
val finalName = if (isOfficialOrVerified && !name.endsWith(editedSuffix)) {
name + editedSuffix
} else {
name
}
val updated = profile.copy(
id = if (isOfficialOrVerified) java.util.UUID.randomUUID().toString() else profile.id,
name = finalName,
description = view.findViewById<TextInputEditText>(R.id.editDescription).text?.toString()?.trim().orEmpty(),
author = view.findViewById<TextInputEditText>(R.id.editAuthor).text?.toString()?.trim().orEmpty(),
version = view.findViewById<TextInputEditText>(R.id.editVersion).text?.toString()?.trim().takeIf { !it.isNullOrBlank() } ?: "1.0.0",
checksConfig = newConfig,
customDomains = (customDomains + binders.domainReachability.domains).toList(),
networkConfig = newNetwork,
updatedAt = System.currentTimeMillis(),
createdAt = if (isOfficialOrVerified) System.currentTimeMillis() else profile.createdAt,
sourceProfileId = if (isOfficialOrVerified) profile.id else profile.sourceProfileId,
marketplaceInfo = null,
)
CustomCheckRepository.save(requireContext(), updated)
return true
}
}

View file

@ -0,0 +1,312 @@
package com.notcvnt.rknhardering
import android.app.Activity
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.edit
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.notcvnt.rknhardering.customcheck.CustomCheckProfile
import com.notcvnt.rknhardering.customcheck.CustomCheckRepository
import com.notcvnt.rknhardering.customcheck.ProfileRowAdapter
import com.notcvnt.rknhardering.customcheck.marketplace.MarketplaceClient
import com.notcvnt.rknhardering.customcheck.marketplace.MarketplaceEntry
import kotlinx.coroutines.launch
internal class SettingsCustomChecksFragment : Fragment(R.layout.fragment_settings_custom_checks) {
companion object {
private const val ARG_IMPORT_URI = "import_uri"
private const val BUILTIN_STANDARD_ID = "__builtin_standard__"
fun newInstance(): SettingsCustomChecksFragment = SettingsCustomChecksFragment()
fun newInstanceWithImport(uri: Uri): SettingsCustomChecksFragment =
SettingsCustomChecksFragment().apply {
arguments = Bundle().apply { putString(ARG_IMPORT_URI, uri.toString()) }
}
}
private lateinit var adapter: ProfileRowAdapter
private var pendingExportProfile: CustomCheckProfile? = null
private val exportLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri = result.data?.data ?: return@registerForActivityResult
val profile = pendingExportProfile ?: return@registerForActivityResult
runCatching {
CustomCheckRepository.exportToFile(requireContext(), profile, uri)
Toast.makeText(requireContext(), R.string.settings_custom_check_export_done, Toast.LENGTH_SHORT).show()
}.onFailure {
Toast.makeText(requireContext(), R.string.settings_custom_check_export_failed, Toast.LENGTH_SHORT).show()
}
pendingExportProfile = null
}
}
private val importLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
uri ?: return@registerForActivityResult
showImportConfirmDialog(uri)
}
private fun showImportConfirmDialog(uri: Uri) {
// Route all file imports through the same unverified-install bottom sheet
// the marketplace uses so users see the same URL preview + warning as for
// any third-party profile. The dialog itself strips official/verified.
SettingsMarketplaceInstallDialogFragment.newInstanceForFile(uri)
.show(childFragmentManager, "import_dialog")
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = ProfileRowAdapter(
onActivate = { profile ->
if (profile.id == BUILTIN_STANDARD_ID) {
CustomCheckRepository.setActiveProfileId(requireContext(), null)
AppUiSettings.prefs(requireContext()).edit {
putBoolean(SettingsPrefs.PREF_CUSTOM_CHECKS_ENABLED, false)
}
} else {
CustomCheckRepository.setActiveProfileId(requireContext(), profile.id)
AppUiSettings.prefs(requireContext()).edit {
putBoolean(SettingsPrefs.PREF_CUSTOM_CHECKS_ENABLED, true)
}
}
loadProfiles()
},
onEdit = { profile -> openEditor(profile.id) },
onClone = { profile ->
runCatching {
val newName = "${profile.name} (copy)"
CustomCheckRepository.duplicate(requireContext(), profile.id, newName)
loadProfiles()
}.onFailure {
Toast.makeText(requireContext(), it.message, Toast.LENGTH_SHORT).show()
}
},
onExport = { profile ->
pendingExportProfile = profile
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/octet-stream"
putExtra(Intent.EXTRA_TITLE, "${profile.name.replace(" ", "_")}.rkncheck")
}
exportLauncher.launch(intent)
},
onDelete = { profile -> confirmAndDelete(profile) },
)
view.findViewById<RecyclerView>(R.id.recyclerProfiles).apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = this@SettingsCustomChecksFragment.adapter
isNestedScrollingEnabled = false
}
view.findViewById<MaterialButton>(R.id.btnCreateProfile).setOnClickListener {
openEditor(null)
}
view.findViewById<MaterialButton>(R.id.btnImportProfile).setOnClickListener {
importLauncher.launch(arrayOf("application/octet-stream", "*/*"))
}
view.findViewById<MaterialButton>(R.id.btnInstructions).setOnClickListener {
val url = "https://github.com/xtclovver/RKNHardering/blob/main/marketplace/CONTRIBUTING.md"
runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
}
// Discover card → Marketplace
view.findViewById<TextView>(R.id.btnSeeAll).setOnClickListener { openMarketplace() }
view.findViewById<View>(R.id.discoverCard).setOnClickListener { /* no-op, body clickable via See all */ }
childFragmentManager.setFragmentResultListener(
SettingsMarketplaceInstallDialogFragment.REQUEST_KEY,
viewLifecycleOwner,
) { _, _ ->
loadProfiles()
view?.let { loadDiscover(it) }
Toast.makeText(requireContext(), R.string.settings_custom_check_import_done, Toast.LENGTH_SHORT).show()
}
loadProfiles()
loadDiscover(view)
handlePendingImport(view)
}
override fun onResume() {
super.onResume()
loadProfiles()
view?.let { loadDiscover(it) }
}
private fun openEditor(profileId: String?) {
val activity = requireActivity() as SettingsActivity
val fragment = SettingsCustomCheckEditorFragment().apply {
if (profileId != null) {
arguments = Bundle().apply { putString("profile_id", profileId) }
}
}
activity.navigateTo(fragment, R.string.settings_custom_check_editor_title)
}
private fun openMarketplace() {
val activity = requireActivity() as SettingsActivity
activity.navigateTo(SettingsMarketplaceFragment(), R.string.settings_custom_check_marketplace)
}
private fun confirmAndDelete(profile: CustomCheckProfile) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(profile.name)
.setMessage(R.string.settings_custom_check_delete_confirm)
.setPositiveButton(R.string.action_delete) { _, _ ->
CustomCheckRepository.delete(requireContext(), profile.id)
loadProfiles()
}
.setNegativeButton(R.string.settings_custom_check_cancel, null)
.show()
}
private fun loadProfiles() {
val ctx = requireContext()
val activeId = CustomCheckRepository.getActiveProfileId(ctx)
val list = CustomCheckRepository.getAll(ctx)
val items = mutableListOf<ProfileRowAdapter.Item>()
// Built-in "Standard" profile as a virtual top entry
items.add(builtinStandardItem(activeId == null))
list.forEach { profile ->
items.add(
ProfileRowAdapter.Item(
profile = profile,
isActive = profile.id == activeId,
isBuiltin = false,
checkersEnabledCount = ProfileRowAdapter.countEnabledCheckers(profile),
checkersTotalCount = ProfileRowAdapter.TOTAL_CHECKERS,
)
)
}
adapter.submitList(items)
}
private fun builtinStandardItem(isActive: Boolean): ProfileRowAdapter.Item {
val virtual = CustomCheckProfile(
id = BUILTIN_STANDARD_ID,
name = getString(R.string.profile_builtin_standard_name),
description = getString(R.string.profile_builtin_standard_desc),
version = "1.0.0",
)
return ProfileRowAdapter.Item(
profile = virtual,
isActive = isActive,
isBuiltin = true,
checkersEnabledCount = ProfileRowAdapter.TOTAL_CHECKERS,
checkersTotalCount = ProfileRowAdapter.TOTAL_CHECKERS,
).also {
// built-in row activation = clear active id
}
}
private fun loadDiscover(root: View) {
val card = root.findViewById<View>(R.id.discoverCard)
val strip = root.findViewById<ViewGroup>(R.id.featuredStrip)
val empty = root.findViewById<TextView>(R.id.discoverEmpty)
strip.removeAllViews()
strip.visibility = View.GONE
empty.visibility = View.GONE
viewLifecycleOwner.lifecycleScope.launch {
val catalogResult = runCatching { MarketplaceClient.fetchCatalog(requireContext()) }
val entries = catalogResult.getOrNull()?.entries.orEmpty()
val featured = entries
.sortedByDescending { it.official }
.take(3)
if (featured.isEmpty()) {
empty.visibility = View.VISIBLE
return@launch
}
val inflater = LayoutInflater.from(card.context)
featured.forEach { entry ->
val tile = inflater.inflate(R.layout.view_discover_featured_tile, strip, false)
val avatarText = tile.findViewById<TextView>(R.id.featuredAvatarText)
val name = tile.findViewById<TextView>(R.id.featuredName)
val badgeIcon = tile.findViewById<ImageView>(R.id.featuredBadgeIcon)
avatarText.text = entry.name.trim().take(1).uppercase().ifBlank { "?" }
val avatarFrame = avatarText.parent as? View
avatarFrame?.background = makeMarketAvatarGradient(entry.name)
name.text = entry.name
when {
entry.official -> {
badgeIcon.visibility = View.VISIBLE
badgeIcon.setImageResource(R.drawable.ic_verified_blue)
}
entry.verified -> {
badgeIcon.visibility = View.VISIBLE
badgeIcon.setImageResource(R.drawable.ic_verified_grey)
}
else -> badgeIcon.visibility = View.GONE
}
// Tap on tile → open marketplace
tile.setOnClickListener { openMarketplace() }
// ensure equal width
val lp = tile.layoutParams as android.widget.LinearLayout.LayoutParams
lp.weight = 1f
lp.width = 0
lp.setMargins(if (strip.childCount == 0) 0 else dp(6), 0, 0, 0)
tile.layoutParams = lp
strip.addView(tile)
}
strip.visibility = View.VISIBLE
}
}
private fun makeMarketAvatarGradient(name: String): GradientDrawable {
val seed = name.firstOrNull()?.code ?: 0
val hue = ((seed * 53) % 360).toFloat()
val c1 = Color.HSVToColor(floatArrayOf(hue, 0.45f, 0.78f))
val c2 = Color.HSVToColor(floatArrayOf((hue + 30f) % 360f, 0.55f, 0.55f))
val density = resources.displayMetrics.density
return GradientDrawable(
GradientDrawable.Orientation.TL_BR,
intArrayOf(c1, c2),
).apply {
cornerRadius = 8f * density
shape = GradientDrawable.RECTANGLE
}
}
private fun dp(value: Int): Int =
(value * resources.displayMetrics.density).toInt()
@Suppress("UNUSED_PARAMETER")
private fun handlePendingImport(view: View) {
arguments?.getString(ARG_IMPORT_URI)?.let { uriStr ->
arguments?.remove(ARG_IMPORT_URI)
showImportConfirmDialog(Uri.parse(uriStr))
}
}
}

View file

@ -0,0 +1,195 @@
package com.notcvnt.rknhardering
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.google.android.material.chip.ChipGroup
import com.google.android.material.textfield.TextInputEditText
import com.notcvnt.rknhardering.customcheck.CustomCheckRepository
import com.notcvnt.rknhardering.customcheck.marketplace.MarketplaceClient
import com.notcvnt.rknhardering.customcheck.marketplace.MarketplaceEntry
import com.notcvnt.rknhardering.customcheck.marketplace.MarketplaceItemAdapter
import kotlinx.coroutines.launch
internal class SettingsMarketplaceFragment : Fragment(R.layout.fragment_settings_marketplace) {
private lateinit var adapter: MarketplaceItemAdapter
private var fullList: List<MarketplaceEntry> = emptyList()
private var catalogSignatureValid: Boolean = false
private var installedIds: Set<String> = emptySet()
private var installedProfiles: Map<String, com.notcvnt.rknhardering.customcheck.CustomCheckProfile> = emptyMap()
private var currentQuery: String = ""
private var currentFilter: Filter = Filter.ALL
private enum class Filter { ALL, OFFICIAL, VERIFIED, RU, PRIVACY, TOR, MOBILE }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = MarketplaceItemAdapter(
onInstall = { entry ->
SettingsMarketplaceInstallDialogFragment.newInstance(entry, catalogSignatureValid)
.show(childFragmentManager, "install_dialog")
},
onOpenInstalled = { entry ->
val activity = requireActivity() as SettingsActivity
val fragment = SettingsCustomCheckEditorFragment().apply {
arguments = Bundle().apply { putString("profile_id", entry.id) }
}
activity.navigateTo(fragment, R.string.settings_custom_check_editor_title)
},
onUpdate = { entry ->
SettingsMarketplaceInstallDialogFragment.newInstance(entry, catalogSignatureValid)
.show(childFragmentManager, "install_dialog")
},
)
view.findViewById<RecyclerView>(R.id.recyclerMarketplace).apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = this@SettingsMarketplaceFragment.adapter
}
val editSearch = view.findViewById<TextInputEditText>(R.id.editSearch)
editSearch.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
currentQuery = s?.toString().orEmpty()
applyFilter()
}
})
view.findViewById<ChipGroup>(R.id.filterChips).setOnCheckedStateChangeListener { _, checkedIds ->
val id = checkedIds.firstOrNull() ?: R.id.chipFilterAll
currentFilter = when (id) {
R.id.chipFilterOfficial -> Filter.OFFICIAL
R.id.chipFilterVerified -> Filter.VERIFIED
R.id.chipFilterRu -> Filter.RU
R.id.chipFilterPrivacy -> Filter.PRIVACY
R.id.chipFilterTor -> Filter.TOR
R.id.chipFilterMobile -> Filter.MOBILE
else -> Filter.ALL
}
applyFilter()
}
view.findViewById<MaterialButton>(R.id.btnRetry).setOnClickListener {
loadCatalog(view)
}
childFragmentManager.setFragmentResultListener(
SettingsMarketplaceInstallDialogFragment.REQUEST_KEY,
viewLifecycleOwner,
) { _, _ ->
refreshInstalledIds()
applyFilter()
}
loadCatalog(view)
}
override fun onResume() {
super.onResume()
// Re-check installed state in case user installed/uninstalled something elsewhere
refreshInstalledIds()
applyFilter()
}
private fun refreshInstalledIds() {
val all = CustomCheckRepository.getAll(requireContext())
installedProfiles = all.associateBy { it.id }
installedIds = installedProfiles.keys
}
private fun hasUpdateFor(entry: MarketplaceEntry): Boolean {
val installed = installedProfiles[entry.id] ?: return false
val info = installed.marketplaceInfo
val expected = entry.expectedHash
if (expected != null && info?.originalHash != null && !info.originalHash.equals(expected, ignoreCase = true)) {
return true
}
if (entry.version.isNotBlank() && installed.version.isNotBlank() &&
entry.version != installed.version) {
return true
}
return false
}
private fun loadCatalog(view: View) {
val progress = view.findViewById<ProgressBar>(R.id.progressLoading)
val errorLayout = view.findViewById<View>(R.id.layoutError)
val textError = view.findViewById<TextView>(R.id.textError)
val textEmpty = view.findViewById<TextView>(R.id.textEmpty)
val recycler = view.findViewById<RecyclerView>(R.id.recyclerMarketplace)
progress.visibility = View.VISIBLE
errorLayout.visibility = View.GONE
textEmpty.visibility = View.GONE
recycler.visibility = View.GONE
viewLifecycleOwner.lifecycleScope.launch {
runCatching { MarketplaceClient.fetchCatalog(requireContext()) }
.onSuccess { catalog ->
progress.visibility = View.GONE
fullList = catalog.entries
catalogSignatureValid = catalog.signatureValid
refreshInstalledIds()
if (fullList.isEmpty()) {
textEmpty.visibility = View.VISIBLE
} else {
recycler.visibility = View.VISIBLE
applyFilter()
}
}
.onFailure { err ->
progress.visibility = View.GONE
textError.text = err.message ?: getString(R.string.marketplace_error_unknown)
errorLayout.visibility = View.VISIBLE
}
}
}
private fun applyFilter() {
val q = currentQuery.lowercase()
val filtered = fullList.asSequence()
.filter { entry ->
when (currentFilter) {
Filter.ALL -> true
Filter.OFFICIAL -> entry.official
Filter.VERIFIED -> entry.verified
Filter.RU -> entry.tags.any { it.equals("ru", ignoreCase = true) }
Filter.PRIVACY -> entry.tags.any { it.equals("privacy", ignoreCase = true) }
Filter.TOR -> entry.tags.any { it.equals("tor", ignoreCase = true) }
Filter.MOBILE -> entry.tags.any { it.equals("mobile", ignoreCase = true) }
}
}
.filter { entry ->
q.isBlank() ||
entry.name.lowercase().contains(q) ||
entry.description.lowercase().contains(q) ||
entry.author.lowercase().contains(q) ||
entry.tags.any { it.lowercase().contains(q) }
}
.map { entry ->
val installed = entry.id in installedIds
MarketplaceItemAdapter.Item(
entry = entry,
installed = installed,
hasUpdate = installed && hasUpdateFor(entry),
)
}
.toList()
adapter.submitList(filtered)
view?.findViewById<TextView>(R.id.textEmpty)?.visibility =
if (filtered.isEmpty() && fullList.isNotEmpty()) View.VISIBLE else View.GONE
}
}

View file

@ -0,0 +1,306 @@
package com.notcvnt.rknhardering
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
import com.notcvnt.rknhardering.customcheck.CustomCheckProfile
import com.notcvnt.rknhardering.customcheck.CustomCheckRepository
import com.notcvnt.rknhardering.customcheck.CustomCheckSerializer
import com.notcvnt.rknhardering.customcheck.MarketplaceInfo
import com.notcvnt.rknhardering.customcheck.marketplace.MarketplaceClient
import com.notcvnt.rknhardering.customcheck.marketplace.MarketplaceEntry
import kotlinx.coroutines.launch
internal class SettingsMarketplaceInstallDialogFragment : BottomSheetDialogFragment() {
companion object {
const val REQUEST_KEY = "marketplace_install_result"
private const val ARG_MODE = "mode"
private const val MODE_MARKETPLACE = "marketplace"
private const val MODE_FILE = "file"
private const val ARG_ID = "entry_id"
private const val ARG_NAME = "entry_name"
private const val ARG_DESC = "entry_description"
private const val ARG_AUTHOR = "entry_author"
private const val ARG_VERSION = "entry_version"
private const val ARG_OFFICIAL = "entry_official"
private const val ARG_VERIFIED = "entry_verified"
private const val ARG_PROFILE_URL = "entry_profile_url"
private const val ARG_EXPECTED_HASH = "entry_expected_hash"
private const val ARG_CATALOG_SIGNED = "entry_catalog_signed"
private const val ARG_FILE_URI = "file_uri"
fun newInstance(entry: MarketplaceEntry, catalogSignatureValid: Boolean) =
SettingsMarketplaceInstallDialogFragment().apply {
arguments = Bundle().apply {
putString(ARG_MODE, MODE_MARKETPLACE)
putString(ARG_ID, entry.id)
putString(ARG_NAME, entry.name)
putString(ARG_DESC, entry.description)
putString(ARG_AUTHOR, entry.author)
putString(ARG_VERSION, entry.version)
putBoolean(ARG_OFFICIAL, entry.official)
putBoolean(ARG_VERIFIED, entry.verified)
putString(ARG_PROFILE_URL, entry.profileUrl)
putString(ARG_EXPECTED_HASH, entry.expectedHash)
putBoolean(ARG_CATALOG_SIGNED, catalogSignatureValid)
}
}
fun newInstanceForFile(uri: Uri) =
SettingsMarketplaceInstallDialogFragment().apply {
arguments = Bundle().apply {
putString(ARG_MODE, MODE_FILE)
putString(ARG_FILE_URI, uri.toString())
}
}
}
private val isMarketplace: Boolean
get() = requireArguments().getString(ARG_MODE) == MODE_MARKETPLACE
private fun marketplaceEntryFromArgs(): MarketplaceEntry {
val args = requireArguments()
return MarketplaceEntry(
id = args.getString(ARG_ID, ""),
name = args.getString(ARG_NAME, ""),
description = args.getString(ARG_DESC, ""),
author = args.getString(ARG_AUTHOR, ""),
version = args.getString(ARG_VERSION, "1.0.0"),
official = args.getBoolean(ARG_OFFICIAL, false),
verified = args.getBoolean(ARG_VERIFIED, false),
profileUrl = args.getString(ARG_PROFILE_URL, ""),
expectedHash = args.getString(ARG_EXPECTED_HASH),
tags = emptyList(),
createdAt = "",
updatedAt = "",
)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View = inflater.inflate(R.layout.fragment_marketplace_install, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (isMarketplace) {
val entry = marketplaceEntryFromArgs()
val catalogSigned = requireArguments().getBoolean(ARG_CATALOG_SIGNED, false)
// verified/official already clamped to false at parse time when catalog
// signature was missing — but be explicit.
val trusted = catalogSigned && (entry.official || entry.verified)
if (trusted) renderVerifiedMarketplace(view, entry, catalogSigned)
else renderUnverifiedMarketplace(view, entry, catalogSigned)
} else {
renderFileImport(view, Uri.parse(requireArguments().getString(ARG_FILE_URI, "")))
}
}
private fun renderVerifiedMarketplace(view: View, entry: MarketplaceEntry, catalogSigned: Boolean) {
val ctx = requireContext()
val iconWrap = view.findViewById<FrameLayout>(R.id.installHeaderIconWrap)
val icon = view.findViewById<ImageView>(R.id.installHeaderIcon)
val title = view.findViewById<TextView>(R.id.installTitle)
val subtitle = view.findViewById<TextView>(R.id.installSubtitle)
val body = view.findViewById<TextView>(R.id.installBody)
val urlBlock = view.findViewById<View>(R.id.installUrlBlock)
val confirm = view.findViewById<MaterialButton>(R.id.installConfirm)
val cancel = view.findViewById<MaterialButton>(R.id.installCancel)
iconWrap.background = avatarSolid(ctx, ContextCompat.getColor(ctx, R.color.md_accent_sky))
icon.setImageResource(R.drawable.ic_verified_blue)
title.text = getString(R.string.install_verified_title)
subtitle.text = getString(R.string.install_subtitle_format, entry.name, entry.author)
body.text = entry.description
urlBlock.visibility = View.GONE
confirm.text = getString(R.string.marketplace_action_install)
confirm.setOnClickListener { fetchAndInstallMarketplace(entry, catalogSigned) }
cancel.setOnClickListener { dismiss() }
}
private fun renderUnverifiedMarketplace(view: View, entry: MarketplaceEntry, catalogSigned: Boolean) {
renderWarning(
view = view,
title = getString(R.string.security_warning_title),
subtitle = getString(R.string.install_subtitle_format, entry.name, entry.author),
body = getString(R.string.security_warning_intro),
fetchProfile = { runCatching { MarketplaceClient.fetchProfile(requireContext(), entry, catalogSigned) }.getOrNull() },
onConfirm = { profile -> installMarketplaceProfile(profile, entry, catalogSigned) },
onFallbackConfirm = { fetchAndInstallMarketplace(entry, catalogSigned) },
)
}
private fun renderFileImport(view: View, uri: Uri) {
renderWarning(
view = view,
title = getString(R.string.security_warning_title),
subtitle = getString(R.string.install_file_subtitle),
body = getString(R.string.security_warning_intro),
fetchProfile = { runCatching { CustomCheckRepository.importFromFile(requireContext(), uri) }.getOrNull() },
onConfirm = { profile -> installImportedProfile(profile) },
onFallbackConfirm = null,
)
}
private fun renderWarning(
view: View,
title: String,
subtitle: String,
body: String,
fetchProfile: suspend () -> CustomCheckProfile?,
onConfirm: (CustomCheckProfile) -> Unit,
onFallbackConfirm: (() -> Unit)?,
) {
val ctx = requireContext()
val iconWrap = view.findViewById<FrameLayout>(R.id.installHeaderIconWrap)
val icon = view.findViewById<ImageView>(R.id.installHeaderIcon)
val titleView = view.findViewById<TextView>(R.id.installTitle)
val subtitleView = view.findViewById<TextView>(R.id.installSubtitle)
val bodyView = view.findViewById<TextView>(R.id.installBody)
val urlBlock = view.findViewById<View>(R.id.installUrlBlock)
val urlList = view.findViewById<TextView>(R.id.installUrlList)
val confirm = view.findViewById<MaterialButton>(R.id.installConfirm)
val cancel = view.findViewById<MaterialButton>(R.id.installCancel)
iconWrap.background = avatarSolid(ctx, ContextCompat.getColor(ctx, R.color.status_amber))
icon.setImageResource(R.drawable.ic_warning_amber)
icon.setColorFilter(Color.parseColor("#FF14121A"))
titleView.text = title
subtitleView.text = subtitle
bodyView.text = body
confirm.text = getString(R.string.security_warning_install_anyway)
confirm.setBackgroundColor(ContextCompat.getColor(ctx, R.color.status_amber))
confirm.setTextColor(Color.parseColor("#FF14121A"))
cancel.setOnClickListener { dismiss() }
lifecycleScope.launch {
val profile = fetchProfile()
if (profile != null) {
val urls = CustomCheckSerializer.extractAllUrls(profile)
if (urls.isEmpty()) {
urlBlock.visibility = View.GONE
} else {
val grouped = urls.groupBy { it.checkName }
val sb = StringBuilder()
grouped.forEach { (checkName, infos) ->
if (sb.isNotEmpty()) sb.append("\n\n")
sb.append("[").append(checkName).append("]\n")
infos.forEach { info ->
sb.append("").append(info.url)
if (info.purpose.isNotBlank()) sb.append(" (").append(info.purpose).append(")")
sb.append('\n')
}
}
// Standalone DNS warning if profile overrides DNS — these checks
// also live in the URL list but the explicit sentence is louder.
if (profile.networkConfig.dohUrl.isNotBlank() ||
profile.networkConfig.dnsServers.isNotBlank()
) {
sb.append("\n\n").append(getString(R.string.install_dns_override_warning))
}
urlList.text = sb.toString().trimEnd()
urlBlock.visibility = View.VISIBLE
}
confirm.setOnClickListener { onConfirm(profile) }
} else if (onFallbackConfirm != null) {
confirm.setOnClickListener { onFallbackConfirm() }
} else {
if (isAdded) {
Toast.makeText(requireContext(), getString(R.string.marketplace_install_failed), Toast.LENGTH_LONG).show()
}
confirm.isEnabled = false
}
}
}
private fun fetchAndInstallMarketplace(entry: MarketplaceEntry, catalogSigned: Boolean) {
lifecycleScope.launch {
runCatching {
val profile = MarketplaceClient.fetchProfile(requireContext(), entry, catalogSigned)
installMarketplaceProfile(profile, entry, catalogSigned)
}.onFailure { err ->
if (isAdded) {
val msg = if (err is MarketplaceClient.HashMismatchException) {
getString(R.string.marketplace_install_hash_mismatch)
} else {
getString(R.string.marketplace_install_failed)
}
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
}
}
}
}
private fun installMarketplaceProfile(profile: CustomCheckProfile, entry: MarketplaceEntry, catalogSigned: Boolean) {
val signatureVerified = catalogSigned && entry.expectedHash != null
val originalHash = CustomCheckSerializer.canonicalHash(profile)
val marketplaceInfo = MarketplaceInfo(
sourceUrl = entry.profileUrl,
// Only confer official/verified when the catalog signature actually
// validated AND the per-profile hash matched.
official = signatureVerified && entry.official,
verified = signatureVerified && entry.verified,
signatureVerified = signatureVerified,
marketplaceId = entry.id,
originalHash = originalHash,
)
val finalProfile = profile.copy(
id = entry.id,
marketplaceInfo = marketplaceInfo,
)
CustomCheckRepository.save(requireContext(), finalProfile)
notifyInstalled(finalProfile)
}
private fun installImportedProfile(profile: CustomCheckProfile) {
// File/clipboard imports never get badges, regardless of what the file claims.
val info = profile.marketplaceInfo?.copy(
official = false,
verified = false,
signatureVerified = false,
originalHash = null,
)
val finalProfile = profile.copy(marketplaceInfo = info)
CustomCheckRepository.save(requireContext(), finalProfile)
notifyInstalled(finalProfile)
}
private fun notifyInstalled(profile: CustomCheckProfile) {
if (isAdded) {
Toast.makeText(
requireContext(),
getString(R.string.marketplace_install_done, profile.name),
Toast.LENGTH_SHORT,
).show()
parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf())
}
dismiss()
}
private fun avatarSolid(ctx: android.content.Context, color: Int): GradientDrawable {
val density = ctx.resources.displayMetrics.density
return GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 12f * density
setColor(color)
}
}
}

View file

@ -16,17 +16,9 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
private lateinit var switchNetworkRequests: MaterialSwitch
private lateinit var cardAutoUpdate: MaterialCardView
private lateinit var switchAutoUpdate: MaterialSwitch
private lateinit var cardCdnPulling: MaterialCardView
private lateinit var switchCdnPulling: MaterialSwitch
private lateinit var cardCdnPullingMeduza: MaterialCardView
private lateinit var switchCdnPullingMeduza: MaterialSwitch
private lateinit var cardCallTransportProbe: MaterialCardView
private lateinit var switchCallTransportProbe: MaterialSwitch
private var suppressAutoUpdateToggleCallback = false
private var suppressCdnPullingToggleCallback = false
private var suppressNetworkRequestsToggleCallback = false
private var cdnWarningDialog: AlertDialog? = null
private var networkDisableDialog: AlertDialog? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -39,8 +31,6 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
override fun onDestroyView() {
super.onDestroyView()
cdnWarningDialog?.dismiss()
cdnWarningDialog = null
networkDisableDialog?.dismiss()
networkDisableDialog = null
}
@ -49,26 +39,13 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
switchNetworkRequests = view.findViewById(R.id.switchNetworkRequests)
cardAutoUpdate = view.findViewById(R.id.cardAutoUpdate)
switchAutoUpdate = view.findViewById(R.id.switchAutoUpdate)
cardCdnPulling = view.findViewById(R.id.cardCdnPulling)
switchCdnPulling = view.findViewById(R.id.switchCdnPulling)
cardCdnPullingMeduza = view.findViewById(R.id.cardCdnPullingMeduza)
switchCdnPullingMeduza = view.findViewById(R.id.switchCdnPullingMeduza)
cardCallTransportProbe = view.findViewById(R.id.cardCallTransportProbe)
switchCallTransportProbe = view.findViewById(R.id.switchCallTransportProbe)
}
private fun loadSettings() {
val networkRequestsEnabled = prefs.getBoolean(SettingsPrefs.PREF_NETWORK_REQUESTS_ENABLED, true)
switchNetworkRequests.isChecked = networkRequestsEnabled
setAutoUpdateSwitch(networkRequestsEnabled && AppUpdateChecker.isAutoUpdateEnabled(requireContext()))
switchCdnPulling.isChecked = prefs.getBoolean(SettingsPrefs.PREF_CDN_PULLING_ENABLED, false)
switchCdnPullingMeduza.isChecked = prefs.getBoolean(SettingsPrefs.PREF_CDN_PULLING_MEDUZA_ENABLED, true)
switchCallTransportProbe.isChecked = prefs.getBoolean(SettingsPrefs.PREF_CALL_TRANSPORT_PROBE_ENABLED, false)
updateAutoUpdateEnabled(networkRequestsEnabled)
updateCdnPullingEnabled(networkRequestsEnabled)
updateCdnPullingMeduzaVisible(switchCdnPulling.isChecked)
updateCallTransportEnabled(networkRequestsEnabled)
}
private fun setupListeners() {
@ -79,8 +56,6 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
} else {
prefs.edit { putBoolean(SettingsPrefs.PREF_NETWORK_REQUESTS_ENABLED, true) }
updateAutoUpdateEnabled(true)
updateCdnPullingEnabled(true)
updateCallTransportEnabled(true)
}
}
@ -88,24 +63,6 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
if (suppressAutoUpdateToggleCallback) return@setOnCheckedChangeListener
AppUpdateChecker.setAutoUpdateEnabled(requireContext(), isChecked)
}
switchCdnPulling.setOnCheckedChangeListener { _, isChecked ->
if (suppressCdnPullingToggleCallback) return@setOnCheckedChangeListener
if (isChecked) {
showCdnPullingWarning()
} else {
prefs.edit { putBoolean(SettingsPrefs.PREF_CDN_PULLING_ENABLED, false) }
updateCdnPullingMeduzaVisible(false)
}
}
switchCdnPullingMeduza.setOnCheckedChangeListener { _, isChecked ->
prefs.edit { putBoolean(SettingsPrefs.PREF_CDN_PULLING_MEDUZA_ENABLED, isChecked) }
}
switchCallTransportProbe.setOnCheckedChangeListener { _, isChecked ->
prefs.edit { putBoolean(SettingsPrefs.PREF_CALL_TRANSPORT_PROBE_ENABLED, isChecked) }
}
}
private fun updateAutoUpdateEnabled(enabled: Boolean) {
@ -119,44 +76,6 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
}
}
private fun updateCdnPullingEnabled(enabled: Boolean) {
cardCdnPulling.alpha = if (enabled) 1.0f else 0.5f
setViewAndChildrenEnabled(cardCdnPulling, enabled)
updateCdnPullingMeduzaVisible(enabled && switchCdnPulling.isChecked)
}
private fun updateCdnPullingMeduzaVisible(visible: Boolean) {
cardCdnPullingMeduza.visibility = if (visible) View.VISIBLE else View.GONE
}
private fun updateCallTransportEnabled(enabled: Boolean) {
cardCallTransportProbe.alpha = if (enabled) 1.0f else 0.5f
setViewAndChildrenEnabled(cardCallTransportProbe, enabled)
}
private fun showCdnPullingWarning() {
cdnWarningDialog = AlertDialog.Builder(requireContext())
.setTitle(R.string.settings_cdn_pulling_warning_title)
.setMessage(buildCdnPullingWarningMessage(requireContext()))
.setPositiveButton(R.string.settings_cdn_pulling_warning_confirm) { _, _ ->
prefs.edit { putBoolean(SettingsPrefs.PREF_CDN_PULLING_ENABLED, true) }
updateCdnPullingMeduzaVisible(true)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
setCdnPullingSwitch(false)
}
.setOnCancelListener {
setCdnPullingSwitch(false)
}
.show()
}
private fun setCdnPullingSwitch(checked: Boolean) {
suppressCdnPullingToggleCallback = true
switchCdnPulling.isChecked = checked
suppressCdnPullingToggleCallback = false
}
private fun setAutoUpdateSwitch(checked: Boolean) {
suppressAutoUpdateToggleCallback = true
switchAutoUpdate.isChecked = checked
@ -170,8 +89,6 @@ internal class SettingsNetworkFragment : Fragment(R.layout.fragment_settings_net
.setPositiveButton(R.string.settings_network_disable_confirm) { _, _ ->
prefs.edit { putBoolean(SettingsPrefs.PREF_NETWORK_REQUESTS_ENABLED, false) }
updateAutoUpdateEnabled(false)
updateCdnPullingEnabled(false)
updateCallTransportEnabled(false)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
setNetworkRequestsSwitch(true)

View file

@ -4,6 +4,8 @@ internal object SettingsPrefs {
const val PREF_SPLIT_TUNNEL_ENABLED = "pref_split_tunnel_enabled"
const val PREF_PROXY_SCAN_ENABLED = "pref_proxy_scan_enabled"
const val PREF_XRAY_API_SCAN_ENABLED = "pref_xray_api_scan_enabled"
const val PREF_CLASH_API_SCAN_ENABLED = "pref_clash_api_scan_enabled"
const val PREF_PROXY_AUTH_PROBE_ENABLED = "pref_proxy_auth_probe_enabled"
const val PREF_PORT_RANGE = "pref_port_range"
const val PREF_PORT_RANGE_START = "pref_port_range_start"
const val PREF_PORT_RANGE_END = "pref_port_range_end"
@ -13,6 +15,7 @@ internal object SettingsPrefs {
const val PREF_CDN_PULLING_ENABLED = "pref_cdn_pulling_enabled"
const val PREF_CDN_PULLING_MEDUZA_ENABLED = "pref_cdn_pulling_meduza_enabled"
const val PREF_CALL_TRANSPORT_PROBE_ENABLED = "pref_call_transport_probe_enabled"
const val PREF_RTT_TRIANGULATION_ENABLED = "pref_rtt_triangulation_enabled"
const val PREF_TUN_PROBE_DEBUG_ENABLED = "pref_tun_probe_debug_enabled"
const val PREF_TUN_PROBE_MODE_OVERRIDE = "pref_tun_probe_mode_override"
const val PREF_DNS_RESOLVER_MODE = "pref_dns_resolver_mode"
@ -24,4 +27,9 @@ internal object SettingsPrefs {
const val PREF_THEME = "pref_theme"
const val PREF_LANGUAGE = "pref_language"
const val PREF_COLOR_VISION_MODE = "pref_color_vision_mode"
const val PREF_EASTER_EGG_PROTANOPIA_UNLOCKED = "pref_easter_egg_protanopia_unlocked"
const val PREF_RED_GREEN_ICON_VARIANT = "pref_red_green_icon_variant"
const val PREF_ICON_MIGRATION_DONE = "pref_icon_migration_done"
const val PREF_ICON_STYLE = "pref_icon_style"
const val PREF_CUSTOM_CHECKS_ENABLED = "pref_custom_checks_enabled"
}

View file

@ -27,6 +27,10 @@ internal class SettingsSplitTunnelFragment : Fragment(R.layout.fragment_settings
private lateinit var switchProxyScan: MaterialSwitch
private lateinit var cardXrayApiScan: MaterialCardView
private lateinit var switchXrayApiScan: MaterialSwitch
private lateinit var cardClashApiScan: MaterialCardView
private lateinit var switchClashApiScan: MaterialSwitch
private lateinit var cardProxyAuthProbe: MaterialCardView
private lateinit var switchProxyAuthProbe: MaterialSwitch
private lateinit var cardPortRange: MaterialCardView
private lateinit var chipGroupPortRange: ChipGroup
private lateinit var customPortRangeContainer: LinearLayout
@ -50,6 +54,10 @@ internal class SettingsSplitTunnelFragment : Fragment(R.layout.fragment_settings
switchProxyScan = view.findViewById(R.id.switchProxyScan)
cardXrayApiScan = view.findViewById(R.id.cardXrayApiScan)
switchXrayApiScan = view.findViewById(R.id.switchXrayApiScan)
cardClashApiScan = view.findViewById(R.id.cardClashApiScan)
switchClashApiScan = view.findViewById(R.id.switchClashApiScan)
cardProxyAuthProbe = view.findViewById(R.id.cardProxyAuthProbe)
switchProxyAuthProbe = view.findViewById(R.id.switchProxyAuthProbe)
cardPortRange = view.findViewById(R.id.cardPortRange)
chipGroupPortRange = view.findViewById(R.id.chipGroupPortRange)
customPortRangeContainer = view.findViewById(R.id.customPortRangeContainer)
@ -63,6 +71,8 @@ internal class SettingsSplitTunnelFragment : Fragment(R.layout.fragment_settings
switchSplitTunnel.isChecked = prefs.getBoolean(SettingsPrefs.PREF_SPLIT_TUNNEL_ENABLED, true)
switchProxyScan.isChecked = prefs.getBoolean(SettingsPrefs.PREF_PROXY_SCAN_ENABLED, true)
switchXrayApiScan.isChecked = prefs.getBoolean(SettingsPrefs.PREF_XRAY_API_SCAN_ENABLED, true)
switchClashApiScan.isChecked = prefs.getBoolean(SettingsPrefs.PREF_CLASH_API_SCAN_ENABLED, true)
switchProxyAuthProbe.isChecked = prefs.getBoolean(SettingsPrefs.PREF_PROXY_AUTH_PROBE_ENABLED, false)
updateLocalScanTogglesEnabled(switchSplitTunnel.isChecked)
updateTunProbeModeEnabled(switchSplitTunnel.isChecked)
@ -105,6 +115,14 @@ internal class SettingsSplitTunnelFragment : Fragment(R.layout.fragment_settings
updatePortRangePreview()
}
switchClashApiScan.setOnCheckedChangeListener { _, isChecked ->
prefs.edit { putBoolean(SettingsPrefs.PREF_CLASH_API_SCAN_ENABLED, isChecked) }
}
switchProxyAuthProbe.setOnCheckedChangeListener { _, isChecked ->
prefs.edit { putBoolean(SettingsPrefs.PREF_PROXY_AUTH_PROBE_ENABLED, isChecked) }
}
chipGroupPortRange.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isEmpty()) return@setOnCheckedStateChangeListener
val value = when (checkedIds.first()) {
@ -141,6 +159,10 @@ internal class SettingsSplitTunnelFragment : Fragment(R.layout.fragment_settings
setViewAndChildrenEnabled(cardProxyScan, enabled)
cardXrayApiScan.alpha = if (enabled) 1.0f else 0.5f
setViewAndChildrenEnabled(cardXrayApiScan, enabled)
cardClashApiScan.alpha = if (enabled) 1.0f else 0.5f
setViewAndChildrenEnabled(cardClashApiScan, enabled)
cardProxyAuthProbe.alpha = if (enabled) 1.0f else 0.5f
setViewAndChildrenEnabled(cardProxyAuthProbe, enabled)
}
private fun updateTunProbeModeEnabled(enabled: Boolean) {

View file

@ -14,11 +14,19 @@ internal data class CellLookupCandidate(
val mcc: String,
val mnc: String,
val areaCode: Long,
val cellId: Long,
val cellId: Long? = null,
val newRadioCellId: Long? = null,
val registered: Boolean,
val signalStrength: Int? = null,
)
internal data class BeaconDbInputDiagnostics(
val supportedCellCount: Int,
val unsupportedCellRadios: List<String>,
val wifiUsedCount: Int,
val wifiCandidateCount: Int,
)
internal data class WifiLookupCandidate(
val macAddress: String,
val frequency: Int? = null,
@ -59,14 +67,14 @@ internal class BeaconDbClient(
cells: List<CellLookupCandidate>,
wifiAccessPoints: List<WifiLookupCandidate>,
): CellLookupResult = withContext(Dispatchers.IO) {
val supportedCells = cells.filter { it.radio in SUPPORTED_RADIOS }.take(MAX_CELL_TOWERS)
val supportedCells = cells.filter(::isSupportedCell).take(MAX_CELL_TOWERS)
val wifiForLookup = wifiAccessPoints.takeIf { it.size >= MIN_WIFI_ACCESS_POINTS }.orEmpty()
if (supportedCells.isEmpty() && wifiForLookup.isEmpty()) {
return@withContext CellLookupResult(
countryCode = null,
latitude = null,
longitude = null,
summary = "BeaconDB: insufficient radio data",
summary = "BeaconDB: insufficient radio data (need >=2 Wi-Fi APs or >=1 supported cell tower)",
)
}
@ -133,7 +141,7 @@ internal class BeaconDbClient(
cells: List<CellLookupCandidate>,
wifiAccessPoints: List<WifiLookupCandidate>,
): String {
val supportedCells = cells.filter { it.radio in SUPPORTED_RADIOS }.take(MAX_CELL_TOWERS)
val supportedCells = cells.filter(::isSupportedCell).take(MAX_CELL_TOWERS)
val wifiForLookup = wifiAccessPoints.takeIf { it.size >= MIN_WIFI_ACCESS_POINTS }.orEmpty()
val cellJson = supportedCells.joinToString(",") { candidate ->
buildString {
@ -173,6 +181,25 @@ internal class BeaconDbClient(
}
}
internal fun inputDiagnostics(
cells: List<CellLookupCandidate>,
wifiAccessPoints: List<WifiLookupCandidate>,
): BeaconDbInputDiagnostics {
val supportedCells = cells.filter(::isSupportedCell).take(MAX_CELL_TOWERS)
val unsupportedRadios = cells
.filterNot(::isSupportedCell)
.map { it.radio.lowercase(Locale.US) }
.distinct()
.sorted()
val wifiForLookup = wifiAccessPoints.takeIf { it.size >= MIN_WIFI_ACCESS_POINTS }.orEmpty()
return BeaconDbInputDiagnostics(
supportedCellCount = supportedCells.size,
unsupportedCellRadios = unsupportedRadios,
wifiUsedCount = wifiForLookup.take(MAX_WIFI_ACCESS_POINTS).size,
wifiCandidateCount = wifiAccessPoints.size,
)
}
internal fun describeFailure(response: HttpResult): String {
return when {
response.code == 404 -> "BeaconDB: no matching location"
@ -204,6 +231,12 @@ internal class BeaconDbClient(
return value.replace("\\", "\\\\").replace("\"", "\\\"")
}
private fun isSupportedCell(candidate: CellLookupCandidate): Boolean {
val radio = candidate.radio.lowercase(Locale.US)
if (radio !in SUPPORTED_RADIOS) return false
return candidate.cellId != null
}
private companion object {
private const val LOOKUP_URL = "https://api.beacondb.net/v1/geolocate"
private const val MAX_CELL_TOWERS = 6

View file

@ -3,7 +3,7 @@ package com.notcvnt.rknhardering.checker
import android.content.Context
import com.notcvnt.rknhardering.LocalProxyOwnerFormatter
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.TunProbeDiagnosticsFormatter
import com.notcvnt.rknhardering.probe.TunProbeDiagnosticsFormatter
import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
@ -19,6 +19,7 @@ import com.notcvnt.rknhardering.probe.IfconfigClient
import com.notcvnt.rknhardering.probe.LocalSocketInspector
import com.notcvnt.rknhardering.probe.LocalSocketListener
import com.notcvnt.rknhardering.probe.MtProtoProber
import com.notcvnt.rknhardering.probe.PortScanPlan
import com.notcvnt.rknhardering.probe.PortScanPlanner
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.PublicIpNetworkComparison
@ -29,11 +30,14 @@ import com.notcvnt.rknhardering.probe.ProxyType
import com.notcvnt.rknhardering.probe.ScanMode
import com.notcvnt.rknhardering.probe.ScanPhase
import com.notcvnt.rknhardering.probe.TunProbeResolveStrategy
import com.notcvnt.rknhardering.probe.ClashApiScanResult
import com.notcvnt.rknhardering.probe.ClashApiScanner
import com.notcvnt.rknhardering.probe.UnderlyingNetworkProber
import com.notcvnt.rknhardering.probe.XrayApiScanResult
import com.notcvnt.rknhardering.probe.XrayApiScanner
import com.notcvnt.rknhardering.vpn.VpnAppCatalog
import com.notcvnt.rknhardering.vpn.VpnAppMetadataScanner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
@ -74,6 +78,7 @@ object BypassChecker {
enum class ProgressLine {
BYPASS,
XRAY_API,
CLASH_API,
UNDERLYING_NETWORK,
}
@ -95,10 +100,16 @@ object BypassChecker {
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
splitTunnelEnabled: Boolean = true,
proxyScanEnabled: Boolean = true,
proxyAuthProbeEnabled: Boolean = false,
xrayApiScanEnabled: Boolean = true,
clashApiScanEnabled: Boolean = true,
portRange: String = "full",
portRangeStart: Int = 1024,
portRangeEnd: Int = 65535,
connectTimeoutMs: Int = 80,
checkUnderlyingNetwork: Boolean = true,
checkVpnNetworkBinding: Boolean = true,
checkMtprotoViaProxy: Boolean = true,
underlyingProbeDeferred: Deferred<UnderlyingNetworkProber.ProbeResult>? = null,
onProgress: (suspend (Progress) -> Unit)? = null,
): BypassResult = coroutineScope {
@ -116,105 +127,47 @@ object BypassChecker {
null
}
val effectiveConnectTimeoutMs = connectTimeoutMs.coerceAtLeast(1)
val scanner = scanPlan?.let {
ProxyScanner(
popularPorts = it.popularPorts,
scanRange = it.scanRange,
connectTimeoutMs = effectiveConnectTimeoutMs,
authProbeEnabled = proxyAuthProbeEnabled,
)
}
val xrayScanner = scanPlan?.let {
when (it.mode) {
ScanMode.POPULAR_ONLY -> XrayApiScanner(
scanPorts = XrayApiScanner.DEFAULT_POPULAR_PORTS,
connectTimeoutMs = effectiveConnectTimeoutMs,
)
else -> XrayApiScanner(
scanRange = it.scanRange,
connectTimeoutMs = effectiveConnectTimeoutMs,
)
}
}
val proxyDeferred = if (splitTunnelEnabled && proxyScanEnabled && scanPlan != null && scanner != null) {
async {
onProgress?.invoke(
Progress(
line = ProgressLine.BYPASS,
phase = context.getString(R.string.checker_bypass_progress_port_scan_phase),
detail = context.getString(R.string.checker_bypass_progress_port_scan_detail),
),
)
if (scanPlan.mode == ScanMode.POPULAR_ONLY) {
scanner.findOpenProxyEndpoints(
mode = ScanMode.POPULAR_ONLY,
manualPort = null,
onProgress = { progress ->
val percent = if (progress.total > 0) (progress.scanned * 100 / progress.total) else 0
onProgress?.invoke(
Progress(
line = ProgressLine.BYPASS,
phase = context.getString(R.string.checker_bypass_progress_popular_ports),
detail = context.getString(
R.string.checker_bypass_progress_port_detail,
progress.currentPort,
percent,
),
),
)
},
)
} else {
scanner.findOpenProxyEndpoints(
mode = scanPlan.mode,
manualPort = null,
onProgress = { progress ->
val phaseText = when (progress.phase) {
ScanPhase.POPULAR_PORTS -> context.getString(R.string.checker_bypass_progress_popular_ports)
ScanPhase.FULL_RANGE -> context.getString(R.string.checker_bypass_progress_full_scan)
}
val percent = if (progress.total > 0) (progress.scanned * 100 / progress.total) else 0
onProgress?.invoke(
Progress(
line = ProgressLine.BYPASS,
phase = phaseText,
detail = context.getString(
R.string.checker_bypass_progress_port_detail,
progress.currentPort,
percent,
),
),
)
},
)
}
}
startProxyScanAsync(context, scanPlan, scanner, onProgress)
} else {
null
}
val xrayDeferred = if (splitTunnelEnabled && xrayApiScanEnabled && xrayScanner != null) {
async {
onProgress?.invoke(
Progress(
line = ProgressLine.XRAY_API,
phase = "Xray API",
detail = context.getString(R.string.checker_bypass_progress_xray_detail),
),
)
xrayScanner.findXrayApi { progress ->
val percent = if (progress.total > 0) (progress.scanned * 100 / progress.total) else 0
onProgress?.invoke(
Progress(
line = ProgressLine.XRAY_API,
phase = "Xray API",
detail = "${progress.host}:${progress.currentPort} ($percent%)",
),
)
}
}
startXrayScanAsync(context, xrayScanner, onProgress)
} else {
null
}
val underlyingDeferred = if (splitTunnelEnabled) {
val clashDeferred = if (splitTunnelEnabled && clashApiScanEnabled) {
startClashScanAsync(context, ClashApiScanner(), onProgress)
} else {
null
}
val underlyingDeferred = if (splitTunnelEnabled && checkUnderlyingNetwork) {
underlyingProbeDeferred ?: async {
onProgress?.invoke(
Progress(
@ -239,12 +192,14 @@ object BypassChecker {
findings = findings,
evidence = evidence,
onProgress = onProgress,
checkMtprotoViaProxy = checkMtprotoViaProxy,
)
}
} else {
null
}
val xrayApiScanResult = xrayDeferred?.await()
val clashApiScanResult = clashDeferred?.await()
val underlyingResult = underlyingDeferred?.await() ?: UnderlyingNetworkProber.ProbeResult(
vpnActive = false,
underlyingReachable = false,
@ -254,15 +209,23 @@ object BypassChecker {
if (splitTunnelEnabled && xrayApiScanEnabled) {
reportXrayApiResult(context, xrayApiScanResult, findings, evidence)
}
val underlyingEvaluation = if (splitTunnelEnabled) {
val clashApiDetected = if (splitTunnelEnabled && clashApiScanEnabled) {
reportClashApiResult(context, clashApiScanResult, findings, evidence)
} else {
false
}
val underlyingEvaluation = if (splitTunnelEnabled && checkUnderlyingNetwork && checkVpnNetworkBinding) {
reportUnderlyingNetworkResult(context, underlyingResult, findings, evidence)
} else {
UnderlyingEvaluation(detected = false, needsReview = false)
}
val detected = proxyEvaluation.confirmedBypass || xrayApiScanResult != null || underlyingEvaluation.detected
val detected = proxyEvaluation.confirmedBypass ||
xrayApiScanResult != null ||
clashApiDetected ||
underlyingEvaluation.detected
val needsReview = !detected && (
proxyEvaluation.proxyChecks.isNotEmpty() ||
proxyChecksNeedReview(proxyEvaluation.proxyChecks) ||
underlyingEvaluation.needsReview
)
@ -274,6 +237,7 @@ object BypassChecker {
vpnNetworkIp = underlyingResult.vpnIp,
underlyingIp = underlyingResult.underlyingIp,
xrayApiScanResult = xrayApiScanResult,
clashApiScanResult = clashApiScanResult,
proxyChecks = proxyEvaluation.proxyChecks,
findings = findings,
detected = detected,
@ -282,6 +246,127 @@ object BypassChecker {
)
}
private fun CoroutineScope.startProxyScanAsync(
context: Context,
scanPlan: PortScanPlan,
scanner: ProxyScanner,
onProgress: (suspend (Progress) -> Unit)?,
): Deferred<List<ProxyEndpoint>> = async {
onProgress?.invoke(
Progress(
line = ProgressLine.BYPASS,
phase = context.getString(R.string.checker_bypass_progress_port_scan_phase),
detail = context.getString(R.string.checker_bypass_progress_port_scan_detail),
),
)
if (scanPlan.mode == ScanMode.POPULAR_ONLY) {
scanner.findOpenProxyEndpoints(
mode = ScanMode.POPULAR_ONLY,
manualPort = null,
onProgress = { progress ->
val percent = if (progress.total > 0) (progress.scanned * 100 / progress.total) else 0
onProgress?.invoke(
Progress(
line = ProgressLine.BYPASS,
phase = context.getString(R.string.checker_bypass_progress_popular_ports),
detail = context.getString(
R.string.checker_bypass_progress_port_detail,
progress.currentPort,
percent,
),
),
)
},
)
} else {
scanner.findOpenProxyEndpoints(
mode = scanPlan.mode,
manualPort = null,
onProgress = { progress ->
val phaseText = when (progress.phase) {
ScanPhase.POPULAR_PORTS -> context.getString(R.string.checker_bypass_progress_popular_ports)
ScanPhase.FULL_RANGE -> context.getString(R.string.checker_bypass_progress_full_scan)
}
val percent = if (progress.total > 0) (progress.scanned * 100 / progress.total) else 0
onProgress?.invoke(
Progress(
line = ProgressLine.BYPASS,
phase = phaseText,
detail = context.getString(
R.string.checker_bypass_progress_port_detail,
progress.currentPort,
percent,
),
),
)
},
)
}
}
private fun CoroutineScope.startXrayScanAsync(
context: Context,
xrayScanner: XrayApiScanner,
onProgress: (suspend (Progress) -> Unit)?,
): Deferred<XrayApiScanResult?> = async {
onProgress?.invoke(
Progress(
line = ProgressLine.XRAY_API,
phase = "Xray API",
detail = context.getString(R.string.checker_bypass_progress_xray_detail),
),
)
xrayScanner.findXrayApi { progress ->
val percent = if (progress.total > 0) (progress.scanned * 100 / progress.total) else 0
onProgress?.invoke(
Progress(
line = ProgressLine.XRAY_API,
phase = "Xray API",
detail = "${progress.host}:${progress.currentPort} ($percent%)",
),
)
}
}
private fun CoroutineScope.startClashScanAsync(
context: Context,
clashScanner: ClashApiScanner,
onProgress: (suspend (Progress) -> Unit)?,
): Deferred<ClashApiScanResult?> = async {
onProgress?.invoke(
Progress(
line = ProgressLine.CLASH_API,
phase = "Clash API",
detail = context.getString(R.string.checker_bypass_progress_clash_detail),
),
)
clashScanner.findClashApi { scanned, total ->
val percent = if (total > 0) (scanned * 100 / total) else 0
onProgress?.invoke(
Progress(
line = ProgressLine.CLASH_API,
phase = "Clash API",
detail = "$percent%",
),
)
}
}
internal fun proxyChecksNeedReview(proxyChecks: List<LocalProxyCheckResult>): Boolean {
return proxyChecks.any(::proxyCheckNeedsReview)
}
private fun proxyCheckNeedsReview(proxyCheck: LocalProxyCheckResult): Boolean {
return when (proxyCheck.status) {
LocalProxyCheckStatus.SAME_IP,
LocalProxyCheckStatus.CONFIRMED_BYPASS -> false
LocalProxyCheckStatus.AUTH_REQUIRED ->
proxyCheck.ownerStatus != LocalProxyOwnerStatus.UNRESOLVED
LocalProxyCheckStatus.PROXY_IP_UNAVAILABLE,
LocalProxyCheckStatus.DIRECT_IP_UNAVAILABLE -> true
}
}
internal suspend fun evaluateProxyEndpoints(
context: Context,
resolverConfig: DnsResolverConfig,
@ -289,6 +374,7 @@ object BypassChecker {
findings: MutableList<Finding>,
evidence: MutableList<EvidenceItem>,
onProgress: (suspend (Progress) -> Unit)? = null,
checkMtprotoViaProxy: Boolean = true,
fetchDirectIp: suspend () -> Result<String> = {
IfconfigClient.fetchDirectIp(resolverConfig = resolverConfig)
},
@ -330,6 +416,7 @@ object BypassChecker {
val isXrayPort = VpnAppCatalog.familiesForPort(proxyEndpoint.port)
.contains(VpnAppCatalog.FAMILY_XRAY)
val mtProtoResult = if (
checkMtprotoViaProxy &&
proxyEndpoint.type == ProxyType.SOCKS5 &&
!proxyEndpoint.authRequired &&
proxyIp == null &&
@ -407,7 +494,8 @@ object BypassChecker {
proxyChecks = proxyChecks,
confirmedBypass = proxyChecks.any {
it.status == LocalProxyCheckStatus.CONFIRMED_BYPASS ||
it.status == LocalProxyCheckStatus.AUTH_REQUIRED
it.endpoint.weakAuthCracked ||
it.endpoint.udpAssociateOpen
},
)
}
@ -465,34 +553,76 @@ object BypassChecker {
}
}
val proxyDetected = proxyCheck.status == LocalProxyCheckStatus.CONFIRMED_BYPASS ||
proxyCheck.status == LocalProxyCheckStatus.AUTH_REQUIRED
val proxyDetected = proxyCheck.status == LocalProxyCheckStatus.CONFIRMED_BYPASS
// Treat AUTH_REQUIRED endpoints with no resolved owner as informational
// noise: bypass cannot be confirmed (no probe possible) and we can't
// attribute the listening socket to a known app. Emitting them as
// evidence inflates the verdict and confuses the user.
val authRequiredUnresolved = proxyCheck.status == LocalProxyCheckStatus.AUTH_REQUIRED &&
proxyCheck.ownerStatus == LocalProxyOwnerStatus.UNRESOLVED
findings.add(
Finding(
description = description,
detected = proxyDetected,
needsReview = !proxyDetected,
needsReview = !proxyDetected && !authRequiredUnresolved,
isInformational = authRequiredUnresolved,
source = EvidenceSource.LOCAL_PROXY,
confidence = EvidenceConfidence.MEDIUM,
family = familySuffix,
packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner),
),
)
evidence.add(
EvidenceItem(
source = EvidenceSource.LOCAL_PROXY,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = buildString {
append("Detected open ${proxyEndpoint.type.name} proxy at ${formatHostPort(proxyEndpoint.host, proxyEndpoint.port)}")
append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus))
append(ownerMetadataSuffix)
},
family = familySuffix,
packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner),
),
)
if (!authRequiredUnresolved) {
evidence.add(
EvidenceItem(
source = EvidenceSource.LOCAL_PROXY,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = buildString {
append("Detected open ${proxyEndpoint.type.name} proxy at ${formatHostPort(proxyEndpoint.host, proxyEndpoint.port)}")
append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus))
append(ownerMetadataSuffix)
},
family = familySuffix,
packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner),
),
)
}
if (proxyEndpoint.weakAuthCracked || proxyEndpoint.udpAssociateOpen) {
evidence.add(
EvidenceItem(
source = EvidenceSource.PROXY_AUTH_BYPASS,
detected = true,
confidence = EvidenceConfidence.HIGH,
description = if (proxyEndpoint.weakAuthCracked) {
"SOCKS5 proxy at ${formatHostPort(proxyEndpoint.host, proxyEndpoint.port)} accepts weak credentials"
} else {
"SOCKS5 proxy at ${formatHostPort(proxyEndpoint.host, proxyEndpoint.port)} allows UDP ASSOCIATE without auth"
},
family = familySuffix,
packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner),
),
)
findings.add(
Finding(
description = context.getString(
if (proxyEndpoint.weakAuthCracked) {
R.string.checker_bypass_proxy_weak_auth
} else {
R.string.checker_bypass_proxy_udp_associate
},
formatHostPort(proxyEndpoint.host, proxyEndpoint.port),
),
detected = true,
source = EvidenceSource.PROXY_AUTH_BYPASS,
confidence = EvidenceConfidence.HIGH,
family = familySuffix,
packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner),
),
)
}
if (proxyCheck.status != LocalProxyCheckStatus.AUTH_REQUIRED) {
findings.add(Finding(context.getString(R.string.checker_bypass_proxy_ip, proxyCheck.proxyIp ?: unavailable)))
@ -561,10 +691,12 @@ object BypassChecker {
}
val ep = xrayApiScanResult.endpoint
val stats = xrayApiScanResult.stats
val statsOnly = stats != null && !xrayApiScanResult.handlerAvailable
findings.add(
Finding(
description = context.getString(
R.string.checker_bypass_xray_api,
if (statsOnly) R.string.checker_bypass_xray_api_stats_only else R.string.checker_bypass_xray_api,
formatHostPort(ep.host, ep.port),
),
detected = true,
@ -578,11 +710,35 @@ object BypassChecker {
source = EvidenceSource.XRAY_API,
detected = true,
confidence = EvidenceConfidence.HIGH,
description = "Detected Xray gRPC API at ${formatHostPort(ep.host, ep.port)}",
description = if (statsOnly) {
"Detected Xray gRPC StatsService at ${formatHostPort(ep.host, ep.port)}"
} else {
"Detected Xray gRPC API at ${formatHostPort(ep.host, ep.port)}"
},
family = VpnAppCatalog.FAMILY_XRAY,
),
)
if (statsOnly) {
val statsSummary = stats ?: return
findings += Finding(
description = context.getString(R.string.checker_bypass_xray_handler_unavailable),
isInformational = true,
source = EvidenceSource.XRAY_API,
family = VpnAppCatalog.FAMILY_XRAY,
)
val sample = statsSummary.sampleNames
.takeIf { it.isNotEmpty() }
?.joinToString()
?: statsSummary.statCount.toString()
findings += Finding(
description = context.getString(R.string.checker_bypass_xray_stats_sample, sample),
isInformational = true,
source = EvidenceSource.XRAY_API,
family = VpnAppCatalog.FAMILY_XRAY,
)
}
for (outbound in xrayApiScanResult.outbounds.take(10)) {
val detail = buildString {
append(" ")
@ -621,6 +777,62 @@ object BypassChecker {
}
}
/**
* Emits findings/evidence for a discovered Clash/mihomo/sing-box REST API.
* Returns true when a live API was detected (config reachable or destination
* IPs leaked), which feeds R1 hard-detect via [EvidenceSource.CLASH_API].
*/
private fun reportClashApiResult(
context: Context,
clashApiScanResult: ClashApiScanResult?,
findings: MutableList<Finding>,
evidence: MutableList<EvidenceItem>,
): Boolean {
if (clashApiScanResult == null ||
(!clashApiScanResult.configAvailable && clashApiScanResult.leakedDestIps.isEmpty())
) {
findings.add(Finding(context.getString(R.string.checker_bypass_no_clash_api)))
return false
}
val ep = clashApiScanResult.endpoint
findings.add(
Finding(
description = context.getString(
R.string.checker_bypass_clash_api,
formatHostPort(ep.host, ep.port),
),
detected = true,
source = EvidenceSource.CLASH_API,
confidence = EvidenceConfidence.HIGH,
family = VpnAppCatalog.FAMILY_CLASH,
),
)
evidence.add(
EvidenceItem(
source = EvidenceSource.CLASH_API,
detected = true,
confidence = EvidenceConfidence.HIGH,
description = "Detected Clash/sing-box REST API at ${formatHostPort(ep.host, ep.port)}",
family = VpnAppCatalog.FAMILY_CLASH,
),
)
for (ip in clashApiScanResult.leakedDestIps.distinct().take(10)) {
findings.add(
Finding(
description = context.getString(R.string.checker_bypass_clash_leak, ip),
detected = true,
source = EvidenceSource.CLASH_API,
confidence = EvidenceConfidence.HIGH,
family = VpnAppCatalog.FAMILY_CLASH,
),
)
}
return true
}
internal fun reportUnderlyingNetworkResult(
context: Context,
result: UnderlyingNetworkProber.ProbeResult,

View file

@ -15,6 +15,7 @@ import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.StunProbeGroupResult
import com.notcvnt.rknhardering.model.StunProbeResult
import com.notcvnt.rknhardering.customcheck.CallTransportConfig
import com.notcvnt.rknhardering.model.StunScope
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.NetworkInterfaceNameNormalizer
@ -98,12 +99,12 @@ object CallTransportChecker {
binding = binding,
)
},
val publicIpFetcher: suspend (PathDescriptor, DnsResolverConfig) -> Result<String> =
{ path, resolverConfig ->
val publicIpFetcher: suspend (PathDescriptor, DnsResolverConfig, Int) -> Result<String> =
{ path, resolverConfig, timeoutMs ->
when (path.path) {
CallTransportNetworkPath.ACTIVE ->
IfconfigClient.fetchDirectIp(
timeoutMs = STUN_HTTP_TIMEOUT_MS,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
okHttpRetryCount = STUN_HTTP_OKHTTP_RETRY_COUNT,
nativeCurlRetryCount = STUN_HTTP_NATIVE_CURL_RETRY_COUNT,
@ -113,7 +114,7 @@ object CallTransportChecker {
IfconfigClient.fetchIpViaNetwork(
primaryBinding = ResolverBinding.AndroidNetworkBinding(path.network),
fallbackBinding = path.fallbackBinding(),
timeoutMs = STUN_HTTP_TIMEOUT_MS,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
okHttpRetryCount = STUN_HTTP_OKHTTP_RETRY_COUNT,
nativeCurlRetryCount = STUN_HTTP_NATIVE_CURL_RETRY_COUNT,
@ -165,6 +166,7 @@ object CallTransportChecker {
resolverConfig: DnsResolverConfig,
callTransportEnabled: Boolean,
onProgress: (suspend (String, String) -> Unit)? = null,
config: CallTransportConfig = CallTransportConfig(enabled = false),
): Evaluation = withContext(Dispatchers.IO) {
if (!callTransportEnabled) {
return@withContext Evaluation()
@ -187,9 +189,11 @@ object CallTransportChecker {
context = context,
resolverConfig = resolverConfig,
onProgress = onProgress,
config = config,
)
}
}
val effectiveTimeoutMs = config.timeoutMs.coerceAtLeast(1)
val directDeferred = async {
IndirectCheckPerformanceSupport.measureSuspendStep("probeDirect", stepTimings) {
probeDirect(
@ -197,11 +201,16 @@ object CallTransportChecker {
resolverConfig = resolverConfig,
onProgress = onProgress,
activeStunGroupsProvider = { stunSweepDeferred.await().groups },
timeoutMs = effectiveTimeoutMs,
)
}
}
val proxyAssistedDeferred = async {
if (!config.checkMtproto) {
IndirectCheckPerformanceSupport.markSkippedStep("probeProxyAssistedTelegram", stepTimings)
return@async emptyList()
}
val proxyEndpoint = proxyEndpointDeferred.await()
if (proxyEndpoint?.type != ProxyType.SOCKS5) {
IndirectCheckPerformanceSupport.markSkippedStep("probeProxyAssistedTelegram", stepTimings)
@ -214,6 +223,7 @@ object CallTransportChecker {
context = context,
proxyEndpoint = proxyEndpoint,
resolverConfig = resolverConfig,
timeoutMs = effectiveTimeoutMs,
)
}
}
@ -260,9 +270,10 @@ object CallTransportChecker {
context: Context,
resolverConfig: DnsResolverConfig,
onProgress: (suspend (String, String) -> Unit)? = null,
config: CallTransportConfig = CallTransportConfig(enabled = false),
): StunSweepResult = withContext(Dispatchers.IO) {
val dependencies = dependenciesOverride ?: Dependencies()
val catalog = cancellationAwareRunCatching { dependencies.loadCatalog(context) }
val rawCatalog = cancellationAwareRunCatching { dependencies.loadCatalog(context) }
.getOrElse { error ->
return@withContext StunSweepResult(
groups = emptyList(),
@ -275,6 +286,23 @@ object CallTransportChecker {
),
)
}
val filteredBuiltin = rawCatalog.stunTargets.filter { target ->
when (target.scope) {
StunScope.GLOBAL -> config.builtinGlobalStunEnabled
StunScope.RU -> config.builtinRuStunEnabled
}
}
val customTargets = config.customStunServers
.filter { it.host.isNotBlank() }
.map { custom ->
CallTransportTargetCatalog.StunTarget(
host = custom.host,
port = custom.port,
scope = StunScope.GLOBAL,
enabled = true,
)
}
val catalog = CallTransportTargetCatalog.Catalog(stunTargets = filteredBuiltin + customTargets)
val paths = cancellationAwareRunCatching { dependencies.loadPaths(context) }
.getOrElse { error ->
@ -331,6 +359,7 @@ object CallTransportChecker {
resolverConfig: DnsResolverConfig,
onProgress: (suspend (String, String) -> Unit)? = null,
activeStunGroupsProvider: (suspend () -> List<StunProbeGroupResult>)? = null,
timeoutMs: Int = STUN_HTTP_TIMEOUT_MS,
): List<CallTransportLeakResult> = withContext(Dispatchers.IO) {
val dependencies = dependenciesOverride ?: Dependencies()
val paths = cancellationAwareRunCatching { dependencies.loadPaths(context) }
@ -368,7 +397,7 @@ object CallTransportChecker {
targets = catalog.stunTargets,
path = path,
sweepGroups = sweepGroups,
fetchPublicIp = { dependencies.publicIpFetcher(path, resolverConfig) },
fetchPublicIp = { dependencies.publicIpFetcher(path, resolverConfig, timeoutMs) },
)
}
} else {
@ -380,7 +409,7 @@ object CallTransportChecker {
probeStunServiceTargets(
targets = catalog.stunTargets,
path = path,
fetchPublicIp = { dependencies.publicIpFetcher(path, resolverConfig) },
fetchPublicIp = { dependencies.publicIpFetcher(path, resolverConfig, timeoutMs) },
stunProbe = { target ->
preferSuccessfulBinding(
dependencies.stunDualStackProbe(
@ -402,6 +431,7 @@ object CallTransportChecker {
context: Context,
proxyEndpoint: ProxyEndpoint,
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
timeoutMs: Int = STUN_HTTP_TIMEOUT_MS,
): List<CallTransportLeakResult> = withContext(Dispatchers.IO) {
val dependencies = dependenciesOverride ?: Dependencies()
if (proxyEndpoint.type != ProxyType.SOCKS5) {
@ -413,7 +443,7 @@ object CallTransportChecker {
cancellationAwareRunCatchingSuspend {
IfconfigClient.fetchIpViaProxy(
endpoint = proxyEndpoint,
timeoutMs = STUN_HTTP_TIMEOUT_MS,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
okHttpRetryCount = STUN_HTTP_OKHTTP_RETRY_COUNT,
nativeCurlRetryCount = STUN_HTTP_NATIVE_CURL_RETRY_COUNT,

View file

@ -3,6 +3,10 @@ package com.notcvnt.rknhardering.checker
import android.content.Context
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.customcheck.CdnPullingConfig
import com.notcvnt.rknhardering.customcheck.CustomCdnTarget
import com.notcvnt.rknhardering.customcheck.mapper.EndpointResponseMapper
import com.notcvnt.rknhardering.customcheck.mapper.MappingField
import com.notcvnt.rknhardering.model.CdnPullingResponse
import com.notcvnt.rknhardering.model.CdnPullingResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
@ -27,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(
@ -38,6 +43,13 @@ object CdnPullingChecker {
val kind: CdnPullingClient.TargetKind,
)
internal data class Dependencies(
val fetchEndpoint: (suspend (Context, EndpointSpec, Int, DnsResolverConfig) -> com.notcvnt.rknhardering.model.CdnPullingResponse)? = null,
)
@Volatile
internal var dependenciesOverride: Dependencies? = null
internal val ENDPOINTS = listOf(
EndpointSpec(
label = "redirector.googlevideo.com",
@ -60,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,
),
@ -71,92 +83,216 @@ object CdnPullingChecker {
timeoutMs: Int = 5_000,
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
meduzaEnabled: Boolean = true,
): CdnPullingResult = withContext(Dispatchers.IO) {
coroutineScope {
val activeEndpoints = if (meduzaEnabled) ENDPOINTS else ENDPOINTS.filter { it.label != "meduza.io" }
val responses = activeEndpoints.map { endpoint ->
async {
val ipv4Deferred = async {
fetchBodyWithRetries(
endpoint = endpoint.url,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
addressFamily = Inet4Address::class.java,
)
config: CdnPullingConfig = CdnPullingConfig(enabled = false),
): CdnPullingResult {
if (!config.enabled) return CdnPullingResult.empty()
val effectiveTimeoutMs = config.timeoutMs
val dependencies = dependenciesOverride ?: Dependencies()
return withContext(Dispatchers.IO) {
coroutineScope {
val activeEndpoints = buildList {
if (config.builtinTargetsEnabled) {
for (endpoint in ENDPOINTS) {
if (endpoint.label == MEDUZA_IO && !config.meduzaEnabled) continue
if (endpoint.label == "rutracker.org" && !config.rutrackerEnabled) continue
add(endpoint)
}
}
val ipv6Deferred = async {
fetchBodyWithRetries(
endpoint = endpoint.url,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
addressFamily = Inet6Address::class.java,
)
}
val ipv4Result = ipv4Deferred.await()
val ipv6Result = ipv6Deferred.await()
val ipv4Raw = ipv4Result.getOrNull()
val ipv6Raw = ipv6Result.getOrNull()
val ipv4Parsed = ipv4Raw?.let { CdnPullingClient.parseBody(endpoint.kind, it) }
val ipv6Parsed = ipv6Raw?.let { CdnPullingClient.parseBody(endpoint.kind, it) }
val ipv4 = ipv4Parsed?.ip?.takeIf { CdnPullingClient.looksLikeIpv4(it) }
val ipv6 = ipv6Parsed?.ip?.takeIf { CdnPullingClient.looksLikeIpv6(it) }
val representativeParsed = ipv4Parsed?.takeIf { it.hasUsefulData }
?: ipv6Parsed?.takeIf { it.hasUsefulData }
val representativeIp = ipv4 ?: ipv6 ?: representativeParsed?.ip
val rawBody = ipv4Raw ?: ipv6Raw
val ipv4Unavailable = ipv4 == null && ipv6 != null
val hasAnyBody = rawBody != null
val hasUsefulData = representativeParsed?.hasUsefulData == true
val error = when {
!hasAnyBody -> formatError(
context,
ipv4Result.exceptionOrNull() ?: ipv6Result.exceptionOrNull(),
)
hasUsefulData -> null
else -> context.getString(R.string.checker_cdn_pulling_error_unrecognized)
}
val ipv4ErrorMessage = when {
ipv4Result.isFailure -> ipv4Result.exceptionOrNull()?.let { formatError(context, it) }
ipv4 == null && ipv4Parsed?.ip != null ->
"server returned non-IPv4 address: ${ipv4Parsed.ip}"
ipv4 == null && ipv4Raw != null ->
"response did not contain an IPv4 address"
else -> null
}
val ipv6ErrorMessage = when {
ipv6Result.isFailure -> ipv6Result.exceptionOrNull()?.let { formatError(context, it) }
ipv6 == null && ipv6Parsed?.ip != null ->
"server returned non-IPv6 address: ${ipv6Parsed.ip}"
ipv6 == null && ipv6Raw != null ->
"response did not contain an IPv6 address"
else -> null
}
CdnPullingResponse(
targetLabel = endpoint.label,
url = endpoint.url,
ip = representativeIp,
ipv4 = ipv4,
ipv6 = ipv6,
ipv4Unavailable = ipv4Unavailable,
ipv4Error = ipv4ErrorMessage,
ipv6Error = ipv6ErrorMessage,
importantFields = representativeParsed?.importantFields.orEmpty(),
rawBody = rawBody,
error = error,
)
}
}.map { it.await() }
evaluate(context, responses)
val builtinResponses = activeEndpoints.map { endpoint ->
async {
val override = dependencies.fetchEndpoint
if (override != null) {
override(context, endpoint, effectiveTimeoutMs, resolverConfig)
} else {
fetchBuiltinEndpoint(context, endpoint, effectiveTimeoutMs, resolverConfig)
}
}
}.map { it.await() }
val customResponses = config.customTargets
.filter { it.enabled }
.map { target ->
async {
fetchCustomEndpoint(context, target, effectiveTimeoutMs, resolverConfig)
}
}.map { it.await() }
evaluate(context, builtinResponses + customResponses)
}
}
}
private suspend fun fetchBuiltinEndpoint(
context: Context,
endpoint: EndpointSpec,
timeoutMs: Int,
resolverConfig: DnsResolverConfig,
): CdnPullingResponse = coroutineScope {
val ipv4Deferred = async {
fetchBodyWithRetries(
endpoint = endpoint.url,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
addressFamily = Inet4Address::class.java,
)
}
val ipv6Deferred = async {
fetchBodyWithRetries(
endpoint = endpoint.url,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
addressFamily = Inet6Address::class.java,
)
}
val ipv4Result = ipv4Deferred.await()
val ipv6Result = ipv6Deferred.await()
val ipv4Raw = ipv4Result.getOrNull()
val ipv6Raw = ipv6Result.getOrNull()
val ipv4Parsed = ipv4Raw?.let { CdnPullingClient.parseBody(endpoint.kind, it) }
val ipv6Parsed = ipv6Raw?.let { CdnPullingClient.parseBody(endpoint.kind, it) }
val ipv4 = ipv4Parsed?.ip?.takeIf { CdnPullingClient.looksLikeIpv4(it) }
val ipv6 = ipv6Parsed?.ip?.takeIf { CdnPullingClient.looksLikeIpv6(it) }
val representativeParsed = ipv4Parsed?.takeIf { it.hasUsefulData }
?: ipv6Parsed?.takeIf { it.hasUsefulData }
val representativeIp = ipv4 ?: ipv6 ?: representativeParsed?.ip
val rawBody = ipv4Raw ?: ipv6Raw
val ipv4Unavailable = ipv4 == null && ipv6 != null
val hasAnyBody = rawBody != null
val hasUsefulData = representativeParsed?.hasUsefulData == true
val error = when {
!hasAnyBody -> formatError(
context,
ipv4Result.exceptionOrNull() ?: ipv6Result.exceptionOrNull(),
)
hasUsefulData -> null
else -> context.getString(R.string.checker_cdn_pulling_error_unrecognized)
}
val ipv4ErrorMessage = when {
ipv4Result.isFailure -> ipv4Result.exceptionOrNull()?.let { formatError(context, it) }
ipv4 == null && ipv4Parsed?.ip != null ->
"server returned non-IPv4 address: ${ipv4Parsed.ip}"
ipv4 == null && ipv4Raw != null ->
"response did not contain an IPv4 address"
else -> null
}
val ipv6ErrorMessage = when {
ipv6Result.isFailure -> ipv6Result.exceptionOrNull()?.let { formatError(context, it) }
ipv6 == null && ipv6Parsed?.ip != null ->
"server returned non-IPv6 address: ${ipv6Parsed.ip}"
ipv6 == null && ipv6Raw != null ->
"response did not contain an IPv6 address"
else -> null
}
CdnPullingResponse(
targetLabel = endpoint.label,
url = endpoint.url,
ip = representativeIp,
ipv4 = ipv4,
ipv6 = ipv6,
ipv4Unavailable = ipv4Unavailable,
ipv4Error = ipv4ErrorMessage,
ipv6Error = ipv6ErrorMessage,
importantFields = representativeParsed?.importantFields.orEmpty(),
rawBody = rawBody,
error = error,
)
}
private suspend fun fetchCustomEndpoint(
context: Context,
target: CustomCdnTarget,
timeoutMs: Int,
resolverConfig: DnsResolverConfig,
): CdnPullingResponse = coroutineScope {
val ipv4Deferred = async {
fetchBodyWithRetries(
endpoint = target.url,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
addressFamily = Inet4Address::class.java,
)
}
val ipv6Deferred = async {
fetchBodyWithRetries(
endpoint = target.url,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
addressFamily = Inet6Address::class.java,
)
}
val ipv4Result = ipv4Deferred.await()
val ipv6Result = ipv6Deferred.await()
val ipv4Raw = ipv4Result.getOrNull()
val ipv6Raw = ipv6Result.getOrNull()
val ipv4ExtractedIp = ipv4Raw?.let {
EndpointResponseMapper.extractField(it, target.responseMapping, MappingField.IP)
}
val ipv6ExtractedIp = ipv6Raw?.let {
EndpointResponseMapper.extractField(it, target.responseMapping, MappingField.IP)
}
val ipv4 = ipv4ExtractedIp?.takeIf { CdnPullingClient.looksLikeIpv4(it) }
val ipv6 = ipv6ExtractedIp?.takeIf { CdnPullingClient.looksLikeIpv6(it) }
val representativeIp = ipv4 ?: ipv6 ?: ipv4ExtractedIp ?: ipv6ExtractedIp
val rawBody = ipv4Raw ?: ipv6Raw
val ipv4Unavailable = ipv4 == null && ipv6 != null
val hasAnyBody = rawBody != null
val hasUsefulData = representativeIp != null
val error = when {
!hasAnyBody -> formatError(
context,
ipv4Result.exceptionOrNull() ?: ipv6Result.exceptionOrNull(),
)
hasUsefulData -> null
else -> context.getString(R.string.checker_cdn_pulling_error_unrecognized)
}
val ipv4ErrorMessage = when {
ipv4Result.isFailure -> ipv4Result.exceptionOrNull()?.let { formatError(context, it) }
ipv4 == null && ipv4ExtractedIp != null ->
"server returned non-IPv4 address: $ipv4ExtractedIp"
ipv4 == null && ipv4Raw != null ->
"response did not contain an IPv4 address"
else -> null
}
val ipv6ErrorMessage = when {
ipv6Result.isFailure -> ipv6Result.exceptionOrNull()?.let { formatError(context, it) }
ipv6 == null && ipv6ExtractedIp != null ->
"server returned non-IPv6 address: $ipv6ExtractedIp"
ipv6 == null && ipv6Raw != null ->
"response did not contain an IPv6 address"
else -> null
}
CdnPullingResponse(
targetLabel = target.label,
url = target.url,
ip = representativeIp,
ipv4 = ipv4,
ipv6 = ipv6,
ipv4Unavailable = ipv4Unavailable,
ipv4Error = ipv4ErrorMessage,
ipv6Error = ipv6ErrorMessage,
importantFields = emptyMap(),
rawBody = rawBody,
error = error,
)
}
internal suspend fun fetchBodyWithRetries(
endpoint: String,
timeoutMs: Int,
@ -195,7 +331,6 @@ object CdnPullingChecker {
val successfulCount = successfulResponses.size
val actionableResponses = successfulResponses.filter { it.targetLabel in ACTIONABLE_TARGETS }
val actionableCount = actionableResponses.size
val actionableTargetCount = responses.count { it.targetLabel in ACTIONABLE_TARGETS }
val allIpv4s = successfulResponses.mapNotNull { it.ipv4 ?: it.ip?.takeIf { ip -> CdnPullingClient.looksLikeIpv4(ip) } }.distinct()
val allIpv6s = successfulResponses.mapNotNull { it.ipv6 ?: it.ip?.takeIf { ip -> CdnPullingClient.looksLikeIpv6(ip) } }.distinct()
@ -207,16 +342,23 @@ object CdnPullingChecker {
}.distinct()
val allSuccessfulResponsesExposeIp = successfulResponses.isNotEmpty() && successfulResponses.all { it.ip != null }
val allActionableResponsesExposeIp = actionableResponses.isNotEmpty() && actionableResponses.all { it.ip != null }
val hasError = successfulCount == 0
val detected = actionableCount > 0
val ipv4Conflict = actionableIpv4s.size > 1
val ipv6Conflict = actionableIpv6s.size > 1
// Only raise review if we actually saw conflicting IPs across actionable
// targets. A single actionable target without an exposed IP is not a
// signal — it just means the endpoint did not advertise one. Likewise,
// partial successes (some endpoints failed entirely) are reported via
// the per-finding error path, not by flagging the whole card.
val ipv4MismatchAcrossAll = allIpv4s.size > 1
val ipv6MismatchAcrossAll = allIpv6s.size > 1
val needsReview = detected && (
ipv4Conflict ||
ipv6Conflict ||
!allActionableResponsesExposeIp
ipv4MismatchAcrossAll ||
ipv6MismatchAcrossAll
)
val findings = buildFindings(successfulResponses, responses)

View file

@ -7,6 +7,7 @@ import android.net.Proxy
import android.net.ProxyInfo
import android.os.Build
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.customcheck.DirectSignsConfig
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
@ -21,11 +22,6 @@ import com.notcvnt.rknhardering.vpn.InstalledVpnAppDetector
object DirectSignsChecker {
private data class SignalOutcome(
val detected: Boolean = false,
val needsReview: Boolean = false,
)
internal data class ProxyProfileSnapshot(
val interfaceName: String? = null,
val isDefault: Boolean = false,
@ -61,21 +57,66 @@ object DirectSignsChecker {
fun check(
context: Context,
tunActiveProbeResult: UnderlyingNetworkProber.ProbeResult? = null,
tunInterfacePresent: Boolean = false,
config: DirectSignsConfig = DirectSignsConfig(),
): CategoryResult {
if (!config.enabled) {
return CategoryResult(
name = context.getString(R.string.checker_direct_category_name),
detected = false,
findings = emptyList(),
needsReview = false,
evidence = emptyList(),
matchedApps = emptyList(),
)
}
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
val matchedApps = mutableListOf<MatchedVpnApp>()
var detected = false
var needsReview = false
val vpnTransportOutcome = checkVpnTransport(context, findings, evidence)
detected = detected || vpnTransportOutcome.detected
needsReview = needsReview || vpnTransportOutcome.needsReview
if (config.checkTransportVpn) {
val vpnTransportOutcome = checkVpnTransport(context, findings, evidence)
detected = detected || vpnTransportOutcome.detected
needsReview = needsReview || vpnTransportOutcome.needsReview
}
val systemProxyOutcome = checkSystemProxy(context, findings, evidence)
val systemProxyOutcome = checkSystemProxy(
context = context,
findings = findings,
evidence = evidence,
checkHttpProxy = config.checkHttpProxy,
checkSocksProxy = config.checkSocksProxy,
checkProxyInfo = config.checkProxyInfo,
)
detected = detected || systemProxyOutcome.detected
needsReview = needsReview || systemProxyOutcome.needsReview
// Per-app exclusion: tun0 physically present but this app is excluded from VPN,
// so vpnActive=false even though the tunnel is running.
if (tunActiveProbeResult != null && !tunActiveProbeResult.vpnActive && tunInterfacePresent) {
findings.add(
Finding(
description = context.getString(R.string.checker_direct_per_app_vpn_excluded),
detected = true,
source = EvidenceSource.TUN_ACTIVE_PROBE,
confidence = EvidenceConfidence.MEDIUM,
),
)
evidence.add(
EvidenceItem(
source = EvidenceSource.TUN_ACTIVE_PROBE,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = "App excluded from active per-app VPN (tun0 present but not accessible)",
kind = com.notcvnt.rknhardering.model.VpnAppKind.TARGETED_BYPASS,
),
)
detected = true
}
tunActiveProbeResult
?.takeIf { it.vpnActive }
?.let { result ->
@ -84,10 +125,12 @@ object DirectSignsChecker {
needsReview = needsReview || tunActiveProbeOutcome.needsReview
}
val appDetection = InstalledVpnAppDetector.detect(context)
findings += appDetection.findings
evidence += appDetection.evidence
matchedApps += appDetection.matchedApps
if (config.checkVpnService) {
val appDetection = InstalledVpnAppDetector.detect(context)
findings += appDetection.findings
evidence += appDetection.evidence
matchedApps += appDetection.matchedApps
}
return CategoryResult(
name = context.getString(R.string.checker_direct_category_name),
@ -186,6 +229,15 @@ object DirectSignsChecker {
description = "NetworkCapabilities string contains VpnTransportInfo",
),
)
readVpnTransportType(caps)?.let { typeLabel ->
findings.add(
Finding(
description = context.getString(R.string.checker_direct_vpn_type, typeLabel),
isInformational = true,
source = EvidenceSource.DIRECT_NETWORK_CAPABILITIES,
),
)
}
}
return SignalOutcome(detected = detected)
@ -196,42 +248,51 @@ object DirectSignsChecker {
context: Context,
findings: MutableList<Finding>,
evidence: MutableList<EvidenceItem>,
checkHttpProxy: Boolean = true,
checkSocksProxy: Boolean = true,
checkProxyInfo: Boolean = true,
): SignalOutcome {
val httpHost = System.getProperty("http.proxyHost") ?: Proxy.getDefaultHost()
val httpPort = System.getProperty("http.proxyPort")
?: Proxy.getDefaultPort().takeIf { it > 0 }?.toString()
val socksHost = System.getProperty("socksProxyHost")
val socksPort = System.getProperty("socksProxyPort")
var detected = false
var needsReview = false
val httpOutcome = addProxyFinding(
context = context,
type = context.getString(R.string.checker_direct_http_proxy),
host = httpHost,
port = httpPort,
findings = findings,
evidence = evidence,
)
detected = detected || httpOutcome.detected
needsReview = needsReview || httpOutcome.needsReview
if (checkHttpProxy) {
val httpHost = System.getProperty("http.proxyHost") ?: Proxy.getDefaultHost()
val httpPort = System.getProperty("http.proxyPort")
?: Proxy.getDefaultPort().takeIf { it > 0 }?.toString()
val httpOutcome = addProxyFinding(
context = context,
type = context.getString(R.string.checker_direct_http_proxy),
host = httpHost,
port = httpPort,
findings = findings,
evidence = evidence,
)
detected = detected || httpOutcome.detected
needsReview = needsReview || httpOutcome.needsReview
}
val socksOutcome = addProxyFinding(
context = context,
type = context.getString(R.string.checker_direct_socks_proxy),
host = socksHost,
port = socksPort,
findings = findings,
evidence = evidence,
)
detected = detected || socksOutcome.detected
needsReview = needsReview || socksOutcome.needsReview
if (checkSocksProxy) {
val socksHost = System.getProperty("socksProxyHost")
val socksPort = System.getProperty("socksProxyPort")
val socksOutcome = addProxyFinding(
context = context,
type = context.getString(R.string.checker_direct_socks_proxy),
host = socksHost,
port = socksPort,
findings = findings,
evidence = evidence,
)
detected = detected || socksOutcome.detected
needsReview = needsReview || socksOutcome.needsReview
}
val proxyInfoResult = evaluateProxyProfileCollection(context, collectProxyInfoProfiles(context))
findings += proxyInfoResult.findings
evidence += proxyInfoResult.evidence
detected = detected || proxyInfoResult.detected
needsReview = needsReview || proxyInfoResult.needsReview
if (checkProxyInfo) {
val proxyInfoResult = evaluateProxyProfileCollection(context, collectProxyInfoProfiles(context))
findings += proxyInfoResult.findings
evidence += proxyInfoResult.evidence
detected = detected || proxyInfoResult.detected
needsReview = needsReview || proxyInfoResult.needsReview
}
return SignalOutcome(detected = detected, needsReview = needsReview)
}
@ -244,8 +305,11 @@ object DirectSignsChecker {
val networkProfiles = mutableListOf<ProxyProfileSnapshot>()
var networkError: String? = null
val activeNetwork = runCatching { cm.activeNetwork }.getOrNull()
runCatching {
defaultProfile = cm.defaultProxy?.let { proxyInfo ->
val activeLinkProperties = activeNetwork?.let { cm.getLinkProperties(it) }
defaultProfile = activeLinkProperties?.httpProxy?.let { proxyInfo ->
proxyProfileSnapshot(proxyInfo = proxyInfo, isDefault = true)
}
}.onFailure { error ->
@ -253,9 +317,14 @@ object DirectSignsChecker {
}
runCatching {
val activeNetwork = cm.activeNetwork
// cm.allNetworks is deprecated since API 31 in favour of NetworkCallback,
// but here we need a one-shot synchronous inspection across all networks
// (active + INTERNET-capable). NetworkCallback would force an async flow
// and add lifecycle overhead with no behavioural gain.
@Suppress("DEPRECATION")
val allNetworks = cm.allNetworks
for (network in cm.allNetworks) {
for (network in allNetworks) {
val caps = cm.getNetworkCapabilities(network)
val isTracked = network == activeNetwork ||
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
@ -707,10 +776,9 @@ object DirectSignsChecker {
),
)
val hasDnsPathMismatch = comparison?.dnsPathMismatch == true
if (hasDnsPathMismatch) {
val transportOnly = comparison!!.usedCurlCompatibleFallback() &&
comparison!!.curlCompatible!!.transportDiagnostics.resolveStrategy != TunProbeResolveStrategy.KOTLIN_INJECTED
if (comparison != null && comparison.dnsPathMismatch) {
val transportOnly = comparison.usedCurlCompatibleFallback() &&
comparison.curlCompatible.transportDiagnostics.resolveStrategy != TunProbeResolveStrategy.KOTLIN_INJECTED
val confidence = if (transportOnly) EvidenceConfidence.MEDIUM else EvidenceConfidence.HIGH
evidence.add(
EvidenceItem(
@ -721,6 +789,20 @@ object DirectSignsChecker {
),
)
detected = true
// Per-app OS-device-binding probe: both paths reachable with different IPs.
// This is the strongest signal for per-app whitelist VPN with excluded app.
if (result.underlyingReachable) {
evidence.add(
EvidenceItem(
source = EvidenceSource.TUN_ACTIVE_PROBE,
detected = true,
confidence = EvidenceConfidence.HIGH,
description = "Per-app VPN exclusion confirmed: tun/underlying IP mismatch (${target.targetGroup}): vpn=$vpnIp underlying=${target.directIp}",
kind = com.notcvnt.rknhardering.model.VpnAppKind.TARGETED_BYPASS,
),
)
}
} else {
evidence.add(
EvidenceItem(
@ -737,6 +819,24 @@ object DirectSignsChecker {
return SignalOutcome(detected = detected, needsReview = needsReview)
}
internal fun vpnTypeLabel(type: Int): String? = when (type) {
1 -> "SERVICE"
2 -> "PLATFORM"
3 -> "LEGACY"
4 -> "OEM"
else -> null
}
private fun readVpnTransportType(caps: NetworkCapabilities): String? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null
return runCatching {
val info = caps.transportInfo ?: return null
if (!info.javaClass.name.contains("VpnTransportInfo")) return null
val type = info.javaClass.getMethod("getType").invoke(info) as? Int ?: return null
vpnTypeLabel(type)
}.getOrNull()
}
internal fun isKnownProxyPort(port: String?): Boolean {
val value = port?.toIntOrNull() ?: return false
return value in KNOWN_PROXY_PORTS || KNOWN_PROXY_PORT_RANGES.any { value in it }

View file

@ -0,0 +1,260 @@
package com.notcvnt.rknhardering.checker
import android.content.Context
import com.notcvnt.rknhardering.customcheck.CustomDomain
import com.notcvnt.rknhardering.model.DomainReachabilityResponse
import com.notcvnt.rknhardering.model.DomainReachabilityResult
import com.notcvnt.rknhardering.model.DomainReachabilityStepStatus
import com.notcvnt.rknhardering.network.DnsResolverConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import javax.net.ssl.SNIHostName
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLParameters
import javax.net.ssl.SSLSocket
/**
* Checks domain reachability by performing a DNS TCP TLS handshake pipeline.
*
* This detects DPI-level blocking at each layer:
* - DNS: NXDOMAIN / timeout DNS-level blocking
* - TCP: Connection refused / timeout IP-level blocking
* - TLS: Connection reset during handshake DPI (SNI-based) blocking
*/
object DomainReachabilityChecker {
private const val DEFAULT_PORT = 443
private const val TCP_TIMEOUT_MS = 8_000
private const val TLS_TIMEOUT_MS = 10_000
private const val DNS_TIMEOUT_MS = 8_000
private const val CONNECTION_RESET_DPI = "Connection Reset (DPI)"
suspend fun check(
context: Context,
domains: List<CustomDomain>,
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
): DomainReachabilityResult = withContext(Dispatchers.IO) {
if (domains.isEmpty()) return@withContext DomainReachabilityResult.empty()
supervisorScope {
val responses = domains.map { domain ->
async {
checkSingleDomain(
domain = domain.domain,
label = domain.description.ifEmpty { domain.domain },
expectedDns = domain.expectedDnsAvailable,
expectedTcp = domain.expectedTcpAvailable,
expectedTls = domain.expectedTlsAvailable,
)
}
}.awaitAll()
DomainReachabilityResult(responses = responses)
}
}
internal suspend fun checkSingleDomain(
domain: String,
label: String,
expectedDns: Boolean = true,
expectedTcp: Boolean = true,
expectedTls: Boolean = true,
): DomainReachabilityResponse = withContext(Dispatchers.IO) {
// Step 1: DNS Resolution
val dnsResult = resolveDns(domain)
if (dnsResult.status == DomainReachabilityStepStatus.FAILED) {
return@withContext DomainReachabilityResponse(
domain = domain,
label = label,
dnsStatus = DomainReachabilityStepStatus.FAILED,
dnsError = dnsResult.error,
resolvedIps = emptyList(),
expectedDnsAvailable = expectedDns,
expectedTcpAvailable = expectedTcp,
expectedTlsAvailable = expectedTls,
)
}
val resolvedIps = dnsResult.ips
val targetIp = resolvedIps.firstOrNull() ?: return@withContext DomainReachabilityResponse(
domain = domain,
label = label,
dnsStatus = DomainReachabilityStepStatus.FAILED,
dnsError = "No addresses resolved",
expectedDnsAvailable = expectedDns,
expectedTcpAvailable = expectedTcp,
expectedTlsAvailable = expectedTls,
)
// Step 2: TCP Connect
val tcpResult = checkTcp(targetIp, DEFAULT_PORT)
if (tcpResult.status == DomainReachabilityStepStatus.FAILED) {
return@withContext DomainReachabilityResponse(
domain = domain,
label = label,
dnsStatus = DomainReachabilityStepStatus.OK,
resolvedIps = resolvedIps,
tcpStatus = DomainReachabilityStepStatus.FAILED,
tcpError = tcpResult.error,
expectedDnsAvailable = expectedDns,
expectedTcpAvailable = expectedTcp,
expectedTlsAvailable = expectedTls,
)
}
// Step 3: TLS Handshake with SNI
val tlsResult = checkTls(targetIp, DEFAULT_PORT, domain)
DomainReachabilityResponse(
domain = domain,
label = label,
dnsStatus = DomainReachabilityStepStatus.OK,
resolvedIps = resolvedIps,
tcpStatus = DomainReachabilityStepStatus.OK,
tlsStatus = tlsResult.status,
tlsError = tlsResult.error,
expectedDnsAvailable = expectedDns,
expectedTcpAvailable = expectedTcp,
expectedTlsAvailable = expectedTls,
)
}
private data class StepResult(
val status: DomainReachabilityStepStatus,
val error: String? = null,
val ips: List<String> = emptyList(),
)
private suspend fun resolveDns(domain: String): StepResult = withContext(Dispatchers.IO) {
try {
val result = withTimeoutOrNull(DNS_TIMEOUT_MS.toLong()) {
withContext(Dispatchers.IO) {
val addresses = InetAddress.getAllByName(domain)
addresses.map { it.hostAddress ?: it.toString() }
}
}
if (result == null) {
StepResult(DomainReachabilityStepStatus.FAILED, "DNS timeout")
} else if (result.isEmpty()) {
StepResult(DomainReachabilityStepStatus.FAILED, "NXDOMAIN")
} else {
StepResult(DomainReachabilityStepStatus.OK, ips = result)
}
} catch (e: java.net.UnknownHostException) {
StepResult(DomainReachabilityStepStatus.FAILED, "NXDOMAIN")
} catch (e: Exception) {
StepResult(DomainReachabilityStepStatus.FAILED, e.message ?: e::class.java.simpleName)
}
}
private suspend fun checkTcp(ip: String, port: Int): StepResult = withContext(Dispatchers.IO) {
try {
val result = withTimeoutOrNull(TCP_TIMEOUT_MS.toLong()) {
withContext(Dispatchers.IO) {
Socket().use { socket ->
socket.connect(InetSocketAddress(ip, port), TCP_TIMEOUT_MS)
}
true
}
}
if (result == null) {
StepResult(DomainReachabilityStepStatus.FAILED, "TCP timeout")
} else {
StepResult(DomainReachabilityStepStatus.OK)
}
} catch (e: java.net.ConnectException) {
StepResult(DomainReachabilityStepStatus.FAILED, "Connection refused")
} catch (e: java.net.SocketTimeoutException) {
StepResult(DomainReachabilityStepStatus.FAILED, "TCP timeout")
} catch (e: Exception) {
StepResult(DomainReachabilityStepStatus.FAILED, e.message ?: e::class.java.simpleName)
}
}
private suspend fun checkTls(ip: String, port: Int, sniHost: String): StepResult =
withContext(Dispatchers.IO) {
try {
val result = withTimeoutOrNull(TLS_TIMEOUT_MS.toLong()) {
withContext(Dispatchers.IO) {
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(TrustAllManager()), null)
val socket = Socket()
socket.connect(InetSocketAddress(ip, port), TCP_TIMEOUT_MS)
socket.soTimeout = TLS_TIMEOUT_MS
val sslSocket = sslContext.socketFactory.createSocket(
socket,
sniHost,
port,
true,
) as SSLSocket
sslSocket.use { ssl ->
val params = SSLParameters()
params.serverNames = listOf(SNIHostName(sniHost))
ssl.sslParameters = params
ssl.startHandshake()
}
true
}
}
if (result == null) {
StepResult(DomainReachabilityStepStatus.FAILED, "TLS timeout")
} else {
StepResult(DomainReachabilityStepStatus.OK)
}
} catch (e: javax.net.ssl.SSLHandshakeException) {
// Certificate errors are fine — the handshake itself completed, meaning DPI didn't block
StepResult(DomainReachabilityStepStatus.OK)
} catch (e: java.net.SocketException) {
val msg = e.message?.lowercase() ?: ""
val error = when {
msg.contains("reset") -> CONNECTION_RESET_DPI
msg.contains("refused") -> "Connection Refused"
msg.contains("broken pipe") -> CONNECTION_RESET_DPI
else -> e.message ?: "SocketException"
}
StepResult(DomainReachabilityStepStatus.FAILED, error)
} catch (e: java.io.EOFException) {
StepResult(DomainReachabilityStepStatus.FAILED, CONNECTION_RESET_DPI)
} catch (e: javax.net.ssl.SSLException) {
val msg = e.message?.lowercase() ?: ""
if (msg.contains("reset") || msg.contains("closed") || msg.contains("peer")) {
StepResult(DomainReachabilityStepStatus.FAILED, CONNECTION_RESET_DPI)
} else {
// Other SSL errors (protocol, etc.) — handshake at least started
StepResult(DomainReachabilityStepStatus.OK)
}
} catch (e: Exception) {
StepResult(
DomainReachabilityStepStatus.FAILED,
e.message ?: e::class.java.simpleName,
)
}
}
/**
* Trust-all X509 manager we don't care about certificate validity,
* only whether the TLS handshake completes without DPI interference.
*/
private class TrustAllManager : javax.net.ssl.X509TrustManager {
override fun checkClientTrusted(
chain: Array<out java.security.cert.X509Certificate>?,
authType: String?,
) = Unit
override fun checkServerTrusted(
chain: Array<out java.security.cert.X509Certificate>?,
authType: String?,
) = Unit
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = emptyArray()
}
}

View file

@ -4,20 +4,27 @@ import android.content.Context
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.rethrowIfCancellation
import com.notcvnt.rknhardering.customcheck.CustomGeoIpProvider
import com.notcvnt.rknhardering.customcheck.GeoIpConfig
import com.notcvnt.rknhardering.customcheck.mapper.EndpointResponseMapper
import com.notcvnt.rknhardering.customcheck.mapper.MappingField
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.GeoIpFacts
import com.notcvnt.rknhardering.model.GeoIpResponse
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.ResolverNetworkStack
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.URLEncoder
object GeoIpChecker {
@ -25,6 +32,14 @@ object GeoIpChecker {
private const val RETRY_DELAY_MS = 250L
private const val GEOIP_TIMEOUT_MS = 10_000
private fun isBuiltinEnabled(config: GeoIpConfig, providerName: String): Boolean {
return config.builtinProviders[providerName] != false
}
private fun parseBooleanString(value: String?): Boolean {
return value?.trim()?.lowercase() in setOf("true", "1", "yes")
}
internal data class GeoIpSnapshot(
val ip: String,
val country: String,
@ -37,38 +52,111 @@ object GeoIpChecker {
val hostingVotes: Int,
val hostingChecks: Int,
val hostingSources: List<String>,
val proxyVotes: Int = 0,
val proxyChecks: Int = 0,
val proxySources: List<String> = emptyList(),
)
internal data class ProviderSnapshot(
val provider: String,
val snapshot: GeoIpSnapshot,
)
val isCustom: Boolean,
val snapshot: GeoIpSnapshot?,
val error: String? = null,
val rawBody: String? = null,
) {
val isSuccess: Boolean get() = snapshot != null
fun toGeoIpResponse(): GeoIpResponse = GeoIpResponse(
provider = provider,
isCustom = isCustom,
ip = snapshot?.ip,
error = error,
rawBody = rawBody,
)
}
private const val IPAPIIS_PROVIDER = "ipapi.is"
private const val IPLOCATE_PROVIDER = "iplocate.io"
private const val IPQUERY_PROVIDER = "ipquery.io"
private const val IPLOOKUP_PROVIDER = "iplookup.it"
private const val IPBOT_PROVIDER = "ipbot.com"
private const val MISSING_IP_FIELD = "Missing ip field"
private const val IPAPIIS_URL = "https://api.ipapi.is/"
private const val IPLOCATE_URL = "https://www.iplocate.io/api/lookup"
private const val IPQUERY_URL = "https://api.ipquery.io/"
private const val IPLOOKUP_URL = "https://www.iplookup.it"
private const val IPBOT_URL = "https://api.ipbot.com/"
suspend fun check(
context: Context,
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
config: GeoIpConfig = GeoIpConfig(),
): CategoryResult = withContext(Dispatchers.IO) {
if (!config.enabled) {
return@withContext CategoryResult(
name = "GeoIP",
detected = false,
findings = emptyList(),
geoFacts = GeoIpFacts(fetchError = false),
)
}
val executionContext = ScanExecutionContext.currentOrDefault()
try {
coroutineScope {
val ipapiIsDeferred = async { fetchWithRetries { fetchIpapiIs(resolverConfig) } }
val iplocateDeferred = async { fetchWithRetries { fetchIplocate(resolverConfig) } }
val ipapiIsDeferred = if (isBuiltinEnabled(config, IPAPIIS_PROVIDER)) {
async { fetchWithRetries { fetchIpapiIs(resolverConfig, timeoutMs = config.timeoutMs) } }
} else null
val iplocateDeferred = if (isBuiltinEnabled(config, IPLOCATE_PROVIDER)) {
async { fetchWithRetries { fetchIplocate(resolverConfig, timeoutMs = config.timeoutMs) } }
} else null
val ipqueryDeferred = if (isBuiltinEnabled(config, IPQUERY_PROVIDER)) {
async { fetchWithRetries { fetchIpquery(resolverConfig, timeoutMs = config.timeoutMs) } }
} else null
val iplookupDeferred = if (isBuiltinEnabled(config, IPLOOKUP_PROVIDER)) {
async { fetchWithRetries { fetchIplookup(resolverConfig, timeoutMs = config.timeoutMs) } }
} else null
val ipbotDeferred = if (isBuiltinEnabled(config, IPBOT_PROVIDER)) {
async { fetchWithRetries { fetchIpbot(resolverConfig, timeoutMs = config.timeoutMs) } }
} else null
val ipapiIsResult = ipapiIsDeferred.await()
val iplocateResult = iplocateDeferred.await()
val ipapiIsResult = ipapiIsDeferred?.await()
val iplocateResult = iplocateDeferred?.await()
val ipqueryResult = ipqueryDeferred?.await()
val iplookupResult = iplookupDeferred?.await()
val ipbotResult = ipbotDeferred?.await()
val providers = listOfNotNull(ipapiIsResult, iplocateResult)
val baseProvider = ipapiIsResult ?: iplocateResult
val seedProviders = listOfNotNull(
ipapiIsResult,
iplocateResult,
ipqueryResult,
iplookupResult,
ipbotResult,
)
val seedProvider = seedProviders.firstOrNull { it.isSuccess }
?: return@coroutineScope noProviderResult(
context.getString(R.string.checker_geo_error_no_provider),
responses = seedProviders
)
val explicitProviders = fetchSpecificIpProviders(
resolverConfig = resolverConfig,
ip = seedProvider.snapshot!!.ip,
config = config,
)
val customSnapshots = fetchCustomProviders(
resolverConfig = resolverConfig,
ip = seedProvider.snapshot.ip,
config = config,
)
val mergedSeed = seedProviders + customSnapshots
val mergedExplicit = explicitProviders + customSnapshots
val providers = mergeProviderLists(
mergedExplicit.ifEmpty { mergedSeed },
mergedSeed,
)
val baseProvider = providers.firstOrNull { it.provider == seedProvider.provider }
?: seedProvider
evaluate(
context = context,
@ -76,6 +164,7 @@ object GeoIpChecker {
baseProvider = baseProvider,
providers = providers,
),
responses = listOf(baseProvider) + providers.filter { it.provider != baseProvider.provider }
)
}
} catch (e: Exception) {
@ -84,140 +173,414 @@ object GeoIpChecker {
}
}
internal suspend fun <T> fetchWithRetries(
private suspend fun fetchSpecificIpProviders(
resolverConfig: DnsResolverConfig,
ip: String,
config: GeoIpConfig = GeoIpConfig(),
): List<ProviderSnapshot> {
if (!isMeaningfulField(ip)) return emptyList()
return coroutineScope {
buildList {
if (isBuiltinEnabled(config, IPAPIIS_PROVIDER)) {
add(async { fetchWithRetries { fetchIpapiIs(resolverConfig, ip, config.timeoutMs) } })
}
if (isBuiltinEnabled(config, IPLOCATE_PROVIDER)) {
add(async { fetchWithRetries { fetchIplocate(resolverConfig, ip, config.timeoutMs) } })
}
if (isBuiltinEnabled(config, IPQUERY_PROVIDER)) {
add(async { fetchWithRetries { fetchIpquery(resolverConfig, ip, config.timeoutMs) } })
}
if (isBuiltinEnabled(config, IPLOOKUP_PROVIDER)) {
add(async { fetchWithRetries { fetchIplookup(resolverConfig, ip, config.timeoutMs) } })
}
if (isBuiltinEnabled(config, IPBOT_PROVIDER)) {
add(async { fetchWithRetries { fetchIpbot(resolverConfig, ip, config.timeoutMs) } })
}
}
.awaitAll()
}
}
private suspend fun fetchCustomProviders(
resolverConfig: DnsResolverConfig,
ip: String,
config: GeoIpConfig,
): List<ProviderSnapshot> {
val enabled = config.customProviders.filter { it.enabled }
if (enabled.isEmpty()) return emptyList()
return coroutineScope {
enabled
.map { provider ->
async {
fetchWithRetries {
fetchCustomProvider(resolverConfig, ip, provider, config.timeoutMs)
}
}
}
.awaitAll()
}
}
private fun fetchCustomProvider(
resolverConfig: DnsResolverConfig,
ip: String,
provider: CustomGeoIpProvider,
timeoutMs: Int,
): ProviderSnapshot {
return try {
val resolvedUrl = when {
provider.url.contains("{ip}") -> provider.url.replace("{ip}", urlEncode(ip))
provider.url.endsWith("?q=") -> "${provider.url}${urlEncode(ip)}"
else -> provider.url
}
val acceptHeaders = when (provider.responseMapping.responseType) {
com.notcvnt.rknhardering.customcheck.ResponseType.JSON -> mapOf("Accept" to "application/json")
else -> mapOf("Accept" to "*/*")
}
val rawBody = fetchRawBody(resolvedUrl, resolverConfig, timeoutMs, acceptHeaders)
?: return ProviderSnapshot(provider.name, true, null, "HTTP Request failed (non-2xx code)")
val mapping = provider.responseMapping
val ipVal = EndpointResponseMapper.extractField(rawBody, mapping, MappingField.IP)
?.takeIf { it.isNotBlank() } ?: ip
val countryCode = EndpointResponseMapper.extractField(rawBody, mapping, MappingField.COUNTRY_CODE)
?.trim() ?: ""
val countryName = EndpointResponseMapper.extractField(rawBody, mapping, MappingField.COUNTRY_NAME)
?.trim() ?: "N/A"
val isp = EndpointResponseMapper.extractField(rawBody, mapping, MappingField.ISP)
?.trim() ?: "N/A"
val org = EndpointResponseMapper.extractField(rawBody, mapping, MappingField.ORG)
?.trim() ?: "N/A"
val asn = EndpointResponseMapper.extractField(rawBody, mapping, MappingField.ASN)
?.trim() ?: "N/A"
val isHosting = parseBooleanString(
EndpointResponseMapper.extractField(rawBody, mapping, MappingField.IS_HOSTING)
)
val isProxy = parseBooleanString(
EndpointResponseMapper.extractField(rawBody, mapping, MappingField.IS_PROXY)
)
ProviderSnapshot(
provider = provider.name,
isCustom = true,
snapshot = GeoIpSnapshot(
ip = ipVal,
country = countryName,
countryCode = countryCode,
isp = isp,
org = org,
asn = asn,
isProxy = isProxy,
isHosting = isHosting,
hostingVotes = 0,
hostingChecks = 0,
hostingSources = emptyList(),
),
rawBody = rawBody,
)
} catch (e: Exception) {
rethrowIfCancellation(e)
ProviderSnapshot(provider.name, true, null, e.message)
}
}
private fun fetchRawBody(url: String, resolverConfig: DnsResolverConfig, timeoutMs: Int = GEOIP_TIMEOUT_MS, headers: Map<String, String> = mapOf("Accept" to "*/*")): String? {
val executionContext = ScanExecutionContext.currentOrDefault()
val response = ResolverNetworkStack.execute(
url = url,
method = "GET",
headers = headers,
timeoutMs = timeoutMs,
config = resolverConfig,
cancellationSignal = executionContext.cancellationSignal,
)
if (response.code !in 200..299) return null
return response.body
}
internal suspend fun fetchWithRetries(
maxAttempts: Int = MAX_FETCH_ATTEMPTS,
retryDelayMs: Long = RETRY_DELAY_MS,
fetcher: suspend () -> T?,
): T? {
fetcher: suspend () -> ProviderSnapshot,
): ProviderSnapshot {
var lastResult: ProviderSnapshot? = null
repeat(maxAttempts.coerceAtLeast(1)) { attempt ->
try {
val result = fetcher()
if (result != null) {
lastResult = result
if (result.isSuccess) {
return result
}
} catch (error: Exception) {
rethrowIfCancellation(error)
// Ignore transient provider errors and retry the next attempt.
}
if (attempt < maxAttempts - 1 && retryDelayMs > 0) {
delay(retryDelayMs)
}
}
return null
return lastResult ?: fetcher() // fallback if maxAttempts=0
}
private fun fetchIpapiIs(resolverConfig: DnsResolverConfig): ProviderSnapshot? {
// Shared fetch/validate/error skeleton for the builtin providers. Each
// provider keeps its own URL construction and field-path cascades; only
// the plumbing is shared.
private inline fun fetchBuiltinProvider(
provider: String,
url: String,
resolverConfig: DnsResolverConfig,
timeoutMs: Int,
parse: (JSONObject) -> GeoIpSnapshot,
): ProviderSnapshot {
return try {
val json = fetchJson(IPAPIIS_URL, resolverConfig)
if (!json.has("ip")) return null
val json = fetchJson(url = url, resolverConfig = resolverConfig, timeoutMs = timeoutMs)
if (!json.has("ip")) {
ProviderSnapshot(provider, false, null, MISSING_IP_FIELD, json.toString())
} else {
ProviderSnapshot(
provider = provider,
isCustom = false,
snapshot = parse(json),
rawBody = json.toString(),
)
}
} catch (error: Exception) {
rethrowIfCancellation(error)
ProviderSnapshot(provider, false, null, error.message)
}
}
private fun fetchIpapiIs(resolverConfig: DnsResolverConfig, ip: String? = null, timeoutMs: Int = GEOIP_TIMEOUT_MS): ProviderSnapshot =
fetchBuiltinProvider(
provider = IPAPIIS_PROVIDER,
url = ip?.let { "$IPAPIIS_URL?q=${urlEncode(it)}" } ?: IPAPIIS_URL,
resolverConfig = resolverConfig,
timeoutMs = timeoutMs,
) { json ->
val location = json.optJSONObject("location")
val company = json.optJSONObject("company")
val datacenter = json.optJSONObject("datacenter")
val asn = json.optJSONObject("asn")
ProviderSnapshot(
provider = IPAPIIS_PROVIDER,
snapshot = GeoIpSnapshot(
ip = firstMeaningful(json.optString("ip"), default = "N/A"),
country = firstMeaningful(location?.optString("country"), default = "N/A"),
countryCode = firstMeaningful(location?.optString("country_code"), default = ""),
isp = firstMeaningful(
company?.optString("name"),
asn?.optString("org"),
datacenter?.optString("datacenter"),
asn?.optString("descr"),
default = "N/A",
),
org = firstMeaningful(
datacenter?.optString("datacenter"),
company?.optString("name"),
asn?.optString("org"),
asn?.optString("descr"),
default = "N/A",
),
asn = formatAsn(
code = asn?.opt("asn")?.toString(),
name = firstMeaningful(
asn?.optString("org"),
asn?.optString("descr"),
default = "N/A",
),
),
isProxy = json.optBoolean("is_proxy", false) ||
json.optBoolean("is_vpn", false) ||
json.optBoolean("is_tor", false),
isHosting = json.optBoolean("is_datacenter", false),
hostingVotes = 0,
hostingChecks = 0,
hostingSources = emptyList(),
GeoIpSnapshot(
ip = firstMeaningful(json.optString("ip"), default = "N/A"),
country = firstMeaningful(location?.optString("country"), default = "N/A"),
countryCode = firstMeaningful(location?.optString("country_code"), default = ""),
isp = firstMeaningful(
company?.optString("name"),
asn?.optString("org"),
datacenter?.optString("datacenter"),
asn?.optString("descr"),
default = "N/A",
),
org = firstMeaningful(
datacenter?.optString("datacenter"),
company?.optString("name"),
asn?.optString("org"),
asn?.optString("descr"),
default = "N/A",
),
asn = formatAsn(
code = asn?.opt("asn")?.toString(),
name = firstMeaningful(
asn?.optString("org"),
asn?.optString("descr"),
default = "N/A",
),
),
isProxy = json.optBoolean("is_proxy", false) ||
json.optBoolean("is_vpn", false) ||
json.optBoolean("is_tor", false),
isHosting = json.optBoolean("is_datacenter", false),
hostingVotes = 0,
hostingChecks = 0,
hostingSources = emptyList(),
)
} catch (error: Exception) {
rethrowIfCancellation(error)
null
}
}
private fun fetchIplocate(resolverConfig: DnsResolverConfig): ProviderSnapshot? {
return try {
val json = fetchJson(IPLOCATE_URL, resolverConfig)
if (!json.has("ip")) return null
private fun fetchIplocate(resolverConfig: DnsResolverConfig, ip: String? = null, timeoutMs: Int = GEOIP_TIMEOUT_MS): ProviderSnapshot =
fetchBuiltinProvider(
provider = IPLOCATE_PROVIDER,
url = ip?.let { "$IPLOCATE_URL/${urlEncode(it)}" } ?: IPLOCATE_URL,
resolverConfig = resolverConfig,
timeoutMs = timeoutMs,
) { json ->
val privacy = json.optJSONObject("privacy")
val company = json.optJSONObject("company")
val hosting = json.optJSONObject("hosting")
val asn = json.optJSONObject("asn")
ProviderSnapshot(
provider = IPLOCATE_PROVIDER,
snapshot = GeoIpSnapshot(
ip = firstMeaningful(json.optString("ip"), default = "N/A"),
country = firstMeaningful(json.optString("country"), default = "N/A"),
countryCode = firstMeaningful(json.optString("country_code"), default = ""),
isp = firstMeaningful(
company?.optString("name"),
asn?.optString("name"),
hosting?.optString("provider"),
default = "N/A",
),
org = firstMeaningful(
hosting?.optString("provider"),
company?.optString("name"),
asn?.optString("name"),
default = "N/A",
),
asn = formatAsn(
code = asn?.optString("asn"),
name = firstMeaningful(asn?.optString("name"), default = "N/A"),
),
isProxy = (privacy?.optBoolean("is_proxy", false) == true) ||
(privacy?.optBoolean("is_vpn", false) == true) ||
(privacy?.optBoolean("is_tor", false) == true),
isHosting = privacy?.optBoolean("is_hosting", false) == true,
hostingVotes = 0,
hostingChecks = 0,
hostingSources = emptyList(),
GeoIpSnapshot(
ip = firstMeaningful(json.optString("ip"), default = "N/A"),
country = firstMeaningful(json.optString("country"), default = "N/A"),
countryCode = firstMeaningful(json.optString("country_code"), default = ""),
isp = firstMeaningful(
company?.optString("name"),
asn?.optString("name"),
hosting?.optString("provider"),
default = "N/A",
),
org = firstMeaningful(
hosting?.optString("provider"),
company?.optString("name"),
asn?.optString("name"),
default = "N/A",
),
asn = formatAsn(
code = asn?.optString("asn"),
name = firstMeaningful(asn?.optString("name"), default = "N/A"),
),
isProxy = (privacy?.optBoolean("is_proxy", false) == true) ||
(privacy?.optBoolean("is_vpn", false) == true) ||
(privacy?.optBoolean("is_tor", false) == true),
isHosting = privacy?.optBoolean("is_hosting", false) == true,
hostingVotes = 0,
hostingChecks = 0,
hostingSources = emptyList(),
)
} catch (error: Exception) {
rethrowIfCancellation(error)
null
}
}
private fun fetchJson(url: String, resolverConfig: DnsResolverConfig): JSONObject {
private fun fetchIpquery(resolverConfig: DnsResolverConfig, ip: String? = null, timeoutMs: Int = GEOIP_TIMEOUT_MS): ProviderSnapshot =
fetchBuiltinProvider(
provider = IPQUERY_PROVIDER,
url = ip?.let { "$IPQUERY_URL${urlEncode(it)}" } ?: "${IPQUERY_URL}?format=json",
resolverConfig = resolverConfig,
timeoutMs = timeoutMs,
) { json ->
val location = json.optJSONObject("location")
val isp = json.optJSONObject("isp")
val risk = json.optJSONObject("risk")
GeoIpSnapshot(
ip = firstMeaningful(json.optString("ip"), default = "N/A"),
country = firstMeaningful(location?.optString("country"), default = "N/A"),
countryCode = firstMeaningful(location?.optString("country_code"), default = ""),
isp = firstMeaningful(
isp?.optString("isp"),
isp?.optString("org"),
default = "N/A",
),
org = firstMeaningful(
isp?.optString("org"),
isp?.optString("isp"),
default = "N/A",
),
asn = formatAsn(
code = isp?.optString("asn"),
name = firstMeaningful(
isp?.optString("org"),
isp?.optString("isp"),
default = "N/A",
),
),
isProxy = (risk?.optBoolean("is_proxy", false) == true) ||
(risk?.optBoolean("is_vpn", false) == true) ||
(risk?.optBoolean("is_tor", false) == true),
isHosting = risk?.optBoolean("is_datacenter", false) == true,
hostingVotes = 0,
hostingChecks = 0,
hostingSources = emptyList(),
)
}
private fun fetchIplookup(resolverConfig: DnsResolverConfig, ip: String? = null, timeoutMs: Int = GEOIP_TIMEOUT_MS): ProviderSnapshot =
fetchBuiltinProvider(
provider = IPLOOKUP_PROVIDER,
url = ip?.let { "$IPLOOKUP_URL/ip/${urlEncode(it)}" } ?: "$IPLOOKUP_URL/json",
resolverConfig = resolverConfig,
timeoutMs = timeoutMs,
) { json ->
val geo = json.optJSONObject("geo")
val network = json.optJSONObject("network")
val privacy = json.optJSONObject("privacy")
GeoIpSnapshot(
ip = firstMeaningful(json.optString("ip"), default = "N/A"),
country = firstMeaningful(geo?.optString("country"), default = "N/A"),
countryCode = firstMeaningful(geo?.optString("country_code"), default = ""),
isp = firstMeaningful(
network?.optString("isp"),
network?.optString("org"),
default = "N/A",
),
org = firstMeaningful(
network?.optString("org"),
network?.optString("isp"),
default = "N/A",
),
asn = formatAsn(
code = network?.opt("asn")?.toString(),
name = firstMeaningful(
network?.optString("org"),
network?.optString("isp"),
default = "N/A",
),
),
isProxy = (privacy?.optBoolean("proxy", false) == true) ||
(privacy?.optBoolean("vpn", false) == true) ||
(privacy?.optBoolean("tor", false) == true),
isHosting = privacy?.optBoolean("hosting", false) == true,
hostingVotes = 0,
hostingChecks = 0,
hostingSources = emptyList(),
)
}
private fun fetchIpbot(resolverConfig: DnsResolverConfig, ip: String? = null, timeoutMs: Int = GEOIP_TIMEOUT_MS): ProviderSnapshot =
fetchBuiltinProvider(
provider = IPBOT_PROVIDER,
url = ip?.let { "$IPBOT_URL${urlEncode(it)}" } ?: IPBOT_URL,
resolverConfig = resolverConfig,
timeoutMs = timeoutMs,
) { json ->
val location = json.optJSONObject("location")
val network = json.optJSONObject("network")
val security = json.optJSONObject("security")
val usageType = security?.optString("usage_type")?.trim()?.uppercase()
val radarType = (network?.opt("radar") as? String)?.trim()?.lowercase()
val threatLists = security?.optJSONArray("threat_lists")
val proxyThreat = threatLists.containsAny("proxy", "proxies", "vpn", "tor")
GeoIpSnapshot(
ip = firstMeaningful(json.optString("ip"), default = "N/A"),
country = firstMeaningful(location?.optString("country"), default = "N/A"),
countryCode = firstMeaningful(location?.optString("country_code"), default = ""),
isp = firstMeaningful(
network?.optString("org"),
default = "N/A",
),
org = firstMeaningful(
network?.optString("org"),
default = "N/A",
),
asn = formatAsn(
code = network?.optString("asn"),
name = firstMeaningful(network?.optString("org"), default = "N/A"),
),
isProxy = (security?.optBoolean("is_proxy", false) == true) ||
proxyThreat ||
radarType == "vpn" ||
radarType == "proxy" ||
radarType == "tor",
isHosting = (security?.optBoolean("is_datacenter", false) == true) ||
usageType == "DCH" ||
radarType == "datacenter",
hostingVotes = 0,
hostingChecks = 0,
hostingSources = emptyList(),
)
}
private fun fetchJson(url: String, resolverConfig: DnsResolverConfig, timeoutMs: Int = GEOIP_TIMEOUT_MS): JSONObject {
val executionContext = ScanExecutionContext.currentOrDefault()
val response = ResolverNetworkStack.execute(
url = url,
method = "GET",
timeoutMs = GEOIP_TIMEOUT_MS,
headers = mapOf("Accept" to "application/json"),
timeoutMs = timeoutMs,
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)
}
@ -226,32 +589,37 @@ object GeoIpChecker {
providers: List<ProviderSnapshot>,
): GeoIpSnapshot {
val compatibleProviders = providers.filter {
isCompatibleIp(
expectedIp = baseProvider.snapshot.ip,
candidateIp = it.snapshot.ip,
it.isSuccess && isCompatibleIp(
expectedIp = baseProvider.snapshot?.ip ?: "",
candidateIp = it.snapshot?.ip ?: "",
)
}
val orderedForFill = buildList {
add(baseProvider)
if (baseProvider.isSuccess) add(baseProvider)
compatibleProviders
.filterNot { it.provider == baseProvider.provider }
.forEach(::add)
}
val hostingVotes = compatibleProviders.count { it.snapshot.isHosting }
val hostingVotes = compatibleProviders.count { it.snapshot!!.isHosting }
val hostingChecks = compatibleProviders.size
val hostingSources = compatibleProviders
.filter { it.snapshot.isHosting }
.filter { it.snapshot!!.isHosting }
.map { it.provider }
val proxyVotes = compatibleProviders.count { it.snapshot!!.isProxy }
val proxyChecks = compatibleProviders.size
val proxySources = compatibleProviders
.filter { it.snapshot!!.isProxy }
.map { it.provider }
return GeoIpSnapshot(
ip = pickField(orderedForFill) { it.snapshot.ip },
country = pickField(orderedForFill) { it.snapshot.country },
countryCode = pickField(orderedForFill, default = "") { it.snapshot.countryCode },
isp = pickField(orderedForFill) { it.snapshot.isp },
org = pickField(orderedForFill) { it.snapshot.org },
asn = pickField(orderedForFill) { it.snapshot.asn },
ip = pickField(orderedForFill) { it.snapshot!!.ip },
country = pickField(orderedForFill) { it.snapshot!!.country },
countryCode = pickField(orderedForFill, default = "") { it.snapshot!!.countryCode },
isp = pickField(orderedForFill) { it.snapshot!!.isp },
org = pickField(orderedForFill) { it.snapshot!!.org },
asn = pickField(orderedForFill) { it.snapshot!!.asn },
isProxy = resolveProxy(
compatibleProviders = compatibleProviders,
),
@ -259,10 +627,13 @@ object GeoIpChecker {
hostingVotes = hostingVotes,
hostingChecks = hostingChecks,
hostingSources = hostingSources,
proxyVotes = proxyVotes,
proxyChecks = proxyChecks,
proxySources = proxySources,
)
}
internal fun evaluate(context: Context, snapshot: GeoIpSnapshot): CategoryResult {
internal fun evaluate(context: Context, snapshot: GeoIpSnapshot, responses: List<ProviderSnapshot>): CategoryResult {
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
@ -310,16 +681,25 @@ object GeoIpChecker {
addGeoFinding(
findings = findings,
evidence = evidence,
description = context.getString(R.string.checker_geo_proxy_db, if (snapshot.isProxy) yesStr else noStr),
description = buildVoteDescription(
prefix = context.getString(R.string.checker_geo_proxy_db, if (snapshot.isProxy) yesStr else noStr),
votes = snapshot.proxyVotes,
checks = snapshot.proxyChecks,
sources = snapshot.proxySources,
),
detected = snapshot.isProxy,
)
val countryCode = snapshot.countryCode.uppercase().ifBlank { null }
val outsideRu = countryCode != null && countryCode != "RU"
val asn = snapshot.asn.takeUnless { it.isBlank() || it == "N/A" }
val geoFacts = GeoIpFacts(
ip = snapshot.ip.takeUnless { it.isBlank() || it == "N/A" },
countryCode = countryCode,
asn = snapshot.asn.takeUnless { it.isBlank() || it == "N/A" },
asn = asn,
asnCode = HomeNetworkCatalog.extractAsnCode(asn),
isp = snapshot.isp.takeUnless { it.isBlank() || it == "N/A" },
org = snapshot.org.takeUnless { it.isBlank() || it == "N/A" },
outsideRu = outsideRu,
hosting = snapshot.isHosting,
proxyDb = snapshot.isProxy,
@ -332,24 +712,27 @@ object GeoIpChecker {
needsReview = needsReview,
evidence = evidence,
geoFacts = geoFacts,
geoIpResponses = responses.map { it.toGeoIpResponse() },
)
}
private fun errorResult(message: String): CategoryResult {
private fun errorResult(message: String, responses: List<ProviderSnapshot> = emptyList()): CategoryResult {
return CategoryResult(
name = "GeoIP",
detected = false,
findings = listOf(Finding(message, isError = true)),
geoFacts = GeoIpFacts(fetchError = true),
geoIpResponses = responses.map { it.toGeoIpResponse() },
)
}
internal fun noProviderResult(message: String): CategoryResult {
internal fun noProviderResult(message: String, responses: List<ProviderSnapshot> = emptyList()): CategoryResult {
return CategoryResult(
name = "GeoIP",
detected = false,
findings = listOf(Finding(message)),
geoFacts = GeoIpFacts(fetchError = true),
geoIpResponses = responses.map { it.toGeoIpResponse() },
)
}
@ -380,7 +763,17 @@ object GeoIpChecker {
}
private fun resolveProxy(compatibleProviders: List<ProviderSnapshot>): Boolean {
return compatibleProviders.any { it.snapshot.isProxy }
return compatibleProviders.any { it.snapshot!!.isProxy }
}
private fun mergeProviderLists(
primary: List<ProviderSnapshot>,
fallback: List<ProviderSnapshot>,
): List<ProviderSnapshot> {
val merged = LinkedHashMap<String, ProviderSnapshot>()
primary.forEach { merged[it.provider] = it }
fallback.forEach { merged.putIfAbsent(it.provider, it) }
return merged.values.toList()
}
private fun pickField(
@ -429,4 +822,34 @@ object GeoIpChecker {
private fun isMeaningfulField(value: String?): Boolean {
return !value.isNullOrBlank() && !value.equals("N/A", ignoreCase = true)
}
private fun buildVoteDescription(
prefix: String,
votes: Int,
checks: Int,
sources: List<String>,
): String = buildString {
append(prefix)
if (checks > 0) {
append(" ($votes/$checks")
if (sources.isNotEmpty()) {
append(": ")
append(sources.joinToString(", "))
}
append(")")
}
}
private fun org.json.JSONArray?.containsAny(vararg candidates: String): Boolean {
if (this == null) return false
val normalizedCandidates = candidates.map { it.lowercase() }.toSet()
for (index in 0 until length()) {
if (optString(index).trim().lowercase() in normalizedCandidates) return true
}
return false
}
private fun urlEncode(value: String): String {
return URLEncoder.encode(value, Charsets.UTF_8.name())
}
}

View file

@ -0,0 +1,281 @@
package com.notcvnt.rknhardering.checker
/**
* Static catalog of home networks for SIM operators we want to recognize as
* "expected roaming exit" the operator's home APN/PGW always terminates the
* data session, so when the user is roaming the public IP belongs to the home
* network instead of the visited country. Without this knowledge such legit
* roaming is indistinguishable from a real bypass.
*
* Keys are `MCC` ("250") or `MCC-MNC` ("208-15"). MCC-only entries match any
* MNC of that country (used as a fallback when MNC is unknown).
*
* The entry stores ASN codes (e.g. 51207) and substring keywords that we look
* for in the GeoIP `isp` / `org` / `asn` strings. ASN match is the strong
* signal; keywords are a fallback when the ASN field isn't structured.
*/
internal data class HomeNetworkProfile(
val mcc: String,
val mnc: String?,
val country: String,
val operator: String,
val asnCodes: Set<String>,
val keywords: Set<String>,
)
internal object HomeNetworkCatalog {
private val PROFILES: List<HomeNetworkProfile> = listOf(
// France — Free Mobile (issue #63)
HomeNetworkProfile(
mcc = "208",
mnc = "15",
country = "FR",
operator = "Free Mobile",
asnCodes = setOf("51207"),
keywords = setOf("free mobile", "free sas"),
),
HomeNetworkProfile(
mcc = "208",
mnc = "16",
country = "FR",
operator = "Free Mobile",
asnCodes = setOf("51207"),
keywords = setOf("free mobile", "free sas"),
),
// France — Orange
HomeNetworkProfile(
mcc = "208",
mnc = "01",
country = "FR",
operator = "Orange",
asnCodes = setOf("3215"),
keywords = setOf("orange s.a.", "france telecom", "orange france"),
),
HomeNetworkProfile(
mcc = "208",
mnc = "02",
country = "FR",
operator = "Orange",
asnCodes = setOf("3215"),
keywords = setOf("orange s.a.", "france telecom", "orange france"),
),
// France — SFR
HomeNetworkProfile(
mcc = "208",
mnc = "10",
country = "FR",
operator = "SFR",
asnCodes = setOf("15557"),
keywords = setOf("sfr", "societe francaise du radiotelephone"),
),
// France — Bouygues Telecom
HomeNetworkProfile(
mcc = "208",
mnc = "20",
country = "FR",
operator = "Bouygues Telecom",
asnCodes = setOf("5410"),
keywords = setOf("bouygues"),
),
// Germany — Deutsche Telekom
HomeNetworkProfile(
mcc = "262",
mnc = "01",
country = "DE",
operator = "Telekom Deutschland",
asnCodes = setOf("3320"),
keywords = setOf("deutsche telekom", "telekom deutschland"),
),
// Germany — Vodafone
HomeNetworkProfile(
mcc = "262",
mnc = "02",
country = "DE",
operator = "Vodafone",
asnCodes = setOf("3209", "1273"),
keywords = setOf("vodafone gmbh", "vodafone germany"),
),
// Germany — Telefonica O2
HomeNetworkProfile(
mcc = "262",
mnc = "07",
country = "DE",
operator = "Telefonica O2",
asnCodes = setOf("6805", "13184"),
keywords = setOf("telefonica germany", "o2 deutschland"),
),
// United Kingdom — EE / BT
HomeNetworkProfile(
mcc = "234",
mnc = "30",
country = "GB",
operator = "EE",
asnCodes = setOf("12576", "5378"),
keywords = setOf("ee limited", "everything everywhere"),
),
// United Kingdom — Vodafone
HomeNetworkProfile(
mcc = "234",
mnc = "15",
country = "GB",
operator = "Vodafone UK",
asnCodes = setOf("1273"),
keywords = setOf("vodafone limited", "vodafone uk"),
),
// United Kingdom — O2
HomeNetworkProfile(
mcc = "234",
mnc = "10",
country = "GB",
operator = "O2 UK",
asnCodes = setOf("5607"),
keywords = setOf("telefonica uk", "o2 uk"),
),
// Italy — TIM
HomeNetworkProfile(
mcc = "222",
mnc = "01",
country = "IT",
operator = "TIM",
asnCodes = setOf("3269"),
keywords = setOf("telecom italia", "tim s.p.a."),
),
// Italy — Vodafone
HomeNetworkProfile(
mcc = "222",
mnc = "10",
country = "IT",
operator = "Vodafone Italia",
asnCodes = setOf("30722"),
keywords = setOf("vodafone italia"),
),
// Spain — Movistar
HomeNetworkProfile(
mcc = "214",
mnc = "07",
country = "ES",
operator = "Movistar",
asnCodes = setOf("3352"),
keywords = setOf("telefonica de espana", "movistar"),
),
// Netherlands — KPN
HomeNetworkProfile(
mcc = "204",
mnc = "08",
country = "NL",
operator = "KPN",
asnCodes = setOf("1136"),
keywords = setOf("kpn b.v.", "kpn mobile"),
),
// Poland — Orange / Play / Plus / T-Mobile
HomeNetworkProfile(
mcc = "260",
mnc = "03",
country = "PL",
operator = "Orange Polska",
asnCodes = setOf("5617"),
keywords = setOf("orange polska"),
),
// United States — T-Mobile
HomeNetworkProfile(
mcc = "310",
mnc = "260",
country = "US",
operator = "T-Mobile USA",
asnCodes = setOf("21928"),
keywords = setOf("t-mobile usa"),
),
// United States — AT&T
HomeNetworkProfile(
mcc = "310",
mnc = "410",
country = "US",
operator = "AT&T Mobility",
asnCodes = setOf("20057", "7018"),
keywords = setOf("at&t mobility", "cellco partnership", "at&t services"),
),
// United States — Verizon
HomeNetworkProfile(
mcc = "311",
mnc = "480",
country = "US",
operator = "Verizon Wireless",
asnCodes = setOf("22394", "6167"),
keywords = setOf("verizon wireless", "cellco partnership"),
),
// Belarus — A1 / MTS / Life
HomeNetworkProfile(
mcc = "257",
mnc = null,
country = "BY",
operator = "Belarus mobile carrier",
asnCodes = setOf("6697", "25106", "44087"),
keywords = setOf("a1 belarus", "mobile telesystems llc", "best ltd"),
),
// Kazakhstan — Beeline / Kcell / Tele2
HomeNetworkProfile(
mcc = "401",
mnc = null,
country = "KZ",
operator = "Kazakhstan mobile carrier",
asnCodes = setOf("21299", "29355", "35168"),
keywords = setOf("kar-tel", "kcell", "mobile telecom-service"),
),
// Ukraine — Kyivstar / Vodafone UA / lifecell
HomeNetworkProfile(
mcc = "255",
mnc = null,
country = "UA",
operator = "Ukraine mobile carrier",
asnCodes = setOf("15895", "21497", "34058"),
keywords = setOf("kyivstar", "pjsc vf ukraine", "lifecell"),
),
// Turkey — Turkcell / Vodafone TR
HomeNetworkProfile(
mcc = "286",
mnc = "01",
country = "TR",
operator = "Turkcell",
asnCodes = setOf("16135"),
keywords = setOf("turkcell"),
),
)
fun lookup(mcc: String?, mnc: String?): HomeNetworkProfile? {
if (mcc.isNullOrBlank()) return null
if (!mnc.isNullOrBlank()) {
val exact = PROFILES.firstOrNull { it.mcc == mcc && it.mnc == mnc }
if (exact != null) return exact
}
return PROFILES.firstOrNull { it.mcc == mcc && it.mnc == null }
}
/**
* Try to match `geoFacts` ASN/ISP strings against the SIM home network.
* Returns reason string (for debugging/UI) on match, null otherwise.
*/
fun matchExpectedExit(profile: HomeNetworkProfile, asn: String?, isp: String?, org: String?): String? {
val asnCode = extractAsnCode(asn)
if (asnCode != null && asnCode in profile.asnCodes) {
return "ASN AS$asnCode matches ${profile.operator} home network (${profile.country})"
}
val haystack = listOf(asn, isp, org)
.filterNotNull()
.joinToString(" | ") { it.lowercase() }
if (haystack.isBlank()) return null
val keyword = profile.keywords.firstOrNull { it in haystack }
return if (keyword != null) {
"ISP/ORG matches ${profile.operator} home network (${profile.country})"
} else {
null
}
}
private val ASN_REGEX = Regex("""AS\s*(\d+)""", RegexOption.IGNORE_CASE)
fun extractAsnCode(asn: String?): String? {
if (asn.isNullOrBlank()) return null
return ASN_REGEX.find(asn)?.groupValues?.getOrNull(1)
}
}

View file

@ -3,6 +3,7 @@ package com.notcvnt.rknhardering.checker
import android.content.Context
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.customcheck.IcmpSpoofingConfig
import com.notcvnt.rknhardering.model.CategoryResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceItem
@ -50,8 +51,8 @@ object IcmpSpoofingChecker {
?: throw IOException("No IPv4 address resolved for $host")
ipv4.hostAddress ?: throw IOException("Resolved IPv4 address is empty for $host")
},
val ping: suspend (String) -> SystemPingProber.PingResult = { address ->
SystemPingProber.probe(address = address)
val ping: suspend (String, Int, Int) -> SystemPingProber.PingResult = { address, count, timeoutSeconds ->
SystemPingProber.probe(address = address, count = count, replyTimeoutSeconds = timeoutSeconds)
},
)
@ -66,17 +67,35 @@ object IcmpSpoofingChecker {
suspend fun check(
context: Context,
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
config: IcmpSpoofingConfig = IcmpSpoofingConfig(enabled = true),
): CategoryResult = withContext(Dispatchers.IO) {
val dependencies = dependenciesOverride ?: Dependencies()
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
val effectiveTargets = buildList {
if (config.builtinTargetsEnabled) addAll(defaultTargets)
config.customTargets
.filter { it.host.isNotBlank() }
.forEach { ct ->
add(Target(host = ct.host.trim(), role = if (ct.isControl) Role.CONTROL else Role.BLOCKED))
}
}
if (effectiveTargets.none { it.role == Role.BLOCKED } || effectiveTargets.none { it.role == Role.CONTROL }) {
// No usable target pair (need at least one blocked + one control) — return inconclusive.
return@withContext unsupportedResult(context, IOException("No usable ICMP target pair configured"))
}
val pingCount = config.pingCount.coerceAtLeast(1)
val timeoutSeconds = (config.timeoutMs / 1000).coerceAtLeast(1)
val outcomes = try {
coroutineScope {
defaultTargets.map { target ->
effectiveTargets.map { target ->
async {
val address = dependencies.resolveIpv4(target.host, resolverConfig)
val ping = dependencies.ping(address)
TargetOutcome(target = target, address = address, ping = ping)
val pingResult = dependencies.ping(address, pingCount, timeoutSeconds)
TargetOutcome(target = target, address = address, ping = pingResult)
}
}.awaitAll()
}

View file

@ -0,0 +1,194 @@
package com.notcvnt.rknhardering.checker
import android.content.Context
import android.os.Build
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.model.ActiveVpnApp
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.vpn.VpnAppCatalog
import com.notcvnt.rknhardering.vpn.VpnAppMetadataScanner
import com.notcvnt.rknhardering.vpn.VpnDumpsysParser
internal fun checkDumpsysVpn(
context: Context,
findings: MutableList<Finding>,
evidence: MutableList<EvidenceItem>,
activeApps: MutableList<ActiveVpnApp>,
): 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()
if (VpnDumpsysParser.isUnavailable(output)) {
findings.add(Finding(context.getString(R.string.checker_indirect_dumpsys_vpn_unavailable)))
return SignalOutcome()
}
val records = VpnDumpsysParser.parseVpnManagement(output)
.filter { it.packageName != null || it.serviceName != null }
if (records.isEmpty()) {
findings.add(Finding(context.getString(R.string.checker_indirect_dumpsys_vpn_none)))
return SignalOutcome()
}
var detected = false
var needsReview = false
for (record in records) {
val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) }
val metadata = VpnAppMetadataScanner.scan(
context = context,
packageName = record.packageName,
serviceNames = listOfNotNull(record.serviceName),
)
val appLabel = VpnAppMetadataScanner.resolveAppLabel(context, record.packageName)
val confidence = when {
signature != null -> EvidenceConfidence.HIGH
record.packageName != null -> EvidenceConfidence.MEDIUM
else -> EvidenceConfidence.LOW
}
val familySuffix = signature?.family?.let { " [$it]" }.orEmpty()
val description = buildString {
append(context.getString(R.string.checker_indirect_dumpsys_vpn_line, record.rawLine))
appLabel?.let { append(" ($it)") }
append(familySuffix)
append(VpnAppMetadataScanner.formatMetadataSuffix(metadata))
}
findings.add(
Finding(
description = description,
detected = true,
source = EvidenceSource.ACTIVE_VPN,
confidence = confidence,
family = signature?.family,
packageName = record.packageName,
),
)
evidence.add(
EvidenceItem(
source = EvidenceSource.ACTIVE_VPN,
detected = true,
confidence = confidence,
description = record.rawLine,
family = signature?.family,
packageName = record.packageName,
kind = signature?.kind,
),
)
activeApps.add(
ActiveVpnApp(
packageName = record.packageName,
serviceName = record.serviceName,
family = signature?.family,
kind = signature?.kind,
source = EvidenceSource.ACTIVE_VPN,
confidence = confidence,
technicalMetadata = metadata,
),
)
detected = true
needsReview = needsReview || signature == null
}
SignalOutcome(detected = detected, needsReview = needsReview)
} catch (e: Exception) {
findings.add(Finding(context.getString(R.string.checker_indirect_dumpsys_vpn_error, e.message)))
SignalOutcome()
}
}
internal fun checkDumpsysVpnService(
context: Context,
findings: MutableList<Finding>,
evidence: MutableList<EvidenceItem>,
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()
if (VpnDumpsysParser.isUnavailable(output)) {
findings.add(Finding(context.getString(R.string.checker_indirect_dumpsys_service_unavailable)))
return SignalOutcome()
}
val records = VpnDumpsysParser.parseVpnServices(output)
if (records.isEmpty()) {
findings.add(Finding(context.getString(R.string.checker_indirect_dumpsys_service_none)))
return SignalOutcome()
}
var detected = false
var needsReview = false
for (record in records) {
val signature = record.packageName?.let { VpnAppCatalog.findByPackageName(it) }
val metadata = VpnAppMetadataScanner.scan(
context = context,
packageName = record.packageName,
serviceNames = listOfNotNull(record.serviceName),
)
val appLabel = VpnAppMetadataScanner.resolveAppLabel(context, record.packageName)
val confidence = when {
signature != null -> EvidenceConfidence.HIGH
record.packageName != null -> EvidenceConfidence.MEDIUM
else -> EvidenceConfidence.LOW
}
val serviceDisplay = if (record.packageName != null && record.serviceName != null) {
"${record.packageName}/${record.serviceName}"
} else {
record.rawLine
}
val familySuffix = signature?.family?.let { " [$it]" }.orEmpty()
val description = buildString {
append(context.getString(R.string.checker_indirect_dumpsys_service_active, serviceDisplay))
appLabel?.let { append(" ($it)") }
append(familySuffix)
append(VpnAppMetadataScanner.formatMetadataSuffix(metadata))
}
findings.add(
Finding(
description = description,
detected = true,
source = EvidenceSource.ACTIVE_VPN,
confidence = confidence,
family = signature?.family,
packageName = record.packageName,
),
)
evidence.add(
EvidenceItem(
source = EvidenceSource.ACTIVE_VPN,
detected = true,
confidence = confidence,
description = serviceDisplay,
family = signature?.family,
packageName = record.packageName,
kind = signature?.kind,
),
)
activeApps.add(
ActiveVpnApp(
packageName = record.packageName,
serviceName = record.serviceName,
family = signature?.family,
kind = signature?.kind,
source = EvidenceSource.ACTIVE_VPN,
confidence = confidence,
technicalMetadata = metadata,
),
)
detected = true
needsReview = needsReview || signature == null
}
SignalOutcome(detected = detected, needsReview = needsReview)
} catch (e: Exception) {
findings.add(Finding(context.getString(R.string.checker_indirect_dumpsys_service_error, e.message)))
SignalOutcome()
}
}

View file

@ -3,6 +3,11 @@ package com.notcvnt.rknhardering.checker
import android.content.Context
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.customcheck.EndpointScope
import com.notcvnt.rknhardering.customcheck.IpComparisonConfig
import com.notcvnt.rknhardering.customcheck.ResponseType
import com.notcvnt.rknhardering.customcheck.mapper.EndpointResponseMapper
import com.notcvnt.rknhardering.customcheck.mapper.MappingField
import com.notcvnt.rknhardering.model.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpCheckerResponse
import com.notcvnt.rknhardering.model.IpCheckerScope
@ -101,10 +106,44 @@ object IpComparisonChecker {
context: Context,
timeoutMs: Int = 5_000,
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
config: IpComparisonConfig = IpComparisonConfig(),
): IpComparisonResult = withContext(Dispatchers.IO) {
if (!config.enabled) {
return@withContext IpComparisonResult(
detected = false,
needsReview = false,
hasError = false,
summary = "",
ruGroup = IpCheckerGroupResult(
title = context.getString(R.string.checker_ip_comp_ru_checkers),
detected = false,
statusLabel = "",
summary = "",
responses = emptyList(),
),
nonRuGroup = IpCheckerGroupResult(
title = context.getString(R.string.checker_ip_comp_non_ru_checkers),
detected = false,
statusLabel = "",
summary = "",
responses = emptyList(),
),
)
}
val effectiveTimeoutMs = config.timeoutMs
coroutineScope {
val executionContext = ScanExecutionContext.currentOrDefault()
val responses = ENDPOINTS.map { endpoint ->
val filteredBuiltin = ENDPOINTS.filter { endpoint ->
when (endpoint.scope) {
IpCheckerScope.RU -> config.builtinRuCheckersEnabled
IpCheckerScope.NON_RU -> config.builtinNonRuCheckersEnabled
}
}
val builtinJobs = filteredBuiltin.map { endpoint ->
async {
val dnsRecords = PublicIpClient.resolveDnsRecords(
endpoint = endpoint.url,
@ -113,7 +152,7 @@ object IpComparisonChecker {
)
val result = fetchIpWithRetries(
endpoint = endpoint.url,
timeoutMs = timeoutMs,
timeoutMs = effectiveTimeoutMs,
resolverConfig = resolverConfig,
addressFamily = endpoint.addressFamily,
)
@ -133,7 +172,53 @@ object IpComparisonChecker {
),
)
}
}.map { it.await() }
}
val customJobs = config.customEndpoints.filter { it.enabled }.map { custom ->
async {
val mappedScope = when (custom.scope) {
EndpointScope.RU -> IpCheckerScope.RU
EndpointScope.NON_RU -> IpCheckerScope.NON_RU
}
val ip: String?
val error: String?
if (custom.responseMapping.responseType == ResponseType.PLAIN_TEXT) {
val result = fetchIpWithRetries(
endpoint = custom.url,
timeoutMs = effectiveTimeoutMs,
resolverConfig = resolverConfig,
addressFamily = null,
)
ip = result.getOrNull()
error = result.exceptionOrNull()?.let(::formatError)
} else {
val testResult = EndpointResponseMapper.testEndpoint(custom.url, effectiveTimeoutMs)
if (testResult.success && testResult.rawBody != null) {
ip = EndpointResponseMapper.extractField(
testResult.rawBody,
custom.responseMapping,
MappingField.IP,
)
error = if (ip == null) "IP field not found in response" else null
} else {
ip = null
error = testResult.error ?: "Request failed"
}
}
IpCheckerResponse(
label = custom.label,
url = custom.url,
scope = mappedScope,
ip = ip,
error = error,
ipv4Records = emptyList(),
ipv6Records = emptyList(),
ignoredIpv6Error = false,
)
}
}
val responses = (builtinJobs + customJobs).map { it.await() }
evaluate(context, responses)
}
}

View file

@ -7,6 +7,7 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.Geocoder
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.ScanResult
@ -17,6 +18,7 @@ import android.telephony.CellInfo
import android.telephony.CellInfoGsm
import android.telephony.CellInfoLte
import android.telephony.CellInfoWcdma
import android.telephony.SubscriptionManager
import android.telephony.TelephonyManager
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
@ -26,6 +28,8 @@ 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.customcheck.LocationSignalsConfig
import com.notcvnt.rknhardering.model.LocationSignalsFacts
import com.notcvnt.rknhardering.network.DnsResolverConfig
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers
@ -45,6 +49,34 @@ object LocationSignalsChecker {
val simCountryIso: String?,
val operatorName: String?,
val isRoaming: Boolean?,
val simMnc: String? = null,
val isActiveDataSubscription: Boolean = false,
val isDefaultDataSubscription: Boolean = false,
)
private data class DataSubscriptionIds(
val active: Int?,
val default: Int?,
)
private data class CellCollectionResult(
val candidates: List<CellLookupCandidate> = emptyList(),
val rawInfoCount: Int = 0,
val rawInfoTypes: List<String> = emptyList(),
val candidateRadios: List<String> = emptyList(),
)
private data class WifiCollectionResult(
val candidates: List<WifiLookupCandidate> = emptyList(),
val cachedScanCandidatesCount: Int = 0,
val freshScanCandidatesCount: Int? = null,
val connectedCandidateAvailable: Boolean = false,
)
private data class BssidCollectionResult(
val bssid: String?,
val source: String?,
val unavailableReason: String?,
)
internal data class LocationSnapshot(
@ -52,6 +84,7 @@ object LocationSignalsChecker {
val networkCountryIso: String?,
val networkOperatorName: String?,
val simCards: List<SimCardInfo>,
val networkMnc: String? = null,
val cellCountryCode: String?,
val cellLookupSummary: String?,
val cellCandidatesCount: Int,
@ -59,6 +92,22 @@ object LocationSignalsChecker {
val bssid: String?,
val cellLookupPermissionGranted: Boolean,
val wifiPermissionGranted: Boolean,
val locationServicesEnabled: Boolean = true,
val telephonyRadioAccessAvailable: Boolean = true,
val wifiFeatureAvailable: Boolean = true,
val nearbyWifiPermissionGranted: Boolean = true,
val networkRequestsEnabled: Boolean = true,
val cellRawInfoCount: Int = 0,
val cellRawInfoTypes: List<String> = emptyList(),
val cellCandidateRadios: List<String> = emptyList(),
val beaconDbCellCandidatesUsedCount: Int = 0,
val beaconDbUnsupportedCellRadios: List<String> = emptyList(),
val beaconDbWifiCandidatesUsedCount: Int = 0,
val wifiCachedScanCandidatesCount: Int = 0,
val wifiFreshScanCandidatesCount: Int? = null,
val wifiConnectedCandidateAvailable: Boolean = false,
val bssidSource: String? = null,
val bssidUnavailableReason: String? = null,
)
private const val RUSSIA_MCC = "250"
@ -67,19 +116,25 @@ object LocationSignalsChecker {
private const val WIFI_SCAN_TIMEOUT_MS = 3_000L
private const val MAX_CELL_TOWERS = 6
private const val MAX_WIFI_ACCESS_POINTS = 12
private const val MAX_NR_CELL_ID = 68_719_476_735L
suspend fun check(
context: Context,
networkRequestsEnabled: Boolean = true,
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
config: LocationSignalsConfig = LocationSignalsConfig(),
): CategoryResult = withContext(Dispatchers.IO) {
evaluate(collectSnapshot(context, networkRequestsEnabled, resolverConfig))
if (!config.enabled) {
return@withContext CategoryResult(name = "Location", detected = false, findings = emptyList())
}
evaluate(collectSnapshot(context, networkRequestsEnabled, resolverConfig, config))
}
private suspend fun collectSnapshot(
context: Context,
networkRequestsEnabled: Boolean,
resolverConfig: DnsResolverConfig,
config: LocationSignalsConfig = LocationSignalsConfig(),
): LocationSnapshot {
val fineLocationGranted = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
val nearbyWifiGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@ -89,8 +144,12 @@ object LocationSignalsChecker {
}
val cellLookupPermissionGranted = fineLocationGranted
val wifiPermissionGranted = fineLocationGranted && nearbyWifiGranted
val locationServicesEnabled = isLocationEnabled(context)
val telephonyRadioAccessAvailable = hasTelephonyRadioAccess(context)
val wifiFeatureAvailable = context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI)
var networkMcc: String? = null
var networkMnc: String? = null
var networkCountryIso: String? = null
var networkOperatorName: String? = null
var cellCountryCode: String? = null
@ -103,6 +162,8 @@ object LocationSignalsChecker {
val networkOperator = tm.networkOperator
if (!networkOperator.isNullOrEmpty() && networkOperator.length >= 3) {
networkMcc = networkOperator.substring(0, 3)
networkMnc = networkOperator.substring(3)
.takeIf { it.isNotEmpty() }
}
networkCountryIso = tm.networkCountryIso?.takeIf { it.isNotEmpty() }
networkOperatorName = tm.networkOperatorName?.takeIf { it.isNotEmpty() }
@ -110,21 +171,29 @@ object LocationSignalsChecker {
val simCards = collectSimCards(context, tm)
val cellCandidates = if (cellLookupPermissionGranted) {
collectCellCandidates(context, tm).also { cellCandidatesCount = it.size }
val cellCollection = if (config.checkCellTowers && cellLookupPermissionGranted && locationServicesEnabled && telephonyRadioAccessAvailable) {
collectCellCandidates(context, tm, simCards).also { cellCandidatesCount = it.candidates.size }
} else {
emptyList()
CellCollectionResult()
}
val wifiCandidates = if (wifiPermissionGranted) {
collectWifiCandidates(context).also { wifiAccessPointCandidatesCount = it.size }
val cellCandidates = cellCollection.candidates
val wifiCollection = if (config.checkWifiSignals && wifiPermissionGranted && locationServicesEnabled && wifiFeatureAvailable) {
collectWifiCandidates(context).also { wifiAccessPointCandidatesCount = it.candidates.size }
} else {
emptyList()
WifiCollectionResult()
}
val wifiCandidates = wifiCollection.candidates
if ((cellLookupPermissionGranted || wifiPermissionGranted) && networkRequestsEnabled) {
val lookup = BeaconDbClient(countryResolver = { lat, lon ->
reverseGeocodeCountry(context, lat, lon)
}, resolverConfig = resolverConfig).lookup(cellCandidates, wifiCandidates)
val beaconDbClient = BeaconDbClient(countryResolver = { lat, lon ->
reverseGeocodeCountry(context, lat, lon)
}, resolverConfig = resolverConfig)
val beaconDbInput = beaconDbClient.inputDiagnostics(cellCandidates, wifiCandidates)
val beaconDbCellCandidatesUsedCount = beaconDbInput.supportedCellCount
val beaconDbUnsupportedCellRadios = beaconDbInput.unsupportedCellRadios
val beaconDbWifiCandidatesUsedCount = beaconDbInput.wifiUsedCount
if (config.checkBeacondb && (cellLookupPermissionGranted || wifiPermissionGranted) && networkRequestsEnabled) {
val lookup = beaconDbClient.lookup(cellCandidates, wifiCandidates)
cellCountryCode = lookup.countryCode
cellLookupSummary = buildString {
append(lookup.summary)
@ -134,14 +203,15 @@ object LocationSignalsChecker {
}
}
val bssid = if (wifiPermissionGranted) {
runCatching { getBssid(context) }.getOrNull()
val bssidResult = if (config.checkWifiSignals && wifiPermissionGranted && locationServicesEnabled && wifiFeatureAvailable) {
collectBssid(context, wifiCandidates)
} else {
null
BssidCollectionResult(bssid = null, source = null, unavailableReason = null)
}
return LocationSnapshot(
networkMcc = networkMcc,
networkMnc = networkMnc,
networkCountryIso = networkCountryIso,
networkOperatorName = networkOperatorName,
simCards = simCards,
@ -149,9 +219,25 @@ object LocationSignalsChecker {
cellLookupSummary = cellLookupSummary,
cellCandidatesCount = cellCandidatesCount,
wifiAccessPointCandidatesCount = wifiAccessPointCandidatesCount,
bssid = bssid,
bssid = bssidResult.bssid,
cellLookupPermissionGranted = cellLookupPermissionGranted,
wifiPermissionGranted = wifiPermissionGranted,
locationServicesEnabled = locationServicesEnabled,
telephonyRadioAccessAvailable = telephonyRadioAccessAvailable,
wifiFeatureAvailable = wifiFeatureAvailable,
nearbyWifiPermissionGranted = nearbyWifiGranted,
networkRequestsEnabled = networkRequestsEnabled,
cellRawInfoCount = cellCollection.rawInfoCount,
cellRawInfoTypes = cellCollection.rawInfoTypes,
cellCandidateRadios = cellCollection.candidateRadios,
beaconDbCellCandidatesUsedCount = beaconDbCellCandidatesUsedCount,
beaconDbUnsupportedCellRadios = beaconDbUnsupportedCellRadios,
beaconDbWifiCandidatesUsedCount = beaconDbWifiCandidatesUsedCount,
wifiCachedScanCandidatesCount = wifiCollection.cachedScanCandidatesCount,
wifiFreshScanCandidatesCount = wifiCollection.freshScanCandidatesCount,
wifiConnectedCandidateAvailable = wifiCollection.connectedCandidateAvailable,
bssidSource = bssidResult.source,
bssidUnavailableReason = bssidResult.unavailableReason,
)
}
@ -159,12 +245,33 @@ object LocationSignalsChecker {
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
private fun isLocationEnabled(context: Context): Boolean {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
?: return true
return runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
locationManager.isLocationEnabled
} else {
@Suppress("DEPRECATION")
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
}.getOrDefault(true)
}
private fun hasTelephonyRadioAccess(context: Context): Boolean {
val packageManager = context.packageManager
return packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY_RADIO_ACCESS) ||
packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
}
private fun collectSimCards(context: Context, tm: TelephonyManager): List<SimCardInfo> {
val subscriptions = if (hasPermission(context, Manifest.permission.READ_PHONE_STATE)) {
getActiveSubscriptions(context)
} else {
null
}
val dataSubscriptionIds = getDataSubscriptionIds()
if (!subscriptions.isNullOrEmpty()) {
return subscriptions.mapNotNull { info ->
@ -174,13 +281,23 @@ object LocationSignalsChecker {
val simMcc = if (!simOperator.isNullOrEmpty() && simOperator.length >= 3) {
simOperator.substring(0, 3)
} else null
val simMnc = if (!simOperator.isNullOrEmpty() && simOperator.length > 3) {
simOperator.substring(3)
} else null
val simCountryIso = subTm.simCountryIso?.takeIf { it.isNotEmpty() }
?: info.countryIso?.takeIf { it.isNotEmpty() }
SimCardInfo(
slotIndex = info.simSlotIndex,
subscriptionId = info.subscriptionId,
simMcc = simMcc,
simCountryIso = subTm.simCountryIso?.takeIf { it.isNotEmpty() },
operatorName = subTm.networkOperatorName?.takeIf { it.isNotEmpty() },
simMnc = simMnc,
simCountryIso = simCountryIso,
operatorName = info.carrierName?.toString()?.takeIf { it.isNotEmpty() }
?: subTm.simOperatorName?.takeIf { it.isNotEmpty() }
?: subTm.networkOperatorName?.takeIf { it.isNotEmpty() },
isRoaming = subTm.isNetworkRoaming,
isActiveDataSubscription = info.subscriptionId == dataSubscriptionIds.active,
isDefaultDataSubscription = info.subscriptionId == dataSubscriptionIds.default,
)
}.getOrNull()
}
@ -192,14 +309,21 @@ object LocationSignalsChecker {
val simMcc = if (!simOperator.isNullOrEmpty() && simOperator.length >= 3) {
simOperator.substring(0, 3)
} else null
val simMnc = if (!simOperator.isNullOrEmpty() && simOperator.length > 3) {
simOperator.substring(3)
} else null
listOf(
SimCardInfo(
slotIndex = 0,
subscriptionId = -1,
simMcc = simMcc,
simMnc = simMnc,
simCountryIso = tm.simCountryIso?.takeIf { it.isNotEmpty() },
operatorName = tm.networkOperatorName?.takeIf { it.isNotEmpty() },
operatorName = tm.simOperatorName?.takeIf { it.isNotEmpty() }
?: tm.networkOperatorName?.takeIf { it.isNotEmpty() },
isRoaming = tm.isNetworkRoaming,
isActiveDataSubscription = dataSubscriptionIds.active != null,
isDefaultDataSubscription = dataSubscriptionIds.default != null,
)
)
}.getOrElse { emptyList() }
@ -208,27 +332,90 @@ object LocationSignalsChecker {
@Suppress("MissingPermission")
private fun getActiveSubscriptions(context: Context): List<android.telephony.SubscriptionInfo>? {
val subscriptionManager = context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE)
as? android.telephony.SubscriptionManager
as? SubscriptionManager
return runCatching { subscriptionManager?.activeSubscriptionInfoList }.getOrNull()
}
private fun getDataSubscriptionIds(): DataSubscriptionIds {
val defaultDataSubscriptionId = runCatching {
normalizeSubscriptionId(SubscriptionManager.getDefaultDataSubscriptionId())
}.getOrNull()
val activeDataSubscriptionId = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
normalizeSubscriptionId(SubscriptionManager.getActiveDataSubscriptionId())
} else {
defaultDataSubscriptionId
}
}.getOrNull()
return DataSubscriptionIds(
active = activeDataSubscriptionId,
default = defaultDataSubscriptionId,
)
}
private fun normalizeSubscriptionId(subscriptionId: Int): Int? {
return subscriptionId.takeIf {
it != SubscriptionManager.INVALID_SUBSCRIPTION_ID &&
it != SubscriptionManager.DEFAULT_SUBSCRIPTION_ID
}
}
private suspend fun collectCellCandidates(
context: Context,
tm: TelephonyManager,
): List<CellLookupCandidate> {
simCards: List<SimCardInfo>,
): CellCollectionResult {
if (!hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
return emptyList()
return CellCollectionResult()
}
val fresh = requestFreshCellInfo(context, tm)
val fallback = getCachedCellInfo(tm)
return (fresh.ifEmpty { fallback })
val subscriptionManagers = simCards
.mapNotNull { it.subscriptionId.takeIf { subscriptionId -> subscriptionId >= 0 } }
.distinct()
.mapNotNull { subscriptionId ->
runCatching { tm.createForSubscriptionId(subscriptionId) }.getOrNull()
}
val managers = (listOf(tm) + subscriptionManagers).distinct()
val cellInfo = managers.flatMap { collectCellInfo(context, it) }
val rawInfoTypes = cellInfo
.map(::cellInfoTypeName)
.distinct()
.sorted()
val candidates = cellInfo
.mapNotNull(::toLookupCandidate)
.distinctBy { listOf(it.radio, it.mcc, it.mnc, it.areaCode, it.cellId) }
.distinctBy { listOf(it.radio, it.mcc, it.mnc, it.areaCode, it.cellId, it.newRadioCellId) }
.sortedWith(
compareByDescending<CellLookupCandidate> { it.registered }
.thenByDescending { it.signalStrength ?: Int.MIN_VALUE },
)
.take(MAX_CELL_TOWERS)
if (candidates.isNotEmpty()) {
return CellCollectionResult(
candidates = candidates,
rawInfoCount = cellInfo.size,
rawInfoTypes = rawInfoTypes,
candidateRadios = summarizeCellRadios(candidates),
)
}
val legacyCandidates = managers
.mapNotNull(::legacyGsmCellCandidate)
.distinctBy { listOf(it.radio, it.mcc, it.mnc, it.areaCode, it.cellId, it.newRadioCellId) }
.take(MAX_CELL_TOWERS)
return CellCollectionResult(
candidates = legacyCandidates,
rawInfoCount = cellInfo.size,
rawInfoTypes = rawInfoTypes,
candidateRadios = summarizeCellRadios(legacyCandidates),
)
}
private suspend fun collectCellInfo(
context: Context,
tm: TelephonyManager,
): List<CellInfo> {
return (requestFreshCellInfo(context, tm) + getCachedCellInfo(tm))
.distinctBy { it.toString() }
}
@Suppress("MissingPermission")
@ -250,6 +437,10 @@ object LocationSignalsChecker {
override fun onCellInfo(cellInfo: MutableList<CellInfo>) {
resumeOnce(continuation, completed, cellInfo.toList())
}
override fun onError(errorCode: Int, detail: Throwable?) {
resumeOnce(continuation, completed, emptyList())
}
},
)
}.isSuccess
@ -270,6 +461,24 @@ object LocationSignalsChecker {
return runCatching { tm.allCellInfo.orEmpty() }.getOrDefault(emptyList())
}
@Suppress("DEPRECATION", "MissingPermission")
private fun legacyGsmCellCandidate(tm: TelephonyManager): CellLookupCandidate? {
val operator = normalizeOperatorCode(tm.networkOperator)?.takeIf { it.length >= 4 } ?: return null
val location = runCatching {
tm.cellLocation as? android.telephony.gsm.GsmCellLocation
}.getOrNull() ?: return null
val areaCode = normalizeCellValue(location.lac) ?: return null
val cellId = normalizeCellValue(location.cid) ?: return null
return CellLookupCandidate(
radio = "gsm",
mcc = operator.substring(0, 3),
mnc = operator.substring(3),
areaCode = areaCode,
cellId = cellId,
registered = true,
)
}
private fun toLookupCandidate(info: CellInfo): CellLookupCandidate? {
return when (info) {
is CellInfoGsm -> {
@ -323,19 +532,87 @@ object LocationSignalsChecker {
)
}
else -> null
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Api29Impl.nrCandidate(info)
} else {
null
}
}
}
private suspend fun collectWifiCandidates(context: Context): List<WifiLookupCandidate> {
private suspend fun collectWifiCandidates(context: Context): WifiCollectionResult {
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
val cachedCandidates = currentWifiCandidates(wifiManager)
val refreshedCandidates = requestFreshWifiScan(context, wifiManager)
val connectedCandidate = currentWifiConnectionCandidate(context, wifiManager)
return (refreshedCandidates ?: cachedCandidates)
.distinctBy { it.macAddress }
.sortedByDescending { it.signalStrength ?: Int.MIN_VALUE }
.take(MAX_WIFI_ACCESS_POINTS)
return WifiCollectionResult(
candidates = mergeWifiCandidates(
cached = cachedCandidates,
refreshed = refreshedCandidates.orEmpty(),
connected = connectedCandidate,
),
cachedScanCandidatesCount = cachedCandidates.size,
freshScanCandidatesCount = refreshedCandidates?.size,
connectedCandidateAvailable = connectedCandidate != null,
)
}
private fun summarizeCellRadios(candidates: List<CellLookupCandidate>): List<String> {
return candidates
.map { it.radio.lowercase(Locale.US) }
.distinct()
.sorted()
}
private fun cellInfoTypeName(info: CellInfo): String {
return info.javaClass.simpleName
.removePrefix("CellInfo")
.takeIf { it.isNotBlank() }
?.lowercase(Locale.US)
?: info.javaClass.name
}
private fun collectBssid(
context: Context,
wifiCandidates: List<WifiLookupCandidate>,
): BssidCollectionResult {
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
val wifiInfo = getWifiInfo(context, wifiManager)
val normalizedBssid = normalizeMacAddress(wifiInfo?.bssid)
if (normalizedBssid != null) {
return BssidCollectionResult(
bssid = normalizedBssid,
source = "connected Wi-Fi info",
unavailableReason = null,
)
}
val singleScanCandidate = wifiCandidates.singleOrNull()
if (singleScanCandidate != null) {
val reason = when {
wifiInfo == null -> "Wi-Fi info unavailable; using the only scan candidate"
wifiInfo.bssid == PLACEHOLDER_BSSID -> "connected Wi-Fi info is redacted by Android; using the only scan candidate"
else -> "connected Wi-Fi BSSID is unavailable; using the only scan candidate"
}
return BssidCollectionResult(
bssid = singleScanCandidate.macAddress,
source = "single Wi-Fi scan candidate",
unavailableReason = reason,
)
}
val reason = when {
wifiInfo == null -> "Wi-Fi info unavailable"
wifiInfo.bssid == PLACEHOLDER_BSSID -> "connected Wi-Fi info is redacted by Android"
wifiInfo.bssid.isNullOrBlank() -> "connected Wi-Fi BSSID is empty"
else -> "connected Wi-Fi BSSID is invalid"
}
return BssidCollectionResult(
bssid = null,
source = null,
unavailableReason = reason,
)
}
@Suppress("MissingPermission", "DEPRECATION")
@ -394,6 +671,39 @@ object LocationSignalsChecker {
}.getOrDefault(emptyList())
}
private fun currentWifiConnectionCandidate(
context: Context,
wifiManager: WifiManager,
): WifiLookupCandidate? {
val wifiInfo = getWifiInfo(context, wifiManager) ?: return null
val macAddress = normalizeMacAddress(wifiInfo.bssid) ?: return null
val ssid = normalizeSsid(wifiInfo.ssid)
if (ssid?.endsWith("_nomap", ignoreCase = true) == true) return null
return WifiLookupCandidate(
macAddress = macAddress,
frequency = wifiInfo.frequency.takeIf { it > 0 },
signalStrength = normalizeSignalStrength(wifiInfo.rssi),
)
}
internal fun mergeWifiCandidates(
cached: List<WifiLookupCandidate>,
refreshed: List<WifiLookupCandidate>,
connected: WifiLookupCandidate?,
): List<WifiLookupCandidate> {
return (cached + refreshed + listOfNotNull(connected))
.groupBy { it.macAddress }
.values
.mapNotNull { candidates ->
candidates.maxWithOrNull(
compareBy<WifiLookupCandidate> { it.signalStrength ?: Int.MIN_VALUE }
.thenBy { it.frequency ?: 0 },
)
}
.sortedByDescending { it.signalStrength ?: Int.MIN_VALUE }
.take(MAX_WIFI_ACCESS_POINTS)
}
private fun toWifiLookupCandidate(scanResult: ScanResult): WifiLookupCandidate? {
val macAddress = normalizeMacAddress(scanResult.BSSID) ?: return null
val ssid = normalizeSsid(scanResultSsid(scanResult)) ?: return null
@ -480,6 +790,29 @@ object LocationSignalsChecker {
}
}
// Keep newer cell identity accessors isolated so older devices never resolve them.
@RequiresApi(Build.VERSION_CODES.Q)
private object Api29Impl {
@DoNotInline
fun nrCandidate(info: CellInfo): CellLookupCandidate? {
if (info !is android.telephony.CellInfoNr) return null
val identity = info.cellIdentity as? android.telephony.CellIdentityNr ?: return null
val mcc = normalizeOperatorCode(identity.mccString) ?: return null
val mnc = normalizeOperatorCode(identity.mncString) ?: return null
val areaCode = normalizeCellValue(identity.tac) ?: return null
val newRadioCellId = normalizeNewRadioCellValue(identity.nci) ?: return null
return CellLookupCandidate(
radio = "nr",
mcc = mcc,
mnc = mnc,
areaCode = areaCode,
newRadioCellId = newRadioCellId,
registered = info.isRegistered,
signalStrength = normalizeSignalStrength(info.cellSignalStrength.dbm),
)
}
}
// Keep API 28-only operator accessors isolated so pre-P devices never resolve them.
@RequiresApi(Build.VERSION_CODES.P)
private object Api28Impl {
@ -528,6 +861,10 @@ object LocationSignalsChecker {
return value.toLong().takeIf { it in 0 until Int.MAX_VALUE.toLong() }
}
private fun normalizeNewRadioCellValue(value: Long): Long? {
return value.takeIf { it in 0..MAX_NR_CELL_ID }
}
private fun normalizeSignalStrength(value: Int): Int? {
return value.takeIf { it in -150..0 }
}
@ -575,15 +912,22 @@ object LocationSignalsChecker {
@Suppress("DEPRECATION")
private fun getBssid(context: Context): String? {
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
return getWifiInfo(context, wifiManager)?.bssid
}
@Suppress("DEPRECATION")
private fun getWifiInfo(context: Context, wifiManager: WifiManager): WifiInfo? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return null
val caps = cm.getNetworkCapabilities(network) ?: return null
if (!caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return null
(caps.transportInfo as? WifiInfo)?.bssid
val network = cm.activeNetwork
val caps = network?.let { cm.getNetworkCapabilities(it) }
val transportInfo = caps
?.takeIf { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) }
?.transportInfo as? WifiInfo
transportInfo ?: wifiManager.connectionInfo
} else {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
wm.connectionInfo?.bssid
wifiManager.connectionInfo
}
}
@ -592,11 +936,35 @@ object LocationSignalsChecker {
val evidence = mutableListOf<EvidenceItem>()
var needsReview = false
val homeSim = selectHomeSim(snapshot)
val homeSimCountryIsRussia = homeSim?.simMcc == RUSSIA_MCC
val networkIsRussia = snapshot.networkMcc == RUSSIA_MCC
val homeRoutedRoaming = networkIsRussia &&
homeSim?.simMcc != null &&
!homeSimCountryIsRussia
val homeRoutedRoamingReason = if (homeRoutedRoaming && homeSim != null) {
val simCountry = homeSim.simCountryIso?.uppercase(Locale.US)
?: countryFromMcc(homeSim.simMcc)
?: "?"
"Home SIM MCC ${homeSim.simMcc} ($simCountry) on visited Russian network MCC ${snapshot.networkMcc}"
} else {
null
}
val anySimReportedRoaming = snapshot.simCards.any { it.isRoaming == true } ||
// Telephony sometimes returns isRoaming=false for foreign SIM in RU
// (e.g. Free Mobile in RU); fall back to MCC mismatch heuristic.
snapshot.simCards.any {
!it.simMcc.isNullOrBlank() &&
!snapshot.networkMcc.isNullOrBlank() &&
it.simMcc != snapshot.networkMcc
}
if (snapshot.networkMcc == null) {
findings += Finding("PLMN: network MCC is unavailable")
} else {
val networkCountry = snapshot.networkCountryIso?.uppercase(Locale.US) ?: "N/A"
val networkIsRussia = snapshot.networkMcc == RUSSIA_MCC
val networkCountry = snapshot.networkCountryIso?.uppercase(Locale.US)
?: countryFromMcc(snapshot.networkMcc)
?: "N/A"
findings += Finding(
description = "Network operator: ${snapshot.networkOperatorName ?: "N/A"} ($networkCountry)",
@ -611,22 +979,63 @@ object LocationSignalsChecker {
}
for (sim in snapshot.simCards) {
val simCountry = sim.simCountryIso?.uppercase(Locale.US) ?: "N/A"
// Prefer simCountryIso for the SIM label — historically this
// was sourced from the network, mislabelling foreign SIMs as
// RU when registered on a Russian visited network.
val simCountry = sim.simCountryIso?.uppercase(Locale.US)
?: countryFromMcc(sim.simMcc)
?: "N/A"
val operatorPart = sim.operatorName?.let { ", $it" } ?: ""
findings += Finding(
description = "SIM[${sim.slotIndex}] MCC: ${sim.simMcc ?: "N/A"} ($simCountry)$operatorPart",
isInformational = true,
)
when (sim.isRoaming) {
true -> findings += Finding("SIM[${sim.slotIndex}] Roaming: yes", isInformational = true)
false -> findings += Finding("SIM[${sim.slotIndex}] Roaming: no", isInformational = true)
null -> Unit
val effectiveRoaming: Boolean? = when {
sim.isRoaming == true -> true
!sim.simMcc.isNullOrBlank() &&
!snapshot.networkMcc.isNullOrBlank() &&
sim.simMcc != snapshot.networkMcc -> true
sim.isRoaming == false -> false
else -> null
}
val roamingLabel = when (effectiveRoaming) {
true -> "yes"
false -> "no"
null -> null
}
if (roamingLabel != null) {
findings += Finding(
description = "SIM[${sim.slotIndex}] Roaming: $roamingLabel",
isInformational = true,
)
}
}
if (homeRoutedRoaming) {
val description = "home_routed_roaming:true (home SIM MCC ${homeSim?.simMcc}, " +
"visited network MCC ${snapshot.networkMcc})"
findings += Finding(
description = description,
source = EvidenceSource.HOME_ROUTED_ROAMING,
confidence = EvidenceConfidence.MEDIUM,
isInformational = false,
)
evidence += EvidenceItem(
source = EvidenceSource.HOME_ROUTED_ROAMING,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = homeRoutedRoamingReason
?: "Foreign SIM connected via Russian visited network",
)
}
if (!networkIsRussia) {
// Foreign visited network. If this is a Russian SIM roaming
// abroad (homeSimCountryIsRussia), the public IP is expected
// to belong to the home (Russian) operator and bypass cannot
// be inferred from country alone — drop confidence to LOW.
val matchingSim = snapshot.simCards.firstOrNull { it.simMcc == snapshot.networkMcc }
val confidence = if (matchingSim?.isRoaming == true) {
val confidence = if (matchingSim?.isRoaming == true || homeSimCountryIsRussia) {
EvidenceConfidence.LOW
} else {
EvidenceConfidence.MEDIUM
@ -648,8 +1057,16 @@ object LocationSignalsChecker {
}
}
if (!snapshot.locationServicesEnabled) {
findings += Finding("Location services: disabled")
}
if (!snapshot.cellLookupPermissionGranted) {
findings += Finding("Cell lookup: ACCESS_FINE_LOCATION permission is not granted")
} else if (!snapshot.locationServicesEnabled) {
findings += Finding("Cell lookup: system location is disabled")
} else if (!snapshot.telephonyRadioAccessAvailable) {
findings += Finding("Cell lookup: telephony radio access is unavailable")
} else {
findings += Finding("Cell lookup candidates: ${snapshot.cellCandidatesCount}")
if (snapshot.cellCandidatesCount == 0) {
@ -659,6 +1076,10 @@ object LocationSignalsChecker {
if (!snapshot.wifiPermissionGranted) {
findings += Finding("Wi-Fi scan: permissions are not granted")
} else if (!snapshot.locationServicesEnabled) {
findings += Finding("Wi-Fi scan: system location is disabled")
} else if (!snapshot.wifiFeatureAvailable) {
findings += Finding("Wi-Fi scan: Wi-Fi feature is unavailable")
} else {
findings += Finding("Wi-Fi scan candidates: ${snapshot.wifiAccessPointCandidatesCount}")
if (snapshot.wifiAccessPointCandidatesCount == 0) {
@ -677,20 +1098,159 @@ object LocationSignalsChecker {
if (!snapshot.wifiPermissionGranted) {
findings += Finding("BSSID: permission is not granted")
} else if (!snapshot.locationServicesEnabled) {
findings += Finding("BSSID: system location is disabled")
} else if (!snapshot.wifiFeatureAvailable) {
findings += Finding("BSSID: Wi-Fi feature is unavailable")
} else if (snapshot.bssid == null || snapshot.bssid == PLACEHOLDER_BSSID) {
findings += Finding("BSSID: unavailable")
} else {
findings += Finding("BSSID: ${snapshot.bssid}")
}
return CategoryResult(
val detected = evidence.any {
it.detected &&
it.confidence >= EvidenceConfidence.MEDIUM &&
it.source != EvidenceSource.HOME_ROUTED_ROAMING
}
val locationFacts = LocationSignalsFacts(
networkMcc = snapshot.networkMcc,
networkMnc = snapshot.networkMnc,
networkCountryIso = snapshot.networkCountryIso?.uppercase(Locale.US),
networkOperatorName = snapshot.networkOperatorName,
networkIsRussia = networkIsRussia,
homeSimMcc = homeSim?.simMcc,
homeSimMnc = homeSim?.simMnc,
homeSimCountryIso = homeSim?.simCountryIso?.uppercase(Locale.US)
?: countryFromMcc(homeSim?.simMcc),
homeSimCountryIsRussia = homeSimCountryIsRussia,
homeSimOperatorName = homeSim?.operatorName,
anySimReportedRoaming = anySimReportedRoaming,
homeRoutedRoaming = homeRoutedRoaming,
homeRoutedRoamingReason = homeRoutedRoamingReason,
)
val result = CategoryResult(
name = "Location signals",
detected = false,
detected = detected,
findings = findings,
needsReview = needsReview,
evidence = evidence,
locationFacts = locationFacts,
)
return LocationSignalsDiagnosticsRegistry.attach(
result,
LocationSignalsDiagnostics(
fineLocationPermissionGranted = snapshot.cellLookupPermissionGranted,
nearbyWifiPermissionGranted = snapshot.nearbyWifiPermissionGranted,
locationServicesEnabled = snapshot.locationServicesEnabled,
telephonyRadioAccessAvailable = snapshot.telephonyRadioAccessAvailable,
wifiFeatureAvailable = snapshot.wifiFeatureAvailable,
networkRequestsEnabled = snapshot.networkRequestsEnabled,
cellRawInfoCount = snapshot.cellRawInfoCount,
cellRawInfoTypes = snapshot.cellRawInfoTypes,
cellCandidateRadios = snapshot.cellCandidateRadios,
beaconDbCellCandidatesUsedCount = snapshot.beaconDbCellCandidatesUsedCount,
beaconDbUnsupportedCellRadios = snapshot.beaconDbUnsupportedCellRadios,
beaconDbWifiCandidatesUsedCount = snapshot.beaconDbWifiCandidatesUsedCount,
wifiAccessPointCandidatesCount = snapshot.wifiAccessPointCandidatesCount,
wifiCachedScanCandidatesCount = snapshot.wifiCachedScanCandidatesCount,
wifiFreshScanCandidatesCount = snapshot.wifiFreshScanCandidatesCount,
wifiConnectedCandidateAvailable = snapshot.wifiConnectedCandidateAvailable,
bssidSource = snapshot.bssidSource,
bssidUnavailableReason = snapshot.bssidUnavailableReason,
),
)
}
private fun selectHomeSim(snapshot: LocationSnapshot): SimCardInfo? {
val simsWithMcc = snapshot.simCards.filter { !it.simMcc.isNullOrBlank() }
return snapshot.simCards.firstOrNull { it.isActiveDataSubscription }
?: snapshot.simCards.firstOrNull { it.isDefaultDataSubscription }
?: simsWithMcc.firstOrNull { it.matchesRegisteredNetwork(snapshot) }
?: simsWithMcc.firstOrNull()
?: snapshot.simCards.firstOrNull()
}
private fun SimCardInfo.matchesRegisteredNetwork(snapshot: LocationSnapshot): Boolean {
if (simMcc.isNullOrBlank() || snapshot.networkMcc.isNullOrBlank()) return false
if (simMcc != snapshot.networkMcc) return false
return snapshot.networkMnc.isNullOrBlank() ||
simMnc.isNullOrBlank() ||
simMnc == snapshot.networkMnc
}
private val MAC_ADDRESS_REGEX = Regex("^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$")
/**
* Lightweight MCC ISO 3166-1 alpha-2 lookup for the most common
* countries. Used to label SIM/Network country when telephony APIs
* return blank simCountryIso (typical when SIM-MCC and Network-MCC
* disagree). Not exhaustive falls back to null.
*/
private fun countryFromMcc(mcc: String?): String? {
if (mcc.isNullOrBlank()) return null
return MCC_TO_ISO[mcc]
}
private val MCC_TO_ISO: Map<String, String> = mapOf(
"202" to "GR", "204" to "NL", "206" to "BE", "208" to "FR",
"212" to "MC", "213" to "AD", "214" to "ES", "216" to "HU",
"218" to "BA", "219" to "HR", "220" to "RS", "222" to "IT",
"226" to "RO", "228" to "CH", "230" to "CZ", "231" to "SK",
"232" to "AT", "234" to "GB", "235" to "GB", "238" to "DK",
"240" to "SE", "242" to "NO", "244" to "FI", "246" to "LT",
"247" to "LV", "248" to "EE", "250" to "RU", "255" to "UA",
"257" to "BY", "259" to "MD", "260" to "PL", "262" to "DE",
"266" to "GI", "268" to "PT", "270" to "LU", "272" to "IE",
"274" to "IS", "276" to "AL", "278" to "MT", "280" to "CY",
"282" to "GE", "283" to "AM", "284" to "BG", "286" to "TR",
"288" to "FO", "290" to "GL", "293" to "SI", "294" to "MK",
"295" to "LI", "297" to "ME", "310" to "US", "311" to "US",
"312" to "US", "313" to "US", "314" to "US", "315" to "US",
"316" to "US", "330" to "PR", "334" to "MX", "338" to "JM",
"340" to "MQ", "342" to "BB", "346" to "KY", "348" to "VG",
"350" to "BM", "352" to "GD", "354" to "MS", "356" to "KN",
"358" to "LC", "360" to "VC", "362" to "AN", "363" to "AW",
"364" to "BS", "365" to "AI", "366" to "DM", "368" to "CU",
"370" to "DO", "372" to "HT", "374" to "TT", "376" to "TC",
"400" to "AZ", "401" to "KZ", "402" to "BT", "404" to "IN",
"405" to "IN", "410" to "PK", "412" to "AF", "413" to "LK",
"414" to "MM", "415" to "LB", "416" to "JO", "417" to "SY",
"418" to "IQ", "419" to "KW", "420" to "SA", "421" to "YE",
"422" to "OM", "424" to "AE", "425" to "IL", "426" to "BH",
"427" to "QA", "428" to "MN", "429" to "NP", "430" to "AE",
"431" to "AE", "432" to "IR", "434" to "UZ", "436" to "TJ",
"437" to "KG", "438" to "TM", "440" to "JP", "441" to "JP",
"450" to "KR", "452" to "VN", "454" to "HK", "455" to "MO",
"456" to "KH", "457" to "LA", "460" to "CN", "461" to "CN",
"466" to "TW", "467" to "KP", "470" to "BD", "472" to "MV",
"502" to "MY", "505" to "AU", "510" to "ID", "514" to "TL",
"515" to "PH", "520" to "TH", "525" to "SG", "528" to "BN",
"530" to "NZ", "534" to "MP", "535" to "GU", "536" to "NR",
"537" to "PG", "539" to "TO", "540" to "SB", "541" to "VU",
"542" to "FJ", "543" to "WF", "544" to "AS", "545" to "KI",
"546" to "NC", "547" to "PF", "548" to "CK", "549" to "WS",
"550" to "FM", "551" to "MH", "552" to "PW", "602" to "EG",
"603" to "DZ", "604" to "MA", "605" to "TN", "606" to "LY",
"607" to "GM", "608" to "SN", "609" to "MR", "610" to "ML",
"611" to "GN", "612" to "CI", "613" to "BF", "614" to "NE",
"615" to "TG", "616" to "BJ", "617" to "MU", "618" to "LR",
"619" to "SL", "620" to "GH", "621" to "NG", "622" to "TD",
"623" to "CF", "624" to "CM", "625" to "CV", "626" to "ST",
"627" to "GQ", "628" to "GA", "629" to "CG", "630" to "CD",
"631" to "AO", "632" to "GW", "633" to "SC", "634" to "SD",
"635" to "RW", "636" to "ET", "637" to "SO", "638" to "DJ",
"639" to "KE", "640" to "TZ", "641" to "UG", "642" to "BI",
"643" to "MZ", "645" to "ZM", "646" to "MG", "647" to "RE",
"648" to "ZW", "649" to "NA", "650" to "MW", "651" to "LS",
"652" to "BW", "653" to "SZ", "654" to "KM", "655" to "ZA",
"657" to "ER", "659" to "SS", "702" to "BZ", "704" to "GT",
"706" to "SV", "708" to "HN", "710" to "NI", "712" to "CR",
"714" to "PA", "716" to "PE", "722" to "AR", "724" to "BR",
"730" to "CL", "732" to "CO", "734" to "VE", "736" to "BO",
"738" to "GY", "740" to "EC", "744" to "PY", "746" to "SR",
"748" to "UY",
)
}

View file

@ -0,0 +1,40 @@
package com.notcvnt.rknhardering.checker
import com.notcvnt.rknhardering.model.CategoryResult
import java.util.Collections
import java.util.WeakHashMap
data class LocationSignalsDiagnostics(
val fineLocationPermissionGranted: Boolean,
val nearbyWifiPermissionGranted: Boolean,
val locationServicesEnabled: Boolean,
val telephonyRadioAccessAvailable: Boolean,
val wifiFeatureAvailable: Boolean,
val networkRequestsEnabled: Boolean,
val cellRawInfoCount: Int,
val cellRawInfoTypes: List<String>,
val cellCandidateRadios: List<String>,
val beaconDbCellCandidatesUsedCount: Int,
val beaconDbUnsupportedCellRadios: List<String>,
val beaconDbWifiCandidatesUsedCount: Int,
val wifiAccessPointCandidatesCount: Int,
val wifiCachedScanCandidatesCount: Int,
val wifiFreshScanCandidatesCount: Int?,
val wifiConnectedCandidateAvailable: Boolean,
val bssidSource: String?,
val bssidUnavailableReason: String?,
)
object LocationSignalsDiagnosticsRegistry {
private val diagnosticsByCategory =
Collections.synchronizedMap(WeakHashMap<CategoryResult, LocationSignalsDiagnostics>())
fun attach(category: CategoryResult, diagnostics: LocationSignalsDiagnostics): CategoryResult {
diagnosticsByCategory[category] = diagnostics
return category
}
fun find(category: CategoryResult): LocationSignalsDiagnostics? {
return diagnosticsByCategory[category]
}
}

View file

@ -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
@ -9,6 +10,7 @@ import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.network.NetworkInterfaceNameNormalizer
import com.notcvnt.rknhardering.network.NetworkInterfacePatterns
import com.notcvnt.rknhardering.probe.NativeEmulatorFinding
import com.notcvnt.rknhardering.probe.NativeInterface
import com.notcvnt.rknhardering.probe.NativeInterfaceProbe
import com.notcvnt.rknhardering.probe.NativeMapsFinding
@ -36,6 +38,8 @@ object NativeSignsChecker {
"libzygisk",
)
private const val ARPHRD_TUNTAP = 65534
private val HIGH_CONFIDENCE_ROOT_MOUNT_MARKERS = setOf(
"magisk",
"zygisk",
@ -47,6 +51,16 @@ object NativeSignsChecker {
"core-only",
)
private val HIGH_CONFIDENCE_EMULATOR_KINDS = setOf(
"qemu_prop",
"qemu_pipe",
"goldfish",
"bluestacks",
)
private val CLONE_USER_IDS = setOf(999)
private val CLONE_USER_ID_RANGE = 950..959 // MIUI dual-app range
internal data class JvmInterfaceSnapshot(
val name: String,
val canonicalName: String?,
@ -95,11 +109,18 @@ object NativeSignsChecker {
detected = detected || mismatchOutcome.detected
needsReview = needsReview || mismatchOutcome.needsReview
val routeOutcome = evaluateRoutes(context)
val nativeRoutes = runCatching { NativeInterfaceProbe.collectRoutes() }.getOrDefault(emptyList())
val routeOutcome = evaluateRoutes(context, nativeRoutes)
findings += routeOutcome.findings
evidence += routeOutcome.evidence
detected = detected || routeOutcome.detected
val hostRouteOutcome = evaluateHostRoutes(context, nativeRoutes)
findings += hostRouteOutcome.findings
evidence += hostRouteOutcome.evidence
detected = detected || hostRouteOutcome.detected
needsReview = needsReview || hostRouteOutcome.needsReview
val hookOutcome = evaluateHookMarkers(context)
findings += hookOutcome.findings
evidence += hookOutcome.evidence
@ -115,6 +136,49 @@ object NativeSignsChecker {
evidence += rootOutcome.evidence
needsReview = needsReview || rootOutcome.needsReview
val emulatorOutcome = evaluateEmulator(
context,
runCatching { NativeInterfaceProbe.collectEmulatorFindings() }.getOrDefault(emptyList()),
collectBuildEmulatorFacts(),
)
findings += emulatorOutcome.findings
evidence += emulatorOutcome.evidence
needsReview = needsReview || emulatorOutcome.needsReview
val isolationOutcome = evaluateIsolation(
context,
userId = extractUserId(context.dataDir?.absolutePath),
isProfileOwner = collectIsProfileOwner(context),
)
findings += isolationOutcome.findings
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),
@ -192,6 +256,27 @@ object NativeSignsChecker {
detected = true
}
val tuntapByType = uniqueByName.filter { iface ->
iface.isUp &&
iface.ifaceType == ARPHRD_TUNTAP &&
!NetworkInterfacePatterns.isVpnInterface(iface.name)
}
for (iface in tuntapByType) {
findings += Finding(
description = context.getString(R.string.checker_native_tuntap_type, iface.name),
detected = true,
source = EvidenceSource.NATIVE_INTERFACE,
confidence = EvidenceConfidence.HIGH,
)
evidence += EvidenceItem(
source = EvidenceSource.NATIVE_INTERFACE,
detected = true,
confidence = EvidenceConfidence.HIGH,
description = "Interface ${iface.name} reports ARPHRD_TUNTAP (type $ARPHRD_TUNTAP) despite non-tunnel name",
)
detected = true
}
val ipsecInterfaces = uniqueByName.filter { iface ->
iface.isUp && NetworkInterfacePatterns.isIpsecInterface(iface.name)
}
@ -332,8 +417,7 @@ object NativeSignsChecker {
return PartialOutcome(findings, evidence, detected = detected, needsReview = needsReview)
}
internal fun evaluateRoutes(context: Context): PartialOutcome {
val routes = runCatching { NativeInterfaceProbe.collectRoutes() }.getOrDefault(emptyList())
internal fun evaluateRoutes(context: Context, routes: List<NativeRouteEntry>): PartialOutcome {
if (routes.isEmpty()) {
return PartialOutcome(
findings = listOf(
@ -421,6 +505,78 @@ object NativeSignsChecker {
return PartialOutcome(findings, evidence, detected = detected)
}
internal fun evaluateHostRoutes(
context: Context,
routes: List<NativeRouteEntry>,
): PartialOutcome {
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
var detected = false
val hostRoutes = routes.filter { route ->
route.source == NativeRouteEntry.RouteSource.NETLINK &&
!route.isDefault &&
(route.prefixLen == 32 || route.prefixLen == 128) &&
route.destination != null &&
!isKernelManagedLocalRoute(route) &&
isPublicRoutableAddress(route.destination) &&
NetworkInterfacePatterns.isStandardInterface(route.interfaceName)
}
for (route in hostRoutes) {
val dst = route.destination ?: continue
findings += Finding(
description = context.getString(
R.string.checker_native_host_route_leak,
dst,
route.interfaceName,
),
detected = true,
source = EvidenceSource.NATIVE_ROUTE,
confidence = EvidenceConfidence.MEDIUM,
)
evidence += EvidenceItem(
source = EvidenceSource.NATIVE_ROUTE,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = "Host route to $dst via physical interface ${route.interfaceName}",
)
detected = true
}
return PartialOutcome(findings, evidence, detected = detected)
}
/**
* Excludes kernel-managed entries from the `local` routing table, which the kernel
* auto-creates for every configured interface address (issue #78). These are NOT
* VPN host-route leaks:
* - `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)
* 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 {
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
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
val inet = runCatching { java.net.InetAddress.getByName(addr) }.getOrNull() ?: return false
val bytes = inet.address ?: return false
if (bytes.size == 16 && (bytes[0].toInt() and 0xFE) == 0xFC) return false
return true
}
internal fun evaluateHookMarkers(context: Context): PartialOutcome {
val markers = runCatching { NativeInterfaceProbe.collectMapsFindings() }.getOrDefault(emptyList())
if (markers.isEmpty()) {
@ -653,11 +809,318 @@ object NativeSignsChecker {
return PartialOutcome(findings, evidence, needsReview = needsReview)
}
internal fun evaluateEmulator(
context: Context,
emulatorFindings: List<NativeEmulatorFinding>,
buildFacts: List<String>,
): PartialOutcome {
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
var needsReview = false
for (finding in emulatorFindings) {
val detail = finding.detail ?: finding.kind
val confidence = if (finding.kind in HIGH_CONFIDENCE_EMULATOR_KINDS) {
EvidenceConfidence.HIGH
} else {
EvidenceConfidence.MEDIUM
}
findings += Finding(
description = context.getString(R.string.checker_native_emulator_marker, detail),
needsReview = true,
source = EvidenceSource.NATIVE_EMULATOR,
confidence = confidence,
)
evidence += EvidenceItem(
source = EvidenceSource.NATIVE_EMULATOR,
detected = true,
confidence = confidence,
description = "Emulator marker [${finding.kind}]: $detail",
)
needsReview = true
}
for (fact in buildFacts) {
findings += Finding(
description = context.getString(R.string.checker_native_emulator_build, fact),
needsReview = true,
source = EvidenceSource.NATIVE_EMULATOR,
confidence = EvidenceConfidence.MEDIUM,
)
evidence += EvidenceItem(
source = EvidenceSource.NATIVE_EMULATOR,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = "Build emulator heuristic: $fact",
)
needsReview = true
}
return PartialOutcome(findings, evidence, needsReview = needsReview)
}
internal fun collectBuildEmulatorFacts(): List<String> {
val facts = mutableListOf<String>()
val fp = android.os.Build.FINGERPRINT.orEmpty()
if (fp.startsWith("generic") || fp.startsWith("unknown") ||
fp.contains("vbox") || fp.contains("emulator") || fp.contains("test-keys")
) {
facts += "Build.FINGERPRINT=$fp"
}
val model = android.os.Build.MODEL.orEmpty()
if (model.contains("google_sdk") || model.contains("Emulator") ||
model.contains("Android SDK built for")
) {
facts += "Build.MODEL=$model"
}
val hw = android.os.Build.HARDWARE.orEmpty()
if (hw in setOf("goldfish", "ranchu", "vbox86")) {
facts += "Build.HARDWARE=$hw"
}
val product = android.os.Build.PRODUCT.orEmpty()
if (product.startsWith("sdk_gphone") || product in setOf("vbox86p", "emulator", "sdk", "google_sdk")) {
facts += "Build.PRODUCT=$product"
}
val manufacturer = android.os.Build.MANUFACTURER.orEmpty()
// "unknown" alone is too broad — some legitimate AOSP/budget devices report it.
// Only flag named emulator vendors here; weaker signals are covered by FINGERPRINT/PRODUCT above.
if (manufacturer.equals("Genymotion", ignoreCase = true)) {
facts += "Build.MANUFACTURER=$manufacturer"
}
return facts
}
internal fun evaluateIsolation(
context: Context,
userId: Int,
isProfileOwner: Boolean,
): PartialOutcome {
val findings = mutableListOf<Finding>()
val evidence = mutableListOf<EvidenceItem>()
var needsReview = false
val isClone = userId in CLONE_USER_IDS || userId in CLONE_USER_ID_RANGE
if (isClone) {
findings += Finding(
description = context.getString(R.string.checker_native_isolation_clone, userId),
needsReview = true,
source = EvidenceSource.SANDBOX_ISOLATION,
confidence = EvidenceConfidence.MEDIUM,
)
evidence += EvidenceItem(
source = EvidenceSource.SANDBOX_ISOLATION,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = "Isolation: clone/dual-app container (user $userId)",
)
needsReview = true
} else if (userId != 0) {
findings += Finding(
description = context.getString(R.string.checker_native_isolation_secondary_user, userId),
needsReview = true,
source = EvidenceSource.SANDBOX_ISOLATION,
confidence = EvidenceConfidence.MEDIUM,
)
evidence += EvidenceItem(
source = EvidenceSource.SANDBOX_ISOLATION,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = "Isolation: secondary user $userId",
)
needsReview = true
}
if (isProfileOwner) {
findings += Finding(
description = context.getString(R.string.checker_native_isolation_work_profile),
needsReview = true,
source = EvidenceSource.SANDBOX_ISOLATION,
confidence = EvidenceConfidence.MEDIUM,
)
evidence += EvidenceItem(
source = EvidenceSource.SANDBOX_ISOLATION,
detected = true,
confidence = EvidenceConfidence.MEDIUM,
description = "Isolation: managed work profile (profile owner)",
)
needsReview = true
}
return PartialOutcome(findings, evidence, needsReview = needsReview)
}
internal fun extractUserId(dataDirPath: String?): Int {
if (dataDirPath == null) return 0
val match = Regex("/data/user/(\\d+)/").find(dataDirPath) ?: return 0
return match.groupValues[1].toIntOrNull() ?: 0
}
internal fun collectIsProfileOwner(context: Context): Boolean {
return runCatching {
val dpm = context.getSystemService(android.content.Context.DEVICE_POLICY_SERVICE)
as? android.app.admin.DevicePolicyManager ?: return false
dpm.isProfileOwnerApp(context.packageName)
}.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

@ -0,0 +1,361 @@
package com.notcvnt.rknhardering.checker
import android.content.Context
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.customcheck.RttTriangulationConfig
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.GeoIpFacts
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.ResolverNetworkStack
import com.notcvnt.rknhardering.probe.SystemPingProber
import com.notcvnt.rknhardering.util.HomeCountryResolver
import java.io.IOException
import java.net.Inet4Address
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
internal object RttTriangulationChecker {
private const val PING_COUNT = 4
private const val THRESHOLD_HOME_MS = 80.0
private const val JITTER_LIMIT_MS = 60.0
private const val MIN_REACHABLE_TARGETS_PER_GROUP = 2
private const val CATEGORY_NAME = "RTT triangulation"
private val RU_TARGETS = listOf(
"yandex.ru",
"mail.ru",
"vk.com",
"sberbank.ru",
"gosuslugi.ru",
)
private val FOREIGN_TARGETS = listOf(
"facebook.com",
"github.com",
"twitter.com",
"reddit.com",
"instagram.com",
)
internal enum class TargetGroup { RU, FOREIGN }
internal data class TargetSpec(val host: String, val group: TargetGroup)
internal data class TargetOutcome(
val spec: TargetSpec,
val ip: String,
val medianRtt: Double?,
val jitter: Double?,
val sent: Int,
val received: Int,
)
internal data class Dependencies(
val resolveIpv4: (String, DnsResolverConfig) -> String = { host, resolverConfig ->
val addresses = ResolverNetworkStack.lookup(
hostname = host,
config = resolverConfig,
cancellationSignal = ScanExecutionContext.currentOrDefault().cancellationSignal,
)
val ipv4 = addresses.firstOrNull { it is Inet4Address }
?: throw IOException("No 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, replyTimeoutSeconds ->
SystemPingProber.probe(address = address, count = count, replyTimeoutSeconds = replyTimeoutSeconds)
},
)
@Volatile
internal var dependenciesOverride: Dependencies? = null
suspend fun check(
context: Context,
resolverConfig: DnsResolverConfig,
geoFacts: GeoIpFacts?,
config: RttTriangulationConfig = RttTriangulationConfig(enabled = false),
): CategoryResult = withContext(Dispatchers.IO) {
if (!config.enabled) {
return@withContext CategoryResult(
name = CATEGORY_NAME,
detected = false,
findings = emptyList(),
)
}
val resolved = HomeCountryResolver.resolve(context, geoFacts)
if (resolved.country == null) {
return@withContext CategoryResult(
name = CATEGORY_NAME,
detected = false,
needsReview = true,
findings = listOf(
Finding(
description = context.getString(R.string.checker_rtt_summary_inconclusive),
needsReview = true,
source = EvidenceSource.RTT_TRIANGULATION,
confidence = EvidenceConfidence.LOW,
),
),
)
}
if (resolved.country != "RU") {
return@withContext CategoryResult(
name = CATEGORY_NAME,
detected = false,
needsReview = true,
findings = listOf(
Finding(
description = context.getString(R.string.checker_rtt_summary_unsupported_country),
needsReview = true,
source = EvidenceSource.RTT_TRIANGULATION,
confidence = EvidenceConfidence.LOW,
),
),
)
}
val deps = dependenciesOverride ?: Dependencies()
val pingCount = if (config.pingCount > 0) config.pingCount else PING_COUNT
val replyTimeoutSeconds = maxOf(1, config.timeoutMs / 1_000)
val builtinRu = if (config.builtinTargetsEnabled) RU_TARGETS.map { TargetSpec(it, TargetGroup.RU) } else emptyList()
val builtinForeign = if (config.builtinTargetsEnabled) FOREIGN_TARGETS.map { TargetSpec(it, TargetGroup.FOREIGN) } else emptyList()
val customSpecs = config.customTargets
.filter { it.host.isNotBlank() }
.map { target ->
val group = if (target.expectedLocation.equals("RU", ignoreCase = true)) TargetGroup.RU else TargetGroup.FOREIGN
TargetSpec(target.host, group)
}
val allSpecs = builtinRu + builtinForeign + customSpecs
val outcomes: List<TargetOutcome> = coroutineScope {
allSpecs.map { spec ->
async {
val (ip, pingResult) = try {
val address = deps.resolveIpv4(spec.host, resolverConfig)
val result = deps.ping(address, pingCount, replyTimeoutSeconds)
address to result
} catch (_: Throwable) {
return@async TargetOutcome(
spec = spec,
ip = "unresolved",
medianRtt = null,
jitter = null,
sent = 0,
received = 0,
)
}
val medianRtt = if (pingResult.received > 0) pingResult.avgRttMs else null
val jitter = if (pingResult.received >= 2 &&
pingResult.maxRttMs != null &&
pingResult.minRttMs != null
) {
pingResult.maxRttMs - pingResult.minRttMs
} else {
null
}
TargetOutcome(
spec = spec,
ip = ip,
medianRtt = medianRtt,
jitter = jitter,
sent = pingResult.sent,
received = pingResult.received,
)
}
}.awaitAll()
}
val ruOutcomes = outcomes.filter { it.spec.group == TargetGroup.RU }
val foreignOutcomes = outcomes.filter { it.spec.group == TargetGroup.FOREIGN }
val homeMedian = median(ruOutcomes.mapNotNull { it.medianRtt })
val foreignMedian = median(foreignOutcomes.mapNotNull { it.medianRtt })
val homeReachableCount = ruOutcomes.count { it.medianRtt != null }
val foreignReachableCount = foreignOutcomes.count { it.medianRtt != null }
val insufficientSamples = homeReachableCount < MIN_REACHABLE_TARGETS_PER_GROUP ||
foreignReachableCount < MIN_REACHABLE_TARGETS_PER_GROUP
val allReachable = outcomes.filter { it.medianRtt != null }
val highJitterCount = allReachable.count { it.jitter != null && it.jitter > JITTER_LIMIT_MS }
val highJitterMajority = allReachable.isNotEmpty() &&
highJitterCount.toDouble() / allReachable.size > 0.5
val (detected, baseConfidence, needsReview, descriptionKey, isError) = when {
insufficientSamples -> RttDecision(
detected = false,
confidence = EvidenceConfidence.LOW,
needsReview = true,
descriptionKey = R.string.checker_rtt_summary_unavailable,
isError = true,
)
homeMedian != null &&
homeMedian > THRESHOLD_HOME_MS &&
foreignMedian != null &&
homeMedian > foreignMedian -> RttDecision(
detected = true,
confidence = EvidenceConfidence.MEDIUM,
needsReview = true,
descriptionKey = R.string.checker_rtt_summary_detected,
isError = false,
)
homeMedian != null &&
homeMedian > THRESHOLD_HOME_MS &&
foreignMedian != null &&
homeMedian <= foreignMedian -> RttDecision(
detected = false,
confidence = EvidenceConfidence.LOW,
needsReview = true,
descriptionKey = R.string.checker_rtt_summary_needs_review,
isError = false,
)
else -> RttDecision(
detected = false,
confidence = EvidenceConfidence.MEDIUM,
needsReview = false,
descriptionKey = R.string.checker_rtt_summary_clean,
isError = false,
)
}
val finalConfidence = if (highJitterMajority) downgrade(baseConfidence) else baseConfidence
val mainFinding = Finding(
description = context.getString(descriptionKey),
detected = detected,
needsReview = needsReview,
isError = isError,
source = EvidenceSource.RTT_TRIANGULATION,
confidence = finalConfidence,
)
val homeLabel = context.getString(R.string.checker_rtt_target_home)
val foreignLabel = context.getString(R.string.checker_rtt_target_foreign)
val unavailable = context.getString(R.string.checker_rtt_value_unavailable)
val mediansFinding = Finding(
description = context.getString(
R.string.checker_rtt_finding_medians,
homeMedian?.let { String.format(Locale.US, "%.0f", it) } ?: unavailable,
foreignMedian?.let { String.format(Locale.US, "%.0f", it) } ?: unavailable,
THRESHOLD_HOME_MS,
),
isInformational = true,
)
val perTargetFindings = outcomes.map { outcome ->
val groupLabel = when (outcome.spec.group) {
TargetGroup.RU -> homeLabel
TargetGroup.FOREIGN -> foreignLabel
}
val description = if (outcome.medianRtt != null) {
context.getString(
R.string.checker_rtt_finding_target,
groupLabel,
outcome.spec.host,
outcome.ip,
outcome.received,
outcome.sent,
outcome.medianRtt,
outcome.jitter ?: 0.0,
)
} else {
context.getString(
R.string.checker_rtt_finding_target_unreachable,
groupLabel,
outcome.spec.host,
outcome.ip,
)
}
Finding(description = description, isInformational = true)
}
val noteFindings = mutableListOf<Finding>()
if (detected) {
noteFindings += Finding(
description = context.getString(R.string.checker_rtt_finding_threshold_note),
isInformational = true,
)
}
if (highJitterMajority) {
noteFindings += Finding(
description = context.getString(R.string.checker_rtt_finding_jitter_note),
isInformational = true,
)
}
val allFindings = listOf(mainFinding, mediansFinding) + noteFindings + perTargetFindings
val evidence = outcomes.map { outcome ->
val groupLabel = when (outcome.spec.group) {
TargetGroup.RU -> context.getString(R.string.checker_rtt_target_home)
TargetGroup.FOREIGN -> context.getString(R.string.checker_rtt_target_foreign)
}
val description = if (outcome.medianRtt != null) {
context.getString(
R.string.checker_rtt_evidence_template,
"[$groupLabel] ${outcome.spec.host}",
outcome.medianRtt,
outcome.jitter ?: 0.0,
)
} else {
context.getString(
R.string.checker_rtt_evidence_unreachable,
"[$groupLabel] ${outcome.spec.host}",
)
}
val evidenceDetected = detected &&
outcome.spec.group == TargetGroup.RU &&
outcome.medianRtt != null &&
outcome.medianRtt > THRESHOLD_HOME_MS
EvidenceItem(
source = EvidenceSource.RTT_TRIANGULATION,
detected = evidenceDetected,
confidence = EvidenceConfidence.MEDIUM,
description = description,
)
}
CategoryResult(
name = CATEGORY_NAME,
detected = detected,
needsReview = needsReview,
findings = allFindings,
evidence = evidence,
)
}
private fun median(values: List<Double>): Double? {
if (values.isEmpty()) return null
val sorted = values.sorted()
val mid = sorted.size / 2
return if (sorted.size % 2 == 0) (sorted[mid - 1] + sorted[mid]) / 2.0 else sorted[mid]
}
private fun downgrade(confidence: EvidenceConfidence): EvidenceConfidence = when (confidence) {
EvidenceConfidence.HIGH -> EvidenceConfidence.MEDIUM
EvidenceConfidence.MEDIUM -> EvidenceConfidence.LOW
EvidenceConfidence.LOW -> EvidenceConfidence.LOW
}
private data class RttDecision(
val detected: Boolean,
val confidence: EvidenceConfidence,
val needsReview: Boolean,
val descriptionKey: Int,
val isError: Boolean,
)
}

View file

@ -0,0 +1,6 @@
package com.notcvnt.rknhardering.checker
internal data class SignalOutcome(
val detected: Boolean = false,
val needsReview: Boolean = false,
)

View file

@ -4,6 +4,7 @@ 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
@ -12,7 +13,9 @@ object VerdictEngine {
private val HARD_DETECT_BYPASS = setOf(
EvidenceSource.SPLIT_TUNNEL_BYPASS,
EvidenceSource.PROXY_AUTH_BYPASS,
EvidenceSource.XRAY_API,
EvidenceSource.CLASH_API,
EvidenceSource.VPN_GATEWAY_LEAK,
EvidenceSource.VPN_NETWORK_BINDING,
)
@ -37,6 +40,8 @@ object VerdictEngine {
private val NATIVE_REVIEW_SOURCES = setOf(
EvidenceSource.NATIVE_HOOK_MARKERS,
EvidenceSource.NATIVE_LIBRARY_INTEGRITY,
EvidenceSource.NATIVE_EMULATOR,
EvidenceSource.SANDBOX_ISOLATION,
)
fun evaluate(
@ -48,21 +53,20 @@ object VerdictEngine {
ipConsensus: IpConsensusResult,
nativeSigns: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()),
icmpSpoofing: CategoryResult = CategoryResult(name = "", detected = false, findings = emptyList()),
geoCheckAvailable: Boolean = true,
): Verdict {
// R1
if (bypassResult.evidence.any { it.detected && it.source in HARD_DETECT_BYPASS }) {
return Verdict.DETECTED
}
// R2 check removed (MATRIX_DIRECT_SOURCES moved to R5)
// R3
if (ipConsensus.probeTargetDivergence) {
return Verdict.DETECTED
}
val geoAxis = ipConsensus.foreignIps.isNotEmpty() ||
ipConsensus.geoCountryMismatch ||
ipConsensus.warpLikeIndicator
// R3
if (ipConsensus.probeTargetDivergence && geoAxis) {
return Verdict.DETECTED
}
if (ipConsensus.probeTargetDirectDivergence && geoAxis) {
return Verdict.DETECTED
}
@ -76,13 +80,19 @@ object VerdictEngine {
it.description.contains("cell_country_ru:true") ||
it.description.contains("location_country_ru:true")
}
val homeRoutedRoaming = locationSignals.locationFacts?.homeRoutedRoaming == true
val geo = geoIp.geoFacts
// Home-routed roaming: foreign SIM on a Russian visited network
// legitimately exits via the SIM's home country. Treat the resulting
// geo mismatch as expected and never auto-detect bypass on geo alone.
val expectedRoamingExit = geo?.expectedRoamingExit == true || homeRoutedRoaming
val geoAxisAvailable = geoCheckAvailable && geo?.fetchError != true
val anyOtherSignal = directSigns.evidence.any { it.detected } ||
indirectSigns.evidence.any { it.detected } ||
ipConsensus.crossChannelMismatch ||
ipConsensus.probeTargetDivergence ||
ipConsensus.probeTargetDirectDivergence
if (locationConfirmsRussia && geo?.outsideRu == true) {
if (locationConfirmsRussia && geo?.outsideRu == true && !expectedRoamingExit) {
return Verdict.DETECTED
}
if (locationConfirmsRussia &&
@ -94,16 +104,22 @@ object VerdictEngine {
}
// R5 — 3-bit matrix (geo x direct x indirect)
val geoHit = geo?.outsideRu == true
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
!geoHit && !directHit && indirectHit -> Verdict.NOT_DETECTED
geoHit && !directHit && !indirectHit -> Verdict.NEEDS_REVIEW
!geoHit && directHit && indirectHit -> Verdict.NEEDS_REVIEW
!geoHit && directHit && indirectHit ->
if (geoAxisAvailable) Verdict.NEEDS_REVIEW else Verdict.DETECTED
geoHit && directHit && !indirectHit -> Verdict.DETECTED
geoHit && !directHit && indirectHit -> Verdict.DETECTED
geoHit && directHit && indirectHit -> Verdict.DETECTED
@ -115,17 +131,27 @@ 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
}
val locationSignalHit = locationSignals.detected && !expectedRoamingExit
if (matrix == Verdict.NOT_DETECTED && (
bypassResult.needsReview ||
directSigns.needsReview ||
indirectSigns.needsReview ||
locationSignalHit ||
hasActionableCallTransportLeak ||
icmpSpoofing.needsReview ||
nativeReviewHit ||
ipConsensus.needsReview ||
ipConsensus.channelConflict.isNotEmpty() ||
ipConsensus.probeTargetDivergence ||
tunProbeReview
)
) {

View file

@ -5,20 +5,43 @@ import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.checker.ipconsensus.AsnResolver
import com.notcvnt.rknhardering.checker.ipconsensus.IpConsensusBuilder
import com.notcvnt.rknhardering.customcheck.CallTransportConfig
import com.notcvnt.rknhardering.customcheck.CdnPullingConfig
import com.notcvnt.rknhardering.customcheck.CustomCdnTarget
import com.notcvnt.rknhardering.customcheck.CustomDomain
import com.notcvnt.rknhardering.customcheck.CustomGeoIpProvider
import com.notcvnt.rknhardering.customcheck.CustomIpEndpoint
import com.notcvnt.rknhardering.customcheck.DirectSignsConfig
import com.notcvnt.rknhardering.customcheck.GeoIpConfig
import com.notcvnt.rknhardering.customcheck.IcmpSpoofingConfig
import com.notcvnt.rknhardering.customcheck.IcmpTarget
import com.notcvnt.rknhardering.customcheck.IndirectSignsConfig
import com.notcvnt.rknhardering.customcheck.IpComparisonConfig
import com.notcvnt.rknhardering.customcheck.LocationSignalsConfig
import com.notcvnt.rknhardering.customcheck.RttTarget
import com.notcvnt.rknhardering.customcheck.RttTriangulationConfig
import com.notcvnt.rknhardering.customcheck.StunServer
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.model.DomainReachabilityResult
import com.notcvnt.rknhardering.model.EvidenceConfidence
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.GeoIpFacts
import com.notcvnt.rknhardering.model.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.LocationSignalsFacts
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.WhitelistAwareDnsFailureCounter
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbe
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.TunProbeModeOverride
import com.notcvnt.rknhardering.probe.UnderlyingNetworkProber
import com.notcvnt.rknhardering.checker.TunInterfaceInfo
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -31,18 +54,41 @@ import kotlinx.coroutines.withContext
data class CheckSettings(
val splitTunnelEnabled: Boolean = true,
val proxyScanEnabled: Boolean = true,
val proxyAuthProbeEnabled: Boolean = false,
val xrayApiScanEnabled: Boolean = true,
val clashApiScanEnabled: Boolean = true,
val networkRequestsEnabled: Boolean = true,
val callTransportProbeEnabled: Boolean = false,
val cdnPullingEnabled: Boolean = false,
val cdnPullingMeduzaEnabled: Boolean = true,
val icmpSpoofingEnabled: Boolean = true,
val rttTriangulationEnabled: Boolean = false,
val nativeSignsEnabled: Boolean = true,
val tunProbeDebugEnabled: Boolean = false,
val tunProbeModeOverride: TunProbeModeOverride = TunProbeModeOverride.AUTO,
val resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
val portRange: String = "full",
val portRangeStart: Int = 1024,
val portRangeEnd: Int = 65535,
val splitTunnelConnectTimeoutMs: Int = 80,
val splitTunnelCheckUnderlyingNetwork: Boolean = true,
val splitTunnelCheckVpnNetworkBinding: Boolean = true,
val splitTunnelCheckMtprotoViaProxy: Boolean = true,
// === Per-checker custom-profile parameters (defaults preserve legacy behaviour) ===
val geoIp: GeoIpConfig = GeoIpConfig(),
val ipComparison: IpComparisonConfig = IpComparisonConfig(),
val cdnPulling: CdnPullingConfig = CdnPullingConfig(enabled = false),
val directSigns: DirectSignsConfig = DirectSignsConfig(),
val indirectSigns: IndirectSignsConfig = IndirectSignsConfig(),
val locationSignals: LocationSignalsConfig = LocationSignalsConfig(),
val icmpSpoofing: IcmpSpoofingConfig = IcmpSpoofingConfig(enabled = true),
val rttTriangulation: RttTriangulationConfig = RttTriangulationConfig(enabled = false),
val callTransport: CallTransportConfig = CallTransportConfig(enabled = false),
// Domain reachability (DPI detection)
val domainReachabilityEnabled: Boolean = false,
val reachabilityDomains: List<CustomDomain> = emptyList(),
)
sealed interface CheckUpdate {
@ -50,6 +96,7 @@ sealed interface CheckUpdate {
data class IpComparisonReady(val result: IpComparisonResult) : CheckUpdate
data class CdnPullingReady(val result: CdnPullingResult) : CheckUpdate
data class IcmpSpoofingReady(val result: CategoryResult) : CheckUpdate
data class RttTriangulationReady(val result: CategoryResult) : CheckUpdate
data class DirectSignsReady(val result: CategoryResult) : CheckUpdate
data class IndirectSignsReady(val result: CategoryResult) : CheckUpdate
data class LocationSignalsReady(val result: CategoryResult) : CheckUpdate
@ -57,77 +104,118 @@ sealed interface CheckUpdate {
data class BypassProgress(val progress: BypassChecker.Progress) : CheckUpdate
data class BypassReady(val result: BypassResult) : CheckUpdate
data class IpConsensusReady(val result: IpConsensusResult) : CheckUpdate
data class DomainReachabilityReady(val result: DomainReachabilityResult) : CheckUpdate
data class VerdictReady(val verdict: Verdict) : CheckUpdate
}
object VpnCheckRunner {
internal data class Dependencies(
val geoIpCheck: suspend (Context, DnsResolverConfig) -> CategoryResult =
{ ctx, resolverConfig -> GeoIpChecker.check(ctx, resolverConfig) },
val ipComparisonCheck: suspend (Context, DnsResolverConfig) -> IpComparisonResult =
{ ctx, resolverConfig -> IpComparisonChecker.check(ctx, resolverConfig = resolverConfig) },
val cdnPullingCheck: suspend (Context, DnsResolverConfig, Boolean) -> CdnPullingResult =
{ ctx, resolverConfig, meduzaEnabled -> CdnPullingChecker.check(ctx, resolverConfig = resolverConfig, meduzaEnabled = meduzaEnabled) },
val icmpSpoofingCheck: suspend (Context, DnsResolverConfig) -> CategoryResult =
{ ctx, resolverConfig -> IcmpSpoofingChecker.check(ctx, resolverConfig) },
val geoIpCheck: suspend (Context, DnsResolverConfig, GeoIpConfig) -> CategoryResult =
{ ctx, resolverConfig, geoIpConfig -> GeoIpChecker.check(ctx, resolverConfig, geoIpConfig) },
val ipComparisonCheck: suspend (Context, DnsResolverConfig, IpComparisonConfig) -> IpComparisonResult =
{ ctx, resolverConfig, ipComparisonConfig ->
IpComparisonChecker.check(ctx, resolverConfig = resolverConfig, config = ipComparisonConfig)
},
val cdnPullingCheck: suspend (Context, DnsResolverConfig, CdnPullingConfig) -> CdnPullingResult =
{ ctx, resolverConfig, cdnConfig ->
CdnPullingChecker.check(ctx, resolverConfig = resolverConfig, config = cdnConfig)
},
val icmpSpoofingCheck: suspend (Context, DnsResolverConfig, IcmpSpoofingConfig) -> CategoryResult =
{ ctx, resolverConfig, icmpConfig -> IcmpSpoofingChecker.check(ctx, resolverConfig, icmpConfig) },
val rttTriangulationCheck: suspend (Context, DnsResolverConfig, GeoIpFacts?, RttTriangulationConfig) -> CategoryResult =
{ ctx, cfg, geo, rttConfig -> RttTriangulationChecker.check(ctx, cfg, geo, rttConfig) },
val underlyingProbe: suspend (
Context,
DnsResolverConfig,
Boolean,
TunProbeModeOverride,
Boolean,
String?,
String?,
) -> UnderlyingNetworkProber.ProbeResult =
{ ctx, resolverConfig, debugEnabled, modeOverride ->
{ ctx, resolverConfig, debugEnabled, modeOverride, tunInterfacePresent, tunInterfaceName, underlyingInterfaceName ->
UnderlyingNetworkProber.probe(
context = ctx,
resolverConfig = resolverConfig,
debugEnabled = debugEnabled,
modeOverride = modeOverride,
tunInterfacePresent = tunInterfacePresent,
tunInterfaceName = tunInterfaceName,
underlyingInterfaceName = underlyingInterfaceName,
)
},
val directCheck: suspend (Context, UnderlyingNetworkProber.ProbeResult?) -> CategoryResult =
{ ctx, tunActiveProbeResult -> DirectSignsChecker.check(ctx, tunActiveProbeResult = tunActiveProbeResult) },
val indirectCheck: suspend (Context, Boolean, Boolean, DnsResolverConfig) -> CategoryResult =
{ ctx, networkRequestsEnabled, callTransportProbeEnabled, resolverConfig ->
val directCheck: suspend (Context, UnderlyingNetworkProber.ProbeResult?, Boolean, DirectSignsConfig) -> CategoryResult =
{ ctx, tunActiveProbeResult, tunInterfacePresent, directConfig ->
DirectSignsChecker.check(
ctx,
tunActiveProbeResult = tunActiveProbeResult,
tunInterfacePresent = tunInterfacePresent,
config = directConfig,
)
},
val tunInterfaceInfoCollector: (Context) -> TunInterfaceInfo =
{ ctx -> IndirectSignsChecker.collectTunInterfaceInfo(ctx) },
val operatorWhitelistProbe: suspend () -> OperatorWhitelistProbeResult =
{ OperatorWhitelistProbe.probe() },
val indirectCheck: suspend (Context, Boolean, Boolean, DnsResolverConfig, IndirectSignsConfig, CallTransportConfig) -> CategoryResult =
{ ctx, networkRequestsEnabled, callTransportProbeEnabled, resolverConfig, indirectConfig, callTransportConfig ->
IndirectSignsChecker.check(
context = ctx,
networkRequestsEnabled = networkRequestsEnabled,
callTransportProbeEnabled = callTransportProbeEnabled,
resolverConfig = resolverConfig,
config = indirectConfig,
callTransportConfig = callTransportConfig,
)
},
val locationCheck: suspend (Context, Boolean, DnsResolverConfig) -> CategoryResult =
{ ctx, networkRequestsEnabled, resolverConfig ->
val locationCheck: suspend (Context, Boolean, DnsResolverConfig, LocationSignalsConfig) -> CategoryResult =
{ ctx, networkRequestsEnabled, resolverConfig, locationConfig ->
LocationSignalsChecker.check(
ctx,
networkRequestsEnabled = networkRequestsEnabled,
resolverConfig = resolverConfig,
config = locationConfig,
)
},
val nativeCheck: suspend (Context) -> CategoryResult =
{ ctx -> NativeSignsChecker.check(ctx) },
val domainReachabilityCheck: suspend (Context, List<CustomDomain>, DnsResolverConfig) -> DomainReachabilityResult =
{ ctx, domains, resolverConfig -> DomainReachabilityChecker.check(ctx, domains, resolverConfig) },
val bypassCheck: suspend (
Context,
DnsResolverConfig,
Boolean,
Boolean,
Boolean,
Boolean,
Boolean,
String,
Int,
Int,
Int,
Boolean,
Boolean,
Boolean,
kotlinx.coroutines.Deferred<UnderlyingNetworkProber.ProbeResult>?,
(suspend (BypassChecker.Progress) -> Unit)?,
) -> BypassResult =
{ ctx, resolverConfig, splitTunnelEnabled, proxyScanEnabled, xrayApiScanEnabled, portRange, portRangeStart, portRangeEnd, underlyingProbeDeferred, onProgress ->
{ ctx, resolverConfig, splitTunnelEnabled, proxyScanEnabled, proxyAuthProbeEnabled, xrayApiScanEnabled, clashApiScanEnabled, portRange, portRangeStart, portRangeEnd, connectTimeoutMs, checkUnderlyingNetwork, checkVpnNetworkBinding, checkMtprotoViaProxy, underlyingProbeDeferred, onProgress ->
BypassChecker.check(
ctx,
resolverConfig,
splitTunnelEnabled,
proxyScanEnabled,
proxyAuthProbeEnabled,
xrayApiScanEnabled,
clashApiScanEnabled,
portRange,
portRangeStart,
portRangeEnd,
connectTimeoutMs,
checkUnderlyingNetwork,
checkVpnNetworkBinding,
checkMtprotoViaProxy,
underlyingProbeDeferred,
onProgress,
)
@ -152,7 +240,7 @@ object VpnCheckRunner {
}
private object Fallbacks {
fun geoIp(context: Context, error: Throwable): CategoryResult = CategoryResult(
fun geoIp(error: Throwable): CategoryResult = CategoryResult(
name = "GeoIP",
detected = false,
findings = listOf(Finding(error.message ?: error::class.java.simpleName, isError = true)),
@ -160,7 +248,9 @@ object VpnCheckRunner {
)
fun ipComparison(context: Context, error: Throwable): IpComparisonResult = IpComparisonResult(
detected = false,
summary = error.message ?: "",
needsReview = true,
hasError = true,
summary = error.message ?: error::class.java.simpleName,
ruGroup = IpCheckerGroupResult(
title = context.getString(R.string.checker_ip_comp_ru_checkers),
detected = false, statusLabel = "", summary = "", responses = emptyList(),
@ -170,7 +260,13 @@ object VpnCheckRunner {
detected = false, statusLabel = "", summary = "", responses = emptyList(),
),
)
fun cdn(error: Throwable): CdnPullingResult = CdnPullingResult.empty()
fun cdn(error: Throwable): CdnPullingResult = CdnPullingResult(
detected = false,
needsReview = true,
hasError = true,
summary = error.message ?: error::class.java.simpleName,
findings = listOf(Finding(error.message ?: error::class.java.simpleName, isError = true)),
)
fun probe(error: Throwable): UnderlyingNetworkProber.ProbeResult =
UnderlyingNetworkProber.ProbeResult(
vpnActive = false,
@ -191,24 +287,37 @@ object VpnCheckRunner {
),
),
)
fun rtt(error: Throwable): CategoryResult = CategoryResult(
name = "RTT triangulation",
detected = false,
needsReview = true,
findings = listOf(
Finding(
error.message ?: error::class.java.simpleName,
isError = true,
source = EvidenceSource.RTT_TRIANGULATION,
),
),
)
fun direct(context: Context, error: Throwable): CategoryResult = CategoryResult(
name = context.getString(R.string.checker_direct_category_name),
detected = false,
needsReview = true,
findings = listOf(Finding(error.message ?: error::class.java.simpleName, isError = true)),
)
fun indirect(context: Context, error: Throwable): CategoryResult = CategoryResult(
fun indirect(error: Throwable): CategoryResult = CategoryResult(
name = "Indirect",
detected = false,
needsReview = true,
findings = listOf(Finding(error.message ?: error::class.java.simpleName, isError = true)),
)
fun location(context: Context, error: Throwable): CategoryResult = CategoryResult(
fun location(error: Throwable): CategoryResult = CategoryResult(
name = "Location",
detected = false,
findings = listOf(Finding(error.message ?: error::class.java.simpleName, isError = true)),
)
fun native(context: Context, error: Throwable): CategoryResult = CategoryResult(
fun domainReachability(error: Throwable): DomainReachabilityResult = DomainReachabilityResult.empty()
fun native(error: Throwable): CategoryResult = CategoryResult(
name = "Native",
detected = false,
findings = listOf(Finding(error.message ?: error::class.java.simpleName, isError = true)),
@ -224,36 +333,144 @@ object VpnCheckRunner {
)
}
private fun annotateExpectedRoamingExit(
geoIp: CategoryResult,
locationFacts: LocationSignalsFacts?,
): CategoryResult {
val geoFacts = geoIp.geoFacts ?: return geoIp
if (locationFacts == null) return geoIp
if (!locationFacts.homeRoutedRoaming) return geoIp
val mcc = locationFacts.homeSimMcc ?: return geoIp
val profile = HomeNetworkCatalog.lookup(mcc, locationFacts.homeSimMnc) ?: return geoIp
val reason = HomeNetworkCatalog.matchExpectedExit(
profile = profile,
asn = geoFacts.asn,
isp = geoFacts.isp,
org = geoFacts.org,
) ?: return geoIp
return geoIp.copy(
geoFacts = geoFacts.copy(
expectedRoamingExit = true,
expectedRoamingExitReason = reason,
),
)
}
private fun relaxCdnPullingForHomeRoutedRoaming(result: CdnPullingResult): CdnPullingResult {
// Home-routed roaming legitimately exposes a foreign IP in CDN trace
// responses. The original detection still records it informationally,
// but it must not raise needsReview when the signal is fully explained
// by the SIM home network.
if (!result.detected && !result.needsReview) return result
return result.copy(
detected = false,
needsReview = false,
findings = result.findings.map { finding ->
when {
finding.isError -> finding.copy(needsReview = false)
finding.detected || finding.needsReview ->
finding.copy(detected = false, needsReview = false, isInformational = true)
else -> finding
}
},
)
}
private fun relaxIcmpSpoofingForHomeRoutedRoaming(result: CategoryResult): CategoryResult {
// The ICMP-spoofing checker assumes a Russian egress; with home-routed
// roaming the egress is intentionally abroad, so blocked targets that
// reply via ICMP cannot be treated as suspicious.
if (!result.needsReview && result.evidence.none { it.detected }) return result
return result.copy(
detected = false,
needsReview = false,
evidence = result.evidence.map { item ->
if (item.source == EvidenceSource.ICMP_SPOOFING && item.detected) {
item.copy(detected = false, confidence = EvidenceConfidence.LOW)
} else item
},
findings = result.findings.map { finding ->
if (finding.needsReview) finding.copy(detected = false, needsReview = false, isInformational = true) else finding
},
)
}
// Wraps a checker deferred so its result is published as a CheckUpdate as
// soon as it completes, preserving the await -> throwIfCancelled -> publish
// sequence and the null contract (no deferred -> no wrapper).
private fun <T> CoroutineScope.publishOnReady(
deferred: Deferred<T>?,
executionContext: ScanExecutionContext,
onUpdate: (suspend (CheckUpdate) -> Unit)?,
toUpdate: (T) -> CheckUpdate,
): Deferred<T>? = deferred?.let { d ->
async {
d.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(toUpdate(result))
}
}
}
suspend fun run(
context: Context,
settings: CheckSettings = CheckSettings(),
executionContext: ScanExecutionContext = ScanExecutionContext(),
onUpdate: (suspend (CheckUpdate) -> Unit)? = null,
): CheckResult = withContext(executionContext.asCoroutineContext()) {
WhitelistAwareDnsFailureCounter.reset()
executionContext.throwIfCancelled()
supervisorScope {
val dependencies = dependenciesOverride ?: Dependencies()
val geoIpDeferred = if (settings.networkRequestsEnabled) {
safeAsync(fallback = { Fallbacks.geoIp(context, it) }) {
dependencies.geoIpCheck(context, settings.resolverConfig)
val geoIpDeferred = if (settings.networkRequestsEnabled && settings.geoIp.enabled) {
safeAsync(fallback = { Fallbacks.geoIp(it) }) {
dependencies.geoIpCheck(context, settings.resolverConfig, settings.geoIp)
}
} else null
val ipComparisonDeferred = if (settings.networkRequestsEnabled) {
val ipComparisonDeferred = if (settings.networkRequestsEnabled && settings.ipComparison.enabled) {
safeAsync(fallback = { Fallbacks.ipComparison(context, it) }) {
dependencies.ipComparisonCheck(context, settings.resolverConfig)
dependencies.ipComparisonCheck(context, settings.resolverConfig, settings.ipComparison)
}
} else null
val cdnPullingDeferred = if (settings.networkRequestsEnabled && settings.cdnPullingEnabled) {
safeAsync(fallback = { Fallbacks.cdn(it) }) {
dependencies.cdnPullingCheck(context, settings.resolverConfig, settings.cdnPullingMeduzaEnabled)
// Project meduzaEnabled toggle from settings into cdnPulling config
val effectiveCdnConfig = settings.cdnPulling.copy(
enabled = true,
meduzaEnabled = settings.cdnPullingMeduzaEnabled,
)
dependencies.cdnPullingCheck(context, settings.resolverConfig, effectiveCdnConfig)
}
} else null
val icmpSpoofingDeferred = if (settings.networkRequestsEnabled && settings.icmpSpoofingEnabled) {
safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.icmp(context, it) }) {
dependencies.icmpSpoofingCheck(context, settings.resolverConfig)
dependencies.icmpSpoofingCheck(context, settings.resolverConfig, settings.icmpSpoofing)
}
} else null
val rttTriangulationDeferred = if (settings.networkRequestsEnabled && settings.rttTriangulationEnabled) {
safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.rtt(it) }) {
val geoFacts = geoIpDeferred?.await()?.geoFacts
dependencies.rttTriangulationCheck(context, settings.resolverConfig, geoFacts, settings.rttTriangulation)
}
} else null
val tunInterfaceInfo = runCatching {
dependencies.tunInterfaceInfoCollector(context)
}.getOrElse {
TunInterfaceInfo(
tunInterfacePresent = false,
tunInterfaceName = null,
underlyingInterfaceName = null,
)
}
val operatorWhitelistDeferred = if (settings.networkRequestsEnabled) {
safeAsync<OperatorWhitelistProbeResult?>(context = Dispatchers.IO, fallback = { null }) {
dependencies.operatorWhitelistProbe()
}
} else null
@ -264,30 +481,50 @@ object VpnCheckRunner {
settings.resolverConfig,
settings.tunProbeDebugEnabled,
settings.tunProbeModeOverride,
tunInterfaceInfo.tunInterfacePresent,
tunInterfaceInfo.tunInterfaceName,
tunInterfaceInfo.underlyingInterfaceName,
)
}
} else null
val directDeferred = safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.direct(context, it) }) {
dependencies.directCheck(
context,
tunActiveProbeDeferred?.await(),
)
}
val indirectDeferred = safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.indirect(context, it) }) {
dependencies.indirectCheck(
context,
settings.networkRequestsEnabled,
settings.callTransportProbeEnabled,
settings.resolverConfig,
)
}
val locationDeferred = safeAsync(fallback = { Fallbacks.location(context, it) }) {
dependencies.locationCheck(context, settings.networkRequestsEnabled, settings.resolverConfig)
}
val nativeDeferred = safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.native(context, it) }) {
dependencies.nativeCheck(context)
}
val directDeferred = if (settings.directSigns.enabled) {
safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.direct(context, it) }) {
dependencies.directCheck(
context,
tunActiveProbeDeferred?.await(),
tunInterfaceInfo.tunInterfacePresent,
settings.directSigns,
)
}
} else null
val indirectDeferred = if (settings.indirectSigns.enabled) {
safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.indirect(it) }) {
dependencies.indirectCheck(
context,
settings.networkRequestsEnabled,
settings.callTransportProbeEnabled,
settings.resolverConfig,
settings.indirectSigns,
settings.callTransport,
)
}
} else null
val locationDeferred = if (settings.locationSignals.enabled) {
safeAsync(fallback = { Fallbacks.location(it) }) {
dependencies.locationCheck(context, settings.networkRequestsEnabled, settings.resolverConfig, settings.locationSignals)
}
} else null
val nativeDeferred = if (settings.nativeSignsEnabled) {
safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.native(it) }) {
dependencies.nativeCheck(context)
}
} else null
val domainReachabilityDeferred = if (settings.domainReachabilityEnabled && settings.reachabilityDomains.isNotEmpty()) {
safeAsync(context = Dispatchers.IO, fallback = { Fallbacks.domainReachability(it) }) {
dependencies.domainReachabilityCheck(context, settings.reachabilityDomains, settings.resolverConfig)
}
} else null
val bypassEnabled = settings.splitTunnelEnabled
val bypassDeferred = if (bypassEnabled) {
safeAsync(fallback = { Fallbacks.bypass(it) }) {
@ -296,10 +533,16 @@ object VpnCheckRunner {
settings.resolverConfig,
settings.splitTunnelEnabled,
settings.proxyScanEnabled,
settings.proxyAuthProbeEnabled,
settings.xrayApiScanEnabled,
settings.clashApiScanEnabled,
settings.portRange,
settings.portRangeStart,
settings.portRangeEnd,
settings.splitTunnelConnectTimeoutMs,
settings.splitTunnelCheckUnderlyingNetwork,
settings.splitTunnelCheckVpnNetworkBinding,
settings.splitTunnelCheckMtprotoViaProxy,
tunActiveProbeDeferred,
{ progress ->
executionContext.throwIfCancelled()
@ -309,70 +552,17 @@ object VpnCheckRunner {
}
} else null
val geoIpReadyDeferred = geoIpDeferred?.let { deferred ->
async {
deferred.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.GeoIpReady(result))
}
}
}
val ipComparisonReadyDeferred = ipComparisonDeferred?.let { deferred ->
async {
deferred.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.IpComparisonReady(result))
}
}
}
val cdnPullingReadyDeferred = cdnPullingDeferred?.let { deferred ->
async {
deferred.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.CdnPullingReady(result))
}
}
}
val icmpSpoofingReadyDeferred = icmpSpoofingDeferred?.let { deferred ->
async {
deferred.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.IcmpSpoofingReady(result))
}
}
}
val directReadyDeferred = async {
directDeferred.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.DirectSignsReady(result))
}
}
val indirectReadyDeferred = async {
indirectDeferred.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.IndirectSignsReady(result))
}
}
val locationReadyDeferred = async {
locationDeferred.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.LocationSignalsReady(result))
}
}
val nativeReadyDeferred = async {
nativeDeferred.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.NativeSignsReady(result))
}
}
val bypassReadyDeferred = bypassDeferred?.let { deferred ->
async {
deferred.await().also { result ->
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.BypassReady(result))
}
}
}
val geoIpReadyDeferred = publishOnReady(geoIpDeferred, executionContext, onUpdate, CheckUpdate::GeoIpReady)
val ipComparisonReadyDeferred = publishOnReady(ipComparisonDeferred, executionContext, onUpdate, CheckUpdate::IpComparisonReady)
val cdnPullingReadyDeferred = publishOnReady(cdnPullingDeferred, executionContext, onUpdate, CheckUpdate::CdnPullingReady)
val icmpSpoofingReadyDeferred = publishOnReady(icmpSpoofingDeferred, executionContext, onUpdate, CheckUpdate::IcmpSpoofingReady)
val rttTriangulationReadyDeferred = publishOnReady(rttTriangulationDeferred, executionContext, onUpdate, CheckUpdate::RttTriangulationReady)
val directReadyDeferred = publishOnReady(directDeferred, executionContext, onUpdate, CheckUpdate::DirectSignsReady)
val indirectReadyDeferred = publishOnReady(indirectDeferred, executionContext, onUpdate, CheckUpdate::IndirectSignsReady)
val locationReadyDeferred = publishOnReady(locationDeferred, executionContext, onUpdate, CheckUpdate::LocationSignalsReady)
val nativeReadyDeferred = publishOnReady(nativeDeferred, executionContext, onUpdate, CheckUpdate::NativeSignsReady)
val domainReachabilityReadyDeferred = publishOnReady(domainReachabilityDeferred, executionContext, onUpdate, CheckUpdate::DomainReachabilityReady)
val bypassReadyDeferred = publishOnReady(bypassDeferred, executionContext, onUpdate, CheckUpdate::BypassReady)
val emptyGeoIpCategory = CategoryResult(name = "GeoIP", detected = false, findings = emptyList())
val emptyIpComparison = IpComparisonResult(
@ -399,6 +589,11 @@ object VpnCheckRunner {
detected = false,
findings = emptyList(),
)
val emptyRttTriangulation = CategoryResult(
name = context.getString(R.string.main_card_rtt_triangulation),
detected = false,
findings = emptyList(),
)
val emptyBypass = BypassResult(
proxyEndpoint = null,
proxyOwner = null,
@ -411,17 +606,53 @@ object VpnCheckRunner {
detected = false,
)
val geoIp = geoIpReadyDeferred?.await() ?: emptyGeoIpCategory
val emptyDirect = CategoryResult(
name = context.getString(R.string.checker_direct_category_name),
detected = false,
findings = emptyList(),
)
val emptyIndirect = CategoryResult(
name = "Indirect",
detected = false,
findings = emptyList(),
)
val emptyLocation = CategoryResult(
name = "Location",
detected = false,
findings = emptyList(),
)
val emptyNative = CategoryResult(
name = "Native",
detected = false,
findings = emptyList(),
)
val rawGeoIp = geoIpReadyDeferred?.await() ?: emptyGeoIpCategory
val ipComparison = ipComparisonReadyDeferred?.await() ?: emptyIpComparison
val cdnPulling = cdnPullingReadyDeferred?.await() ?: emptyCdnPulling
val icmpSpoofing = icmpSpoofingReadyDeferred?.await() ?: emptyIcmpSpoofing
val directSigns = directReadyDeferred.await()
val indirectSigns = indirectReadyDeferred.await()
val locationSignals = locationReadyDeferred.await()
val nativeSigns = nativeReadyDeferred.await()
val rawCdnPulling = cdnPullingReadyDeferred?.await() ?: emptyCdnPulling
val rawIcmpSpoofing = icmpSpoofingReadyDeferred?.await() ?: emptyIcmpSpoofing
val rttTriangulation = rttTriangulationReadyDeferred?.await() ?: emptyRttTriangulation
val directSigns = directReadyDeferred?.await() ?: emptyDirect
val indirectSigns = indirectReadyDeferred?.await() ?: emptyIndirect
val locationSignals = locationReadyDeferred?.await() ?: emptyLocation
val nativeSigns = nativeReadyDeferred?.await() ?: emptyNative
val bypassResult = bypassReadyDeferred?.await() ?: emptyBypass
val domainReachability = domainReachabilityReadyDeferred?.await() ?: DomainReachabilityResult.empty()
val tunProbeResult = tunActiveProbeDeferred?.await()
// Cross-checker reconciliation: when the SIM home network is foreign
// and the visited network is in Russia, the ISP-level egress will
// legitimately appear abroad. Try to confirm via ASN match against
// GeoIP to suppress false positives in CDN/ICMP/GeoIP categories.
val geoIp = annotateExpectedRoamingExit(rawGeoIp, locationSignals.locationFacts)
val homeRoutedRoaming = locationSignals.locationFacts?.homeRoutedRoaming == true
val cdnPulling = if (homeRoutedRoaming) {
relaxCdnPullingForHomeRoutedRoaming(rawCdnPulling)
} else rawCdnPulling
val icmpSpoofing = if (homeRoutedRoaming) {
relaxIcmpSpoofingForHomeRoutedRoaming(rawIcmpSpoofing)
} else rawIcmpSpoofing
val ipConsensus = runCatching {
IpConsensusBuilder.build(
geoIp = geoIp,
@ -430,7 +661,7 @@ object VpnCheckRunner {
tunProbe = tunProbeResult,
bypass = bypassResult,
callTransportLeaks = indirectSigns.callTransportLeaks,
asnResolver = AsnResolver.default(context, settings.resolverConfig),
asnResolver = AsnResolver.default(settings.resolverConfig),
)
}.getOrElse { IpConsensusResult.empty(needsReview = true) }
@ -447,10 +678,13 @@ object VpnCheckRunner {
ipConsensus = ipConsensus,
nativeSigns = nativeSigns,
icmpSpoofing = icmpSpoofing,
geoCheckAvailable = settings.networkRequestsEnabled,
)
executionContext.throwIfCancelled()
onUpdate?.invoke(CheckUpdate.VerdictReady(verdict))
val operatorWhitelistResult = operatorWhitelistDeferred?.await()
CheckResult(
geoIp = geoIp,
ipComparison = ipComparison,
@ -463,7 +697,10 @@ object VpnCheckRunner {
tunProbeDiagnostics = tunProbeResult?.tunProbeDiagnostics,
nativeSigns = nativeSigns,
icmpSpoofing = icmpSpoofing,
rttTriangulation = rttTriangulation,
ipConsensus = ipConsensus,
operatorWhitelistProbe = operatorWhitelistResult,
domainReachability = domainReachability,
)
}
}

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

@ -1,6 +1,5 @@
package com.notcvnt.rknhardering.checker.ipconsensus
import android.content.Context
import com.notcvnt.rknhardering.model.AsnInfo
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.ResolverNetworkStack
@ -44,7 +43,7 @@ class AsnResolver(
const val BATCH_TIMEOUT_MS = 5_000L
const val PER_REQUEST_TIMEOUT_MS = 3_000L
fun default(context: Context, resolverConfig: DnsResolverConfig): AsnResolver {
fun default(resolverConfig: DnsResolverConfig): AsnResolver {
return AsnResolver(
lookup = { ip -> lookupViaIpapiIs(ip, resolverConfig) },
)

View file

@ -145,8 +145,8 @@ object IpConsensusBuilder {
channel = Channel.DIRECT,
source = "geoip",
targetGroup = null,
countryCode = geoIp.geoFacts?.countryCode,
asn = geoIp.geoFacts?.asn,
countryCode = geoIp.geoFacts.countryCode,
asn = geoIp.geoFacts.asn,
)
}

View file

@ -0,0 +1,155 @@
package com.notcvnt.rknhardering.crypto
import java.math.BigInteger
import java.security.MessageDigest
// RFC 8032 Ed25519 — verify only. Pure Kotlin, no third-party deps.
// Constant-time is not a goal (verification has no secret input). Sign is provided
// 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)
.multiply(BigInteger("121666").modInverse(P)).mod(P)
private val I = BigInteger.valueOf(2).modPow(P.subtract(BigInteger.ONE).shiftRight(2), P)
private val B: Point = run {
val by = BigInteger.valueOf(4).multiply(BigInteger.valueOf(5).modInverse(P)).mod(P)
val bx = recoverX(by, false)
Point(bx, by)
}
fun verify(publicKey: ByteArray, message: ByteArray, signature: ByteArray): Boolean {
if (publicKey.size != 32 || signature.size != 64) return false
val a = runCatching { decodePoint(publicKey) }.getOrNull() ?: return false
val r = signature.copyOfRange(0, 32)
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)
md.update(r)
md.update(publicKey)
md.update(message)
val k = decodeScalarLE(md.digest()).mod(L)
val left = scalarMul(B, s)
val right = add(rPoint, scalarMul(a, k))
return left.x == right.x && left.y == right.y
}
fun sign(privateKey: ByteArray, publicKey: ByteArray, message: ByteArray): ByteArray {
require(privateKey.size == 32 && publicKey.size == 32)
val md = MessageDigest.getInstance(SHA_512)
val h = md.digest(privateKey)
val a = clampScalar(h.copyOfRange(0, 32))
val prefix = h.copyOfRange(32, 64)
md.reset(); md.update(prefix); md.update(message)
val r = decodeScalarLE(md.digest()).mod(L)
val rEnc = encodePoint(scalarMul(B, r))
md.reset(); md.update(rEnc); md.update(publicKey); md.update(message)
val k = decodeScalarLE(md.digest()).mod(L)
val s = r.add(k.multiply(a)).mod(L)
return rEnc + encodeScalarLE(s)
}
fun derivePublicKey(privateKey: ByteArray): ByteArray {
require(privateKey.size == 32)
val h = MessageDigest.getInstance(SHA_512).digest(privateKey)
val a = clampScalar(h.copyOfRange(0, 32))
return encodePoint(scalarMul(B, a))
}
private fun clampScalar(bytes: ByteArray): BigInteger {
val c = bytes.copyOf()
c[0] = (c[0].toInt() and 248).toByte()
c[31] = (c[31].toInt() and 127 or 64).toByte()
return decodeScalarLE(c)
}
private fun decodeScalarLE(bytes: ByteArray): BigInteger {
val be = ByteArray(bytes.size + 1)
for (i in bytes.indices) be[bytes.size - i] = bytes[i]
return BigInteger(be)
}
private fun encodeScalarLE(s: BigInteger): ByteArray {
val be = s.toByteArray()
val out = ByteArray(32)
val take = minOf(be.size, 32)
for (i in 0 until take) out[i] = be[be.size - 1 - i]
return out
}
private data class Point(val x: BigInteger, val y: BigInteger)
private fun recoverX(y: BigInteger, sign: Boolean): BigInteger {
val y2 = y.multiply(y).mod(P)
val num = y2.subtract(BigInteger.ONE).mod(P)
val den = D.multiply(y2).add(BigInteger.ONE).mod(P)
val xx = num.multiply(den.modInverse(P)).mod(P)
var x = xx.modPow(P.add(BigInteger.valueOf(3)).shiftRight(3), P)
if (x.multiply(x).subtract(xx).mod(P) != BigInteger.ZERO) {
x = x.multiply(I).mod(P)
}
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
}
private fun decodePoint(bytes: ByteArray): Point {
require(bytes.size == 32)
val raw = bytes.copyOf()
val sign = (raw[31].toInt() and 0x80) != 0
raw[31] = (raw[31].toInt() and 0x7F).toByte()
val y = decodeScalarLE(raw)
require(y < P) { "y out of range" }
val x = recoverX(y, sign)
val p = Point(x, y)
require(onCurve(p)) { "not on curve" }
return p
}
private fun encodePoint(p: Point): ByteArray {
val out = encodeScalarLE(p.y.mod(P))
if (p.x.testBit(0)) out[31] = (out[31].toInt() or 0x80).toByte()
return out
}
private fun onCurve(p: Point): Boolean {
val x2 = p.x.multiply(p.x).mod(P)
val y2 = p.y.multiply(p.y).mod(P)
val lhs = y2.subtract(x2).mod(P)
val rhs = BigInteger.ONE.add(D.multiply(x2).mod(P).multiply(y2).mod(P)).mod(P)
return lhs == rhs
}
private fun add(p1: Point, p2: Point): Point {
val xy = p1.x.multiply(p2.y).mod(P)
val yx = p1.y.multiply(p2.x).mod(P)
val xxyy = D.multiply(p1.x).mod(P).multiply(p2.x).mod(P)
.multiply(p1.y).mod(P).multiply(p2.y).mod(P)
val x3num = xy.add(yx).mod(P)
val x3den = BigInteger.ONE.add(xxyy).mod(P)
val y3num = p1.y.multiply(p2.y).mod(P).add(p1.x.multiply(p2.x).mod(P)).mod(P)
val y3den = BigInteger.ONE.subtract(xxyy).mod(P)
return Point(
x3num.multiply(x3den.modInverse(P)).mod(P),
y3num.multiply(y3den.modInverse(P)).mod(P),
)
}
private fun scalarMul(p: Point, scalar: BigInteger): Point {
var result = Point(BigInteger.ZERO, BigInteger.ONE)
var addend = p
var s = scalar.mod(L)
while (s > BigInteger.ZERO) {
if (s.testBit(0)) result = add(result, addend)
addend = add(addend, addend)
s = s.shiftRight(1)
}
return result
}
}

View file

@ -0,0 +1,56 @@
package com.notcvnt.rknhardering.customcheck
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.net.Uri
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object CustomCheckExportSupport {
private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.US)
// ── file I/O — delegates to repository ───────────────────────────────────
fun exportToFile(context: Context, profile: CustomCheckProfile, uri: Uri) {
CustomCheckRepository.exportToFile(context, profile, uri)
}
fun importFromFile(context: Context, uri: Uri): CustomCheckProfile =
CustomCheckRepository.importFromFile(context, uri)
// ── clipboard ─────────────────────────────────────────────────────────────
fun exportToClipboard(context: Context, profile: CustomCheckProfile) {
val json = CustomCheckSerializer.serialize(profile)
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("rkncheck", json))
}
fun importFromClipboard(context: Context): CustomCheckProfile? {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val text = clipboard.primaryClip
?.getItemAt(0)
?.coerceToText(context)
?.toString()
?: return null
val validation = CustomCheckSerializer.validate(text)
if (validation is ValidationResult.Error) return null
return runCatching { CustomCheckSerializer.deserialize(text) }.getOrNull()
}
// ── filename ──────────────────────────────────────────────────────────────
fun buildExportFileName(profile: CustomCheckProfile): String {
val sanitized = profile.name
.lowercase(Locale.US)
.replace(Regex("[^a-z0-9]+"), "-")
.trim('-')
.take(50)
.ifEmpty { "profile" }
val date = DATE_FORMAT.format(Date(profile.updatedAt))
return "$sanitized-$date.rkncheck"
}
}

View file

@ -0,0 +1,239 @@
package com.notcvnt.rknhardering.customcheck
import java.io.Serializable
import java.util.UUID
// === Main Profile ===
data class CustomCheckProfile(
val id: String = UUID.randomUUID().toString(),
val name: String,
val description: String = "",
val author: String = "",
val version: String = "1.0.0",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val checksConfig: ChecksConfig = ChecksConfig(),
val customDomains: List<CustomDomain> = emptyList(),
val networkConfig: NetworkConfig = NetworkConfig(),
val marketplaceInfo: MarketplaceInfo? = null,
val sourceProfileId: String? = null,
)
// === Checks Configuration ===
data class ChecksConfig(
val geoIp: GeoIpConfig = GeoIpConfig(),
val ipComparison: IpComparisonConfig = IpComparisonConfig(),
val cdnPulling: CdnPullingConfig = CdnPullingConfig(enabled = false),
val directSigns: DirectSignsConfig = DirectSignsConfig(),
val indirectSigns: IndirectSignsConfig = IndirectSignsConfig(),
val nativeSigns: CheckToggle = CheckToggle(),
val locationSignals: LocationSignalsConfig = LocationSignalsConfig(),
val icmpSpoofing: IcmpSpoofingConfig = IcmpSpoofingConfig(enabled = false),
val rttTriangulation: RttTriangulationConfig = RttTriangulationConfig(enabled = false),
val callTransport: CallTransportConfig = CallTransportConfig(enabled = false),
val splitTunnel: SplitTunnelConfig = SplitTunnelConfig(),
val domainReachabilityEnabled: Boolean = true,
)
// === Base toggle ===
data class CheckToggle(val enabled: Boolean = true)
// === Response Mapping — universal API response field mapper ===
enum class ResponseType {
JSON, // Standard JSON, fields via JSONPath ($.field.nested)
PLAIN_TEXT, // Plain text (single IP in response)
KEY_VALUE, // Format key=value\n (like cloudflare trace)
REGEX, // Regex with named groups
}
data class ResponseMapping(
val responseType: ResponseType = ResponseType.JSON,
val ipPath: String? = null,
val countryCodePath: String? = null,
val countryNamePath: String? = null,
val ispPath: String? = null,
val orgPath: String? = null,
val asnPath: String? = null,
val isHostingPath: String? = null,
val isProxyPath: String? = null,
) : Serializable
// === GeoIP with full customization ===
data class GeoIpConfig(
val enabled: Boolean = true,
val timeoutMs: Int = 10_000,
val builtinProviders: Map<String, Boolean> = emptyMap(),
val customProviders: List<CustomGeoIpProvider> = emptyList(),
)
data class CustomGeoIpProvider(
val name: String,
val url: String,
val enabled: Boolean = true,
val responseMapping: ResponseMapping = ResponseMapping(),
)
// === IP Comparison ===
enum class EndpointScope { RU, NON_RU }
data class IpComparisonConfig(
val enabled: Boolean = true,
val timeoutMs: Int = 8_000,
val builtinRuCheckersEnabled: Boolean = true,
val builtinNonRuCheckersEnabled: Boolean = true,
val customEndpoints: List<CustomIpEndpoint> = emptyList(),
)
data class CustomIpEndpoint(
val label: String,
val url: String,
val scope: EndpointScope = EndpointScope.RU,
val enabled: Boolean = true,
val responseMapping: ResponseMapping = ResponseMapping(responseType = ResponseType.PLAIN_TEXT),
)
// === CDN Pulling ===
data class CdnPullingConfig(
val enabled: Boolean = false,
val timeoutMs: Int = 10_000,
val meduzaEnabled: Boolean = true,
val rutrackerEnabled: Boolean = true,
val builtinTargetsEnabled: Boolean = true,
val customTargets: List<CustomCdnTarget> = emptyList(),
)
data class CustomCdnTarget(
val label: String,
val url: String,
val enabled: Boolean = true,
val responseMapping: ResponseMapping = ResponseMapping(responseType = ResponseType.KEY_VALUE),
)
// === Direct Signs — granular toggles ===
data class DirectSignsConfig(
val enabled: Boolean = true,
val checkTransportVpn: Boolean = true,
val checkHttpProxy: Boolean = true,
val checkSocksProxy: Boolean = true,
val checkProxyInfo: Boolean = true,
val checkVpnService: Boolean = true,
)
// === Indirect Signs — granular toggles ===
data class IndirectSignsConfig(
val enabled: Boolean = true,
val checkNotVpnCap: Boolean = true,
val checkVpnInterfaces: Boolean = true,
val checkMtuAnomaly: Boolean = true,
val checkIpsec: Boolean = true,
val checkRouting: Boolean = true,
val checkDns: Boolean = true,
val checkProxyTools: Boolean = true,
val checkLocalListeners: Boolean = true,
val checkDumpsys: Boolean = true,
val listenerPortThreshold: Int = 5,
)
// === Location Signals ===
data class LocationSignalsConfig(
val enabled: Boolean = true,
val checkBeacondb: Boolean = true,
val checkCellTowers: Boolean = true,
val checkWifiSignals: Boolean = true,
)
// === ICMP Spoofing with custom targets ===
data class IcmpSpoofingConfig(
val enabled: Boolean = true,
val timeoutMs: Int = 5_000,
val pingCount: Int = 3,
val builtinTargetsEnabled: Boolean = true,
val customTargets: List<IcmpTarget> = emptyList(),
)
data class IcmpTarget(
val host: String,
val label: String,
val isControl: Boolean = false,
)
// === RTT Triangulation ===
data class RttTriangulationConfig(
val enabled: Boolean = false,
val timeoutMs: Int = 5_000,
val pingCount: Int = 5,
val builtinTargetsEnabled: Boolean = true,
val customTargets: List<RttTarget> = emptyList(),
)
data class RttTarget(
val host: String,
val label: String,
val expectedLocation: String = "",
)
// === Call Transport & STUN ===
data class CallTransportConfig(
val enabled: Boolean = false,
val timeoutMs: Int = 5_000,
val builtinGlobalStunEnabled: Boolean = true,
val builtinRuStunEnabled: Boolean = true,
val checkMtproto: Boolean = true,
val customStunServers: List<StunServer> = emptyList(),
)
data class StunServer(
val host: String,
val port: Int = 3478,
val label: String,
)
// === Split Tunnel ===
data class SplitTunnelConfig(
val enabled: Boolean = true,
val proxyScan: Boolean = true,
val xrayApiScan: Boolean = true,
val portRange: String = "popular",
val portRangeStart: Int = 1024,
val portRangeEnd: Int = 65535,
val connectTimeoutMs: Int = 300,
val checkUnderlyingNetwork: Boolean = true,
val checkVpnNetworkBinding: Boolean = true,
val checkMtprotoViaProxy: Boolean = true,
)
// === Custom domains ===
data class CustomDomain(
val domain: String,
val checkType: String,
val description: String = "",
val expectedDnsAvailable: Boolean = true,
val expectedTcpAvailable: Boolean = true,
val expectedTlsAvailable: Boolean = true,
)
// === Network configuration ===
data class NetworkConfig(
val networkRequestsEnabled: Boolean = true,
val dnsMode: String = "system",
val dnsPreset: String = "custom",
val dnsServers: String = "",
val dohUrl: String = "",
val dohBootstrap: String = "",
)
// === Marketplace info ===
// signatureVerified: true only when this profile entered the device via a
// catalog whose Ed25519 signature validated against the bundled public key
// AND the profile body matched the catalog's expected_hash. Any other source
// (file import, clipboard, hand-edited storage) must store false. Drives the
// "Official" / "Verified" badges — the in-file flags official/verified are
// advisory and ignored unless signatureVerified is also true.
data class MarketplaceInfo(
val sourceUrl: String? = null,
val official: Boolean = false,
val verified: Boolean = false,
val signatureVerified: Boolean = false,
val marketplaceId: String? = null,
val originalHash: String? = null,
)

View file

@ -0,0 +1,158 @@
package com.notcvnt.rknhardering.customcheck
import android.content.Context
import android.net.Uri
import androidx.core.content.edit
import java.io.File
import java.util.UUID
object CustomCheckRepository {
private const val DIR_NAME = "custom_checks"
private const val FILE_EXT = ".rkncheck"
private const val PREFS_NAME = "custom_check_prefs"
// Key owned by SettingsPrefs (Phase 3). Inline here to avoid dependency.
private const val KEY_ACTIVE_PROFILE_ID = "pref_active_custom_profile_id"
// Flips to true after a one-time pass that strips official/verified flags
// from any profile saved before the signature-required model landed.
private const val KEY_LEGACY_MIGRATION_DONE = "pref_marketplace_signature_migration_v1"
// Per-profile expected canonical hash, written at install time and read by
// verifyIntegrity. Stored outside the profile file so an attacker who can
// overwrite the .rkncheck file cannot also rewrite the trusted hash.
private const val KEY_PREFIX_TRUSTED_HASH = "pref_trusted_hash_"
// ── helpers ───────────────────────────────────────────────────────────────
private fun checksDir(context: Context): File =
File(context.filesDir, DIR_NAME).also { it.mkdirs() }
private fun profileFile(context: Context, id: String): File =
File(checksDir(context), "$id$FILE_EXT")
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// ── CRUD ──────────────────────────────────────────────────────────────────
fun getAll(context: Context): List<CustomCheckProfile> {
ensureLegacyMigration(context)
val dir = checksDir(context)
return dir.listFiles { f -> f.extension == "rkncheck" }
?.mapNotNull { f ->
runCatching { CustomCheckSerializer.deserializeFromStorage(f.readText()) }
.map { CustomCheckSerializer.verifyIntegrity(it, trustedHash(context, it.id)) }
.getOrNull()
}
?.sortedBy { it.name }
?: emptyList()
}
fun getById(context: Context, id: String): CustomCheckProfile? {
ensureLegacyMigration(context)
val file = profileFile(context, id)
if (!file.exists()) return null
return runCatching { CustomCheckSerializer.deserializeFromStorage(file.readText()) }
.map { CustomCheckSerializer.verifyIntegrity(it, trustedHash(context, id)) }
.getOrNull()
}
fun save(context: Context, profile: CustomCheckProfile) {
profileFile(context, profile.id).writeText(CustomCheckSerializer.serialize(profile))
// Mirror originalHash into SharedPrefs for verified profiles so verifyIntegrity
// has a tamper-evident reference even if the .rkncheck file is later overwritten.
val info = profile.marketplaceInfo
if (info?.signatureVerified == true && info.originalHash != null) {
prefs(context).edit { putString(KEY_PREFIX_TRUSTED_HASH + profile.id, info.originalHash) }
} else {
prefs(context).edit { remove(KEY_PREFIX_TRUSTED_HASH + profile.id) }
}
}
internal fun trustedHash(context: Context, profileId: String): String? =
prefs(context).getString(KEY_PREFIX_TRUSTED_HASH + profileId, null)
// One-time pass: strip official/verified from any profile stored before the
// signature-required model landed. Older builds wrote those flags directly
// from the catalog with no Ed25519 step; we cannot retroactively trust them.
private fun ensureLegacyMigration(context: Context) {
val prefs = prefs(context)
if (prefs.getBoolean(KEY_LEGACY_MIGRATION_DONE, false)) return
val dir = checksDir(context)
dir.listFiles { f -> f.extension == "rkncheck" }?.forEach { f ->
runCatching {
val profile = CustomCheckSerializer.deserializeFromStorage(f.readText())
val info = profile.marketplaceInfo
// Pre-signature builds had no Ed25519 path, so any official/verified
// flag on an existing profile is unbacked by definition. Force-clear
// and never write a trusted hash for these files — the user will see
// an unverified badge until they reinstall from the signed catalog.
if (info != null) {
val downgraded = profile.copy(
marketplaceInfo = info.copy(
official = false,
verified = false,
signatureVerified = false,
),
)
f.writeText(CustomCheckSerializer.serialize(downgraded))
prefs.edit { remove(KEY_PREFIX_TRUSTED_HASH + profile.id) }
}
}
}
prefs.edit { putBoolean(KEY_LEGACY_MIGRATION_DONE, true) }
}
fun delete(context: Context, id: String) {
val file = profileFile(context, id)
if (!file.exists() || file.delete()) {
prefs(context).edit { remove(KEY_PREFIX_TRUSTED_HASH + id) }
// Clear active profile if deleted
if (getActiveProfileId(context) == id) {
setActiveProfileId(context, null)
}
} else {
android.util.Log.w("CustomCheckRepository", "Failed to delete profile file: $id")
}
}
fun duplicate(context: Context, id: String, newName: String): CustomCheckProfile {
val original = getById(context, id) ?: error("Profile not found: $id")
val copy = original.copy(
id = UUID.randomUUID().toString(),
name = newName,
updatedAt = System.currentTimeMillis(),
createdAt = System.currentTimeMillis(),
sourceProfileId = original.id,
)
save(context, copy)
return copy
}
// ── active profile ────────────────────────────────────────────────────────
fun getActiveProfileId(context: Context): String? =
prefs(context).getString(KEY_ACTIVE_PROFILE_ID, null)
fun setActiveProfileId(context: Context, id: String?) {
prefs(context).edit { putString(KEY_ACTIVE_PROFILE_ID, id) }
}
// ── export / import via ContentResolver ───────────────────────────────────
fun exportToFile(context: Context, profile: CustomCheckProfile, uri: Uri) {
context.contentResolver.openOutputStream(uri)?.use { out ->
out.write(CustomCheckSerializer.serialize(profile).toByteArray(Charsets.UTF_8))
} ?: error("Cannot open output stream for $uri")
}
fun importFromFile(context: Context, uri: Uri): CustomCheckProfile {
val json = context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }
?: error("Cannot open input stream for $uri")
val result = CustomCheckSerializer.validate(json)
if (result is ValidationResult.Error) error("Invalid profile: ${result.message}")
// Untrusted import: deserialize() strips signature_verified and downgrades
// official/verified — caller must NOT promote those flags without a verified
// catalog round-trip.
return CustomCheckSerializer.deserialize(json)
}
}

View file

@ -0,0 +1,146 @@
package com.notcvnt.rknhardering.customcheck
import android.content.Context
import com.notcvnt.rknhardering.checker.CheckSettings
import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.DnsResolverMode
import com.notcvnt.rknhardering.network.DnsResolverPreset
object CustomCheckRunner {
/**
* Maps a [CustomCheckProfile] onto [baseSettings], overriding only the fields
* the profile controls. All other baseSettings fields are preserved as-is.
*/
fun toCheckSettings(profile: CustomCheckProfile, baseSettings: CheckSettings): CheckSettings {
val st = profile.checksConfig.splitTunnel
val net = profile.networkConfig
val cdn = profile.checksConfig.cdnPulling
val ct = profile.checksConfig.callTransport
val icmp = profile.checksConfig.icmpSpoofing
val rtt = profile.checksConfig.rttTriangulation
val resolverConfig = DnsResolverConfig(
mode = DnsResolverMode.fromPref(net.dnsMode),
preset = DnsResolverPreset.fromPref(net.dnsPreset),
customDirectServers = DnsResolverConfig.parseAddressList(net.dnsServers),
customDohUrl = net.dohUrl.trim().takeUnless { it.isEmpty() },
customDohBootstrapHosts = DnsResolverConfig.parseAddressList(net.dohBootstrap),
).sanitized()
// === Route customDomains to per-checker configs ===
val domainsByType = profile.customDomains.groupBy { it.checkType }
val icmpDomains = domainsByType["icmp"]?.map { d ->
IcmpTarget(host = d.domain, label = d.description.ifEmpty { d.domain })
} ?: emptyList()
val rttDomains = domainsByType["rtt"]?.map { d ->
RttTarget(host = d.domain, label = d.description.ifEmpty { d.domain })
} ?: emptyList()
val stunDomains = domainsByType["stun"]?.map { d ->
StunServer(host = d.domain, port = 3478, label = d.description.ifEmpty { d.domain })
} ?: emptyList()
val cdnDomains = domainsByType["cdn_pulling"]?.map { d ->
CustomCdnTarget(
label = d.description.ifEmpty { d.domain },
url = "https://${d.domain}/cdn-cgi/trace",
responseMapping = ResponseMapping(
responseType = ResponseType.KEY_VALUE,
ipPath = "ip=(.+)",
),
)
} ?: emptyList()
val ipCompDomains = domainsByType["ip_comparison"]?.map { d ->
CustomIpEndpoint(
label = d.description.ifEmpty { d.domain },
url = "https://${d.domain}/",
responseMapping = ResponseMapping(responseType = ResponseType.PLAIN_TEXT),
)
} ?: emptyList()
val geoIpDomains = domainsByType["geo_ip"]?.map { d ->
CustomGeoIpProvider(
name = d.description.ifEmpty { d.domain },
url = "https://${d.domain}/",
responseMapping = ResponseMapping(responseType = ResponseType.JSON),
)
} ?: emptyList()
val reachabilityToggle = profile.checksConfig.domainReachabilityEnabled
val reachabilityDomainsRaw = (domainsByType["reachability"] ?: emptyList()) +
(domainsByType["dpi"] ?: emptyList())
val reachabilityDomains = if (reachabilityToggle) reachabilityDomainsRaw else emptyList()
// Merge domain-derived targets into existing configs
val effectiveIcmp = icmp.copy(
customTargets = icmp.customTargets + icmpDomains,
enabled = icmp.enabled || icmpDomains.isNotEmpty(),
)
val effectiveRtt = rtt.copy(
customTargets = rtt.customTargets + rttDomains,
enabled = rtt.enabled || rttDomains.isNotEmpty(),
)
val effectiveCt = ct.copy(
customStunServers = ct.customStunServers + stunDomains,
enabled = ct.enabled || stunDomains.isNotEmpty(),
)
val effectiveCdn = cdn.copy(
customTargets = cdn.customTargets + cdnDomains,
enabled = cdn.enabled || cdnDomains.isNotEmpty(),
)
val effectiveIpComp = profile.checksConfig.ipComparison.copy(
customEndpoints = profile.checksConfig.ipComparison.customEndpoints + ipCompDomains,
)
val effectiveGeoIp = profile.checksConfig.geoIp.copy(
customProviders = profile.checksConfig.geoIp.customProviders + geoIpDomains,
)
return baseSettings.copy(
splitTunnelEnabled = st.enabled,
proxyScanEnabled = st.proxyScan,
xrayApiScanEnabled = st.xrayApiScan,
networkRequestsEnabled = net.networkRequestsEnabled,
callTransportProbeEnabled = effectiveCt.enabled,
cdnPullingEnabled = effectiveCdn.enabled,
cdnPullingMeduzaEnabled = cdn.meduzaEnabled,
icmpSpoofingEnabled = effectiveIcmp.enabled,
rttTriangulationEnabled = effectiveRtt.enabled,
nativeSignsEnabled = profile.checksConfig.nativeSigns.enabled,
portRange = st.portRange,
portRangeStart = st.portRangeStart,
portRangeEnd = st.portRangeEnd,
splitTunnelConnectTimeoutMs = st.connectTimeoutMs,
splitTunnelCheckUnderlyingNetwork = st.checkUnderlyingNetwork,
splitTunnelCheckVpnNetworkBinding = st.checkVpnNetworkBinding,
splitTunnelCheckMtprotoViaProxy = st.checkMtprotoViaProxy,
resolverConfig = resolverConfig,
// Per-checker deep customization
geoIp = effectiveGeoIp,
ipComparison = effectiveIpComp,
cdnPulling = effectiveCdn,
directSigns = profile.checksConfig.directSigns,
indirectSigns = profile.checksConfig.indirectSigns,
locationSignals = profile.checksConfig.locationSignals,
icmpSpoofing = effectiveIcmp,
rttTriangulation = effectiveRtt,
callTransport = effectiveCt,
// Domain reachability (DPI)
domainReachabilityEnabled = reachabilityDomains.isNotEmpty(),
reachabilityDomains = reachabilityDomains,
)
}
fun isProfileActive(context: Context): Boolean =
CustomCheckRepository.getActiveProfileId(context) != null
fun getActiveProfile(context: Context): CustomCheckProfile? {
val id = CustomCheckRepository.getActiveProfileId(context) ?: return null
return CustomCheckRepository.getById(context, id)
}
}

View file

@ -0,0 +1,768 @@
package com.notcvnt.rknhardering.customcheck
import org.json.JSONArray
import org.json.JSONObject
// ─── Validation result ───────────────────────────────────────────────────────
sealed interface ValidationResult {
object Ok : ValidationResult
data class Error(val message: String) : ValidationResult
}
// ─── URL extraction ──────────────────────────────────────────────────────────
data class UrlInfo(
val url: String,
val purpose: String,
val checkName: String,
)
// ─── Serializer ──────────────────────────────────────────────────────────────
object CustomCheckSerializer {
private const val SCHEMA_VERSION = 1
// Caps on imported payload size to keep memory predictable and to make
// attacker-controlled payloads less interesting (no point ferrying 10k URLs).
private const val MAX_CUSTOM_ENTRIES = 32
private const val MAX_LABEL_LEN = 128
// ── serialize ────────────────────────────────────────────────────────────
fun serialize(profile: CustomCheckProfile): String {
val root = JSONObject()
root.put("schema_version", SCHEMA_VERSION)
root.put("id", profile.id)
root.put("name", profile.name)
root.put("description", profile.description)
root.put("author", profile.author)
root.put("version", profile.version)
root.put("created_at", profile.createdAt)
root.put("updated_at", profile.updatedAt)
root.put("checks", serializeChecksConfig(profile.checksConfig))
root.put("custom_domains", serializeCustomDomains(profile.customDomains))
root.put("network", serializeNetworkConfig(profile.networkConfig))
profile.marketplaceInfo?.let { root.put("marketplace", serializeMarketplaceInfo(it, includeSignatureVerified = true)) }
profile.sourceProfileId?.let { root.put("source_profile_id", it) }
return root.toString(2)
}
private fun serializeChecksConfig(c: ChecksConfig): JSONObject {
val obj = JSONObject()
obj.put("geo_ip", serializeGeoIp(c.geoIp))
obj.put("ip_comparison", serializeIpComparison(c.ipComparison))
obj.put("cdn_pulling", serializeCdnPulling(c.cdnPulling))
obj.put("direct_signs", serializeDirectSigns(c.directSigns))
obj.put("indirect_signs", serializeIndirectSigns(c.indirectSigns))
obj.put("native_signs", JSONObject().also { it.put("enabled", c.nativeSigns.enabled) })
obj.put("location_signals", serializeLocationSignals(c.locationSignals))
obj.put("icmp_spoofing", serializeIcmpSpoofing(c.icmpSpoofing))
obj.put("rtt_triangulation", serializeRttTriangulation(c.rttTriangulation))
obj.put("call_transport", serializeCallTransport(c.callTransport))
obj.put("split_tunnel", serializeSplitTunnel(c.splitTunnel))
obj.put("domain_reachability_enabled", c.domainReachabilityEnabled)
return obj
}
private fun serializeResponseMapping(m: ResponseMapping): JSONObject {
val obj = JSONObject()
obj.put("response_type", m.responseType.name)
m.ipPath?.let { obj.put("ip_path", it) }
m.countryCodePath?.let { obj.put("country_code_path", it) }
m.countryNamePath?.let { obj.put("country_name_path", it) }
m.ispPath?.let { obj.put("isp_path", it) }
m.orgPath?.let { obj.put("org_path", it) }
m.asnPath?.let { obj.put("asn_path", it) }
m.isHostingPath?.let { obj.put("is_hosting_path", it) }
m.isProxyPath?.let { obj.put("is_proxy_path", it) }
return obj
}
private fun serializeGeoIp(g: GeoIpConfig): JSONObject {
val obj = JSONObject()
obj.put("enabled", g.enabled)
obj.put("timeout_ms", g.timeoutMs)
val builtinObj = JSONObject()
g.builtinProviders.forEach { (k, v) -> builtinObj.put(k, v) }
obj.put("builtin_providers", builtinObj)
val arr = JSONArray()
g.customProviders.forEach { p ->
val e = JSONObject()
e.put("name", p.name)
e.put("url", p.url)
e.put("enabled", p.enabled)
e.put("response_mapping", serializeResponseMapping(p.responseMapping))
arr.put(e)
}
obj.put("custom_providers", arr)
return obj
}
private fun serializeIpComparison(c: IpComparisonConfig): JSONObject {
val obj = JSONObject()
obj.put("enabled", c.enabled)
obj.put("timeout_ms", c.timeoutMs)
obj.put("builtin_ru_checkers_enabled", c.builtinRuCheckersEnabled)
obj.put("builtin_non_ru_checkers_enabled", c.builtinNonRuCheckersEnabled)
val arr = JSONArray()
c.customEndpoints.forEach { ep ->
val e = JSONObject()
e.put("label", ep.label)
e.put("url", ep.url)
e.put("scope", ep.scope.name)
e.put("enabled", ep.enabled)
e.put("response_mapping", serializeResponseMapping(ep.responseMapping))
arr.put(e)
}
obj.put("custom_endpoints", arr)
return obj
}
private fun serializeCdnPulling(c: CdnPullingConfig): JSONObject {
val obj = JSONObject()
obj.put("enabled", c.enabled)
obj.put("timeout_ms", c.timeoutMs)
obj.put("meduza_enabled", c.meduzaEnabled)
obj.put("rutracker_enabled", c.rutrackerEnabled)
obj.put("builtin_targets_enabled", c.builtinTargetsEnabled)
val arr = JSONArray()
c.customTargets.forEach { t ->
val e = JSONObject()
e.put("label", t.label)
e.put("url", t.url)
e.put("enabled", t.enabled)
e.put("response_mapping", serializeResponseMapping(t.responseMapping))
arr.put(e)
}
obj.put("custom_targets", arr)
return obj
}
private fun serializeDirectSigns(d: DirectSignsConfig): JSONObject {
val obj = JSONObject()
obj.put("enabled", d.enabled)
obj.put("check_transport_vpn", d.checkTransportVpn)
obj.put("check_http_proxy", d.checkHttpProxy)
obj.put("check_socks_proxy", d.checkSocksProxy)
obj.put("check_proxy_info", d.checkProxyInfo)
obj.put("check_vpn_service", d.checkVpnService)
return obj
}
private fun serializeIndirectSigns(i: IndirectSignsConfig): JSONObject {
val obj = JSONObject()
obj.put("enabled", i.enabled)
obj.put("check_not_vpn_cap", i.checkNotVpnCap)
obj.put("check_vpn_interfaces", i.checkVpnInterfaces)
obj.put("check_mtu_anomaly", i.checkMtuAnomaly)
obj.put("check_ipsec", i.checkIpsec)
obj.put("check_routing", i.checkRouting)
obj.put("check_dns", i.checkDns)
obj.put("check_proxy_tools", i.checkProxyTools)
obj.put("check_local_listeners", i.checkLocalListeners)
obj.put("check_dumpsys", i.checkDumpsys)
obj.put("listener_port_threshold", i.listenerPortThreshold)
return obj
}
private fun serializeLocationSignals(l: LocationSignalsConfig): JSONObject {
val obj = JSONObject()
obj.put("enabled", l.enabled)
obj.put("check_beacondb", l.checkBeacondb)
obj.put("check_cell_towers", l.checkCellTowers)
obj.put("check_wifi_signals", l.checkWifiSignals)
return obj
}
private fun serializeIcmpSpoofing(c: IcmpSpoofingConfig): JSONObject {
val obj = JSONObject()
obj.put("enabled", c.enabled)
obj.put("timeout_ms", c.timeoutMs)
obj.put("ping_count", c.pingCount)
obj.put("builtin_targets_enabled", c.builtinTargetsEnabled)
val arr = JSONArray()
c.customTargets.forEach { t ->
val e = JSONObject()
e.put("host", t.host)
e.put("label", t.label)
e.put("is_control", t.isControl)
arr.put(e)
}
obj.put("custom_targets", arr)
return obj
}
private fun serializeRttTriangulation(c: RttTriangulationConfig): JSONObject {
val obj = JSONObject()
obj.put("enabled", c.enabled)
obj.put("timeout_ms", c.timeoutMs)
obj.put("ping_count", c.pingCount)
obj.put("builtin_targets_enabled", c.builtinTargetsEnabled)
val arr = JSONArray()
c.customTargets.forEach { t ->
val e = JSONObject()
e.put("host", t.host)
e.put("label", t.label)
e.put("expected_location", t.expectedLocation)
arr.put(e)
}
obj.put("custom_targets", arr)
return obj
}
private fun serializeCallTransport(c: CallTransportConfig): JSONObject {
val obj = JSONObject()
obj.put("enabled", c.enabled)
obj.put("timeout_ms", c.timeoutMs)
obj.put("builtin_global_stun_enabled", c.builtinGlobalStunEnabled)
obj.put("builtin_ru_stun_enabled", c.builtinRuStunEnabled)
obj.put("check_mtproto", c.checkMtproto)
val arr = JSONArray()
c.customStunServers.forEach { s ->
val e = JSONObject()
e.put("host", s.host)
e.put("port", s.port)
e.put("label", s.label)
arr.put(e)
}
obj.put("custom_stun_servers", arr)
return obj
}
private fun serializeSplitTunnel(s: SplitTunnelConfig): JSONObject {
val obj = JSONObject()
obj.put("enabled", s.enabled)
obj.put("proxy_scan", s.proxyScan)
obj.put("xray_api_scan", s.xrayApiScan)
obj.put("port_range", s.portRange)
obj.put("port_range_start", s.portRangeStart)
obj.put("port_range_end", s.portRangeEnd)
obj.put("connect_timeout_ms", s.connectTimeoutMs)
obj.put("check_underlying_network", s.checkUnderlyingNetwork)
obj.put("check_vpn_network_binding", s.checkVpnNetworkBinding)
obj.put("check_mtproto_via_proxy", s.checkMtprotoViaProxy)
return obj
}
private fun serializeCustomDomains(domains: List<CustomDomain>): JSONArray {
val arr = JSONArray()
domains.forEach { d ->
val e = JSONObject()
e.put("domain", d.domain)
e.put("check_type", d.checkType)
e.put("description", d.description)
e.put("expected_dns_available", d.expectedDnsAvailable)
e.put("expected_tcp_available", d.expectedTcpAvailable)
e.put("expected_tls_available", d.expectedTlsAvailable)
arr.put(e)
}
return arr
}
private fun serializeNetworkConfig(n: NetworkConfig): JSONObject {
val obj = JSONObject()
obj.put("network_requests_enabled", n.networkRequestsEnabled)
obj.put("dns_mode", n.dnsMode)
obj.put("dns_preset", n.dnsPreset)
obj.put("dns_servers", n.dnsServers)
obj.put("doh_url", n.dohUrl)
obj.put("doh_bootstrap", n.dohBootstrap)
return obj
}
private fun serializeMarketplaceInfo(m: MarketplaceInfo, includeSignatureVerified: Boolean): JSONObject {
val obj = JSONObject()
if (m.sourceUrl != null) obj.put("source_url", m.sourceUrl) else obj.put("source_url", JSONObject.NULL)
obj.put("official", m.official)
obj.put("verified", m.verified)
// signature_verified is a device-local trust signal — only serialized when
// writing to local storage, never included in the canonical hash and never
// honored when an imported file claims it.
if (includeSignatureVerified) obj.put("signature_verified", m.signatureVerified)
if (m.marketplaceId != null) obj.put("marketplace_id", m.marketplaceId) else obj.put("marketplace_id", JSONObject.NULL)
if (m.originalHash != null) obj.put("original_hash", m.originalHash) else obj.put("original_hash", JSONObject.NULL)
return obj
}
// ── deserialize ──────────────────────────────────────────────────────────
// Deserialize a profile from an UNTRUSTED source (file/clipboard/network).
// marketplace.signature_verified, official, verified are forced to false here —
// those bits can only be set later by code that actually verified the catalog
// signature for this profile. URLs and hosts are scrubbed via UrlSanitizer.
fun deserialize(json: String): CustomCheckProfile = deserializeInternal(json, trustSignatureField = false)
// Deserialize from device-local storage where we trust the signature_verified
// bit because the app itself wrote it after a successful catalog check.
fun deserializeFromStorage(json: String): CustomCheckProfile = deserializeInternal(json, trustSignatureField = true)
private fun deserializeInternal(json: String, trustSignatureField: Boolean): CustomCheckProfile {
val root = JSONObject(json)
val id = root.optString("id", java.util.UUID.randomUUID().toString())
val name = root.optString("name", "Unnamed Profile")
val description = root.optString("description", "")
val author = root.optString("author", "")
val version = root.optString("version", "1.0.0")
val createdAt = root.optLong("created_at", System.currentTimeMillis())
val updatedAt = root.optLong("updated_at", System.currentTimeMillis())
val checksConfig = if (root.has("checks")) deserializeChecksConfig(root.getJSONObject("checks"))
else ChecksConfig()
val customDomains = if (root.has("custom_domains")) deserializeCustomDomains(root.getJSONArray("custom_domains"))
else emptyList()
val networkConfig = if (root.has("network")) deserializeNetworkConfig(root.getJSONObject("network"))
else NetworkConfig()
val rawMarketplaceInfo = if (root.has("marketplace")) deserializeMarketplaceInfo(root.getJSONObject("marketplace"), trustSignatureField)
else null
// Force official/verified to false when the trust signal is missing. The
// catalog-verification path will re-mint these flags after Ed25519 succeeds.
val marketplaceInfo = rawMarketplaceInfo?.let {
if (it.signatureVerified) it else it.copy(official = false, verified = false)
}
val sourceProfileId = if (root.has("source_profile_id") && !root.isNull("source_profile_id"))
root.getString("source_profile_id") else null
return CustomCheckProfile(
id = id,
name = name,
description = description,
author = author,
version = version,
createdAt = createdAt,
updatedAt = updatedAt,
checksConfig = checksConfig,
customDomains = customDomains,
networkConfig = networkConfig,
marketplaceInfo = marketplaceInfo,
sourceProfileId = sourceProfileId,
)
}
private fun deserializeChecksConfig(obj: JSONObject): ChecksConfig {
return ChecksConfig(
geoIp = if (obj.has("geo_ip")) deserializeGeoIp(obj.getJSONObject("geo_ip")) else GeoIpConfig(),
ipComparison = if (obj.has("ip_comparison")) deserializeIpComparison(obj.getJSONObject("ip_comparison")) else IpComparisonConfig(),
cdnPulling = if (obj.has("cdn_pulling")) deserializeCdnPulling(obj.getJSONObject("cdn_pulling")) else CdnPullingConfig(enabled = false),
directSigns = if (obj.has("direct_signs")) deserializeDirectSigns(obj.getJSONObject("direct_signs")) else DirectSignsConfig(),
indirectSigns = if (obj.has("indirect_signs")) deserializeIndirectSigns(obj.getJSONObject("indirect_signs")) else IndirectSignsConfig(),
nativeSigns = if (obj.has("native_signs")) CheckToggle(obj.getJSONObject("native_signs").optBoolean("enabled", true)) else CheckToggle(),
locationSignals = if (obj.has("location_signals")) deserializeLocationSignals(obj.getJSONObject("location_signals")) else LocationSignalsConfig(),
icmpSpoofing = if (obj.has("icmp_spoofing")) deserializeIcmpSpoofing(obj.getJSONObject("icmp_spoofing")) else IcmpSpoofingConfig(enabled = false),
rttTriangulation = if (obj.has("rtt_triangulation")) deserializeRttTriangulation(obj.getJSONObject("rtt_triangulation")) else RttTriangulationConfig(enabled = false),
callTransport = if (obj.has("call_transport")) deserializeCallTransport(obj.getJSONObject("call_transport")) else CallTransportConfig(enabled = false),
splitTunnel = if (obj.has("split_tunnel")) deserializeSplitTunnel(obj.getJSONObject("split_tunnel")) else SplitTunnelConfig(),
domainReachabilityEnabled = obj.optBoolean("domain_reachability_enabled", true),
)
}
private fun deserializeResponseMapping(obj: JSONObject): ResponseMapping {
val type = runCatching { ResponseType.valueOf(obj.optString("response_type", "JSON")) }
.getOrDefault(ResponseType.JSON)
return ResponseMapping(
responseType = type,
ipPath = obj.optStringOrNull("ip_path"),
countryCodePath = obj.optStringOrNull("country_code_path"),
countryNamePath = obj.optStringOrNull("country_name_path"),
ispPath = obj.optStringOrNull("isp_path"),
orgPath = obj.optStringOrNull("org_path"),
asnPath = obj.optStringOrNull("asn_path"),
isHostingPath = obj.optStringOrNull("is_hosting_path"),
isProxyPath = obj.optStringOrNull("is_proxy_path"),
)
}
private fun JSONObject.optStringOrNull(key: String): String? =
if (has(key) && !isNull(key)) optString(key).takeIf { it.isNotEmpty() } else null
private fun deserializeGeoIp(obj: JSONObject): GeoIpConfig {
val builtinProviders = mutableMapOf<String, Boolean>()
if (obj.has("builtin_providers")) {
val bp = obj.getJSONObject("builtin_providers")
bp.keys().forEach { k -> builtinProviders[k] = bp.optBoolean(k, true) }
}
val customProviders = mutableListOf<CustomGeoIpProvider>()
if (obj.has("custom_providers")) {
val arr = obj.getJSONArray("custom_providers")
for (i in 0 until arr.length().coerceAtMost(MAX_CUSTOM_ENTRIES)) {
val e = arr.getJSONObject(i)
val sanitizedUrl = UrlSanitizer.sanitizeGeoIpProviderUrl(e.optString("url", ""))
if (sanitizedUrl.isEmpty()) continue
customProviders.add(
CustomGeoIpProvider(
name = e.optString("name", "").take(MAX_LABEL_LEN),
url = sanitizedUrl,
enabled = e.optBoolean("enabled", true),
responseMapping = if (e.has("response_mapping")) deserializeResponseMapping(e.getJSONObject("response_mapping")) else ResponseMapping(),
)
)
}
}
return GeoIpConfig(
enabled = obj.optBoolean("enabled", true),
timeoutMs = obj.optInt("timeout_ms", 10_000),
builtinProviders = builtinProviders,
customProviders = customProviders,
)
}
private fun deserializeIpComparison(obj: JSONObject): IpComparisonConfig {
val endpoints = mutableListOf<CustomIpEndpoint>()
if (obj.has("custom_endpoints")) {
val arr = obj.getJSONArray("custom_endpoints")
for (i in 0 until arr.length().coerceAtMost(MAX_CUSTOM_ENTRIES)) {
val e = arr.getJSONObject(i)
val sanitizedUrl = UrlSanitizer.sanitizeHttpsUrl(e.optString("url", ""))
if (sanitizedUrl.isEmpty()) continue
val scope = runCatching { EndpointScope.valueOf(e.optString("scope", "RU")) }.getOrDefault(EndpointScope.RU)
endpoints.add(
CustomIpEndpoint(
label = e.optString("label", "").take(MAX_LABEL_LEN),
url = sanitizedUrl,
scope = scope,
enabled = e.optBoolean("enabled", true),
responseMapping = if (e.has("response_mapping")) deserializeResponseMapping(e.getJSONObject("response_mapping"))
else ResponseMapping(responseType = ResponseType.PLAIN_TEXT),
)
)
}
}
return IpComparisonConfig(
enabled = obj.optBoolean("enabled", true),
timeoutMs = obj.optInt("timeout_ms", 8_000),
builtinRuCheckersEnabled = obj.optBoolean("builtin_ru_checkers_enabled", true),
builtinNonRuCheckersEnabled = obj.optBoolean("builtin_non_ru_checkers_enabled", true),
customEndpoints = endpoints,
)
}
private fun deserializeCdnPulling(obj: JSONObject): CdnPullingConfig {
val targets = mutableListOf<CustomCdnTarget>()
if (obj.has("custom_targets")) {
val arr = obj.getJSONArray("custom_targets")
for (i in 0 until arr.length().coerceAtMost(MAX_CUSTOM_ENTRIES)) {
val e = arr.getJSONObject(i)
val sanitizedUrl = UrlSanitizer.sanitizeHttpsUrl(e.optString("url", ""))
if (sanitizedUrl.isEmpty()) continue
targets.add(
CustomCdnTarget(
label = e.optString("label", "").take(MAX_LABEL_LEN),
url = sanitizedUrl,
enabled = e.optBoolean("enabled", true),
responseMapping = if (e.has("response_mapping")) deserializeResponseMapping(e.getJSONObject("response_mapping"))
else ResponseMapping(responseType = ResponseType.KEY_VALUE),
)
)
}
}
return CdnPullingConfig(
enabled = obj.optBoolean("enabled", false),
timeoutMs = obj.optInt("timeout_ms", 10_000),
meduzaEnabled = obj.optBoolean("meduza_enabled", true),
rutrackerEnabled = obj.optBoolean("rutracker_enabled", true),
builtinTargetsEnabled = obj.optBoolean("builtin_targets_enabled", true),
customTargets = targets,
)
}
private fun deserializeDirectSigns(obj: JSONObject): DirectSignsConfig = DirectSignsConfig(
enabled = obj.optBoolean("enabled", true),
checkTransportVpn = obj.optBoolean("check_transport_vpn", true),
checkHttpProxy = obj.optBoolean("check_http_proxy", true),
checkSocksProxy = obj.optBoolean("check_socks_proxy", true),
checkProxyInfo = obj.optBoolean("check_proxy_info", true),
checkVpnService = obj.optBoolean("check_vpn_service", true),
)
private fun deserializeIndirectSigns(obj: JSONObject): IndirectSignsConfig = IndirectSignsConfig(
enabled = obj.optBoolean("enabled", true),
checkNotVpnCap = obj.optBoolean("check_not_vpn_cap", true),
checkVpnInterfaces = obj.optBoolean("check_vpn_interfaces", true),
checkMtuAnomaly = obj.optBoolean("check_mtu_anomaly", true),
checkIpsec = obj.optBoolean("check_ipsec", true),
checkRouting = obj.optBoolean("check_routing", true),
checkDns = obj.optBoolean("check_dns", true),
checkProxyTools = obj.optBoolean("check_proxy_tools", true),
checkLocalListeners = obj.optBoolean("check_local_listeners", true),
checkDumpsys = obj.optBoolean("check_dumpsys", true),
listenerPortThreshold = obj.optInt("listener_port_threshold", 5),
)
private fun deserializeLocationSignals(obj: JSONObject): LocationSignalsConfig = LocationSignalsConfig(
enabled = obj.optBoolean("enabled", true),
checkBeacondb = obj.optBoolean("check_beacondb", true),
checkCellTowers = obj.optBoolean("check_cell_towers", true),
checkWifiSignals = obj.optBoolean("check_wifi_signals", true),
)
private fun deserializeIcmpSpoofing(obj: JSONObject): IcmpSpoofingConfig {
val targets = mutableListOf<IcmpTarget>()
if (obj.has("custom_targets")) {
val arr = obj.getJSONArray("custom_targets")
for (i in 0 until arr.length().coerceAtMost(MAX_CUSTOM_ENTRIES)) {
val e = arr.getJSONObject(i)
val host = UrlSanitizer.sanitizeHost(e.optString("host", ""))
if (host.isEmpty()) continue
targets.add(IcmpTarget(
host = host,
label = e.optString("label", "").take(MAX_LABEL_LEN),
isControl = e.optBoolean("is_control", false),
))
}
}
return IcmpSpoofingConfig(
enabled = obj.optBoolean("enabled", false),
timeoutMs = obj.optInt("timeout_ms", 5_000),
pingCount = obj.optInt("ping_count", 3),
builtinTargetsEnabled = obj.optBoolean("builtin_targets_enabled", true),
customTargets = targets,
)
}
private fun deserializeRttTriangulation(obj: JSONObject): RttTriangulationConfig {
val targets = mutableListOf<RttTarget>()
if (obj.has("custom_targets")) {
val arr = obj.getJSONArray("custom_targets")
for (i in 0 until arr.length().coerceAtMost(MAX_CUSTOM_ENTRIES)) {
val e = arr.getJSONObject(i)
val host = UrlSanitizer.sanitizeHost(e.optString("host", ""))
if (host.isEmpty()) continue
targets.add(RttTarget(
host = host,
label = e.optString("label", "").take(MAX_LABEL_LEN),
expectedLocation = e.optString("expected_location", "").take(MAX_LABEL_LEN),
))
}
}
return RttTriangulationConfig(
enabled = obj.optBoolean("enabled", false),
timeoutMs = obj.optInt("timeout_ms", 5_000),
pingCount = obj.optInt("ping_count", 5),
builtinTargetsEnabled = obj.optBoolean("builtin_targets_enabled", true),
customTargets = targets,
)
}
private fun deserializeCallTransport(obj: JSONObject): CallTransportConfig {
val servers = mutableListOf<StunServer>()
if (obj.has("custom_stun_servers")) {
val arr = obj.getJSONArray("custom_stun_servers")
for (i in 0 until arr.length().coerceAtMost(MAX_CUSTOM_ENTRIES)) {
val e = arr.getJSONObject(i)
val host = UrlSanitizer.sanitizeHost(e.optString("host", ""))
if (host.isEmpty()) continue
val port = e.optInt("port", 3478).coerceIn(1, 65535)
servers.add(StunServer(
host = host,
port = port,
label = e.optString("label", "").take(MAX_LABEL_LEN),
))
}
}
return CallTransportConfig(
enabled = obj.optBoolean("enabled", false),
timeoutMs = obj.optInt("timeout_ms", 5_000),
builtinGlobalStunEnabled = obj.optBoolean("builtin_global_stun_enabled", true),
builtinRuStunEnabled = obj.optBoolean("builtin_ru_stun_enabled", true),
checkMtproto = obj.optBoolean("check_mtproto", true),
customStunServers = servers,
)
}
private fun deserializeSplitTunnel(obj: JSONObject): SplitTunnelConfig = SplitTunnelConfig(
enabled = obj.optBoolean("enabled", true),
proxyScan = obj.optBoolean("proxy_scan", true),
xrayApiScan = obj.optBoolean("xray_api_scan", true),
portRange = obj.optString("port_range", "popular"),
portRangeStart = obj.optInt("port_range_start", 1024),
portRangeEnd = obj.optInt("port_range_end", 65535),
connectTimeoutMs = obj.optInt("connect_timeout_ms", 300),
checkUnderlyingNetwork = obj.optBoolean("check_underlying_network", true),
checkVpnNetworkBinding = obj.optBoolean("check_vpn_network_binding", true),
checkMtprotoViaProxy = obj.optBoolean("check_mtproto_via_proxy", true),
)
private fun deserializeCustomDomains(arr: JSONArray): List<CustomDomain> {
val list = mutableListOf<CustomDomain>()
for (i in 0 until arr.length().coerceAtMost(MAX_CUSTOM_ENTRIES)) {
val e = arr.getJSONObject(i)
val domain = UrlSanitizer.sanitizeHost(e.optString("domain", ""))
if (domain.isEmpty()) continue
list.add(CustomDomain(
domain = domain,
checkType = e.optString("check_type", "").take(MAX_LABEL_LEN),
description = e.optString("description", "").take(MAX_LABEL_LEN),
expectedDnsAvailable = e.optBoolean("expected_dns_available", true),
expectedTcpAvailable = e.optBoolean("expected_tcp_available", true),
expectedTlsAvailable = e.optBoolean("expected_tls_available", true),
))
}
return list
}
private fun deserializeNetworkConfig(obj: JSONObject): NetworkConfig = NetworkConfig(
networkRequestsEnabled = obj.optBoolean("network_requests_enabled", true),
dnsMode = obj.optString("dns_mode", "system"),
dnsPreset = obj.optString("dns_preset", "custom"),
dnsServers = UrlSanitizer.sanitizeAddressList(obj.optString("dns_servers", "")),
dohUrl = UrlSanitizer.sanitizeHttpsUrl(obj.optString("doh_url", "")),
dohBootstrap = UrlSanitizer.sanitizeAddressList(obj.optString("doh_bootstrap", "")),
)
private fun deserializeMarketplaceInfo(obj: JSONObject, trustSignatureVerifiedField: Boolean): MarketplaceInfo = MarketplaceInfo(
sourceUrl = if (obj.has("source_url") && !obj.isNull("source_url")) obj.getString("source_url") else null,
official = obj.optBoolean("official", false),
verified = obj.optBoolean("verified", false),
// signature_verified inside a file is only honored when reading from
// local storage (where the app itself wrote it). For imports we ignore
// the field entirely so attackers cannot forge it.
signatureVerified = trustSignatureVerifiedField && obj.optBoolean("signature_verified", false),
marketplaceId = if (obj.has("marketplace_id") && !obj.isNull("marketplace_id")) obj.getString("marketplace_id") else null,
originalHash = if (obj.has("original_hash") && !obj.isNull("original_hash")) obj.getString("original_hash") else null,
)
// ── validate ─────────────────────────────────────────────────────────────
fun validate(json: String): ValidationResult {
val root = try {
JSONObject(json)
} catch (e: Exception) {
return ValidationResult.Error("Invalid JSON: ${e.message}")
}
if (!root.has("schema_version")) return ValidationResult.Error("Missing required field: schema_version")
if (!root.has("id")) return ValidationResult.Error("Missing required field: id")
if (!root.has("name")) return ValidationResult.Error("Missing required field: name")
val name = root.optString("name", "")
if (name.isBlank()) return ValidationResult.Error("Field 'name' must not be blank")
return ValidationResult.Ok
}
// ── canonical hash ────────────────────────────────────────────────────────
//
// SHA-256 over a canonical JSON projection of the profile that excludes the
// marketplace block, the active id, and the timestamps. The intent is that the
// hash is stable across (a) the JSON downloaded from GitHub and (b) the JSON
// saved on the device — so the app can verify that a profile marked
// official/verified has not been edited in place by the user.
fun canonicalHash(profile: CustomCheckProfile): String {
val root = JSONObject()
root.put("name", profile.name)
root.put("description", profile.description)
root.put("author", profile.author)
root.put("version", profile.version)
root.put("checks", serializeChecksConfig(profile.checksConfig))
root.put("custom_domains", serializeCustomDomains(profile.customDomains))
root.put("network", serializeNetworkConfig(profile.networkConfig))
return sha256Hex(root.toString())
}
// Re-validates a profile loaded from local storage.
// * If trustedHash is null (never installed from signed catalog), strip any
// official/verified the file might claim.
// * If trustedHash is provided, recompute canonicalHash and require match.
// The trustedHash comes from outside the file (SharedPrefs) so an attacker
// who can rewrite the .rkncheck cannot also rewrite this anchor.
fun verifyIntegrity(profile: CustomCheckProfile, trustedHash: String? = null): CustomCheckProfile {
val info = profile.marketplaceInfo ?: return profile
if (trustedHash == null) {
return if (info.official || info.verified || info.signatureVerified) {
profile.copy(marketplaceInfo = info.copy(official = false, verified = false, signatureVerified = false))
} else profile
}
val recomputed = canonicalHash(profile)
if (recomputed.equals(trustedHash, ignoreCase = true)) return profile
return profile.copy(
marketplaceInfo = info.copy(official = false, verified = false, signatureVerified = false),
)
}
private fun sha256Hex(input: String): String {
val digest = java.security.MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(input.toByteArray(Charsets.UTF_8))
val sb = StringBuilder(bytes.size * 2)
for (b in bytes) {
val v = b.toInt() and 0xFF
sb.append(HEX_CHARS[v ushr 4])
sb.append(HEX_CHARS[v and 0x0F])
}
return sb.toString()
}
private val HEX_CHARS = "0123456789abcdef".toCharArray()
// ── extractAllUrls ────────────────────────────────────────────────────────
fun extractAllUrls(profile: CustomCheckProfile): List<UrlInfo> {
val result = mutableListOf<UrlInfo>()
val net = profile.networkConfig
if (net.dohUrl.isNotBlank()) {
result.add(UrlInfo(url = net.dohUrl, purpose = "DoH endpoint", checkName = "DNS"))
}
if (net.dnsServers.isNotBlank()) {
result.add(UrlInfo(url = net.dnsServers, purpose = "DNS servers", checkName = "DNS"))
}
if (net.dohBootstrap.isNotBlank()) {
result.add(UrlInfo(url = net.dohBootstrap, purpose = "DoH bootstrap", checkName = "DNS"))
}
profile.checksConfig.geoIp.customProviders.forEach { p ->
if (p.url.isNotBlank()) {
result.add(UrlInfo(url = p.url, purpose = p.name, checkName = "GeoIP"))
}
}
profile.checksConfig.ipComparison.customEndpoints.forEach { ep ->
if (ep.url.isNotBlank()) {
result.add(UrlInfo(url = ep.url, purpose = ep.label, checkName = "IP Comparison"))
}
}
profile.checksConfig.cdnPulling.customTargets.forEach { t ->
if (t.url.isNotBlank()) {
result.add(UrlInfo(url = t.url, purpose = t.label, checkName = "CDN Pulling"))
}
}
profile.checksConfig.icmpSpoofing.customTargets.forEach { t ->
if (t.host.isNotBlank()) {
result.add(UrlInfo(url = "ping ${t.host}", purpose = t.label, checkName = "ICMP Spoofing"))
}
}
profile.checksConfig.rttTriangulation.customTargets.forEach { t ->
if (t.host.isNotBlank()) {
result.add(UrlInfo(url = "ping ${t.host}", purpose = t.label, checkName = "RTT Triangulation"))
}
}
profile.checksConfig.callTransport.customStunServers.forEach { s ->
if (s.host.isNotBlank()) {
result.add(UrlInfo(url = "stun://${s.host}:${s.port}", purpose = s.label, checkName = "Call Transport"))
}
}
profile.customDomains.forEach { d ->
if (d.domain.isNotBlank()) {
result.add(
UrlInfo(
url = d.domain,
purpose = d.description.ifBlank { d.checkType },
checkName = "Custom Domain (${d.checkType})",
),
)
}
}
return result
}
}

View file

@ -0,0 +1,210 @@
package com.notcvnt.rknhardering.customcheck
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupMenu
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.card.MaterialCardView
import com.notcvnt.rknhardering.R
internal class ProfileRowAdapter(
private val onActivate: (CustomCheckProfile) -> Unit,
private val onEdit: (CustomCheckProfile) -> Unit,
private val onClone: (CustomCheckProfile) -> Unit,
private val onExport: (CustomCheckProfile) -> Unit,
private val onDelete: (CustomCheckProfile) -> Unit,
) : ListAdapter<ProfileRowAdapter.Item, ProfileRowAdapter.ViewHolder>(DIFF) {
data class Item(
val profile: CustomCheckProfile,
val isActive: Boolean,
val isBuiltin: Boolean = false,
val checkersEnabledCount: Int = 0,
val checkersTotalCount: Int = TOTAL_CHECKERS,
)
companion object {
private const val MENU_EDIT = 1
private const val MENU_CLONE = 2
private const val MENU_EXPORT = 3
private const val MENU_DELETE = 4
private val DIFF = object : DiffUtil.ItemCallback<Item>() {
override fun areItemsTheSame(a: Item, b: Item) = a.profile.id == b.profile.id
override fun areContentsTheSame(a: Item, b: Item) = a == b
}
fun countEnabledCheckers(profile: CustomCheckProfile): Int {
val c = profile.checksConfig
var n = 0
if (c.geoIp.enabled) n++
if (c.ipComparison.enabled) n++
if (c.cdnPulling.enabled) n++
if (c.directSigns.enabled) n++
if (c.indirectSigns.enabled) n++
if (c.nativeSigns.enabled) n++
if (c.locationSignals.enabled) n++
if (c.icmpSpoofing.enabled) n++
if (c.rttTriangulation.enabled) n++
if (c.callTransport.enabled) n++
if (c.splitTunnel.enabled) n++
if (c.domainReachabilityEnabled) n++
return n
}
const val TOTAL_CHECKERS = 12
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.view_profile_row, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val card: MaterialCardView = itemView as MaterialCardView
private val avatarContainer: View = itemView.findViewById(R.id.avatarContainer)
private val avatarIcon: ImageView = itemView.findViewById(R.id.avatarIcon)
private val avatarText: TextView = itemView.findViewById(R.id.avatarText)
private val textName: TextView = itemView.findViewById(R.id.textProfileName)
private val textSubtitle: TextView = itemView.findViewById(R.id.textProfileSubtitle)
private val installedMarker: ImageView = itemView.findViewById(R.id.installedMarker)
private val radioContainer: View = itemView.findViewById(R.id.radioContainer)
private val radioCheck: View = itemView.findViewById(R.id.radioCheck)
private val btnMenu: ImageView = itemView.findViewById(R.id.btnMenu)
fun bind(item: Item) {
val profile = item.profile
val ctx = itemView.context
textName.text = profile.name
// Avatar
if (item.isBuiltin) {
avatarContainer.setBackgroundResource(R.drawable.bg_profile_avatar_builtin)
avatarIcon.visibility = View.VISIBLE
avatarText.visibility = View.GONE
} else {
avatarIcon.visibility = View.GONE
avatarText.visibility = View.VISIBLE
val initials = profile.name.trim().take(1).uppercase().ifBlank { "?" }
avatarText.text = initials
avatarContainer.background = makeAvatarGradient(profile.name)
}
// Installed marker (from marketplace): blue for official, grey for verified, hidden otherwise
val mp = profile.marketplaceInfo
when {
mp?.official == true -> {
installedMarker.visibility = View.VISIBLE
installedMarker.setImageResource(R.drawable.ic_verified_blue)
}
mp?.verified == true -> {
installedMarker.visibility = View.VISIBLE
installedMarker.setImageResource(R.drawable.ic_verified_grey)
}
else -> installedMarker.visibility = View.GONE
}
// Subtitle: "11 checkers · all enabled" / "v1.0.0 · 8 checkers · from marketplace"
textSubtitle.text = buildSubtitle(item)
// Active border
val density = ctx.resources.displayMetrics.density
card.strokeWidth = (1.5f * density).toInt()
if (item.isActive) {
card.strokeColor = resolveAttrColor(ctx, com.google.android.material.R.attr.colorPrimary)
radioContainer.setBackgroundResource(R.drawable.bg_radio_circle_active)
radioCheck.visibility = View.VISIBLE
} else {
card.strokeColor = Color.TRANSPARENT
radioContainer.setBackgroundResource(R.drawable.bg_radio_circle_inactive)
radioCheck.visibility = View.GONE
}
// Tap activates
card.setOnClickListener {
if (!item.isActive) onActivate(profile)
}
// Overflow menu — only for custom (non-builtin) profiles
if (item.isBuiltin) {
btnMenu.visibility = View.GONE
} else {
btnMenu.visibility = View.VISIBLE
btnMenu.setOnClickListener { v ->
val popup = PopupMenu(v.context, v)
popup.menu.add(0, MENU_EDIT, 0, v.context.getString(R.string.action_edit))
popup.menu.add(0, MENU_CLONE, 1, v.context.getString(R.string.action_clone))
popup.menu.add(0, MENU_EXPORT, 2, v.context.getString(R.string.action_export))
popup.menu.add(0, MENU_DELETE, 3, v.context.getString(R.string.action_delete))
popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
MENU_EDIT -> onEdit(profile)
MENU_CLONE -> onClone(profile)
MENU_EXPORT -> onExport(profile)
MENU_DELETE -> onDelete(profile)
}
true
}
popup.show()
}
}
}
private fun buildSubtitle(item: Item): String {
val profile = item.profile
val parts = mutableListOf<String>()
if (profile.version.isNotBlank() && profile.version != "1.0.0") {
parts.add("v${profile.version}")
}
val total = item.checkersTotalCount
val enabled = item.checkersEnabledCount
if (enabled == total) {
parts.add(itemView.context.getString(R.string.profile_subtitle_all_enabled, total))
} else {
parts.add(itemView.context.getString(R.string.profile_subtitle_enabled_of, enabled, total))
}
if (profile.marketplaceInfo != null) {
parts.add(itemView.context.getString(R.string.profile_subtitle_from_marketplace))
}
return parts.joinToString(" · ")
}
}
private fun makeAvatarGradient(name: String): GradientDrawable {
val seed = name.firstOrNull()?.code ?: 0
val hue1 = ((seed * 37) % 360).toFloat()
val hue2 = ((hue1 + 40f) % 360f)
val c1 = Color.HSVToColor(floatArrayOf(hue1, 0.50f, 0.64f))
val c2 = Color.HSVToColor(floatArrayOf(hue2, 0.45f, 0.48f))
val density = android.content.res.Resources.getSystem().displayMetrics.density
return GradientDrawable(
GradientDrawable.Orientation.TL_BR,
intArrayOf(c1, c2),
).apply {
cornerRadius = 12f * density
shape = GradientDrawable.RECTANGLE
}
}
private fun resolveAttrColor(ctx: android.content.Context, attr: Int): Int {
val arr = ctx.obtainStyledAttributes(intArrayOf(attr))
val color = arr.getColor(0, Color.TRANSPARENT)
arr.recycle()
return color
}
}

View file

@ -0,0 +1,116 @@
package com.notcvnt.rknhardering.customcheck
import java.net.IDN
import java.net.InetAddress
import java.net.URI
import java.util.Locale
// Validates URLs and host names extracted from imported profiles. Goal: stop
// profiles from steering checks at attacker-controlled cleartext endpoints or
// the user's own LAN/loopback.
//
// Rules (all silently drop the value when violated; the caller treats a dropped
// value as if the field were empty):
// * URLs: only https:// is accepted. http://, file://, content://, javascript:,
// data: are rejected. Host must pass the host check below.
// * Hosts (used by ICMP/RTT/STUN/DoH bootstrap): no IP-literal in private/loopback/
// link-local ranges, no .local mDNS. Hostnames must look like a public DNS name.
// * Length cap on URL: 512 chars. On host: 253 (RFC 1035 + IDN headroom).
object UrlSanitizer {
private const val MAX_URL_LEN = 512
private const val MAX_HOST_LEN = 253
private const val IP_PLACEHOLDER = "{ip}"
// Public, routable literal used only to validate a placeholder URL. Must pass
// isPublicAddress (TEST-NET ranges like 203.0.113.x are rejected there).
private const val IP_PLACEHOLDER_PROBE = "8.8.8.8"
private val DISALLOWED_HOST_SUFFIXES = listOf(".local", ".localhost", ".internal", ".lan", ".home")
private val DISALLOWED_HOST_EXACT = setOf("localhost", "broadcasthost")
fun sanitizeHttpsUrl(raw: String): String {
if (raw.isBlank() || raw.length > MAX_URL_LEN) return ""
val uri = runCatching { URI(raw) }.getOrNull() ?: return ""
val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return ""
if (scheme != "https") return ""
val host = uri.host ?: return ""
if (!isPublicHost(host)) return ""
return raw
}
// GeoIP custom providers may embed the documented "{ip}" placeholder in the
// URL (see GeoIpChecker.fetchCustomProvider). The raw "{" / "}" are illegal in
// a java.net.URI, so the generic sanitizeHttpsUrl drops such URLs. This variant
// validates a copy with "{ip}" replaced by a public IP literal and returns the
// ORIGINAL string (placeholder intact) when that copy passes. Only the GeoIP
// provider path substitutes "{ip}" at fetch time; do not use this for endpoints
// that send the URL verbatim.
fun sanitizeGeoIpProviderUrl(raw: String): String {
if (raw.isBlank() || raw.length > MAX_URL_LEN) return ""
val probe = raw.replace(IP_PLACEHOLDER, IP_PLACEHOLDER_PROBE)
if (sanitizeHttpsUrl(probe).isEmpty()) return ""
return raw
}
fun sanitizeHost(raw: String): String {
val trimmed = raw.trim()
if (trimmed.isEmpty() || trimmed.length > MAX_HOST_LEN) return ""
if (!isPublicHost(trimmed)) return ""
return trimmed
}
// Drops dns_servers / doh_bootstrap entries pointing at private/loopback addresses.
// Accepts both single string and comma/whitespace separated lists.
fun sanitizeAddressList(raw: String): String {
if (raw.isBlank()) return ""
return raw.split(Regex("[,\\s]+"))
.map { it.trim() }
.filter { it.isNotEmpty() && isPublicAddress(it) }
.joinToString(", ")
}
private fun isPublicHost(host: String): Boolean {
val h = host.removeSurrounding("[", "]").lowercase(Locale.ROOT)
if (h.isEmpty() || h.length > MAX_HOST_LEN) return false
if (h in DISALLOWED_HOST_EXACT) return false
if (DISALLOWED_HOST_SUFFIXES.any { h.endsWith(it) }) return false
// IP literal — must not be private/loopback/link-local
if (looksLikeIpLiteral(h)) return isPublicAddress(h)
// Hostname — must contain a dot and start/end with alphanumeric
if (!h.contains('.')) return false
val ascii = runCatching { IDN.toASCII(h) }.getOrNull() ?: return false
return ascii.matches(Regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)+$"))
}
private fun looksLikeIpLiteral(s: String): Boolean =
s.matches(Regex("^[0-9.]+$")) || s.contains(':')
internal fun isPublicAddress(s: String): Boolean {
val addr = runCatching { InetAddress.getByName(s) }.getOrNull() ?: return false
if (addr.isLoopbackAddress) return false
if (addr.isAnyLocalAddress) return false
if (addr.isLinkLocalAddress) return false
if (addr.isSiteLocalAddress) return false
if (addr.isMulticastAddress) return false
val bytes = addr.address
// CGNAT 100.64.0.0/10
if (bytes.size == 4 && (bytes[0].toInt() and 0xFF) == 100) {
val second = bytes[1].toInt() and 0xFF
if (second in 64..127) return false
}
// 169.254.0.0/16 already covered by isLinkLocalAddress
// 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 240.0.0.0/4
if (bytes.size == 4) {
val b0 = bytes[0].toInt() and 0xFF
val b1 = bytes[1].toInt() and 0xFF
val b2 = bytes[2].toInt() and 0xFF
if (b0 == 192 && b1 == 0 && (b2 == 0 || b2 == 2)) return false
if (b0 == 198 && (b1 == 18 || b1 == 19)) return false
if (b0 == 198 && b1 == 51 && b2 == 100) return false
if (b0 == 203 && b1 == 0 && b2 == 113) return false
if (b0 >= 240) return false
}
return true
}
}

View file

@ -0,0 +1,259 @@
package com.notcvnt.rknhardering.customcheck.mapper
import com.notcvnt.rknhardering.customcheck.ResponseMapping
import com.notcvnt.rknhardering.customcheck.ResponseType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
enum class MappingField {
IP, COUNTRY_CODE, COUNTRY_NAME, ISP, ORG, ASN, IS_HOSTING, IS_PROXY
}
object EndpointResponseMapper {
private val IPV4_REGEX = Regex("""^(\d{1,3}\.){3}\d{1,3}$""")
private val IPV6_REGEX = Regex("""^[0-9a-fA-F:]+$""")
private val COUNTRY_CODE_REGEX = Regex("""^[A-Z]{2}$""")
private val ASN_STRING_REGEX = Regex("""^AS\d+(?:\s.*)?$""")
data class TestResult(
val success: Boolean,
val statusCode: Int?,
val rawBody: String?,
val error: String?,
val responseTimeMs: Long,
val detectedType: ResponseType?,
val suggestedMapping: ResponseMapping?,
)
suspend fun testEndpoint(url: String, timeoutMs: Int = 5000): TestResult =
withContext(Dispatchers.IO) {
val client = OkHttpClient.Builder()
.connectTimeout(timeoutMs.toLong(), TimeUnit.MILLISECONDS)
.readTimeout(timeoutMs.toLong(), TimeUnit.MILLISECONDS)
.build()
val start = System.currentTimeMillis()
try {
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
val elapsed = System.currentTimeMillis() - start
val body = response.body?.string() ?: ""
if (response.isSuccessful) {
val type = autoDetectResponseType(body)
val mapping = autoDetectMapping(body, type)
TestResult(
success = true,
statusCode = response.code,
rawBody = body,
error = null,
responseTimeMs = elapsed,
detectedType = type,
suggestedMapping = mapping,
)
} else {
TestResult(
success = false,
statusCode = response.code,
rawBody = body,
error = "HTTP ${response.code}",
responseTimeMs = elapsed,
detectedType = null,
suggestedMapping = null,
)
}
} catch (e: Exception) {
val elapsed = System.currentTimeMillis() - start
TestResult(
success = false,
statusCode = null,
rawBody = null,
error = e.message ?: e.javaClass.simpleName,
responseTimeMs = elapsed,
detectedType = null,
suggestedMapping = null,
)
}
}
fun autoDetectResponseType(rawResponse: String): ResponseType {
val trimmed = rawResponse.trim()
// 1. Try JSON object
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) { /* not a JSON array, try next format */ }
// 3. key=value lines
val lines = trimmed.lines().filter { it.isNotBlank() }
val kvPattern = Regex("""^\w+=.+$""")
if (lines.size >= 2 && lines.count { kvPattern.matches(it) } >= lines.size * 0.5) {
return ResponseType.KEY_VALUE
}
// 4. Plain text IP
if (looksLikeIp(trimmed)) return ResponseType.PLAIN_TEXT
// 5. Fallback
return ResponseType.REGEX
}
fun autoDetectMapping(rawResponse: String, responseType: ResponseType): ResponseMapping {
return when (responseType) {
ResponseType.JSON -> detectJsonMapping(rawResponse)
ResponseType.KEY_VALUE -> detectKeyValueMapping(rawResponse)
ResponseType.PLAIN_TEXT -> ResponseMapping(
responseType = ResponseType.PLAIN_TEXT,
ipPath = "",
)
ResponseType.REGEX -> ResponseMapping(responseType = ResponseType.REGEX)
}
}
fun extractField(rawResponse: String, mapping: ResponseMapping, field: MappingField): String? {
val path = when (field) {
MappingField.IP -> mapping.ipPath
MappingField.COUNTRY_CODE -> mapping.countryCodePath
MappingField.COUNTRY_NAME -> mapping.countryNamePath
MappingField.ISP -> mapping.ispPath
MappingField.ORG -> mapping.orgPath
MappingField.ASN -> mapping.asnPath
MappingField.IS_HOSTING -> mapping.isHostingPath
MappingField.IS_PROXY -> mapping.isProxyPath
} ?: return null
return when (mapping.responseType) {
ResponseType.JSON -> {
try {
val json = JSONObject(rawResponse.trim())
ResponseMappingParser.extractJsonPath(json, path)?.toString()
} catch (_: Exception) {
null
}
}
ResponseType.KEY_VALUE -> {
ResponseMappingParser.extractKeyValue(rawResponse, path)
}
ResponseType.PLAIN_TEXT -> {
// When path is empty and field is IP, return the whole trimmed body
if (field == MappingField.IP && path.isEmpty()) rawResponse.trim() else null
}
ResponseType.REGEX -> {
ResponseMappingParser.extractRegex(rawResponse, path)
}
}
}
fun extractAll(rawResponse: String, mapping: ResponseMapping): Map<MappingField, String?> {
return MappingField.values().associateWith { field ->
extractField(rawResponse, mapping, field)
}
}
// --- Private helpers ---
private fun looksLikeIp(s: String): Boolean {
val t = s.trim()
return IPV4_REGEX.matches(t) || (IPV6_REGEX.matches(t) && t.contains(':'))
}
private fun detectJsonMapping(rawResponse: String): ResponseMapping {
val json = try { JSONObject(rawResponse.trim()) } catch (_: Exception) { return ResponseMapping(responseType = ResponseType.JSON) }
val flat = flattenJson(json, "$")
var ipPath: String? = null
var countryCodePath: String? = null
var countryNamePath: String? = null
var ispPath: String? = null
var orgPath: String? = null
var asnPath: String? = null
var isHostingPath: String? = null
var isProxyPath: String? = null
for ((path, value) in flat) {
val key = path.substringAfterLast('.').substringAfterLast('[').removeSuffix("]").lowercase()
val strVal = value?.toString() ?: continue
if (ipPath == null && looksLikeIp(strVal)) {
ipPath = path
}
if (countryCodePath == null && key in setOf("country_code", "cc", "countrycode", "country") && COUNTRY_CODE_REGEX.matches(strVal)) {
countryCodePath = 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
}
if (orgPath == null && key in setOf("org", "organization")) {
orgPath = path
}
if (asnPath == null && key in setOf("asn", "as_number", "asnumber", "as")) {
asnPath = path
}
if (asnPath == null && ASN_STRING_REGEX.matches(strVal)) {
asnPath = path
}
if (isHostingPath == null && key in setOf("datacenter", "hosting", "is_hosting", "is_datacenter")) {
isHostingPath = path
}
if (isProxyPath == null && key in setOf("proxy", "vpn", "is_proxy", "is_vpn")) {
isProxyPath = path
}
}
return ResponseMapping(
responseType = ResponseType.JSON,
ipPath = ipPath,
countryCodePath = countryCodePath,
countryNamePath = countryNamePath,
ispPath = ispPath,
orgPath = orgPath,
asnPath = asnPath,
isHostingPath = isHostingPath,
isProxyPath = isProxyPath,
)
}
private fun detectKeyValueMapping(rawResponse: String): ResponseMapping {
val ipPath = if (ResponseMappingParser.extractKeyValue(rawResponse, "ip") != null) "ip" else null
val countryCodePath = if (ResponseMappingParser.extractKeyValue(rawResponse, "loc") != null) "loc" else null
return ResponseMapping(
responseType = ResponseType.KEY_VALUE,
ipPath = ipPath,
countryCodePath = countryCodePath,
)
}
// Flatten a JSONObject into a list of (jsonpath, value) pairs for all leaf nodes
private fun flattenJson(obj: JSONObject, prefix: String): List<Pair<String, Any?>> {
val result = mutableListOf<Pair<String, Any?>>()
for (key in obj.keys()) {
val path = "$prefix.$key"
when (val v = obj.opt(key)) {
is JSONObject -> result += flattenJson(v, path)
is JSONArray -> result += flattenArray(v, path)
else -> result += path to v
}
}
return result
}
private fun flattenArray(arr: JSONArray, prefix: String): List<Pair<String, Any?>> {
val result = mutableListOf<Pair<String, Any?>>()
for (i in 0 until arr.length()) {
val path = "$prefix[$i]"
when (val v = arr.opt(i)) {
is JSONObject -> result += flattenJson(v, path)
is JSONArray -> result += flattenArray(v, path)
else -> result += path to v
}
}
return result
}
}

View file

@ -0,0 +1,85 @@
package com.notcvnt.rknhardering.customcheck.mapper
import org.json.JSONArray
import org.json.JSONObject
object ResponseMappingParser {
// JSONPath-lite: supports $.field, $.field.nested, $.array[0].field
fun extractJsonPath(json: JSONObject, path: String): Any? {
if (!path.startsWith("$")) return null
val stripped = path.removePrefix("$").removePrefix(".")
if (stripped.isEmpty()) return json
return traversePath(json, tokenizePath(stripped))
}
// key=value\n format (cloudflare trace)
fun extractKeyValue(text: String, key: String): String? {
for (line in text.lines()) {
val eq = line.indexOf('=')
if (eq < 0) continue
val k = line.substring(0, eq).trim()
if (k == key) return line.substring(eq + 1).trim()
}
return null
}
// Returns first capture group of pattern, or null if no match
fun extractRegex(text: String, pattern: String): String? {
return try {
Regex(pattern).find(text)?.groupValues?.getOrNull(1)
} catch (_: Exception) {
null
}
}
// Split path like "field.nested[0].sub" into tokens ["field", "nested", "[0]", "sub"]
private fun tokenizePath(path: String): List<String> {
val tokens = mutableListOf<String>()
val current = StringBuilder()
var i = 0
while (i < path.length) {
when {
path[i] == '.' -> {
if (current.isNotEmpty()) { tokens += current.toString(); current.clear() }
i++
}
path[i] == '[' -> {
if (current.isNotEmpty()) { tokens += current.toString(); current.clear() }
val end = path.indexOf(']', i)
if (end < 0) break
tokens += path.substring(i, end + 1) // "[0]"
i = end + 1
if (i < path.length && path[i] == '.') i++
}
else -> { current.append(path[i]); i++ }
}
}
if (current.isNotEmpty()) tokens += current.toString()
return tokens
}
private fun traversePath(node: Any?, tokens: List<String>): Any? {
if (tokens.isEmpty()) return node
val token = tokens[0]
val rest = tokens.drop(1)
return when {
token.startsWith("[") && token.endsWith("]") -> {
val idx = token.removePrefix("[").removeSuffix("]").toIntOrNull() ?: return null
when (node) {
is JSONArray -> {
if (idx < 0 || idx >= node.length()) return null
traversePath(node.opt(idx), rest)
}
else -> null
}
}
node is JSONObject -> {
if (!node.has(token)) return null
traversePath(node.opt(token), rest)
}
else -> null
}
}
}

View file

@ -0,0 +1,173 @@
package com.notcvnt.rknhardering.customcheck.marketplace
import android.content.Context
import com.notcvnt.rknhardering.AppUiSettings
import com.notcvnt.rknhardering.SettingsPrefs
import com.notcvnt.rknhardering.crypto.Ed25519
import com.notcvnt.rknhardering.customcheck.CustomCheckProfile
import com.notcvnt.rknhardering.customcheck.CustomCheckSerializer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
object MarketplaceClient {
const val CATALOG_URL =
"https://raw.githubusercontent.com/xtclovver/RKNHardering/main/marketplace/catalog.json"
private const val CATALOG_SIG_URL =
"https://raw.githubusercontent.com/xtclovver/RKNHardering/main/marketplace/catalog.sig"
private const val PUBKEY_ASSET = "marketplace_pubkey.hex"
// SPKI sha256 pins for raw.githubusercontent.com. Two pins (current + backup)
// keep the catalog reachable through one GitHub cert rotation. To refresh, run
// openssl s_client -servername raw.githubusercontent.com -connect raw.githubusercontent.com:443 \
// | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER \
// | openssl dgst -sha256 -binary | openssl enc -base64
// We deliberately keep this list non-strict (no pin = network failure) — the
// Ed25519 signature is the real trust anchor. If both pins go stale the
// catalog still won't ship without the signature check passing.
private val CATALOG_HOST_PINS: List<String> = listOf(
// Placeholder pins — replace with real openssl-derived SPKI digests when
// shipping. Empty list disables pinning.
)
class NetworkDisabledException :
IllegalStateException("Network requests are disabled in settings")
class HashMismatchException(val expected: String, val actual: String) :
IllegalStateException("Profile hash mismatch: expected=$expected actual=$actual")
class CatalogSignatureException(message: String) : IllegalStateException(message)
private val client: OkHttpClient by lazy {
val builder = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
if (CATALOG_HOST_PINS.isNotEmpty()) {
val pinnerBuilder = CertificatePinner.Builder()
CATALOG_HOST_PINS.forEach { pin ->
pinnerBuilder.add("raw.githubusercontent.com", "sha256/$pin")
}
builder.certificatePinner(pinnerBuilder.build())
}
builder.build()
}
fun isNetworkAllowed(context: Context): Boolean =
AppUiSettings.prefs(context)
.getBoolean(SettingsPrefs.PREF_NETWORK_REQUESTS_ENABLED, true)
suspend fun fetchCatalog(context: Context): MarketplaceCatalog = withContext(Dispatchers.IO) {
if (!isNetworkAllowed(context)) throw NetworkDisabledException()
val catalogBody = httpGetBytes(CATALOG_URL)
val signatureHex = runCatching { httpGetString(CATALOG_SIG_URL).trim() }.getOrNull()
val signatureValid = signatureHex
?.let { runCatching { hexToBytes(it) }.getOrNull() }
?.let { sig -> verifyCatalogSignature(context, catalogBody, sig) }
?: false
parseCatalog(String(catalogBody, Charsets.UTF_8), signatureValid)
}
suspend fun fetchProfile(context: Context, entry: MarketplaceEntry, catalogSignatureValid: Boolean): CustomCheckProfile =
withContext(Dispatchers.IO) {
if (!isNetworkAllowed(context)) throw NetworkDisabledException()
val body = httpGetString(entry.profileUrl)
val profile = CustomCheckSerializer.deserialize(body)
val expected = entry.expectedHash
if (expected != null) {
val actual = CustomCheckSerializer.canonicalHash(profile)
if (!actual.equals(expected, ignoreCase = true)) {
throw HashMismatchException(expected = expected, actual = actual)
}
}
// signatureVerified only when the catalog itself was signed by the
// bundled public key AND we had a hash to match against AND it matched.
val signatureVerified = catalogSignatureValid && expected != null
if (!signatureVerified) profile else {
val info = profile.marketplaceInfo
if (info != null) {
profile.copy(marketplaceInfo = info.copy(signatureVerified = true))
} else profile
}
}
private fun httpGetBytes(url: String): ByteArray {
val request = Request.Builder().url(url).build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}")
response.body?.bytes() ?: error("Empty response body")
}
}
private fun httpGetString(url: String): String {
val request = Request.Builder().url(url).build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}")
response.body?.string() ?: error("Empty response body")
}
}
private fun verifyCatalogSignature(context: Context, body: ByteArray, signature: ByteArray): Boolean {
val pubKey = runCatching { loadPublicKey(context) }.getOrNull() ?: return false
return Ed25519.verify(pubKey, body, signature)
}
private fun loadPublicKey(context: Context): ByteArray {
val hex = context.assets.open(PUBKEY_ASSET).use { it.readBytes() }.toString(Charsets.UTF_8).trim()
return hexToBytes(hex)
}
private fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim().lowercase()
require(clean.length % 2 == 0) { "odd hex length" }
val out = ByteArray(clean.length / 2)
for (i in out.indices) out[i] = clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
return out
}
private fun parseCatalog(json: String, signatureValid: Boolean): MarketplaceCatalog {
val root = JSONObject(json)
val schemaVersion = root.optInt("schema_version", 1)
val updatedAt = root.optString("updated_at", "")
val entriesArr = root.optJSONArray("entries")
val entries = mutableListOf<MarketplaceEntry>()
if (entriesArr != null) {
for (i in 0 until entriesArr.length()) {
val e = entriesArr.getJSONObject(i)
val tagsArr = e.optJSONArray("tags")
val tags = mutableListOf<String>()
if (tagsArr != null) {
for (j in 0 until tagsArr.length()) tags.add(tagsArr.getString(j))
}
// official/verified come from the catalog but only mean anything when
// the catalog itself was signed by us. Clamp them here so downstream
// code can rely on the invariant.
val rawOfficial = e.optBoolean("official", false)
val rawVerified = e.optBoolean("verified", false)
entries.add(
MarketplaceEntry(
id = e.optString("id", ""),
name = e.optString("name", ""),
description = e.optString("description", ""),
author = e.optString("author", ""),
version = e.optString("version", "1.0.0"),
official = signatureValid && rawOfficial,
verified = signatureValid && rawVerified,
profileUrl = e.optString("profile_url", ""),
tags = tags,
createdAt = e.optString("created_at", ""),
updatedAt = e.optString("updated_at", ""),
expectedHash = if (e.has("expected_hash") && !e.isNull("expected_hash"))
e.getString("expected_hash").takeIf { it.isNotBlank() } else null,
)
)
}
}
return MarketplaceCatalog(schemaVersion, updatedAt, entries, signatureValid = signatureValid)
}
}

View file

@ -0,0 +1,122 @@
package com.notcvnt.rknhardering.customcheck.marketplace
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.notcvnt.rknhardering.R
internal class MarketplaceItemAdapter(
private val onInstall: (MarketplaceEntry) -> Unit,
private val onOpenInstalled: (MarketplaceEntry) -> Unit,
private val onUpdate: (MarketplaceEntry) -> Unit = {},
) : ListAdapter<MarketplaceItemAdapter.Item, MarketplaceItemAdapter.ViewHolder>(DIFF) {
data class Item(
val entry: MarketplaceEntry,
val installed: Boolean,
val hasUpdate: Boolean = false,
)
companion object {
private val DIFF = object : DiffUtil.ItemCallback<Item>() {
override fun areItemsTheSame(a: Item, b: Item) = a.entry.id == b.entry.id
override fun areContentsTheSame(a: Item, b: Item) = a == b
}
}
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val avatarFrame: View = view.findViewById(R.id.marketplaceAvatar)
val avatarInitial: TextView = view.findViewById(R.id.textAvatarInitial)
val textName: TextView = view.findViewById(R.id.textItemName)
val textAuthor: TextView = view.findViewById(R.id.textItemAuthor)
val textDescription: TextView = view.findViewById(R.id.textItemDescription)
val badgeOfficial: TextView = view.findViewById(R.id.badgeOfficial)
val badgeVerified: TextView = view.findViewById(R.id.badgeVerified)
val installedMarker: ImageView = view.findViewById(R.id.installedMarker)
val btnInstall: MaterialButton = view.findViewById(R.id.btnInstall)
val btnInstalled: MaterialButton = view.findViewById(R.id.btnInstalled)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.view_marketplace_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
val entry = item.entry
val ctx = holder.itemView.context
holder.avatarInitial.text = entry.name.trim().take(1).uppercase().ifBlank { "?" }
holder.avatarFrame.background = makeAvatarGradient(ctx, entry)
holder.textName.text = entry.name
holder.textAuthor.text = ctx.getString(R.string.marketplace_by_author, entry.author)
if (entry.description.isNotBlank()) {
holder.textDescription.text = entry.description
holder.textDescription.visibility = View.VISIBLE
} else {
holder.textDescription.visibility = View.GONE
}
holder.badgeOfficial.visibility = View.GONE
holder.badgeVerified.visibility = View.GONE
when {
entry.official -> {
holder.installedMarker.visibility = View.VISIBLE
holder.installedMarker.setImageResource(R.drawable.ic_verified_blue)
}
entry.verified -> {
holder.installedMarker.visibility = View.VISIBLE
holder.installedMarker.setImageResource(R.drawable.ic_verified_grey)
}
else -> holder.installedMarker.visibility = View.GONE
}
when {
item.installed && item.hasUpdate -> {
holder.btnInstall.visibility = View.VISIBLE
holder.btnInstalled.visibility = View.GONE
holder.btnInstall.text = ctx.getString(R.string.marketplace_action_update)
holder.btnInstall.setOnClickListener { onUpdate(entry) }
}
item.installed -> {
holder.btnInstall.visibility = View.GONE
holder.btnInstalled.visibility = View.VISIBLE
holder.btnInstalled.setOnClickListener { onOpenInstalled(entry) }
}
else -> {
holder.btnInstall.visibility = View.VISIBLE
holder.btnInstalled.visibility = View.GONE
holder.btnInstall.text = ctx.getString(R.string.marketplace_action_install)
holder.btnInstall.setOnClickListener { onInstall(entry) }
}
}
}
private fun makeAvatarGradient(ctx: android.content.Context, entry: MarketplaceEntry): GradientDrawable {
val seed = entry.id.hashCode()
val hue = ((seed.ushr(0) % 360 + 360) % 360).toFloat()
val c1 = Color.HSVToColor(floatArrayOf(hue, 0.40f, 0.85f))
val c2 = Color.HSVToColor(floatArrayOf((hue + 30f) % 360f, 0.55f, 0.60f))
val density = ctx.resources.displayMetrics.density
return GradientDrawable(
GradientDrawable.Orientation.TL_BR,
intArrayOf(c1, c2),
).apply {
cornerRadius = 14f * density
shape = GradientDrawable.RECTANGLE
}
}
}

View file

@ -0,0 +1,26 @@
package com.notcvnt.rknhardering.customcheck.marketplace
data class MarketplaceCatalog(
val schemaVersion: Int,
val updatedAt: String,
val entries: List<MarketplaceEntry>,
// True only if catalog.json was downloaded together with a catalog.sig that
// validated against the bundled marketplace public key. Drives whether the
// catalog can confer official/verified status on entries.
val signatureValid: Boolean = false,
)
data class MarketplaceEntry(
val id: String,
val name: String,
val description: String,
val author: String,
val version: String,
val official: Boolean,
val verified: Boolean,
val profileUrl: String,
val tags: List<String>,
val createdAt: String,
val updatedAt: String,
val expectedHash: String? = null,
)

View file

@ -0,0 +1,142 @@
package com.notcvnt.rknhardering.customcheck.ui
import android.animation.ObjectAnimator
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import com.google.android.material.materialswitch.MaterialSwitch
import com.notcvnt.rknhardering.R
/**
* Controller for an inflated view_checker_section.xml instance.
*
* Handles the multi-open accordion behavior, master toggle greyout, chevron rotation,
* background swap (collapsed/expanded), summary text, and disabled note.
*
* Each section is identified by [id] (one of [GEO_IP], [IP_COMPARISON], ...) so that
* open/disabled state can be persisted across configuration changes.
*/
internal class CheckerSectionController(
val root: View,
val id: String,
private val sectionsContainer: ViewGroup,
) {
val header: View = root.findViewById(R.id.sectionHeader)
val statusDot: View = root.findViewById(R.id.sectionStatusDot)
val iconContainer: View = root.findViewById(R.id.sectionIconContainer)
val icon: ImageView = root.findViewById(R.id.sectionIcon)
val titleView: TextView = root.findViewById(R.id.sectionTitle)
val summaryView: TextView = root.findViewById(R.id.sectionSummary)
val masterSwitch: MaterialSwitch = root.findViewById(R.id.sectionMasterSwitch)
val chevron: ImageView = root.findViewById(R.id.sectionChevron)
val divider: View = root.findViewById(R.id.sectionDivider)
val body: FrameLayout = root.findViewById(R.id.sectionBody)
val disabledNote: TextView = root.findViewById(R.id.sectionDisabledNote)
var summaryProvider: () -> String = { "" }
var onMasterChanged: ((Boolean) -> Unit)? = null
private var expanded: Boolean = false
private var enabled: Boolean = true
init {
header.setOnClickListener { toggle() }
masterSwitch.setOnCheckedChangeListener { _, checked ->
setEnabled(checked, propagate = true)
}
}
fun setTitle(text: CharSequence) {
titleView.text = text
}
fun setIcon(iconRes: Int) {
icon.setImageResource(iconRes)
}
fun setExpanded(value: Boolean, animate: Boolean = true) {
if (expanded == value) return
expanded = value
if (animate) {
TransitionManager.beginDelayedTransition(
sectionsContainer,
AutoTransition().apply { duration = 180 },
)
}
body.visibility = if (value) View.VISIBLE else View.GONE
divider.visibility = if (value) View.VISIBLE else View.GONE
root.setBackgroundResource(
if (value) R.drawable.bg_checker_section_expanded
else R.drawable.bg_checker_section_collapsed
)
val angle = if (value) 90f else 0f
ObjectAnimator.ofFloat(chevron, View.ROTATION, chevron.rotation, angle)
.setDuration(if (animate) 150L else 0L)
.start()
updateNoteVisibility()
}
val isExpanded: Boolean get() = expanded
fun setEnabled(value: Boolean, propagate: Boolean) {
enabled = value
masterSwitch.setOnCheckedChangeListener(null)
masterSwitch.isChecked = value
masterSwitch.setOnCheckedChangeListener { _, checked ->
setEnabled(checked, propagate = true)
}
body.alpha = if (value) 1f else 0.35f
setSubtreeEnabled(body, value)
statusDot.setBackgroundResource(
if (value) R.drawable.dot_status_green
else R.drawable.dot_status_neutral
)
refreshSummary()
updateNoteVisibility()
if (propagate) {
onMasterChanged?.invoke(value)
}
}
fun isMasterEnabled(): Boolean = enabled
fun refreshSummary() {
summaryView.text = if (enabled) summaryProvider() else summaryView.context.getString(R.string.checker_section_summary_disabled)
}
private fun toggle() {
setExpanded(!expanded, animate = true)
}
private fun updateNoteVisibility() {
disabledNote.visibility = if (expanded && !enabled) View.VISIBLE else View.GONE
}
private fun setSubtreeEnabled(view: View, value: Boolean) {
view.isEnabled = value
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
setSubtreeEnabled(view.getChildAt(i), value)
}
}
}
companion object {
const val GEO_IP = "geo_ip"
const val IP_COMPARISON = "ip_comparison"
const val CDN_PULLING = "cdn_pulling"
const val DIRECT_SIGNS = "direct_signs"
const val INDIRECT_SIGNS = "indirect_signs"
const val NATIVE_SIGNS = "native_signs"
const val LOCATION_SIGNALS = "location_signals"
const val ICMP_SPOOFING = "icmp_spoofing"
const val RTT_TRIANGULATION = "rtt_triangulation"
const val CALL_TRANSPORT = "call_transport"
const val SPLIT_TUNNEL = "split_tunnel"
const val DOMAIN_REACHABILITY = "domain_reachability"
}
}

View file

@ -0,0 +1,254 @@
package com.notcvnt.rknhardering.customcheck.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.lifecycle.LifecycleCoroutineScope
import com.google.android.material.button.MaterialButton
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import com.notcvnt.rknhardering.R
import kotlinx.coroutines.launch
internal class GenericInlineEditorController(
private val parent: ViewGroup,
private val scope: LifecycleCoroutineScope,
) {
data class Config(
val titleRes: Int,
val urlHintRes: Int,
val labelHintRes: Int,
val extraInputHintRes: Int? = null,
val extraSwitchTextRes: Int? = null,
/** When true, shows expected DNS/TCP/TLS checkboxes (for domain reachability) */
val showExpectedChecks: Boolean = false,
val testAction: suspend (url: String, extraInput: String, isSwitchChecked: Boolean) -> Pair<Boolean, String>,
val saveAction: (url: String, label: String, extraInput: String, isSwitchChecked: Boolean) -> Unit,
/** Extended save that also passes expected booleans */
val saveActionEx: ((url: String, label: String, extraInput: String, isSwitchChecked: Boolean, expectedDns: Boolean, expectedTcp: Boolean, expectedTls: Boolean) -> Unit)? = null,
)
data class InitialValues(
val url: String = "",
val label: String = "",
val extraInput: String = "",
val switchChecked: Boolean = false,
val expectedDns: Boolean = true,
val expectedTcp: Boolean = true,
val expectedTls: Boolean = true,
)
private var rootView: View? = null
private var cbDns: CheckBox? = null
private var cbTcp: CheckBox? = null
private var cbTls: CheckBox? = null
fun show(
config: Config,
onCancel: () -> Unit,
initialValues: InitialValues? = null,
) {
dismiss()
val inflater = LayoutInflater.from(parent.context)
val view = inflater.inflate(R.layout.view_inline_endpoint_editor, parent, false)
parent.addView(view)
rootView = view
val titleText = view.findViewById<TextView>(R.id.inlineEditorTitle)
val urlLayout = view.findViewById<View>(R.id.inlineEditorUrl).parent.parent as TextInputLayout
val urlEdit = view.findViewById<TextInputEditText>(R.id.inlineEditorUrl)
val labelLayout = view.findViewById<View>(R.id.inlineEditorLabel).parent.parent as TextInputLayout
val labelEdit = view.findViewById<TextInputEditText>(R.id.inlineEditorLabel)
val extraInputLayout = view.findViewById<TextInputLayout>(R.id.inlineEditorExtraInputLayout)
val extraInput = view.findViewById<TextInputEditText>(R.id.inlineEditorExtraInput)
val extraSwitch = view.findViewById<MaterialSwitch>(R.id.inlineEditorExtraSwitch)
val btnTest = view.findViewById<MaterialButton>(R.id.inlineEditorTest)
val btnSave = view.findViewById<MaterialButton>(R.id.inlineEditorSave)
val btnCancel = view.findViewById<MaterialButton>(R.id.inlineEditorCancel)
val btnClose = view.findViewById<ImageView>(R.id.inlineEditorClose)
val resultBlock = view.findViewById<View>(R.id.inlineEditorResult)
val resultType = view.findViewById<TextView>(R.id.inlineEditorResultType)
val resultStatus = view.findViewById<TextView>(R.id.inlineEditorResultStatus)
val errorView = view.findViewById<TextView>(R.id.inlineEditorError)
// Hide CDN/IP specific stuff
view.findViewById<View>(R.id.inlineEditorScopeChips).visibility = View.GONE
view.findViewById<View>(R.id.inlineEditorMappingContainer).visibility = View.GONE
view.findViewById<View>(R.id.inlineEditorRetest).visibility = View.GONE
view.findViewById<View>(R.id.inlineEditorRawBody).parent.parent?.let { (it as View).visibility = View.GONE }
titleText.setText(config.titleRes)
urlLayout.hint = view.context.getString(config.urlHintRes)
labelLayout.hint = view.context.getString(config.labelHintRes)
if (config.extraInputHintRes != null) {
extraInputLayout.visibility = View.VISIBLE
extraInputLayout.hint = view.context.getString(config.extraInputHintRes)
} else {
extraInputLayout.visibility = View.GONE
}
if (config.extraSwitchTextRes != null) {
extraSwitch.visibility = View.VISIBLE
extraSwitch.setText(config.extraSwitchTextRes)
} else {
extraSwitch.visibility = View.GONE
}
// Expected availability checkboxes for domain reachability
if (config.showExpectedChecks) {
val ctx = view.context
val density = ctx.resources.displayMetrics.density
val container = view.findViewById<LinearLayout>(R.id.inlineEditorResult).parent as ViewGroup
// Insert expected checks section before save/cancel buttons
val checksContainer = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
lp.topMargin = (8 * density).toInt()
layoutParams = lp
}
val checksLabel = TextView(ctx).apply {
text = ctx.getString(R.string.editor_expected_availability)
setTextColor(ctx.getColor(android.R.color.darker_gray))
textSize = 11f
setTypeface(null, android.graphics.Typeface.BOLD)
val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
lp.bottomMargin = (4 * density).toInt()
layoutParams = lp
}
checksContainer.addView(checksLabel)
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = android.view.Gravity.CENTER_VERTICAL
}
val dnsCb = CheckBox(ctx).apply {
text = "DNS"
isChecked = initialValues?.expectedDns ?: true
textSize = 12f
}
val tcpCb = CheckBox(ctx).apply {
text = "TCP"
isChecked = initialValues?.expectedTcp ?: true
textSize = 12f
}
val tlsCb = CheckBox(ctx).apply {
text = "TLS"
isChecked = initialValues?.expectedTls ?: true
textSize = 12f
}
row.addView(dnsCb)
row.addView(tcpCb)
row.addView(tlsCb)
checksContainer.addView(row)
cbDns = dnsCb
cbTcp = tcpCb
cbTls = tlsCb
// Find the error view and add before it
val errorIdx = (container as LinearLayout).indexOfChild(errorView)
container.addView(checksContainer, errorIdx)
}
// Pre-populate if editing
if (initialValues != null) {
urlEdit.setText(initialValues.url)
labelEdit.setText(initialValues.label)
if (config.extraInputHintRes != null) {
extraInput.setText(initialValues.extraInput)
}
if (config.extraSwitchTextRes != null) {
extraSwitch.isChecked = initialValues.switchChecked
}
btnSave.isEnabled = true
}
resultBlock.visibility = View.GONE
if (initialValues == null) btnSave.isEnabled = false
val performTest: () -> Unit = {
val url = urlEdit.text?.toString()?.trim().orEmpty()
if (url.isBlank()) {
errorView.text = view.context.getString(R.string.inline_editor_url_required)
errorView.visibility = View.VISIBLE
} else {
errorView.visibility = View.GONE
btnTest.isEnabled = false
btnTest.setText(R.string.inline_editor_testing)
scope.launch {
val extra = extraInput.text?.toString()?.trim().orEmpty()
val (success, message) = config.testAction(url, extra, extraSwitch.isChecked)
btnTest.isEnabled = true
btnTest.setText(R.string.inline_editor_test)
resultBlock.visibility = View.VISIBLE
if (success) {
resultType.text = "OK"
resultType.setTextColor(view.context.getColor(R.color.status_green))
btnSave.isEnabled = true
} else {
resultType.text = "ERR"
resultType.setTextColor(view.context.getColor(R.color.status_red))
btnSave.isEnabled = true // allow saving even if test failed
}
resultStatus.text = message
}
}
}
btnTest.setOnClickListener { performTest() }
btnSave.setOnClickListener {
val url = urlEdit.text?.toString()?.trim().orEmpty()
if (url.isBlank()) return@setOnClickListener
val label = labelEdit.text?.toString()?.trim().orEmpty()
val extra = extraInput.text?.toString()?.trim().orEmpty()
if (config.saveActionEx != null) {
config.saveActionEx.invoke(
url, label, extra, extraSwitch.isChecked,
cbDns?.isChecked ?: true,
cbTcp?.isChecked ?: true,
cbTls?.isChecked ?: true,
)
} else {
config.saveAction(url, label, extra, extraSwitch.isChecked)
}
dismiss()
}
val cancelHandler = {
onCancel()
dismiss()
}
btnCancel.setOnClickListener { cancelHandler() }
btnClose.setOnClickListener { cancelHandler() }
}
fun dismiss() {
rootView?.let { parent.removeView(it) }
rootView = null
cbDns = null
cbTcp = null
cbTls = null
}
/** Update the expected checkboxes from external test results */
fun setExpectedChecks(dns: Boolean, tcp: Boolean, tls: Boolean) {
cbDns?.isChecked = dns
cbTcp?.isChecked = tcp
cbTls?.isChecked = tls
}
}

View file

@ -0,0 +1,281 @@
package com.notcvnt.rknhardering.customcheck.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.lifecycle.LifecycleCoroutineScope
import com.google.android.material.button.MaterialButton
import com.google.android.material.chip.ChipGroup
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.customcheck.EndpointScope
import com.notcvnt.rknhardering.customcheck.ResponseMapping
import com.notcvnt.rknhardering.customcheck.ResponseType
import com.notcvnt.rknhardering.customcheck.mapper.EndpointResponseMapper
import com.notcvnt.rknhardering.customcheck.mapper.MappingField
import kotlinx.coroutines.launch
/**
* Controller for view_inline_endpoint_editor.xml. Handles the URL test mapping save flow.
*
* Lifecycle:
* - Caller inflates the layout into [parent] via [show], passing kind (which fields are visible)
* and an [onSave] callback.
* - Editor stays in parent until user taps Save or Cancel (close icon also cancels).
*/
internal class InlineEndpointEditorController(
private val parent: ViewGroup,
private val scope: LifecycleCoroutineScope,
) {
enum class Kind { GEO_IP, IP_COMPARISON, CDN }
data class Result(
val url: String,
val label: String,
val mapping: ResponseMapping,
val scope: EndpointScope,
)
private var rootView: View? = null
private var currentRawBody: String? = null
private var currentMapping: ResponseMapping = ResponseMapping()
private var autoDetectedFields: Set<MappingField> = emptySet()
private var currentKind: Kind = Kind.GEO_IP
data class EditData(
val url: String,
val label: String,
val mapping: ResponseMapping,
val scope: EndpointScope = EndpointScope.RU,
)
fun show(
kind: Kind,
onCancel: () -> Unit,
onSave: (Result) -> Unit,
editData: EditData? = null,
) {
dismiss()
val inflater = LayoutInflater.from(parent.context)
val view = inflater.inflate(R.layout.view_inline_endpoint_editor, parent, false)
parent.addView(view)
rootView = view
currentKind = kind
val urlEdit = view.findViewById<TextInputEditText>(R.id.inlineEditorUrl)
val labelEdit = view.findViewById<TextInputEditText>(R.id.inlineEditorLabel)
val scopeChips = view.findViewById<ChipGroup>(R.id.inlineEditorScopeChips)
val btnTest = view.findViewById<MaterialButton>(R.id.inlineEditorTest)
val btnSave = view.findViewById<MaterialButton>(R.id.inlineEditorSave)
val btnCancel = view.findViewById<MaterialButton>(R.id.inlineEditorCancel)
val btnClose = view.findViewById<ImageView>(R.id.inlineEditorClose)
val btnRetest = view.findViewById<TextView>(R.id.inlineEditorRetest)
val resultBlock = view.findViewById<View>(R.id.inlineEditorResult)
val resultType = view.findViewById<TextView>(R.id.inlineEditorResultType)
val resultStatus = view.findViewById<TextView>(R.id.inlineEditorResultStatus)
val rawBody = view.findViewById<TextView>(R.id.inlineEditorRawBody)
val mappingContainer = view.findViewById<LinearLayout>(R.id.inlineEditorMappingContainer)
val errorView = view.findViewById<TextView>(R.id.inlineEditorError)
scopeChips.visibility = if (kind == Kind.IP_COMPARISON) View.VISIBLE else View.GONE
resultBlock.visibility = View.GONE
btnSave.isEnabled = false
if (editData != null) {
urlEdit.setText(editData.url)
labelEdit.setText(editData.label)
currentMapping = editData.mapping
autoDetectedFields = fieldsFromMapping(editData.mapping)
if (kind == Kind.IP_COMPARISON && editData.scope == EndpointScope.NON_RU) {
scopeChips.check(R.id.inlineEditorScopeNonRu)
}
btnSave.isEnabled = true
}
val performTest: () -> Unit = {
val url = urlEdit.text?.toString()?.trim().orEmpty()
if (url.isBlank()) {
errorView.text = view.context.getString(R.string.inline_editor_url_required)
errorView.visibility = View.VISIBLE
} else {
errorView.visibility = View.GONE
btnTest.isEnabled = false
btnRetest.isEnabled = false
btnTest.setText(R.string.inline_editor_testing)
scope.launch {
val result = EndpointResponseMapper.testEndpoint(url)
btnTest.isEnabled = true
btnRetest.isEnabled = true
btnTest.setText(R.string.inline_editor_test)
if (result.success && result.detectedType != null) {
currentRawBody = result.rawBody
currentMapping = result.suggestedMapping ?: ResponseMapping(responseType = result.detectedType)
autoDetectedFields = fieldsFromMapping(currentMapping)
resultType.text = result.detectedType.name
resultStatus.text = view.context.getString(
R.string.inline_editor_result_status,
result.statusCode ?: 0,
result.responseTimeMs,
)
rawBody.text = (result.rawBody ?: "").take(2000)
resultBlock.visibility = View.VISIBLE
rebuildMappingRows(mappingContainer)
btnSave.isEnabled = true
} else {
errorView.text = view.context.getString(
R.string.inline_editor_test_failed,
result.error ?: view.context.getString(R.string.inline_editor_unknown_error),
)
errorView.visibility = View.VISIBLE
resultBlock.visibility = View.GONE
btnSave.isEnabled = false
}
}
}
}
btnTest.setOnClickListener { performTest() }
btnRetest.setOnClickListener { performTest() }
btnCancel.setOnClickListener { dismiss(); onCancel() }
btnClose.setOnClickListener { dismiss(); onCancel() }
btnSave.setOnClickListener {
val url = urlEdit.text?.toString()?.trim().orEmpty()
if (url.isBlank()) {
errorView.text = view.context.getString(R.string.inline_editor_url_required)
errorView.visibility = View.VISIBLE
return@setOnClickListener
}
val label = labelEdit.text?.toString()?.trim().orEmpty().ifBlank { url }
val endpointScope = if (scopeChips.checkedChipId == R.id.inlineEditorScopeNonRu) {
EndpointScope.NON_RU
} else {
EndpointScope.RU
}
val mapping = currentMapping.copy(
responseType = currentMapping.responseType,
)
onSave(Result(url = url, label = label, mapping = mapping, scope = endpointScope))
dismiss()
}
}
fun dismiss() {
rootView?.let { parent.removeView(it) }
rootView = null
currentRawBody = null
currentMapping = ResponseMapping()
autoDetectedFields = emptySet()
}
fun isShowing(): Boolean = rootView != null
private fun rebuildMappingRows(container: LinearLayout) {
container.removeAllViews()
val inflater = LayoutInflater.from(container.context)
val fieldsToShow = if (currentKind == Kind.IP_COMPARISON) {
arrayOf(MappingField.IP)
} else {
MappingField.values()
}
fieldsToShow.forEach { field ->
val row = inflater.inflate(R.layout.view_inline_mapping_row, container, false)
val fieldName = row.findViewById<TextView>(R.id.mappingFieldName)
val pathView = row.findViewById<TextView>(R.id.mappingPath)
val autoBadge = row.findViewById<TextView>(R.id.mappingAutoBadge)
val editorWrap = row.findViewById<TextInputLayout>(R.id.mappingEditorWrap)
val editor = row.findViewById<TextInputEditText>(R.id.mappingEditor)
val header = row.findViewById<View>(R.id.mappingHeader)
fieldName.text = displayName(field)
val path = pathFor(field)
val displayPath = path?.takeIf { it.isNotBlank() }
pathView.text = displayPath ?: container.context.getString(R.string.inline_editor_not_detected)
pathView.setTextColor(
if (displayPath != null) {
androidx.core.content.ContextCompat.getColor(container.context, R.color.status_green)
} else {
androidx.core.content.ContextCompat.getColor(container.context, android.R.color.darker_gray)
}
)
autoBadge.visibility = if (field in autoDetectedFields) View.VISIBLE else View.GONE
header.setOnClickListener {
if (editorWrap.visibility == View.VISIBLE) {
editorWrap.visibility = View.GONE
} else {
editor.setText(path.orEmpty())
editorWrap.visibility = View.VISIBLE
editor.requestFocus()
}
}
editor.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
val newPath = editor.text?.toString()?.trim().orEmpty()
setPathFor(field, newPath.ifBlank { null })
pathView.text = newPath.ifBlank {
container.context.getString(R.string.inline_editor_not_detected)
}
}
}
container.addView(row)
}
}
private fun pathFor(field: MappingField): String? = when (field) {
MappingField.IP -> currentMapping.ipPath
MappingField.COUNTRY_CODE -> currentMapping.countryCodePath
MappingField.COUNTRY_NAME -> currentMapping.countryNamePath
MappingField.ISP -> currentMapping.ispPath
MappingField.ORG -> currentMapping.orgPath
MappingField.ASN -> currentMapping.asnPath
MappingField.IS_HOSTING -> currentMapping.isHostingPath
MappingField.IS_PROXY -> currentMapping.isProxyPath
}
private fun setPathFor(field: MappingField, value: String?) {
currentMapping = when (field) {
MappingField.IP -> currentMapping.copy(ipPath = value)
MappingField.COUNTRY_CODE -> currentMapping.copy(countryCodePath = value)
MappingField.COUNTRY_NAME -> currentMapping.copy(countryNamePath = value)
MappingField.ISP -> currentMapping.copy(ispPath = value)
MappingField.ORG -> currentMapping.copy(orgPath = value)
MappingField.ASN -> currentMapping.copy(asnPath = value)
MappingField.IS_HOSTING -> currentMapping.copy(isHostingPath = value)
MappingField.IS_PROXY -> currentMapping.copy(isProxyPath = value)
}
}
private fun displayName(field: MappingField): String = when (field) {
MappingField.IP -> "IP"
MappingField.COUNTRY_CODE -> "Country code"
MappingField.COUNTRY_NAME -> "Country name"
MappingField.ISP -> "ISP"
MappingField.ORG -> "Organization"
MappingField.ASN -> "ASN"
MappingField.IS_HOSTING -> "Hosting"
MappingField.IS_PROXY -> "Proxy/VPN"
}
private fun fieldsFromMapping(m: ResponseMapping): Set<MappingField> {
val set = mutableSetOf<MappingField>()
if (!m.ipPath.isNullOrBlank()) set += MappingField.IP
if (!m.countryCodePath.isNullOrBlank()) set += MappingField.COUNTRY_CODE
if (!m.countryNamePath.isNullOrBlank()) set += MappingField.COUNTRY_NAME
if (!m.ispPath.isNullOrBlank()) set += MappingField.ISP
if (!m.orgPath.isNullOrBlank()) set += MappingField.ORG
if (!m.asnPath.isNullOrBlank()) set += MappingField.ASN
if (!m.isHostingPath.isNullOrBlank()) set += MappingField.IS_HOSTING
if (!m.isProxyPath.isNullOrBlank()) set += MappingField.IS_PROXY
return set
}
}

View file

@ -0,0 +1,140 @@
package com.notcvnt.rknhardering.customcheck.ui.editor
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import com.google.android.material.button.MaterialButton
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.customcheck.CustomCheckProfile
import com.notcvnt.rknhardering.customcheck.CustomDomain
import com.notcvnt.rknhardering.customcheck.ui.CheckerSectionController
import com.notcvnt.rknhardering.customcheck.ui.GenericInlineEditorController
internal class DomainReachabilitySectionBinder(host: SectionBinder.Host) : SectionBinder<Boolean>(host) {
override val sectionId = CheckerSectionController.DOMAIN_REACHABILITY
override val titleRes = R.string.settings_custom_check_section_domain_reachability
override val iconRes = R.drawable.ic_public
override val bodyLayout = R.layout.section_body_domain_reachability
/** Reachability-type domains split out of profile.customDomains. */
val domains = mutableListOf<CustomDomain>()
override fun bind(body: View, profile: CustomCheckProfile) {
domains.clear()
profile.customDomains
.filter { isReachabilityType(it.checkType) }
.forEach { domains.add(it.copy(checkType = "reachability")) }
val container = body.findViewById<LinearLayout>(R.id.containerReachabilityDomains)
rebuildPills(container)
body.findViewById<MaterialButton>(R.id.btnAddReachabilityDomain).setOnClickListener {
showEditor(container)
}
}
override fun collect(body: View?, enabled: Boolean): Boolean = enabled
override fun summary(body: View): String =
host.string(R.string.editor_summary_domain_reachability, domains.size)
private fun rebuildPills(container: LinearLayout) {
EndpointPills.rebuild(container, domains.mapIndexed { idx, d ->
EndpointPillData(
name = d.domain,
url = d.description.ifEmpty { d.domain },
scope = null,
onRemove = {
domains.removeAt(idx)
rebuildPills(container)
refreshOwnSummary()
},
onEdit = {
showEditor(container, editIndex = idx)
},
)
})
}
private fun showEditor(container: LinearLayout, editIndex: Int = -1) {
val btnAdd = container.parent.let { (it as ViewGroup).findViewById<View>(R.id.btnAddReachabilityDomain) }
val slot = container.parent.let { (it as ViewGroup).findViewById<FrameLayout>(R.id.inlineEditorSlotDomainReachability) }
btnAdd.visibility = View.GONE
val existing = if (editIndex >= 0) domains[editIndex] else null
val initialValues = existing?.let {
GenericInlineEditorController.InitialValues(
url = it.domain,
label = it.description,
expectedDns = it.expectedDnsAvailable,
expectedTcp = it.expectedTcpAvailable,
expectedTls = it.expectedTlsAvailable,
)
}
val controller = GenericInlineEditorController(slot, host.lifecycleScope)
controller.show(
config = GenericInlineEditorController.Config(
titleRes = R.string.settings_custom_check_add_reachability_domain,
urlHintRes = R.string.settings_custom_check_domain_field,
labelHintRes = R.string.settings_custom_check_description_field,
extraInputHintRes = null,
extraSwitchTextRes = null,
showExpectedChecks = true,
testAction = { domain, _, _ ->
val response = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
com.notcvnt.rknhardering.checker.DomainReachabilityChecker.checkSingleDomain(
domain = domain,
label = domain,
)
}
// Auto-set expected booleans from test result (on Main thread)
val dnsOk = response.dnsStatus == com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.OK
val tcpOk = response.tcpStatus == com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.OK
val tlsOk = response.tlsStatus == com.notcvnt.rknhardering.model.DomainReachabilityStepStatus.OK
controller.setExpectedChecks(dnsOk, tcpOk, tlsOk)
val statusIcon = { ok: Boolean -> if (ok) "" else "" }
val msg = host.string(
R.string.domain_reachability_test_result,
statusIcon(dnsOk),
statusIcon(tcpOk),
statusIcon(tlsOk),
)
val allOk = dnsOk && tcpOk && tlsOk
allOk to msg
},
saveAction = { _, _, _, _ -> },
saveActionEx = { domain, label, _, _, expectedDns, expectedTcp, expectedTls ->
val newDomain = CustomDomain(
domain = domain,
checkType = "reachability",
description = label,
expectedDnsAvailable = expectedDns,
expectedTcpAvailable = expectedTcp,
expectedTlsAvailable = expectedTls,
)
if (editIndex >= 0) {
domains[editIndex] = newDomain
} else {
domains.add(newDomain)
}
rebuildPills(container)
refreshOwnSummary()
btnAdd.visibility = View.VISIBLE
}
),
onCancel = { btnAdd.visibility = View.VISIBLE },
initialValues = initialValues,
)
}
companion object {
fun isReachabilityType(checkType: String): Boolean {
val ct = checkType.trim().lowercase()
return ct == "reachability" || ct == "dpi"
}
}
}

View file

@ -0,0 +1,23 @@
package com.notcvnt.rknhardering.customcheck.ui.editor
/** All twelve section binders of the custom-check editor, in visual order. */
internal class EditorSectionBinders(host: SectionBinder.Host) {
val geoIp = GeoIpSectionBinder(host)
val ipComparison = IpComparisonSectionBinder(host)
val cdnPulling = CdnPullingSectionBinder(host)
val directSigns = DirectSignsSectionBinder(host)
val indirectSigns = IndirectSignsSectionBinder(host)
val nativeSigns = NativeSignsSectionBinder(host)
val locationSignals = LocationSignalsSectionBinder(host)
val icmpSpoofing = IcmpSpoofingSectionBinder(host)
val rttTriangulation = RttTriangulationSectionBinder(host)
val callTransport = CallTransportSectionBinder(host)
val splitTunnel = SplitTunnelSectionBinder(host)
val domainReachability = DomainReachabilitySectionBinder(host)
val ordered: List<SectionBinder<*>> = listOf(
geoIp, ipComparison, cdnPulling, directSigns, indirectSigns, nativeSigns,
locationSignals, icmpSpoofing, rttTriangulation, callTransport, splitTunnel,
domainReachability,
)
}

View file

@ -0,0 +1,103 @@
package com.notcvnt.rknhardering.customcheck.ui.editor
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.customcheck.EndpointScope
internal data class EndpointPillData(
val name: String,
val url: String,
val scope: EndpointScope?,
val onRemove: () -> Unit,
val onEdit: (() -> Unit)? = null,
)
/** Renders the removable endpoint "pill" rows used by the editor sections. */
internal object EndpointPills {
fun rebuild(container: LinearLayout, items: List<EndpointPillData>) {
container.removeAllViews()
items.forEach { data ->
container.addView(makePillView(container, data))
}
}
fun makePillView(parent: ViewGroup, data: EndpointPillData): View {
val ctx = parent.context
val density = ctx.resources.displayMetrics.density
val pill = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
background = androidx.core.content.ContextCompat.getDrawable(ctx, R.drawable.bg_endpoint_pill)
setPadding((10 * density).toInt(), (8 * density).toInt(), (10 * density).toInt(), (8 * density).toInt())
val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
lp.bottomMargin = (4 * density).toInt()
layoutParams = lp
gravity = android.view.Gravity.CENTER_VERTICAL
}
val dot = View(ctx).apply {
setBackgroundResource(R.drawable.dot_status_green)
layoutParams = LinearLayout.LayoutParams((4 * density).toInt(), (4 * density).toInt())
}
pill.addView(dot)
val texts = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val lp = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
lp.marginStart = (8 * density).toInt()
layoutParams = lp
}
val name = TextView(ctx).apply {
text = data.name
textSize = 12f
setTextColor(androidx.core.content.ContextCompat.getColor(ctx, R.color.md_on_surface))
}
val url = TextView(ctx).apply {
text = data.url
textSize = 10f
typeface = android.graphics.Typeface.MONOSPACE
setTextColor(androidx.core.content.ContextCompat.getColor(ctx, R.color.md_on_surface_variant))
maxLines = 1
ellipsize = android.text.TextUtils.TruncateAt.END
}
texts.addView(name)
texts.addView(url)
pill.addView(texts)
if (data.onEdit != null) {
// Make the pill body (dot + texts) clickable for editing
pill.isClickable = true
pill.isFocusable = true
pill.setOnClickListener { data.onEdit.invoke() }
}
if (data.scope != null) {
val scopeView = TextView(ctx).apply {
text = if (data.scope == EndpointScope.NON_RU) "NON_RU" else "RU"
textSize = 9f
setPadding((6 * density).toInt(), (2 * density).toInt(), (6 * density).toInt(), (2 * density).toInt())
setBackgroundResource(R.drawable.bg_endpoint_pill)
setTextColor(androidx.core.content.ContextCompat.getColor(ctx, R.color.md_on_surface_variant))
}
val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
lp.marginStart = (6 * density).toInt()
scopeView.layoutParams = lp
pill.addView(scopeView)
}
val remove = TextView(ctx).apply {
text = ""
textSize = 14f
setPadding((8 * density).toInt(), 0, (4 * density).toInt(), 0)
setTextColor(androidx.core.content.ContextCompat.getColor(ctx, R.color.md_on_surface_variant))
isClickable = true
isFocusable = true
setOnClickListener { data.onRemove() }
}
pill.addView(remove)
return pill
}
}

View file

@ -0,0 +1,96 @@
package com.notcvnt.rknhardering.customcheck.ui.editor
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.annotation.IdRes
import com.google.android.material.button.MaterialButton
import com.notcvnt.rknhardering.customcheck.EndpointScope
import com.notcvnt.rknhardering.customcheck.ui.InlineEndpointEditorController
/**
* Shared add/edit wiring for the sections backed by InlineEndpointEditorController
* (GeoIP providers, IP-comparison endpoints, CDN targets). Owns the mutable item
* list and the pill rendering; the per-section binder supplies the item mapping.
*/
internal class InlineEndpointSection<T>(
private val host: SectionBinder.Host,
private val sectionId: String,
private val kind: InlineEndpointEditorController.Kind,
@param:IdRes private val slotId: Int,
@param:IdRes private val addButtonId: Int,
@param:IdRes private val containerId: Int,
private val pillName: (T) -> String,
private val pillUrl: (T) -> String,
private val pillScope: (T) -> EndpointScope?,
private val fromResult: (InlineEndpointEditorController.Result) -> T,
private val toEditData: (T) -> InlineEndpointEditorController.EditData,
) {
val items = mutableListOf<T>()
private var editor: InlineEndpointEditorController? = null
fun bind(body: View, configItems: List<T>) {
items.clear()
items.addAll(configItems)
val container = body.findViewById<LinearLayout>(containerId)
rebuildPills(body, container)
val btnAdd = body.findViewById<MaterialButton>(addButtonId)
btnAdd.setOnClickListener {
if (editor?.isShowing() == true) return@setOnClickListener
btnAdd.visibility = View.GONE
showEditor(body, container, btnAdd, editIndex = -1)
}
}
private fun rebuildPills(body: View, container: LinearLayout) {
EndpointPills.rebuild(
container,
items.mapIndexed { idx, item ->
EndpointPillData(
name = pillName(item),
url = pillUrl(item),
scope = pillScope(item),
onRemove = {
items.removeAt(idx)
rebuildPills(body, container)
host.refreshSummary(sectionId)
},
onEdit = {
val btnAdd = body.findViewById<MaterialButton>(addButtonId)
btnAdd.visibility = View.GONE
showEditor(body, container, btnAdd, editIndex = idx)
},
)
},
)
}
private fun showEditor(body: View, container: LinearLayout, btnAdd: MaterialButton, editIndex: Int) {
val slot = body.findViewById<ViewGroup>(slotId)
val controller = InlineEndpointEditorController(slot, host.lifecycleScope)
editor = controller
controller.show(
kind = kind,
onCancel = {
btnAdd.visibility = View.VISIBLE
editor = null
},
onSave = { result ->
val item = fromResult(result)
if (editIndex >= 0) {
items[editIndex] = item
} else {
items.add(item)
}
rebuildPills(body, container)
btnAdd.visibility = View.VISIBLE
editor = null
host.refreshSummary(sectionId)
},
editData = if (editIndex >= 0) toEditData(items[editIndex]) else null,
)
}
}

View file

@ -0,0 +1,206 @@
package com.notcvnt.rknhardering.customcheck.ui.editor
import android.view.View
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.textfield.TextInputEditText
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.customcheck.CdnPullingConfig
import com.notcvnt.rknhardering.customcheck.CustomCdnTarget
import com.notcvnt.rknhardering.customcheck.CustomCheckProfile
import com.notcvnt.rknhardering.customcheck.CustomGeoIpProvider
import com.notcvnt.rknhardering.customcheck.CustomIpEndpoint
import com.notcvnt.rknhardering.customcheck.GeoIpConfig
import com.notcvnt.rknhardering.customcheck.IpComparisonConfig
import com.notcvnt.rknhardering.customcheck.ui.CheckerSectionController
import com.notcvnt.rknhardering.customcheck.ui.InlineEndpointEditorController
internal class GeoIpSectionBinder(host: SectionBinder.Host) : SectionBinder<GeoIpConfig>(host) {
override val sectionId = CheckerSectionController.GEO_IP
override val titleRes = R.string.settings_custom_check_section_geo_ip
override val iconRes = R.drawable.ic_public
override val bodyLayout = R.layout.section_body_geo_ip
private val endpoints = InlineEndpointSection<CustomGeoIpProvider>(
host = host,
sectionId = sectionId,
kind = InlineEndpointEditorController.Kind.GEO_IP,
slotId = R.id.inlineEditorSlotGeoIp,
addButtonId = R.id.btnAddGeoIpEndpoint,
containerId = R.id.containerGeoIpEndpoints,
pillName = { it.name.ifBlank { it.url } },
pillUrl = { it.url },
pillScope = { null },
fromResult = { result ->
CustomGeoIpProvider(
name = result.label,
url = result.url,
responseMapping = result.mapping,
)
},
toEditData = { p ->
InlineEndpointEditorController.EditData(
url = p.url,
label = p.name,
mapping = p.responseMapping,
)
},
)
override fun bind(body: View, profile: CustomCheckProfile) {
val cfg = profile.checksConfig.geoIp
body.findViewById<TextInputEditText>(R.id.editGeoIpTimeout).setText(cfg.timeoutMs.toString())
body.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIpapiIs).isChecked = cfg.builtinProviders["ipapi.is"] != false
body.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIplocate).isChecked = cfg.builtinProviders["iplocate.io"] != false
body.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIpquery).isChecked = cfg.builtinProviders["ipquery.io"] != false
body.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIplookup).isChecked = cfg.builtinProviders["iplookup.it"] != false
body.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIpbot).isChecked = cfg.builtinProviders["ipbot.com"] != false
endpoints.bind(body, cfg.customProviders)
}
override fun collect(body: View?, enabled: Boolean): GeoIpConfig = GeoIpConfig(
enabled = enabled,
timeoutMs = body?.findViewById<TextInputEditText>(R.id.editGeoIpTimeout)?.text?.toString()?.toIntOrNull() ?: 10_000,
builtinProviders = mapOf(
"ipapi.is" to (body?.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIpapiIs)?.isChecked ?: true),
"iplocate.io" to (body?.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIplocate)?.isChecked ?: true),
"ipquery.io" to (body?.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIpquery)?.isChecked ?: true),
"iplookup.it" to (body?.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIplookup)?.isChecked ?: true),
"ipbot.com" to (body?.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIpbot)?.isChecked ?: true),
),
customProviders = endpoints.items.toList(),
)
override fun summary(body: View): String {
val builtinTotal = 5
val builtinOn = listOf(
body.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIpapiIs).isChecked,
body.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIplocate).isChecked,
body.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIpquery).isChecked,
body.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIplookup).isChecked,
body.findViewById<MaterialSwitch>(R.id.switchGeoIpBuiltinIpbot).isChecked,
).count { it }
return host.string(R.string.editor_summary_geoip, builtinOn, builtinTotal, endpoints.items.size)
}
}
internal class IpComparisonSectionBinder(host: SectionBinder.Host) : SectionBinder<IpComparisonConfig>(host) {
override val sectionId = CheckerSectionController.IP_COMPARISON
override val titleRes = R.string.settings_custom_check_section_ip_comparison
override val iconRes = R.drawable.ic_compare_arrows
override val bodyLayout = R.layout.section_body_ip_comparison
private val endpoints = InlineEndpointSection<CustomIpEndpoint>(
host = host,
sectionId = sectionId,
kind = InlineEndpointEditorController.Kind.IP_COMPARISON,
slotId = R.id.inlineEditorSlotIp,
addButtonId = R.id.btnAddIpEndpoint,
containerId = R.id.containerIpEndpoints,
pillName = { it.label.ifBlank { it.url } },
pillUrl = { it.url },
pillScope = { it.scope },
fromResult = { result ->
CustomIpEndpoint(
label = result.label,
url = result.url,
scope = result.scope,
responseMapping = result.mapping,
)
},
toEditData = { ep ->
InlineEndpointEditorController.EditData(
url = ep.url,
label = ep.label,
mapping = ep.responseMapping,
scope = ep.scope,
)
},
)
override fun bind(body: View, profile: CustomCheckProfile) {
val cfg = profile.checksConfig.ipComparison
body.findViewById<TextInputEditText>(R.id.editIpComparisonTimeout).setText(cfg.timeoutMs.toString())
body.findViewById<MaterialSwitch>(R.id.switchIpComparisonBuiltinRu).isChecked = cfg.builtinRuCheckersEnabled
body.findViewById<MaterialSwitch>(R.id.switchIpComparisonBuiltinNonRu).isChecked = cfg.builtinNonRuCheckersEnabled
endpoints.bind(body, cfg.customEndpoints)
}
override fun collect(body: View?, enabled: Boolean): IpComparisonConfig = IpComparisonConfig(
enabled = enabled,
timeoutMs = body?.findViewById<TextInputEditText>(R.id.editIpComparisonTimeout)?.text?.toString()?.toIntOrNull() ?: 8_000,
builtinRuCheckersEnabled = body?.findViewById<MaterialSwitch>(R.id.switchIpComparisonBuiltinRu)?.isChecked ?: true,
builtinNonRuCheckersEnabled = body?.findViewById<MaterialSwitch>(R.id.switchIpComparisonBuiltinNonRu)?.isChecked ?: true,
customEndpoints = endpoints.items.toList(),
)
override fun summary(body: View): String {
val ru = if (body.findViewById<MaterialSwitch>(R.id.switchIpComparisonBuiltinRu).isChecked) 1 else 0
val nonRu = if (body.findViewById<MaterialSwitch>(R.id.switchIpComparisonBuiltinNonRu).isChecked) 1 else 0
return host.string(R.string.editor_summary_ip_comparison, ru + nonRu, 2, endpoints.items.size)
}
}
internal class CdnPullingSectionBinder(host: SectionBinder.Host) : SectionBinder<CdnPullingConfig>(host) {
override val sectionId = CheckerSectionController.CDN_PULLING
override val titleRes = R.string.settings_custom_check_section_cdn_pulling
override val iconRes = R.drawable.ic_cloud
override val bodyLayout = R.layout.section_body_cdn_pulling
override val enabledFallback = false
private val endpoints = InlineEndpointSection<CustomCdnTarget>(
host = host,
sectionId = sectionId,
kind = InlineEndpointEditorController.Kind.CDN,
slotId = R.id.inlineEditorSlotCdn,
addButtonId = R.id.btnAddCdnEndpoint,
containerId = R.id.containerCdnEndpoints,
pillName = { it.label.ifBlank { it.url } },
pillUrl = { it.url },
pillScope = { null },
fromResult = { result ->
CustomCdnTarget(
label = result.label,
url = result.url,
responseMapping = result.mapping,
)
},
toEditData = { t ->
InlineEndpointEditorController.EditData(
url = t.url,
label = t.label,
mapping = t.responseMapping,
)
},
)
override fun bind(body: View, profile: CustomCheckProfile) {
val cfg = profile.checksConfig.cdnPulling
body.findViewById<TextInputEditText>(R.id.editCdnTimeout).setText(cfg.timeoutMs.toString())
body.findViewById<MaterialSwitch>(R.id.switchCdnBuiltinTargets).isChecked = cfg.builtinTargetsEnabled
body.findViewById<MaterialSwitch>(R.id.switchCdnMeduza).isChecked = cfg.meduzaEnabled
body.findViewById<MaterialSwitch>(R.id.switchCdnRutracker).isChecked = cfg.rutrackerEnabled
endpoints.bind(body, cfg.customTargets)
}
override fun collect(body: View?, enabled: Boolean): CdnPullingConfig = CdnPullingConfig(
enabled = enabled,
timeoutMs = body?.findViewById<TextInputEditText>(R.id.editCdnTimeout)?.text?.toString()?.toIntOrNull() ?: 10_000,
builtinTargetsEnabled = body?.findViewById<MaterialSwitch>(R.id.switchCdnBuiltinTargets)?.isChecked ?: true,
meduzaEnabled = body?.findViewById<MaterialSwitch>(R.id.switchCdnMeduza)?.isChecked ?: true,
rutrackerEnabled = body?.findViewById<MaterialSwitch>(R.id.switchCdnRutracker)?.isChecked ?: true,
customTargets = endpoints.items.toList(),
)
override fun summary(body: View): String {
val builtins = (if (body.findViewById<MaterialSwitch>(R.id.switchCdnBuiltinTargets).isChecked) 1 else 0) +
(if (body.findViewById<MaterialSwitch>(R.id.switchCdnMeduza).isChecked) 1 else 0) +
(if (body.findViewById<MaterialSwitch>(R.id.switchCdnRutracker).isChecked) 1 else 0)
return host.string(R.string.editor_summary_cdn, builtins, 3, endpoints.items.size)
}
}

View file

@ -0,0 +1,345 @@
package com.notcvnt.rknhardering.customcheck.ui.editor
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import com.google.android.material.button.MaterialButton
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.textfield.TextInputEditText
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.customcheck.CallTransportConfig
import com.notcvnt.rknhardering.customcheck.CustomCheckProfile
import com.notcvnt.rknhardering.customcheck.IcmpSpoofingConfig
import com.notcvnt.rknhardering.customcheck.IcmpTarget
import com.notcvnt.rknhardering.customcheck.RttTarget
import com.notcvnt.rknhardering.customcheck.RttTriangulationConfig
import com.notcvnt.rknhardering.customcheck.StunServer
import com.notcvnt.rknhardering.customcheck.ui.CheckerSectionController
import com.notcvnt.rknhardering.customcheck.ui.GenericInlineEditorController
internal class IcmpSpoofingSectionBinder(host: SectionBinder.Host) : SectionBinder<IcmpSpoofingConfig>(host) {
override val sectionId = CheckerSectionController.ICMP_SPOOFING
override val titleRes = R.string.settings_custom_check_section_icmp_spoofing
override val iconRes = R.drawable.ic_network_check
override val bodyLayout = R.layout.section_body_icmp_spoofing
override val enabledFallback = false
val targets = mutableListOf<IcmpTarget>()
override fun bind(body: View, profile: CustomCheckProfile) {
val cfg = profile.checksConfig.icmpSpoofing
body.findViewById<TextInputEditText>(R.id.editIcmpTimeout).setText(cfg.timeoutMs.toString())
body.findViewById<TextInputEditText>(R.id.editIcmpPingCount).setText(cfg.pingCount.toString())
body.findViewById<MaterialSwitch>(R.id.switchIcmpBuiltinTargets).isChecked = cfg.builtinTargetsEnabled
targets.clear()
targets.addAll(cfg.customTargets)
val container = body.findViewById<LinearLayout>(R.id.containerIcmpTargets)
rebuildPills(container)
body.findViewById<MaterialButton>(R.id.btnAddIcmpTarget).setOnClickListener {
showEditor(container)
}
}
override fun collect(body: View?, enabled: Boolean): IcmpSpoofingConfig = IcmpSpoofingConfig(
enabled = enabled,
timeoutMs = body?.findViewById<TextInputEditText>(R.id.editIcmpTimeout)?.text?.toString()?.toIntOrNull() ?: 5_000,
pingCount = body?.findViewById<TextInputEditText>(R.id.editIcmpPingCount)?.text?.toString()?.toIntOrNull() ?: 3,
builtinTargetsEnabled = body?.findViewById<MaterialSwitch>(R.id.switchIcmpBuiltinTargets)?.isChecked ?: true,
customTargets = targets.toList(),
)
override fun summary(body: View): String {
val builtin = if (body.findViewById<MaterialSwitch>(R.id.switchIcmpBuiltinTargets).isChecked) 1 else 0
return host.string(R.string.editor_summary_targets, builtin + targets.size)
}
private fun rebuildPills(container: LinearLayout) {
EndpointPills.rebuild(container, targets.mapIndexed { idx, t ->
EndpointPillData(
name = t.label.ifBlank { t.host },
url = "ping ${t.host}",
scope = null,
onRemove = {
targets.removeAt(idx)
rebuildPills(container)
refreshOwnSummary()
},
onEdit = {
showEditor(container, editIndex = idx)
},
)
})
}
private fun showEditor(container: LinearLayout, editIndex: Int = -1) {
val btnAdd = container.parent.let { (it as ViewGroup).findViewById<View>(R.id.btnAddIcmpTarget) }
val slot = container.parent.let { (it as ViewGroup).findViewById<FrameLayout>(R.id.inlineEditorSlotIcmp) }
btnAdd.visibility = View.GONE
val existing = if (editIndex >= 0) targets[editIndex] else null
val initialValues = existing?.let {
GenericInlineEditorController.InitialValues(
url = it.host, label = it.label, switchChecked = it.isControl,
)
}
val controller = GenericInlineEditorController(slot, host.lifecycleScope)
controller.show(
config = GenericInlineEditorController.Config(
titleRes = R.string.settings_custom_check_add_icmp_target,
urlHintRes = R.string.settings_custom_check_target_host,
labelHintRes = R.string.settings_custom_check_target_label,
extraInputHintRes = null,
extraSwitchTextRes = R.string.settings_custom_check_icmp_target_is_control,
testAction = { hostName, _, _ ->
val result = runCatching { com.notcvnt.rknhardering.probe.SystemPingProber.probe(hostName, count = 1, replyTimeoutSeconds = 3) }.getOrNull()
if (result != null && result.hasReplies) {
true to "Ping OK: ${result.avgRttMs}ms"
} else {
false to "Ping failed or timeout"
}
},
saveAction = { hostName, label, _, isControl ->
val l = label.ifBlank { hostName }
val target = IcmpTarget(host = hostName, label = l, isControl = isControl)
if (editIndex >= 0) {
targets[editIndex] = target
} else {
targets.add(target)
}
rebuildPills(container)
refreshOwnSummary()
btnAdd.visibility = View.VISIBLE
}
),
onCancel = { btnAdd.visibility = View.VISIBLE },
initialValues = initialValues,
)
}
}
internal class RttTriangulationSectionBinder(host: SectionBinder.Host) : SectionBinder<RttTriangulationConfig>(host) {
override val sectionId = CheckerSectionController.RTT_TRIANGULATION
override val titleRes = R.string.settings_custom_check_section_rtt_triangulation
override val iconRes = R.drawable.ic_compare_arrows
override val bodyLayout = R.layout.section_body_rtt_triangulation
override val enabledFallback = false
val targets = mutableListOf<RttTarget>()
override fun bind(body: View, profile: CustomCheckProfile) {
val cfg = profile.checksConfig.rttTriangulation
body.findViewById<TextInputEditText>(R.id.editRttTimeout).setText(cfg.timeoutMs.toString())
body.findViewById<TextInputEditText>(R.id.editRttPingCount).setText(cfg.pingCount.toString())
body.findViewById<MaterialSwitch>(R.id.switchRttBuiltinTargets).isChecked = cfg.builtinTargetsEnabled
targets.clear()
targets.addAll(cfg.customTargets)
val container = body.findViewById<LinearLayout>(R.id.containerRttTargets)
rebuildPills(container)
body.findViewById<MaterialButton>(R.id.btnAddRttTarget).setOnClickListener {
showEditor(container)
}
}
override fun collect(body: View?, enabled: Boolean): RttTriangulationConfig = RttTriangulationConfig(
enabled = enabled,
timeoutMs = body?.findViewById<TextInputEditText>(R.id.editRttTimeout)?.text?.toString()?.toIntOrNull() ?: 5_000,
pingCount = body?.findViewById<TextInputEditText>(R.id.editRttPingCount)?.text?.toString()?.toIntOrNull() ?: 5,
builtinTargetsEnabled = body?.findViewById<MaterialSwitch>(R.id.switchRttBuiltinTargets)?.isChecked ?: true,
customTargets = targets.toList(),
)
override fun summary(body: View): String {
val builtin = if (body.findViewById<MaterialSwitch>(R.id.switchRttBuiltinTargets).isChecked) 1 else 0
return host.string(R.string.editor_summary_targets, builtin + targets.size)
}
private fun rebuildPills(container: LinearLayout) {
EndpointPills.rebuild(container, targets.mapIndexed { idx, t ->
EndpointPillData(
name = t.label.ifBlank { t.host },
url = "ping ${t.host}",
scope = null,
onRemove = {
targets.removeAt(idx)
rebuildPills(container)
refreshOwnSummary()
},
onEdit = {
showEditor(container, editIndex = idx)
},
)
})
}
private fun showEditor(container: LinearLayout, editIndex: Int = -1) {
val btnAdd = container.parent.let { (it as ViewGroup).findViewById<View>(R.id.btnAddRttTarget) }
val slot = container.parent.let { (it as ViewGroup).findViewById<FrameLayout>(R.id.inlineEditorSlotRtt) }
btnAdd.visibility = View.GONE
val existing = if (editIndex >= 0) targets[editIndex] else null
val initialValues = existing?.let {
GenericInlineEditorController.InitialValues(
url = it.host, label = it.label, extraInput = it.expectedLocation,
)
}
val controller = GenericInlineEditorController(slot, host.lifecycleScope)
controller.show(
config = GenericInlineEditorController.Config(
titleRes = R.string.settings_custom_check_add_rtt_target,
urlHintRes = R.string.settings_custom_check_target_host,
labelHintRes = R.string.settings_custom_check_target_label,
extraInputHintRes = R.string.settings_custom_check_rtt_expected_location,
extraSwitchTextRes = null,
testAction = { hostName, _, _ ->
val result = runCatching { com.notcvnt.rknhardering.probe.SystemPingProber.probe(hostName, count = 1, replyTimeoutSeconds = 3) }.getOrNull()
if (result != null && result.hasReplies) {
true to "Ping OK: ${result.avgRttMs}ms"
} else {
false to "Ping failed or timeout"
}
},
saveAction = { hostName, label, loc, _ ->
val l = label.ifBlank { hostName }
val target = RttTarget(host = hostName, label = l, expectedLocation = loc)
if (editIndex >= 0) {
targets[editIndex] = target
} else {
targets.add(target)
}
rebuildPills(container)
refreshOwnSummary()
btnAdd.visibility = View.VISIBLE
}
),
onCancel = { btnAdd.visibility = View.VISIBLE },
initialValues = initialValues,
)
}
}
internal class CallTransportSectionBinder(host: SectionBinder.Host) : SectionBinder<CallTransportConfig>(host) {
override val sectionId = CheckerSectionController.CALL_TRANSPORT
override val titleRes = R.string.settings_custom_check_section_call_transport
override val iconRes = R.drawable.ic_call
override val bodyLayout = R.layout.section_body_call_transport
override val enabledFallback = false
val servers = mutableListOf<StunServer>()
override fun bind(body: View, profile: CustomCheckProfile) {
val cfg = profile.checksConfig.callTransport
body.findViewById<TextInputEditText>(R.id.editCallTransportTimeout).setText(cfg.timeoutMs.toString())
body.findViewById<MaterialSwitch>(R.id.switchCallTransportBuiltinGlobalStun).isChecked = cfg.builtinGlobalStunEnabled
body.findViewById<MaterialSwitch>(R.id.switchCallTransportBuiltinRuStun).isChecked = cfg.builtinRuStunEnabled
body.findViewById<MaterialSwitch>(R.id.switchCallTransportMtproto).isChecked = cfg.checkMtproto
servers.clear()
servers.addAll(cfg.customStunServers)
val container = body.findViewById<LinearLayout>(R.id.containerStunServers)
rebuildPills(container)
body.findViewById<MaterialButton>(R.id.btnAddStunServer).setOnClickListener {
showEditor(container)
}
}
override fun collect(body: View?, enabled: Boolean): CallTransportConfig = CallTransportConfig(
enabled = enabled,
timeoutMs = body?.findViewById<TextInputEditText>(R.id.editCallTransportTimeout)?.text?.toString()?.toIntOrNull() ?: 5_000,
builtinGlobalStunEnabled = body?.findViewById<MaterialSwitch>(R.id.switchCallTransportBuiltinGlobalStun)?.isChecked ?: true,
builtinRuStunEnabled = body?.findViewById<MaterialSwitch>(R.id.switchCallTransportBuiltinRuStun)?.isChecked ?: true,
checkMtproto = body?.findViewById<MaterialSwitch>(R.id.switchCallTransportMtproto)?.isChecked ?: true,
customStunServers = servers.toList(),
)
override fun summary(body: View): String {
val builtin = (if (body.findViewById<MaterialSwitch>(R.id.switchCallTransportBuiltinGlobalStun).isChecked) 1 else 0) +
(if (body.findViewById<MaterialSwitch>(R.id.switchCallTransportBuiltinRuStun).isChecked) 1 else 0)
return host.string(R.string.editor_summary_targets, builtin + servers.size)
}
private fun rebuildPills(container: LinearLayout) {
EndpointPills.rebuild(container, servers.mapIndexed { idx, s ->
EndpointPillData(
name = s.label.ifBlank { "${s.host}:${s.port}" },
url = "stun://${s.host}:${s.port}",
scope = null,
onRemove = {
servers.removeAt(idx)
rebuildPills(container)
refreshOwnSummary()
},
onEdit = {
showEditor(container, editIndex = idx)
},
)
})
}
private fun showEditor(container: LinearLayout, editIndex: Int = -1) {
val btnAdd = container.parent.let { (it as ViewGroup).findViewById<View>(R.id.btnAddStunServer) }
val slot = container.parent.let { (it as ViewGroup).findViewById<FrameLayout>(R.id.inlineEditorSlotStun) }
btnAdd.visibility = View.GONE
val existing = if (editIndex >= 0) servers[editIndex] else null
val initialValues = existing?.let {
GenericInlineEditorController.InitialValues(
url = it.host, label = it.label, extraInput = it.port.toString(),
)
}
val controller = GenericInlineEditorController(slot, host.lifecycleScope)
controller.show(
config = GenericInlineEditorController.Config(
titleRes = R.string.settings_custom_check_add_stun_server,
urlHintRes = R.string.settings_custom_check_target_host,
labelHintRes = R.string.settings_custom_check_target_label,
extraInputHintRes = R.string.settings_custom_check_stun_port,
extraSwitchTextRes = null,
testAction = { hostName, portStr, _ ->
val port = portStr.toIntOrNull() ?: 3478
val result = runCatching {
com.notcvnt.rknhardering.probe.StunBindingClient.probeDualStack(
host = hostName,
port = port,
resolverConfig = com.notcvnt.rknhardering.network.DnsResolverConfig.system(),
binding = null
)
}.getOrNull()
val mappedIp = result?.ipv4Result?.getOrNull()?.mappedIp ?: result?.ipv6Result?.getOrNull()?.mappedIp
if (mappedIp != null) {
true to "STUN OK. Mapped IP: $mappedIp"
} else {
false to "STUN failed"
}
},
saveAction = { hostName, label, portStr, _ ->
val p = portStr.toIntOrNull() ?: 3478
val l = label.ifBlank { hostName }
val server = StunServer(host = hostName, port = p, label = l)
if (editIndex >= 0) {
servers[editIndex] = server
} else {
servers.add(server)
}
rebuildPills(container)
refreshOwnSummary()
btnAdd.visibility = View.VISIBLE
}
),
onCancel = { btnAdd.visibility = View.VISIBLE },
initialValues = initialValues,
)
}
}

View file

@ -0,0 +1,58 @@
package com.notcvnt.rknhardering.customcheck.ui.editor
import android.view.View
import androidx.annotation.DrawableRes
import androidx.annotation.LayoutRes
import androidx.annotation.StringRes
import androidx.lifecycle.LifecycleCoroutineScope
import com.notcvnt.rknhardering.customcheck.CustomCheckProfile
/**
* One checker section of the custom-check editor: knows how to load its slice
* of the profile into the section body views ([bind]), read it back
* ([collect]) and render the collapsed summary line ([summary]).
*
* [collect] must reproduce the exact legacy defaults for absent/invalid input
* (every `?:` fallback) the round-trip and defaults tests pin them.
*/
internal abstract class SectionBinder<C>(protected val host: Host) {
/** The slice of the editor fragment the binders are allowed to touch. */
interface Host {
val lifecycleScope: LifecycleCoroutineScope
fun string(@StringRes res: Int, vararg args: Any): String
fun refreshSummary(sectionId: String)
}
abstract val sectionId: String
@get:StringRes
abstract val titleRes: Int
@get:DrawableRes
abstract val iconRes: Int
@get:LayoutRes
abstract val bodyLayout: Int
/**
* Legacy fallback for the master-switch state when the section controller
* is unavailable at save time (the original saveAndExit `?:` defaults).
*/
open val enabledFallback: Boolean = true
/** Populates the inflated body from the profile. */
abstract fun bind(body: View, profile: CustomCheckProfile)
/**
* Reconstructs this section's config slice from the body views.
* [body] is null when the section body is unavailable; the legacy defaults
* apply. [enabled] is the master-switch state.
*/
abstract fun collect(body: View?, enabled: Boolean): C
/** Collapsed-state summary, or null to keep the controller's default. */
open fun summary(body: View): String? = null
protected fun refreshOwnSummary() = host.refreshSummary(sectionId)
}

View file

@ -0,0 +1,210 @@
package com.notcvnt.rknhardering.customcheck.ui.editor
import android.view.View
import android.widget.LinearLayout
import com.google.android.material.chip.ChipGroup
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.textfield.TextInputEditText
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.customcheck.CheckToggle
import com.notcvnt.rknhardering.customcheck.CustomCheckProfile
import com.notcvnt.rknhardering.customcheck.DirectSignsConfig
import com.notcvnt.rknhardering.customcheck.IndirectSignsConfig
import com.notcvnt.rknhardering.customcheck.LocationSignalsConfig
import com.notcvnt.rknhardering.customcheck.SplitTunnelConfig
import com.notcvnt.rknhardering.customcheck.ui.CheckerSectionController
internal class DirectSignsSectionBinder(host: SectionBinder.Host) : SectionBinder<DirectSignsConfig>(host) {
override val sectionId = CheckerSectionController.DIRECT_SIGNS
override val titleRes = R.string.settings_custom_check_section_direct_signs
override val iconRes = R.drawable.ic_security
override val bodyLayout = R.layout.section_body_direct_signs
override fun bind(body: View, profile: CustomCheckProfile) {
val cfg = profile.checksConfig.directSigns
body.findViewById<MaterialSwitch>(R.id.switchDirectTransportVpn).isChecked = cfg.checkTransportVpn
body.findViewById<MaterialSwitch>(R.id.switchDirectHttpProxy).isChecked = cfg.checkHttpProxy
body.findViewById<MaterialSwitch>(R.id.switchDirectSocksProxy).isChecked = cfg.checkSocksProxy
body.findViewById<MaterialSwitch>(R.id.switchDirectProxyInfo).isChecked = cfg.checkProxyInfo
body.findViewById<MaterialSwitch>(R.id.switchDirectVpnService).isChecked = cfg.checkVpnService
}
override fun collect(body: View?, enabled: Boolean): DirectSignsConfig = DirectSignsConfig(
enabled = enabled,
checkTransportVpn = body?.findViewById<MaterialSwitch>(R.id.switchDirectTransportVpn)?.isChecked ?: true,
checkHttpProxy = body?.findViewById<MaterialSwitch>(R.id.switchDirectHttpProxy)?.isChecked ?: true,
checkSocksProxy = body?.findViewById<MaterialSwitch>(R.id.switchDirectSocksProxy)?.isChecked ?: true,
checkProxyInfo = body?.findViewById<MaterialSwitch>(R.id.switchDirectProxyInfo)?.isChecked ?: true,
checkVpnService = body?.findViewById<MaterialSwitch>(R.id.switchDirectVpnService)?.isChecked ?: true,
)
override fun summary(body: View): String {
val total = 5
val on = listOf(
R.id.switchDirectTransportVpn,
R.id.switchDirectHttpProxy,
R.id.switchDirectSocksProxy,
R.id.switchDirectProxyInfo,
R.id.switchDirectVpnService,
).count { body.findViewById<MaterialSwitch>(it).isChecked }
return host.string(R.string.editor_summary_x_of_y_active, on, total)
}
}
internal class IndirectSignsSectionBinder(host: SectionBinder.Host) : SectionBinder<IndirectSignsConfig>(host) {
override val sectionId = CheckerSectionController.INDIRECT_SIGNS
override val titleRes = R.string.settings_custom_check_section_indirect_signs
override val iconRes = R.drawable.ic_lan
override val bodyLayout = R.layout.section_body_indirect_signs
override fun bind(body: View, profile: CustomCheckProfile) {
val cfg = profile.checksConfig.indirectSigns
body.findViewById<MaterialSwitch>(R.id.switchIndirectNotVpnCap).isChecked = cfg.checkNotVpnCap
body.findViewById<MaterialSwitch>(R.id.switchIndirectVpnInterfaces).isChecked = cfg.checkVpnInterfaces
body.findViewById<MaterialSwitch>(R.id.switchIndirectMtuAnomaly).isChecked = cfg.checkMtuAnomaly
body.findViewById<MaterialSwitch>(R.id.switchIndirectIpsec).isChecked = cfg.checkIpsec
body.findViewById<MaterialSwitch>(R.id.switchIndirectRouting).isChecked = cfg.checkRouting
body.findViewById<MaterialSwitch>(R.id.switchIndirectDns).isChecked = cfg.checkDns
body.findViewById<MaterialSwitch>(R.id.switchIndirectProxyTools).isChecked = cfg.checkProxyTools
body.findViewById<MaterialSwitch>(R.id.switchIndirectLocalListeners).isChecked = cfg.checkLocalListeners
body.findViewById<MaterialSwitch>(R.id.switchIndirectDumpsys).isChecked = cfg.checkDumpsys
body.findViewById<TextInputEditText>(R.id.editIndirectListenerPortThreshold)
.setText(cfg.listenerPortThreshold.toString())
}
override fun collect(body: View?, enabled: Boolean): IndirectSignsConfig = IndirectSignsConfig(
enabled = enabled,
checkNotVpnCap = body?.findViewById<MaterialSwitch>(R.id.switchIndirectNotVpnCap)?.isChecked ?: true,
checkVpnInterfaces = body?.findViewById<MaterialSwitch>(R.id.switchIndirectVpnInterfaces)?.isChecked ?: true,
checkMtuAnomaly = body?.findViewById<MaterialSwitch>(R.id.switchIndirectMtuAnomaly)?.isChecked ?: true,
checkIpsec = body?.findViewById<MaterialSwitch>(R.id.switchIndirectIpsec)?.isChecked ?: true,
checkRouting = body?.findViewById<MaterialSwitch>(R.id.switchIndirectRouting)?.isChecked ?: true,
checkDns = body?.findViewById<MaterialSwitch>(R.id.switchIndirectDns)?.isChecked ?: true,
checkProxyTools = body?.findViewById<MaterialSwitch>(R.id.switchIndirectProxyTools)?.isChecked ?: true,
checkLocalListeners = body?.findViewById<MaterialSwitch>(R.id.switchIndirectLocalListeners)?.isChecked ?: true,
checkDumpsys = body?.findViewById<MaterialSwitch>(R.id.switchIndirectDumpsys)?.isChecked ?: true,
listenerPortThreshold = body?.findViewById<TextInputEditText>(R.id.editIndirectListenerPortThreshold)
?.text?.toString()?.toIntOrNull() ?: 5,
)
override fun summary(body: View): String {
val ids = listOf(
R.id.switchIndirectNotVpnCap, R.id.switchIndirectVpnInterfaces,
R.id.switchIndirectMtuAnomaly, R.id.switchIndirectIpsec,
R.id.switchIndirectRouting, R.id.switchIndirectDns,
R.id.switchIndirectProxyTools, R.id.switchIndirectLocalListeners,
R.id.switchIndirectDumpsys,
)
val on = ids.count { body.findViewById<MaterialSwitch>(it).isChecked }
return host.string(R.string.editor_summary_x_of_y_active, on, ids.size)
}
}
internal class NativeSignsSectionBinder(host: SectionBinder.Host) : SectionBinder<CheckToggle>(host) {
override val sectionId = CheckerSectionController.NATIVE_SIGNS
override val titleRes = R.string.settings_custom_check_section_native_signs
override val iconRes = R.drawable.ic_lock
override val bodyLayout = R.layout.section_body_native_signs
override fun bind(body: View, profile: CustomCheckProfile) {
// No body content.
}
override fun collect(body: View?, enabled: Boolean): CheckToggle = CheckToggle(enabled = enabled)
}
internal class LocationSignalsSectionBinder(host: SectionBinder.Host) : SectionBinder<LocationSignalsConfig>(host) {
override val sectionId = CheckerSectionController.LOCATION_SIGNALS
override val titleRes = R.string.settings_custom_check_section_location_signals
override val iconRes = R.drawable.ic_location_on
override val bodyLayout = R.layout.section_body_location_signals
override fun bind(body: View, profile: CustomCheckProfile) {
val cfg = profile.checksConfig.locationSignals
body.findViewById<MaterialSwitch>(R.id.switchLocationBeacondb).isChecked = cfg.checkBeacondb
body.findViewById<MaterialSwitch>(R.id.switchLocationCellTowers).isChecked = cfg.checkCellTowers
body.findViewById<MaterialSwitch>(R.id.switchLocationWifiSignals).isChecked = cfg.checkWifiSignals
}
override fun collect(body: View?, enabled: Boolean): LocationSignalsConfig = LocationSignalsConfig(
enabled = enabled,
checkBeacondb = body?.findViewById<MaterialSwitch>(R.id.switchLocationBeacondb)?.isChecked ?: true,
checkCellTowers = body?.findViewById<MaterialSwitch>(R.id.switchLocationCellTowers)?.isChecked ?: true,
checkWifiSignals = body?.findViewById<MaterialSwitch>(R.id.switchLocationWifiSignals)?.isChecked ?: true,
)
override fun summary(body: View): String {
val ids = listOf(
R.id.switchLocationBeacondb,
R.id.switchLocationCellTowers,
R.id.switchLocationWifiSignals,
)
val on = ids.count { body.findViewById<MaterialSwitch>(it).isChecked }
return host.string(R.string.editor_summary_x_of_y_active, on, ids.size)
}
}
internal class SplitTunnelSectionBinder(host: SectionBinder.Host) : SectionBinder<SplitTunnelConfig>(host) {
override val sectionId = CheckerSectionController.SPLIT_TUNNEL
override val titleRes = R.string.settings_custom_check_section_split_tunnel
override val iconRes = R.drawable.ic_call_split
override val bodyLayout = R.layout.section_body_split_tunnel
override fun bind(body: View, profile: CustomCheckProfile) {
val cfg = profile.checksConfig.splitTunnel
body.findViewById<MaterialSwitch>(R.id.switchSplitProxyScan).isChecked = cfg.proxyScan
body.findViewById<MaterialSwitch>(R.id.switchSplitXrayApiScan).isChecked = cfg.xrayApiScan
body.findViewById<TextInputEditText>(R.id.editSplitConnectTimeout).setText(cfg.connectTimeoutMs.toString())
body.findViewById<MaterialSwitch>(R.id.switchSplitCheckUnderlyingNetwork).isChecked = cfg.checkUnderlyingNetwork
body.findViewById<MaterialSwitch>(R.id.switchSplitCheckVpnNetworkBinding).isChecked = cfg.checkVpnNetworkBinding
body.findViewById<MaterialSwitch>(R.id.switchSplitCheckMtprotoViaProxy).isChecked = cfg.checkMtprotoViaProxy
val chipGroup = body.findViewById<ChipGroup>(R.id.chipGroupPortRange)
val layoutCustom = body.findViewById<LinearLayout>(R.id.layoutCustomPortRange)
when (cfg.portRange) {
"full" -> chipGroup.check(R.id.chipPortRangeFull)
"custom" -> { chipGroup.check(R.id.chipPortRangeCustom); layoutCustom.visibility = View.VISIBLE }
else -> chipGroup.check(R.id.chipPortRangePopular)
}
body.findViewById<TextInputEditText>(R.id.editPortRangeStart).setText(cfg.portRangeStart.toString())
body.findViewById<TextInputEditText>(R.id.editPortRangeEnd).setText(cfg.portRangeEnd.toString())
chipGroup.setOnCheckedStateChangeListener { _, _ ->
layoutCustom.visibility = if (chipGroup.checkedChipId == R.id.chipPortRangeCustom) View.VISIBLE else View.GONE
}
}
override fun collect(body: View?, enabled: Boolean): SplitTunnelConfig = SplitTunnelConfig(
enabled = enabled,
proxyScan = body?.findViewById<MaterialSwitch>(R.id.switchSplitProxyScan)?.isChecked ?: true,
xrayApiScan = body?.findViewById<MaterialSwitch>(R.id.switchSplitXrayApiScan)?.isChecked ?: true,
portRange = when (body?.findViewById<ChipGroup>(R.id.chipGroupPortRange)?.checkedChipId) {
R.id.chipPortRangeFull -> "full"
R.id.chipPortRangeCustom -> "custom"
else -> "popular"
},
portRangeStart = body?.findViewById<TextInputEditText>(R.id.editPortRangeStart)?.text?.toString()?.toIntOrNull() ?: 1024,
portRangeEnd = body?.findViewById<TextInputEditText>(R.id.editPortRangeEnd)?.text?.toString()?.toIntOrNull() ?: 65535,
connectTimeoutMs = body?.findViewById<TextInputEditText>(R.id.editSplitConnectTimeout)?.text?.toString()?.toIntOrNull() ?: 300,
checkUnderlyingNetwork = body?.findViewById<MaterialSwitch>(R.id.switchSplitCheckUnderlyingNetwork)?.isChecked ?: true,
checkVpnNetworkBinding = body?.findViewById<MaterialSwitch>(R.id.switchSplitCheckVpnNetworkBinding)?.isChecked ?: true,
checkMtprotoViaProxy = body?.findViewById<MaterialSwitch>(R.id.switchSplitCheckMtprotoViaProxy)?.isChecked ?: true,
)
override fun summary(body: View): String {
val ids = listOf(
R.id.switchSplitProxyScan,
R.id.switchSplitXrayApiScan,
R.id.switchSplitCheckUnderlyingNetwork,
R.id.switchSplitCheckVpnNetworkBinding,
R.id.switchSplitCheckMtprotoViaProxy,
)
val on = ids.count { body.findViewById<MaterialSwitch>(it).isChecked }
return host.string(R.string.editor_summary_x_of_y_active, on, ids.size)
}
}

View file

@ -1,5 +1,7 @@
package com.notcvnt.rknhardering
package com.notcvnt.rknhardering.export
import com.notcvnt.rknhardering.util.maskIp
import com.notcvnt.rknhardering.util.maskInfoValue
import com.notcvnt.rknhardering.model.Verdict
import java.text.SimpleDateFormat
import java.util.Date

View file

@ -1,6 +1,8 @@
package com.notcvnt.rknhardering
package com.notcvnt.rknhardering.export
import android.content.Context
import com.notcvnt.rknhardering.BuildConfig
import com.notcvnt.rknhardering.LocalProxyOwnerFormatter
import com.notcvnt.rknhardering.model.ActiveVpnApp
import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.CallTransportLeakResult
@ -9,17 +11,29 @@ import com.notcvnt.rknhardering.model.CdnPullingResponse
import com.notcvnt.rknhardering.model.CdnPullingResult
import com.notcvnt.rknhardering.model.EvidenceItem
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.GeoIpFacts
import com.notcvnt.rknhardering.model.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpCheckerResponse
import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.LocationSignalsFacts
import com.notcvnt.rknhardering.model.LocalProxyCheckResult
import com.notcvnt.rknhardering.model.LocalProxyOwner
import com.notcvnt.rknhardering.model.MatchedVpnApp
import com.notcvnt.rknhardering.model.StunProbeGroupResult
import com.notcvnt.rknhardering.model.StunProbeResult
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
import com.notcvnt.rknhardering.model.VerdictNarrativeBuilder
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.PublicIpTransportDiagnostics
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.TunEndpointAttempt
import com.notcvnt.rknhardering.probe.TunProbeAttemptDiagnostics
import com.notcvnt.rknhardering.probe.TunProbeDiagnostics
import com.notcvnt.rknhardering.probe.TunProbePathDiagnostics
import com.notcvnt.rknhardering.probe.XrayApiScanResult
import com.notcvnt.rknhardering.probe.XrayOutboundSummary
import com.notcvnt.rknhardering.probe.XrayStatsSummary
import org.json.JSONArray
import org.json.JSONObject
@ -65,6 +79,36 @@ internal object CheckResultJsonExportFormatter {
},
)
put("reasons", jsonArray(narrative.reasonRows))
narrative.homeRoutedRoamingNote?.let { put("homeRoutedRoamingNote", it) }
narrative.whitelistNote?.let { put("whitelistNote", it) }
val locationFacts = snapshot.result.locationSignals.locationFacts
if (locationFacts != null) {
put("homeRoutedRoaming", locationFacts.homeRoutedRoaming)
put(
"roamingDiagnostics",
JSONObject().apply {
put("networkMcc", locationFacts.networkMcc)
put("networkMnc", locationFacts.networkMnc)
put("networkCountryIso", locationFacts.networkCountryIso)
put("networkIsRussia", locationFacts.networkIsRussia)
put("homeSimMcc", locationFacts.homeSimMcc)
put("homeSimMnc", locationFacts.homeSimMnc)
put("homeSimCountryIso", locationFacts.homeSimCountryIso)
put("homeSimCountryIsRussia", locationFacts.homeSimCountryIsRussia)
put("homeSimOperatorName", locationFacts.homeSimOperatorName?.let {
maskExportValue(it, snapshot.privacyMode)
})
put("anySimReportedRoaming", locationFacts.anySimReportedRoaming)
put("homeRoutedRoamingReason", locationFacts.homeRoutedRoamingReason)
val expectedExit = snapshot.result.geoIp.geoFacts?.expectedRoamingExit == true
put("expectedRoamingExit", expectedExit)
put(
"expectedRoamingExitReason",
snapshot.result.geoIp.geoFacts?.expectedRoamingExitReason,
)
},
)
}
},
)
root.put(
@ -75,12 +119,20 @@ internal object CheckResultJsonExportFormatter {
put("cdnPulling", cdnPullingToJson(snapshot.result.cdnPulling, snapshot.privacyMode))
put("directSigns", categoryToJson(snapshot.result.directSigns, snapshot.privacyMode))
put("indirectSigns", categoryToJson(snapshot.result.indirectSigns, snapshot.privacyMode))
put("nativeSigns", categoryToJson(snapshot.result.nativeSigns, snapshot.privacyMode))
put("icmpSpoofing", categoryToJson(snapshot.result.icmpSpoofing, snapshot.privacyMode))
put("rttTriangulation", categoryToJson(snapshot.result.rttTriangulation, snapshot.privacyMode))
put("locationSignals", categoryToJson(snapshot.result.locationSignals, snapshot.privacyMode))
put("bypass", bypassToJson(context, snapshot.result.bypassResult, snapshot.privacyMode))
},
)
root.put("ipConsensus", buildIpConsensusJson(snapshot.result.ipConsensus, snapshot.privacyMode))
snapshot.result.tunProbeDiagnostics?.let {
root.put("tunProbeDiagnostics", tunProbeDiagnosticsToJson(it, snapshot.privacyMode))
}
snapshot.result.operatorWhitelistProbe?.let {
root.put("operator_whitelist", operatorWhitelistProbeToJson(it))
}
return root.toString(2)
}
@ -90,11 +142,72 @@ internal object CheckResultJsonExportFormatter {
put("detected", category.detected)
put("needsReview", category.needsReview)
put("hasError", category.hasError)
put("status", sectionStatusTag(category.detected, category.needsReview, category.hasError))
put("findings", JSONArray().apply { category.findings.forEach { put(findingToJson(it, privacyMode)) } })
put("evidence", JSONArray().apply { category.evidence.forEach { put(evidenceToJson(it, privacyMode)) } })
put("matchedApps", JSONArray().apply { category.matchedApps.forEach { put(matchedAppToJson(it)) } })
put("activeApps", JSONArray().apply { category.activeApps.forEach { put(activeAppToJson(it)) } })
put("callTransportLeaks", JSONArray().apply { category.callTransportLeaks.forEach { put(callTransportLeakToJson(it, privacyMode)) } })
put("stunProbeGroups", JSONArray().apply { category.stunProbeGroups.forEach { put(stunProbeGroupToJson(it, privacyMode)) } })
category.geoFacts?.let { put("geoFacts", geoFactsToJson(it, privacyMode)) }
category.locationFacts?.let { put("locationFacts", locationFactsToJson(it, privacyMode)) }
}
}
private fun geoFactsToJson(facts: GeoIpFacts, privacyMode: Boolean): JSONObject {
return JSONObject().apply {
put("ip", maskExportIp(facts.ip, privacyMode))
put("countryCode", facts.countryCode)
put("asn", facts.asn?.let { maskExportValue(it, privacyMode) })
put("asnCode", facts.asnCode)
put("isp", facts.isp?.let { maskExportValue(it, privacyMode) })
put("org", facts.org?.let { maskExportValue(it, privacyMode) })
put("outsideRu", facts.outsideRu)
put("hosting", facts.hosting)
put("proxyDb", facts.proxyDb)
put("fetchError", facts.fetchError)
put("expectedRoamingExit", facts.expectedRoamingExit)
put("expectedRoamingExitReason", facts.expectedRoamingExitReason)
}
}
private fun locationFactsToJson(facts: LocationSignalsFacts, privacyMode: Boolean): JSONObject {
return JSONObject().apply {
put("networkMcc", facts.networkMcc)
put("networkMnc", facts.networkMnc)
put("networkCountryIso", facts.networkCountryIso)
put("networkOperatorName", facts.networkOperatorName?.let { maskExportValue(it, privacyMode) })
put("networkIsRussia", facts.networkIsRussia)
put("homeSimMcc", facts.homeSimMcc)
put("homeSimMnc", facts.homeSimMnc)
put("homeSimCountryIso", facts.homeSimCountryIso)
put("homeSimCountryIsRussia", facts.homeSimCountryIsRussia)
put("homeSimOperatorName", facts.homeSimOperatorName?.let { maskExportValue(it, privacyMode) })
put("anySimReportedRoaming", facts.anySimReportedRoaming)
put("homeRoutedRoaming", facts.homeRoutedRoaming)
put("homeRoutedRoamingReason", facts.homeRoutedRoamingReason)
}
}
private fun stunProbeGroupToJson(group: StunProbeGroupResult, privacyMode: Boolean): JSONObject {
return JSONObject().apply {
put("scope", group.scope.name)
put("respondedCount", group.respondedCount)
put("totalCount", group.totalCount)
put("results", JSONArray().apply { group.results.forEach { put(stunProbeResultToJson(it, privacyMode)) } })
}
}
private fun stunProbeResultToJson(result: StunProbeResult, privacyMode: Boolean): JSONObject {
return JSONObject().apply {
put("host", maskExportHostOrIp(result.host, privacyMode))
put("port", result.port)
put("scope", result.scope.name)
put("mappedIpv4", maskExportIp(result.mappedIpv4, privacyMode))
put("mappedIpv6", maskExportIp(result.mappedIpv6, privacyMode))
put("mappedIpDisplay", result.mappedIpDisplay?.let { maskExportValue(it, privacyMode) })
put("error", result.error?.let { maskExportValue(it, privacyMode) })
put("hasResponse", result.hasResponse)
}
}
@ -102,7 +215,8 @@ internal object CheckResultJsonExportFormatter {
return JSONObject().apply {
put("detected", result.detected)
put("needsReview", result.needsReview)
put("status", sectionStatusTag(result.detected, result.needsReview))
put("hasError", result.hasError)
put("status", sectionStatusTag(result.detected, result.needsReview, result.hasError))
put("summary", maskExportValue(result.summary, privacyMode))
put("ruGroup", ipCheckerGroupToJson(result.ruGroup, privacyMode))
put("nonRuGroup", ipCheckerGroupToJson(result.nonRuGroup, privacyMode))
@ -143,7 +257,8 @@ internal object CheckResultJsonExportFormatter {
return JSONObject().apply {
put("detected", bypass.detected)
put("needsReview", bypass.needsReview)
put("status", sectionStatusTag(bypass.detected, bypass.needsReview))
put("hasError", bypass.hasError)
put("status", sectionStatusTag(bypass.detected, bypass.needsReview, bypass.hasError))
put("proxyEndpoint", bypass.proxyEndpoint?.let { proxyEndpointToJson(it, privacyMode) })
put("proxyOwner", bypass.proxyOwner?.let { proxyOwnerToJson(it) })
put("proxyOwnerText", bypass.proxyOwner?.let { LocalProxyOwnerFormatter.format(context, it) })
@ -266,6 +381,11 @@ internal object CheckResultJsonExportFormatter {
put("targetLabel", maskExportValue(response.targetLabel, privacyMode))
put("url", maskExportValue(response.url, privacyMode))
put("ip", maskExportIp(response.ip, privacyMode))
put("ipv4", maskExportIp(response.ipv4, privacyMode))
put("ipv6", maskExportIp(response.ipv6, privacyMode))
put("ipv4Unavailable", response.ipv4Unavailable)
put("ipv4Error", response.ipv4Error?.let { maskExportValue(it, privacyMode) })
put("ipv6Error", response.ipv6Error?.let { maskExportValue(it, privacyMode) })
put(
"importantFields",
JSONObject().apply {
@ -287,6 +407,7 @@ internal object CheckResultJsonExportFormatter {
put("host", maskExportHostOrIp(endpoint.host, privacyMode))
put("port", endpoint.port)
put("type", endpoint.type.name)
put("authRequired", endpoint.authRequired)
}
}
@ -335,6 +456,15 @@ internal object CheckResultJsonExportFormatter {
}
},
)
put("handlerAvailable", scanResult.handlerAvailable)
scanResult.stats?.let { put("stats", xrayStatsToJson(it)) }
}
}
private fun xrayStatsToJson(stats: XrayStatsSummary): JSONObject {
return JSONObject().apply {
put("statCount", stats.statCount)
put("sampleNames", jsonArray(stats.sampleNames))
}
}
@ -375,17 +505,131 @@ internal object CheckResultJsonExportFormatter {
}
},
)
put(
"unparsedIps",
JSONArray().apply {
consensus.unparsedIps.forEach { item ->
put(
JSONObject().apply {
put("raw", maskExportValue(item.raw, privacyMode))
put("source", item.source)
},
)
}
},
)
put(
"channelIps",
JSONObject().apply {
consensus.channelIps.toSortedMap(compareBy { it.name }).forEach { (channel, ips) ->
put(channel.name, jsonArray(ips.toList().sorted().map { maskExportIp(it, privacyMode) ?: it }))
}
},
)
put("crossChannelMismatch", consensus.crossChannelMismatch)
put("dualStackObserved", consensus.dualStackObserved)
put("warpLikeIndicator", consensus.warpLikeIndicator)
put("probeTargetDivergence", consensus.probeTargetDivergence)
put("probeTargetDirectDivergence", consensus.probeTargetDirectDivergence)
put("geoCountryMismatch", consensus.geoCountryMismatch)
put("sameAsnAcrossChannels", consensus.sameAsnAcrossChannels)
put("channelConflict", jsonArray(consensus.channelConflict.map { it.name }))
put("foreignIps", jsonArray(consensus.foreignIps.toList().map { maskExportIp(it, privacyMode) ?: it }))
put("needsReview", consensus.needsReview)
}
}
private fun tunProbeDiagnosticsToJson(
diagnostics: TunProbeDiagnostics,
privacyMode: Boolean,
): JSONObject {
return JSONObject().apply {
put("enabled", diagnostics.enabled)
put("modeOverride", diagnostics.modeOverride.name)
put("activeNetworkIsVpn", diagnostics.activeNetworkIsVpn)
put("vpnNetworkPresent", diagnostics.vpnNetworkPresent)
put("underlyingNetworkPresent", diagnostics.underlyingNetworkPresent)
diagnostics.vpnPath?.let { put("vpnPath", tunProbePathToJson(it, privacyMode)) }
diagnostics.underlyingPath?.let { put("underlyingPath", tunProbePathToJson(it, privacyMode)) }
}
}
private fun tunProbePathToJson(
path: TunProbePathDiagnostics,
privacyMode: Boolean,
): JSONObject {
return JSONObject().apply {
put("interfaceName", path.interfaceName)
put("selectedMode", path.selectedMode?.name)
put("selectedIp", maskExportIp(path.selectedIp, privacyMode))
put("selectedError", path.selectedError?.let { maskExportValue(it, privacyMode) })
put("dnsPathMismatch", path.dnsPathMismatch)
put("strict", tunProbeAttemptToJson(path.strict, privacyMode))
put("curlCompatible", tunProbeAttemptToJson(path.curlCompatible, privacyMode))
}
}
private fun tunProbeAttemptToJson(
attempt: TunProbeAttemptDiagnostics,
privacyMode: Boolean,
): JSONObject {
return JSONObject().apply {
put("mode", attempt.mode.name)
put("status", attempt.status.name)
put("ip", maskExportIp(attempt.ip, privacyMode))
put("error", attempt.error?.let { maskExportValue(it, privacyMode) })
put("endpointAttempts", JSONArray().apply { attempt.endpointAttempts.forEach { put(tunEndpointAttemptToJson(it, privacyMode)) } })
put("transportDiagnostics", transportDiagnosticsToJson(attempt.transportDiagnostics, privacyMode))
}
}
private fun tunEndpointAttemptToJson(
attempt: TunEndpointAttempt,
privacyMode: Boolean,
): JSONObject {
return JSONObject().apply {
put("endpoint", maskExportValue(attempt.endpoint, privacyMode))
put("familyHint", attempt.familyHint)
put("status", attempt.status.name)
put("ip", maskExportIp(attempt.ip, privacyMode))
put("error", attempt.error?.let { maskExportValue(it, privacyMode) })
}
}
private fun transportDiagnosticsToJson(
diagnostics: PublicIpTransportDiagnostics,
privacyMode: Boolean,
): JSONObject {
return JSONObject().apply {
put("engine", diagnostics.engine?.name)
put("engineDebugName", diagnostics.engine?.debugName)
put("resolveStrategy", diagnostics.resolveStrategy?.name)
put("resolveStrategyDebugName", diagnostics.resolveStrategy?.debugName)
put("curlCode", diagnostics.curlCode)
put("httpCode", diagnostics.httpCode)
put("nativeLibraryLoaded", diagnostics.nativeLibraryLoaded)
put("caBundleVersion", diagnostics.caBundleVersion)
put("resolvedAddressesUsed", jsonArray(diagnostics.resolvedAddressesUsed.map { maskExportIp(it, privacyMode) ?: it }))
}
}
private fun operatorWhitelistProbeToJson(probe: OperatorWhitelistProbeResult): JSONObject {
return JSONObject().apply {
put("detected", probe.whitelistDetected)
put("google_reachable", probe.googleReachable)
put("apple_reachable", probe.appleReachable)
put("firefox_reachable", probe.firefoxReachable)
put("russian_control_reachable", probe.russianControlReachable)
put("duration_ms", probe.durationMs)
put(
"errors",
JSONObject().apply {
probe.errors.forEach { (key, value) -> put(key, value) }
},
)
}
}
private fun jsonArray(items: List<String>): JSONArray {
return JSONArray().apply {
items.forEach(::put)

View file

@ -1,6 +1,9 @@
package com.notcvnt.rknhardering
package com.notcvnt.rknhardering.export
import android.content.Context
import com.notcvnt.rknhardering.BuildConfig
import com.notcvnt.rknhardering.LocalProxyOwnerFormatter
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.model.ActiveVpnApp
import com.notcvnt.rknhardering.model.BypassResult
import com.notcvnt.rknhardering.model.CallTransportLeakResult
@ -9,18 +12,37 @@ import com.notcvnt.rknhardering.model.CdnPullingResponse
import com.notcvnt.rknhardering.model.CdnPullingResult
import com.notcvnt.rknhardering.model.EvidenceItem
import com.notcvnt.rknhardering.model.Finding
import com.notcvnt.rknhardering.model.GeoIpFacts
import com.notcvnt.rknhardering.model.IpCheckerGroupResult
import com.notcvnt.rknhardering.model.IpCheckerResponse
import com.notcvnt.rknhardering.model.IpComparisonResult
import com.notcvnt.rknhardering.model.IpConsensusResult
import com.notcvnt.rknhardering.model.LocalProxyCheckResult
import com.notcvnt.rknhardering.model.MatchedVpnApp
import com.notcvnt.rknhardering.model.StunProbeGroupResult
import com.notcvnt.rknhardering.model.StunProbeResult
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.model.VerdictNarrative
import com.notcvnt.rknhardering.model.VerdictNarrativeBuilder
import com.notcvnt.rknhardering.model.VpnAppTechnicalMetadata
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.PublicIpTransportDiagnostics
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.XrayOutboundSummary
import com.notcvnt.rknhardering.probe.XrayApiScanResult
import com.notcvnt.rknhardering.probe.XrayStatsSummary
import com.notcvnt.rknhardering.probe.TunEndpointAttempt
import com.notcvnt.rknhardering.probe.TunProbeAttemptDiagnostics
import com.notcvnt.rknhardering.probe.TunProbeDiagnostics
import com.notcvnt.rknhardering.probe.TunProbePathDiagnostics
internal object CheckResultMarkdownExportFormatter {
private const val NONE = "<none>"
private const val NONE_DASH = "- <none>"
private const val NONE_INDENTED = " - <none>"
private const val FINDINGS_HEADING = "### Findings"
fun format(
context: Context,
snapshot: CompletedExportSnapshot,
@ -44,9 +66,13 @@ internal object CheckResultMarkdownExportFormatter {
appendCdnPullingSection(builder, context, result.cdnPulling, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_direct_signs), result.directSigns, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_indirect_signs), result.indirectSigns, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_native_signs), result.nativeSigns, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_icmp_spoofing), result.icmpSpoofing, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_rtt_triangulation), result.rttTriangulation, snapshot.privacyMode)
appendCategorySection(builder, context.getString(R.string.main_card_location_signals), result.locationSignals, snapshot.privacyMode)
appendIpChannelsSection(builder, result.ipConsensus, snapshot.privacyMode)
appendIpChannelsSection(builder, context, result.ipConsensus, snapshot.privacyMode)
appendTunProbeDiagnosticsSection(builder, result.tunProbeDiagnostics, snapshot.privacyMode)
appendOperatorWhitelistSection(builder, context, result.operatorWhitelistProbe)
appendBypassSection(builder, context, result.bypassResult, snapshot.privacyMode)
builder.appendLine("## Footer")
builder.appendLine("- Timestamp: ${formatExportTimestamp(snapshot.finishedAtMillis)}")
@ -85,7 +111,7 @@ internal object CheckResultMarkdownExportFormatter {
builder.appendLine()
builder.appendLine("### What was discovered")
if (narrative.discoveredRows.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
} else {
narrative.discoveredRows.forEach { row ->
builder.appendLine("- ${row.label}: ${row.value}")
@ -115,7 +141,7 @@ internal object CheckResultMarkdownExportFormatter {
appendSectionSummaryRow(
builder,
title = context.getString(R.string.main_card_ip_comparison),
status = sectionStatusTag(result.ipComparison.detected, result.ipComparison.needsReview),
status = sectionStatusTag(result.ipComparison.detected, result.ipComparison.needsReview, result.ipComparison.hasError),
summary = buildSummary(result.ipComparison.summary, snapshot.privacyMode),
)
appendSectionSummaryRow(
@ -136,12 +162,24 @@ internal object CheckResultMarkdownExportFormatter {
status = sectionStatusTag(result.indirectSigns.detected, result.indirectSigns.needsReview, result.indirectSigns.hasError),
summary = buildCategorySummary(result.indirectSigns, snapshot.privacyMode),
)
appendSectionSummaryRow(
builder,
title = context.getString(R.string.main_card_native_signs),
status = sectionStatusTag(result.nativeSigns.detected, result.nativeSigns.needsReview, result.nativeSigns.hasError),
summary = buildCategorySummary(result.nativeSigns, snapshot.privacyMode),
)
appendSectionSummaryRow(
builder,
title = context.getString(R.string.main_card_icmp_spoofing),
status = sectionStatusTag(result.icmpSpoofing.detected, result.icmpSpoofing.needsReview, result.icmpSpoofing.hasError),
summary = buildCategorySummary(result.icmpSpoofing, snapshot.privacyMode),
)
appendSectionSummaryRow(
builder,
title = context.getString(R.string.main_card_rtt_triangulation),
status = sectionStatusTag(result.rttTriangulation.detected, result.rttTriangulation.needsReview, result.rttTriangulation.hasError),
summary = buildCategorySummary(result.rttTriangulation, snapshot.privacyMode),
)
appendSectionSummaryRow(
builder,
title = context.getString(R.string.main_card_location_signals),
@ -151,7 +189,7 @@ internal object CheckResultMarkdownExportFormatter {
appendSectionSummaryRow(
builder,
title = context.getString(R.string.settings_split_tunnel),
status = sectionStatusTag(result.bypassResult.detected, result.bypassResult.needsReview),
status = sectionStatusTag(result.bypassResult.detected, result.bypassResult.needsReview, result.bypassResult.hasError),
summary = buildBypassSummary(result.bypassResult, snapshot.privacyMode),
)
}
@ -179,8 +217,10 @@ internal object CheckResultMarkdownExportFormatter {
builder.appendLine("- Matched apps: ${category.matchedApps.size}")
builder.appendLine("- Active apps: ${category.activeApps.size}")
builder.appendLine("- Call transport signals: ${category.callTransportLeaks.size}")
builder.appendLine("- STUN probe groups: ${category.stunProbeGroups.size}")
builder.appendLine("- Geo facts: ${if (category.geoFacts != null) "present" else NONE}")
builder.appendLine()
builder.appendLine("### Findings")
builder.appendLine(FINDINGS_HEADING)
appendStringList(builder, category.findings.map { formatFinding(it, privacyMode) })
builder.appendLine()
builder.appendLine("### Evidence")
@ -195,6 +235,18 @@ internal object CheckResultMarkdownExportFormatter {
builder.appendLine("### Call transport")
appendStringList(builder, category.callTransportLeaks.map { formatCallTransportLeak(it, privacyMode) })
builder.appendLine()
category.geoFacts?.let { facts ->
builder.appendLine("### Geo facts")
builder.appendLine("- ${formatGeoFacts(facts, privacyMode)}")
builder.appendLine()
}
if (category.stunProbeGroups.isNotEmpty()) {
builder.appendLine("### STUN probe groups")
category.stunProbeGroups.forEach { group ->
appendStunProbeGroup(builder, group, privacyMode)
}
builder.appendLine()
}
}
private fun appendIpComparisonSection(
@ -204,7 +256,7 @@ internal object CheckResultMarkdownExportFormatter {
privacyMode: Boolean,
) {
builder.appendLine("## ${context.getString(R.string.main_card_ip_comparison)}")
builder.appendLine("- Status: ${sectionStatusTag(result.detected, result.needsReview)}")
builder.appendLine("- Status: ${sectionStatusTag(result.detected, result.needsReview, result.hasError)}")
builder.appendLine("- Summary: ${buildSummary(result.summary, privacyMode)}")
builder.appendLine()
appendIpCheckerGroupSection(builder, "RU", result.ruGroup, privacyMode)
@ -222,11 +274,11 @@ internal object CheckResultMarkdownExportFormatter {
builder.appendLine("- Title: ${maskExportValue(group.title, privacyMode)}")
builder.appendLine("- Status label: ${maskExportValue(group.statusLabel, privacyMode)}")
builder.appendLine("- Summary: ${buildSummary(group.summary, privacyMode)}")
builder.appendLine("- Canonical IP: ${maskExportIp(group.canonicalIp, privacyMode) ?: "<none>"}")
builder.appendLine("- Canonical IP: ${maskExportIp(group.canonicalIp, privacyMode) ?: NONE}")
builder.appendLine("- Ignored IPv6 errors: ${group.ignoredIpv6ErrorCount}")
builder.appendLine("- Responses:")
if (group.responses.isEmpty()) {
builder.appendLine(" - <none>")
builder.appendLine(NONE_INDENTED)
} else {
group.responses.forEach { response ->
builder.appendLine(" - ${formatIpCheckerResponse(response, privacyMode)}")
@ -245,12 +297,12 @@ internal object CheckResultMarkdownExportFormatter {
builder.appendLine("- Status: ${sectionStatusTag(result.detected, result.needsReview, result.hasError)}")
builder.appendLine("- Summary: ${buildSummary(result.summary, privacyMode)}")
builder.appendLine()
builder.appendLine("### Findings")
builder.appendLine(FINDINGS_HEADING)
appendStringList(builder, result.findings.map { formatFinding(it, privacyMode) })
builder.appendLine()
builder.appendLine("### Responses")
if (result.responses.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
} else {
result.responses.forEachIndexed { index, response ->
appendCdnPullingResponse(builder, index + 1, response, privacyMode)
@ -267,11 +319,16 @@ internal object CheckResultMarkdownExportFormatter {
) {
builder.appendLine("#### Response $index: ${maskExportValue(response.targetLabel, privacyMode)}")
builder.appendLine("- URL: ${maskExportValue(response.url, privacyMode)}")
builder.appendLine("- IP: ${maskExportIp(response.ip, privacyMode) ?: "<none>"}")
builder.appendLine("- Error: ${response.error?.let { maskExportValue(it, privacyMode) } ?: "<none>"}")
builder.appendLine("- IP: ${maskExportIp(response.ip, privacyMode) ?: NONE}")
builder.appendLine("- IPv4: ${maskExportIp(response.ipv4, privacyMode) ?: NONE}")
builder.appendLine("- IPv6: ${maskExportIp(response.ipv6, privacyMode) ?: NONE}")
builder.appendLine("- IPv4 unavailable: ${response.ipv4Unavailable}")
builder.appendLine("- IPv4 error: ${response.ipv4Error?.let { maskExportValue(it, privacyMode) } ?: NONE}")
builder.appendLine("- IPv6 error: ${response.ipv6Error?.let { maskExportValue(it, privacyMode) } ?: NONE}")
builder.appendLine("- Error: ${response.error?.let { maskExportValue(it, privacyMode) } ?: NONE}")
builder.appendLine("- Important fields:")
if (response.importantFields.isEmpty()) {
builder.appendLine(" - <none>")
builder.appendLine(NONE_INDENTED)
} else {
response.importantFields.forEach { (key, value) ->
builder.appendLine(" - $key: ${maskExportValue(value, privacyMode)}")
@ -293,16 +350,16 @@ internal object CheckResultMarkdownExportFormatter {
privacyMode: Boolean,
) {
builder.appendLine("## ${context.getString(R.string.settings_split_tunnel)}")
builder.appendLine("- Status: ${sectionStatusTag(bypass.detected, bypass.needsReview)}")
builder.appendLine("- Local proxy: ${bypass.proxyEndpoint?.let { formatExportHostPort(it.host, it.port, privacyMode) + " (${it.type})" } ?: "<none>"}")
builder.appendLine("- Owner app: ${bypass.proxyOwner?.let { LocalProxyOwnerFormatter.format(context, it) } ?: "<none>"}")
builder.appendLine("- Direct IP: ${maskExportIp(bypass.directIp, privacyMode) ?: "<none>"}")
builder.appendLine("- Proxy IP: ${maskExportIp(bypass.proxyIp, privacyMode) ?: "<none>"}")
builder.appendLine("- VPN network IP: ${maskExportIp(bypass.vpnNetworkIp, privacyMode) ?: "<none>"}")
builder.appendLine("- Underlying IP: ${maskExportIp(bypass.underlyingIp, privacyMode) ?: "<none>"}")
builder.appendLine("- Xray API: ${bypass.xrayApiScanResult?.let { formatExportHostPort(it.endpoint.host, it.endpoint.port, privacyMode) } ?: "<none>"}")
builder.appendLine("- Status: ${sectionStatusTag(bypass.detected, bypass.needsReview, bypass.hasError)}")
builder.appendLine("- Local proxy: ${bypass.proxyEndpoint?.let { formatProxyEndpoint(it, privacyMode) } ?: NONE}")
builder.appendLine("- Owner app: ${bypass.proxyOwner?.let { LocalProxyOwnerFormatter.format(context, it) } ?: NONE}")
builder.appendLine("- Direct IP: ${maskExportIp(bypass.directIp, privacyMode) ?: NONE}")
builder.appendLine("- Proxy IP: ${maskExportIp(bypass.proxyIp, privacyMode) ?: NONE}")
builder.appendLine("- VPN network IP: ${maskExportIp(bypass.vpnNetworkIp, privacyMode) ?: NONE}")
builder.appendLine("- Underlying IP: ${maskExportIp(bypass.underlyingIp, privacyMode) ?: NONE}")
builder.appendLine("- Xray API: ${bypass.xrayApiScanResult?.let { formatXrayApiSummary(it, privacyMode) } ?: NONE}")
builder.appendLine()
builder.appendLine("### Findings")
builder.appendLine(FINDINGS_HEADING)
appendStringList(builder, bypass.findings.map { formatFinding(it, privacyMode) })
builder.appendLine()
builder.appendLine("### Evidence")
@ -357,10 +414,10 @@ internal object CheckResultMarkdownExportFormatter {
private fun formatActiveApp(app: ActiveVpnApp): String {
return buildList {
add("package=${app.packageName ?: "<none>"}")
add("service=${app.serviceName ?: "<none>"}")
add("family=${app.family ?: "<none>"}")
add("kind=${app.kind ?: "<none>"}")
add("package=${app.packageName ?: NONE}")
add("service=${app.serviceName ?: NONE}")
add("family=${app.family ?: NONE}")
add("kind=${app.kind ?: NONE}")
add("source=${app.source}")
add("confidence=${app.confidence}")
addAll(formatTechnicalMetadata(app.technicalMetadata))
@ -403,6 +460,47 @@ internal object CheckResultMarkdownExportFormatter {
}.joinToString(" | ")
}
private fun formatGeoFacts(facts: GeoIpFacts, privacyMode: Boolean): String {
return buildList {
add("ip=${maskExportIp(facts.ip, privacyMode) ?: NONE}")
add("countryCode=${facts.countryCode ?: NONE}")
add("asn=${facts.asn?.let { maskExportValue(it, privacyMode) } ?: NONE}")
add("outsideRu=${facts.outsideRu}")
add("hosting=${facts.hosting}")
add("proxyDb=${facts.proxyDb}")
add("fetchError=${facts.fetchError}")
}.joinToString(" | ")
}
private fun appendStunProbeGroup(
builder: StringBuilder,
group: StunProbeGroupResult,
privacyMode: Boolean,
) {
builder.appendLine("- scope=${group.scope} | responded=${group.respondedCount}/${group.totalCount}")
if (group.results.isEmpty()) {
builder.appendLine(NONE_INDENTED)
return
}
group.results.forEach { result ->
builder.appendLine(" - ${formatStunProbeResult(result, privacyMode)}")
}
}
private fun formatStunProbeResult(
result: StunProbeResult,
privacyMode: Boolean,
): String {
return buildList {
add("target=${formatExportHostPort(result.host, result.port, privacyMode)}")
add("scope=${result.scope}")
add("hasResponse=${result.hasResponse}")
result.mappedIpv4?.let { add("mappedIpv4=${maskExportIp(it, privacyMode)}") }
result.mappedIpv6?.let { add("mappedIpv6=${maskExportIp(it, privacyMode)}") }
result.error?.let { add("error=${maskExportValue(it, privacyMode)}") }
}.joinToString(" | ")
}
private fun formatIpCheckerResponse(
response: IpCheckerResponse,
privacyMode: Boolean,
@ -411,16 +509,16 @@ internal object CheckResultMarkdownExportFormatter {
add("label=${maskExportValue(response.label, privacyMode)}")
add("scope=${response.scope}")
add("url=${maskExportValue(response.url, privacyMode)}")
add("ip=${maskExportIp(response.ip, privacyMode) ?: "<none>"}")
add("error=${response.error?.let { maskExportValue(it, privacyMode) } ?: "<none>"}")
add("ip=${maskExportIp(response.ip, privacyMode) ?: NONE}")
add("error=${response.error?.let { maskExportValue(it, privacyMode) } ?: NONE}")
add(
"ipv4Records=${
response.ipv4Records.joinToString(", ") { maskExportIp(it, privacyMode) ?: it }.ifBlank { "<none>" }
response.ipv4Records.joinToString(", ") { maskExportIp(it, privacyMode) ?: it }.ifBlank { NONE }
}",
)
add(
"ipv6Records=${
response.ipv6Records.joinToString(", ") { maskExportIp(it, privacyMode) ?: it }.ifBlank { "<none>" }
response.ipv6Records.joinToString(", ") { maskExportIp(it, privacyMode) ?: it }.ifBlank { NONE }
}",
)
add("ignoredIpv6Error=${response.ignoredIpv6Error}")
@ -434,27 +532,51 @@ internal object CheckResultMarkdownExportFormatter {
return buildList {
add("endpoint=${formatExportHostPort(proxyCheck.endpoint.host, proxyCheck.endpoint.port, privacyMode)}")
add("type=${proxyCheck.endpoint.type}")
add("authRequired=${proxyCheck.endpoint.authRequired}")
add("ownerStatus=${proxyCheck.ownerStatus}")
add("proxyIp=${maskExportIp(proxyCheck.proxyIp, privacyMode) ?: "<none>"}")
add("proxyIp=${maskExportIp(proxyCheck.proxyIp, privacyMode) ?: NONE}")
add("status=${proxyCheck.status}")
add("mtProtoReachable=${proxyCheck.mtProtoReachable?.toString() ?: "<not-run>"}")
add("mtProtoTarget=${proxyCheck.mtProtoTarget?.let { maskExportHostPort(it, privacyMode) } ?: "<none>"}")
add("summaryReason=${proxyCheck.summaryReason ?: "<none>"}")
add("mtProtoTarget=${proxyCheck.mtProtoTarget?.let { maskExportHostPort(it, privacyMode) } ?: NONE}")
add("summaryReason=${proxyCheck.summaryReason ?: NONE}")
}.joinToString(" | ")
}
private fun formatProxyEndpoint(endpoint: ProxyEndpoint, privacyMode: Boolean): String {
return buildList {
add(formatExportHostPort(endpoint.host, endpoint.port, privacyMode))
add("type=${endpoint.type}")
add("authRequired=${endpoint.authRequired}")
}.joinToString(" | ")
}
private fun formatXrayApiSummary(scanResult: XrayApiScanResult, privacyMode: Boolean): String {
return buildList {
add(formatExportHostPort(scanResult.endpoint.host, scanResult.endpoint.port, privacyMode))
add("handlerAvailable=${scanResult.handlerAvailable}")
scanResult.stats?.let { add("stats=${formatXrayStats(it)}") }
}.joinToString(" | ")
}
private fun formatXrayStats(stats: XrayStatsSummary): String {
return buildList {
add("statCount=${stats.statCount}")
add("sampleNames=${stats.sampleNames.joinToString(", ").ifBlank { NONE }}")
}.joinToString(" ")
}
private fun formatXrayOutbound(
outbound: XrayOutboundSummary,
privacyMode: Boolean,
): String {
return buildList {
add("tag=${outbound.tag}")
add("protocol=${outbound.protocolName ?: "<none>"}")
add("address=${outbound.address?.let { maskExportHostOrIp(it, privacyMode) } ?: "<none>"}")
add("port=${outbound.port ?: "<none>"}")
add("sni=${outbound.sni ?: "<none>"}")
add("senderSettingsType=${outbound.senderSettingsType ?: "<none>"}")
add("proxySettingsType=${outbound.proxySettingsType ?: "<none>"}")
add("protocol=${outbound.protocolName ?: NONE}")
add("address=${outbound.address?.let { maskExportHostOrIp(it, privacyMode) } ?: NONE}")
add("port=${outbound.port ?: NONE}")
add("sni=${outbound.sni ?: NONE}")
add("senderSettingsType=${outbound.senderSettingsType ?: NONE}")
add("proxySettingsType=${outbound.proxySettingsType ?: NONE}")
add("uuidPresent=${!outbound.uuid.isNullOrBlank()}")
add("publicKeyPresent=${!outbound.publicKey.isNullOrBlank()}")
}.joinToString(" | ")
@ -473,18 +595,18 @@ internal object CheckResultMarkdownExportFormatter {
}
val xray = bypass.xrayApiScanResult
?.let { formatExportHostPort(it.endpoint.host, it.endpoint.port, privacyMode) }
return xray ?: "<none>"
return xray ?: NONE
}
private fun buildSummary(summary: String?, privacyMode: Boolean): String {
val normalized = summary?.trim().orEmpty()
if (normalized.isBlank()) return "<none>"
if (normalized.isBlank()) return NONE
return maskExportValue(normalized, privacyMode)
}
private fun appendStringList(builder: StringBuilder, items: List<String>) {
if (items.isEmpty()) {
builder.appendLine("- <none>")
builder.appendLine(NONE_DASH)
return
}
items.forEach { item -> builder.appendLine("- $item") }
@ -492,14 +614,18 @@ internal object CheckResultMarkdownExportFormatter {
private fun appendIpChannelsSection(
builder: StringBuilder,
context: Context,
consensus: IpConsensusResult,
privacyMode: Boolean,
) {
if (consensus.observedIps.isEmpty()) {
if (!consensus.hasReportableContent()) {
return
}
builder.appendLine("## IP каналы")
builder.appendLine("| Канал | Target | IP | Family | Страна | ASN | Источники |")
builder.appendLine("## ${context.getString(R.string.ip_channels_title)}")
val channelCol = context.getString(R.string.export_ip_channels_col_channel)
val countryCol = context.getString(R.string.export_ip_channels_col_country)
val sourcesCol = context.getString(R.string.export_ip_channels_col_sources)
builder.appendLine("| $channelCol | Target | IP | Family | $countryCol | ASN | $sourcesCol |")
builder.appendLine("| --- | --- | --- | --- | --- | --- | --- |")
consensus.observedIps.forEach { ip ->
val channel = escapeTableCell(ip.channel.name)
@ -514,17 +640,171 @@ internal object CheckResultMarkdownExportFormatter {
builder.appendLine()
val flags = buildList {
if (consensus.crossChannelMismatch) add("crossChannelMismatch=true")
if (consensus.dualStackObserved) add("dualStackObserved=true")
if (consensus.warpLikeIndicator) add("warpLikeIndicator=true")
if (consensus.probeTargetDivergence) add("probeTargetDivergence=true")
if (consensus.probeTargetDirectDivergence) add("probeTargetDirectDivergence=true")
if (consensus.geoCountryMismatch) add("geoCountryMismatch=true")
if (consensus.sameAsnAcrossChannels) add("sameAsnAcrossChannels=true")
if (consensus.channelConflict.isNotEmpty()) add("channelConflict=${consensus.channelConflict.joinToString(", ")}")
if (consensus.foreignIps.isNotEmpty()) {
add("foreignIps=${consensus.foreignIps.joinToString(", ") { maskExportIp(it, privacyMode) ?: it }}")
}
if (consensus.needsReview) add("needsReview=true")
}
if (flags.isNotEmpty()) {
builder.appendLine("Флаги: ${flags.joinToString(", ")}")
builder.appendLine("${context.getString(R.string.export_flags_label)}: ${flags.joinToString(", ")}")
}
builder.appendLine()
if (consensus.channelIps.isNotEmpty()) {
builder.appendLine("Channel IPs:")
consensus.channelIps.toSortedMap(compareBy { it.name }).forEach { (channel, ips) ->
val rendered = ips.toList()
.sorted()
.joinToString(", ") { ip -> maskExportIp(ip, privacyMode) ?: ip }
.ifBlank { NONE }
builder.appendLine("- $channel: $rendered")
}
builder.appendLine()
}
if (consensus.unparsedIps.isNotEmpty()) {
builder.appendLine("Unparsed IP inputs:")
consensus.unparsedIps.forEach { item ->
builder.appendLine("- source=${item.source} | raw=${maskExportValue(item.raw, privacyMode)}")
}
builder.appendLine()
}
}
private fun IpConsensusResult.hasReportableContent(): Boolean {
return observedIps.isNotEmpty() ||
unparsedIps.isNotEmpty() ||
channelIps.isNotEmpty() ||
channelConflict.isNotEmpty() ||
crossChannelMismatch ||
dualStackObserved ||
foreignIps.isNotEmpty() ||
geoCountryMismatch ||
sameAsnAcrossChannels ||
warpLikeIndicator ||
probeTargetDivergence ||
probeTargetDirectDivergence ||
needsReview
}
private fun appendOperatorWhitelistSection(
builder: StringBuilder,
context: Context,
probe: OperatorWhitelistProbeResult?,
) {
probe ?: return
val available = context.getString(R.string.export_available)
val unavailable = context.getString(R.string.export_unavailable)
fun reachable(value: Boolean): String = if (value) available else unavailable
val detected = if (probe.whitelistDetected) {
context.getString(R.string.export_detected_yes)
} else {
context.getString(R.string.export_detected_no)
}
builder.appendLine("## ${context.getString(R.string.export_operator_whitelist_title)}")
builder.appendLine("- ${context.getString(R.string.export_detected_label)}: $detected")
builder.appendLine("- google.com/generate_204: ${reachable(probe.googleReachable)}")
builder.appendLine("- apple captive portal: ${reachable(probe.appleReachable)}")
builder.appendLine("- firefox detectportal: ${reachable(probe.firefoxReachable)}")
builder.appendLine(
"- yandex.ru (${context.getString(R.string.export_control_suffix)}): " +
reachable(probe.russianControlReachable),
)
builder.appendLine(
"- ${context.getString(R.string.export_duration_label)}: " +
"${probe.durationMs} ${context.getString(R.string.export_duration_unit_ms)}",
)
builder.appendLine()
}
private fun appendTunProbeDiagnosticsSection(
builder: StringBuilder,
diagnostics: TunProbeDiagnostics?,
privacyMode: Boolean,
) {
diagnostics ?: return
builder.appendLine("## TUN probe diagnostics")
builder.appendLine("- Enabled: ${diagnostics.enabled}")
builder.appendLine("- Mode override: ${diagnostics.modeOverride}")
builder.appendLine("- Active network is VPN: ${diagnostics.activeNetworkIsVpn ?: "<unknown>"}")
builder.appendLine("- VPN network present: ${diagnostics.vpnNetworkPresent}")
builder.appendLine("- Underlying network present: ${diagnostics.underlyingNetworkPresent}")
appendTunProbePath(builder, "VPN path", diagnostics.vpnPath, privacyMode)
appendTunProbePath(builder, "Underlying path", diagnostics.underlyingPath, privacyMode)
builder.appendLine()
}
private fun appendTunProbePath(
builder: StringBuilder,
title: String,
path: TunProbePathDiagnostics?,
privacyMode: Boolean,
) {
builder.appendLine("### $title")
if (path == null) {
builder.appendLine(NONE_DASH)
return
}
builder.appendLine("- Interface: ${path.interfaceName ?: NONE}")
builder.appendLine("- Selected mode: ${path.selectedMode ?: NONE}")
builder.appendLine("- Selected IP: ${maskExportIp(path.selectedIp, privacyMode) ?: NONE}")
builder.appendLine("- Selected error: ${path.selectedError?.let { maskExportValue(it, privacyMode) } ?: NONE}")
builder.appendLine("- DNS path mismatch: ${path.dnsPathMismatch}")
builder.appendLine("- Strict: ${formatTunProbeAttempt(path.strict, privacyMode)}")
builder.appendLine("- Curl compatible: ${formatTunProbeAttempt(path.curlCompatible, privacyMode)}")
}
private fun formatTunProbeAttempt(
attempt: TunProbeAttemptDiagnostics,
privacyMode: Boolean,
): String {
return buildList {
add("mode=${attempt.mode}")
add("status=${attempt.status}")
add("ip=${maskExportIp(attempt.ip, privacyMode) ?: NONE}")
add("error=${attempt.error?.let { maskExportValue(it, privacyMode) } ?: NONE}")
add("endpointAttempts=${formatTunEndpointAttempts(attempt.endpointAttempts, privacyMode)}")
add("transport=${formatTransportDiagnostics(attempt.transportDiagnostics, privacyMode)}")
}.joinToString(" | ")
}
private fun formatTunEndpointAttempts(
attempts: List<TunEndpointAttempt>,
privacyMode: Boolean,
): String {
return attempts.joinToString("; ") { attempt ->
buildList {
add("endpoint=${maskExportValue(attempt.endpoint, privacyMode)}")
add("family=${attempt.familyHint}")
add("status=${attempt.status}")
add("ip=${maskExportIp(attempt.ip, privacyMode) ?: NONE}")
add("error=${attempt.error?.let { maskExportValue(it, privacyMode) } ?: NONE}")
}.joinToString(" ")
}.ifBlank { NONE }
}
private fun formatTransportDiagnostics(
diagnostics: PublicIpTransportDiagnostics,
privacyMode: Boolean,
): String {
return buildList {
add("engine=${diagnostics.engine?.debugName ?: NONE}")
add("resolveStrategy=${diagnostics.resolveStrategy?.debugName ?: NONE}")
add("curlCode=${diagnostics.curlCode ?: NONE}")
add("httpCode=${diagnostics.httpCode ?: NONE}")
add("nativeLibraryLoaded=${diagnostics.nativeLibraryLoaded ?: "<unknown>"}")
add("caBundleVersion=${diagnostics.caBundleVersion ?: NONE}")
add(
"resolvedAddressesUsed=${
diagnostics.resolvedAddressesUsed.joinToString(", ") { maskExportIp(it, privacyMode) ?: it }.ifBlank { NONE }
}",
)
}.joinToString(" ")
}
private fun escapeTableCell(value: String): String {

View file

@ -1,17 +1,45 @@
package com.notcvnt.rknhardering.model
import com.notcvnt.rknhardering.probe.OperatorWhitelistProbeResult
import com.notcvnt.rknhardering.probe.ProxyEndpoint
import com.notcvnt.rknhardering.probe.TunProbeDiagnostics
import com.notcvnt.rknhardering.probe.ClashApiScanResult
import com.notcvnt.rknhardering.probe.XrayApiScanResult
data class GeoIpFacts(
val ip: String? = null,
val countryCode: String? = null,
val asn: String? = null,
val asnCode: String? = null,
val isp: String? = null,
val org: String? = null,
val outsideRu: Boolean = false,
val hosting: Boolean = false,
val proxyDb: Boolean = false,
val fetchError: Boolean = false,
val expectedRoamingExit: Boolean = false,
val expectedRoamingExitReason: String? = null,
)
/**
* Structured signals from LocationSignalsChecker, used by VerdictEngine and the
* UI to recognise "home-routed roaming" a foreign SIM connected via the
* Russian visited network whose data plane still exits via the home country.
*/
data class LocationSignalsFacts(
val networkMcc: String? = null,
val networkMnc: String? = null,
val networkCountryIso: String? = null,
val networkOperatorName: String? = null,
val networkIsRussia: Boolean = false,
val homeSimMcc: String? = null,
val homeSimMnc: String? = null,
val homeSimCountryIso: String? = null,
val homeSimCountryIsRussia: Boolean = false,
val homeSimOperatorName: String? = null,
val anySimReportedRoaming: Boolean = false,
val homeRoutedRoaming: Boolean = false,
val homeRoutedRoamingReason: String? = null,
)
enum class EvidenceConfidence {
@ -25,19 +53,23 @@ enum class EvidenceSource {
DIRECT_NETWORK_CAPABILITIES,
INDIRECT_NETWORK_CAPABILITIES,
ICMP_SPOOFING,
RTT_TRIANGULATION,
SYSTEM_PROXY,
INSTALLED_APP,
VPN_SERVICE_DECLARATION,
ACTIVE_VPN,
LOCAL_PROXY,
XRAY_API,
CLASH_API,
SPLIT_TUNNEL_BYPASS,
PROXY_AUTH_BYPASS,
NETWORK_INTERFACE,
ROUTING,
DNS,
PROXY_TECHNICAL_SIGNAL,
DUMPSYS,
LOCATION_SIGNALS,
HOME_ROUTED_ROAMING,
VPN_GATEWAY_LEAK,
VPN_NETWORK_BINDING,
TUN_ACTIVE_PROBE,
@ -51,6 +83,8 @@ enum class EvidenceSource {
NATIVE_JVM_MISMATCH,
NATIVE_LIBRARY_INTEGRITY,
NATIVE_ROOT_DETECTION,
NATIVE_EMULATOR,
SANDBOX_ISOLATION,
}
enum class StunScope {
@ -221,6 +255,14 @@ data class LocalProxyCheckResult(
val summaryReason: LocalProxySummaryReason? = null,
)
data class GeoIpResponse(
val provider: String,
val isCustom: Boolean,
val ip: String? = null,
val error: String? = null,
val rawBody: String? = null,
)
data class CategoryResult(
val name: String,
val detected: Boolean,
@ -232,6 +274,8 @@ data class CategoryResult(
val callTransportLeaks: List<CallTransportLeakResult> = emptyList(),
val stunProbeGroups: List<StunProbeGroupResult> = emptyList(),
val geoFacts: GeoIpFacts? = null,
val locationFacts: LocationSignalsFacts? = null,
val geoIpResponses: List<GeoIpResponse> = emptyList(),
) {
val hasError: Boolean
get() = findings.any { it.isError }
@ -251,12 +295,16 @@ data class BypassResult(
val vpnNetworkIp: String? = null,
val underlyingIp: String? = null,
val xrayApiScanResult: XrayApiScanResult?,
val clashApiScanResult: ClashApiScanResult? = null,
val proxyChecks: List<LocalProxyCheckResult> = emptyList(),
val findings: List<Finding>,
val detected: Boolean,
val needsReview: Boolean = false,
val evidence: List<EvidenceItem> = emptyList(),
)
) {
val hasError: Boolean
get() = findings.any { it.isError }
}
enum class IpCheckerScope {
RU,
@ -288,6 +336,7 @@ data class IpCheckerGroupResult(
data class IpComparisonResult(
val detected: Boolean,
val needsReview: Boolean = false,
val hasError: Boolean = false,
val summary: String,
val ruGroup: IpCheckerGroupResult,
val nonRuGroup: IpCheckerGroupResult,
@ -327,6 +376,72 @@ data class CdnPullingResult(
}
}
// === Domain Reachability (DPI) ===
enum class DomainReachabilityStepStatus {
OK,
FAILED,
SKIPPED,
}
data class DomainReachabilityResponse(
val domain: String,
val label: String,
val dnsStatus: DomainReachabilityStepStatus,
val dnsError: String? = null,
val resolvedIps: List<String> = emptyList(),
val tcpStatus: DomainReachabilityStepStatus = DomainReachabilityStepStatus.SKIPPED,
val tcpError: String? = null,
val tlsStatus: DomainReachabilityStepStatus = DomainReachabilityStepStatus.SKIPPED,
val tlsError: String? = null,
val expectedDnsAvailable: Boolean = true,
val expectedTcpAvailable: Boolean = true,
val expectedTlsAvailable: Boolean = true,
) {
val reachable: Boolean
get() = dnsStatus == DomainReachabilityStepStatus.OK &&
tcpStatus == DomainReachabilityStepStatus.OK &&
tlsStatus == DomainReachabilityStepStatus.OK
val blocked: Boolean
get() = dnsStatus == DomainReachabilityStepStatus.FAILED ||
tcpStatus == DomainReachabilityStepStatus.FAILED ||
tlsStatus == DomainReachabilityStepStatus.FAILED
val blockStage: String?
get() = when {
dnsStatus == DomainReachabilityStepStatus.FAILED -> "DNS"
tcpStatus == DomainReachabilityStepStatus.FAILED -> "TCP"
tlsStatus == DomainReachabilityStepStatus.FAILED -> "TLS"
else -> null
}
/** true when every step matches the expected availability */
val matchesExpectation: Boolean
get() {
val dnsOk = (dnsStatus == DomainReachabilityStepStatus.OK) == expectedDnsAvailable
val tcpOk = dnsStatus == DomainReachabilityStepStatus.FAILED ||
(tcpStatus == DomainReachabilityStepStatus.OK) == expectedTcpAvailable
val tlsOk = tcpStatus != DomainReachabilityStepStatus.OK ||
(tlsStatus == DomainReachabilityStepStatus.OK) == expectedTlsAvailable
return dnsOk && tcpOk && tlsOk
}
}
data class DomainReachabilityResult(
val responses: List<DomainReachabilityResponse> = emptyList(),
) {
val reachableCount: Int get() = responses.count { it.reachable }
val blockedCount: Int get() = responses.count { it.blocked }
val totalCount: Int get() = responses.size
val hasBlocked: Boolean get() = blockedCount > 0
val isEmpty: Boolean get() = responses.isEmpty()
companion object {
fun empty(): DomainReachabilityResult = DomainReachabilityResult()
}
}
data class CheckResult(
val geoIp: CategoryResult,
val ipComparison: IpComparisonResult,
@ -347,5 +462,14 @@ data class CheckResult(
detected = false,
findings = emptyList(),
),
val rttTriangulation: CategoryResult = CategoryResult(
name = "",
detected = false,
findings = emptyList(),
),
val ipConsensus: IpConsensusResult = IpConsensusResult.empty(),
val operatorWhitelistProbe: OperatorWhitelistProbeResult? = null,
val customProfileId: String? = null,
val customProfileName: String? = null,
val domainReachability: DomainReachabilityResult = DomainReachabilityResult.empty(),
)

View file

@ -1,16 +1,8 @@
package com.notcvnt.rknhardering
package com.notcvnt.rknhardering.model
import android.content.Context
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.CheckResult
import com.notcvnt.rknhardering.model.EvidenceSource
import com.notcvnt.rknhardering.model.Verdict
import com.notcvnt.rknhardering.LocalProxyOwnerFormatter
import com.notcvnt.rknhardering.R
import com.notcvnt.rknhardering.probe.XrayOutboundSummary
enum class ExposureStatus {
@ -40,6 +32,8 @@ data class VerdictNarrative(
val meaningRows: List<String>,
val discoveredRows: List<NarrativeRow>,
val reasonRows: List<String>,
val homeRoutedRoamingNote: String? = null,
val whitelistNote: String? = null,
)
object VerdictNarrativeBuilder {
@ -57,9 +51,36 @@ object VerdictNarrativeBuilder {
meaningRows = buildMeaningRows(context, result.verdict, exposureStatus),
discoveredRows = buildDiscoveredRows(context, snapshot, exposureStatus, privacyMode),
reasonRows = buildReasonRows(context, result),
homeRoutedRoamingNote = buildHomeRoutedRoamingNote(context, result),
whitelistNote = buildWhitelistNote(context, result),
)
}
private fun buildWhitelistNote(context: Context, result: CheckResult): String? {
if (result.operatorWhitelistProbe?.whitelistDetected != true) return null
return context.getString(R.string.narrative_whitelist_note)
}
private fun buildHomeRoutedRoamingNote(context: Context, result: CheckResult): String? {
val facts = result.locationSignals.locationFacts ?: return null
if (!facts.homeRoutedRoaming) return null
val operator = facts.homeSimOperatorName?.takeIf { it.isNotBlank() }
val country = facts.homeSimCountryIso?.takeIf { it.isNotBlank() }
val asnConfirmed = result.geoIp.geoFacts?.expectedRoamingExit == true
return when {
asnConfirmed && operator != null && country != null ->
context.getString(R.string.narrative_home_routed_roaming_confirmed, operator, country)
asnConfirmed && country != null ->
context.getString(R.string.narrative_home_routed_roaming_confirmed_country, country)
operator != null && country != null ->
context.getString(R.string.narrative_home_routed_roaming_likely, operator, country)
country != null ->
context.getString(R.string.narrative_home_routed_roaming_likely_country, country)
else ->
context.getString(R.string.narrative_home_routed_roaming_generic)
}
}
private fun collectSnapshot(context: Context, result: CheckResult): Snapshot {
val xrayApi = result.bypassResult.xrayApiScanResult
val gatewayLeakFinding = result.bypassResult.findings.firstOrNull {

View file

@ -3,15 +3,6 @@ package com.notcvnt.rknhardering.network
object NetworkInterfaceNameNormalizer {
private const val STACKED_V4_PREFIX = "v4-"
private val STANDARD_BASE_PATTERNS = listOf(
Regex("^wlan.*"),
Regex("^rmnet.*"),
Regex("^eth.*"),
Regex("^lo$"),
Regex("^ccmni.*"),
Regex("^ccemni.*"),
)
fun canonicalName(name: String?): String? {
if (name.isNullOrBlank()) return name
val baseName = name.removePrefix(STACKED_V4_PREFIX)
@ -20,6 +11,6 @@ object NetworkInterfaceNameNormalizer {
}
private fun isStandardBaseInterface(name: String): Boolean {
return STANDARD_BASE_PATTERNS.any { it.matches(name) }
return NetworkInterfacePatterns.STACKED_BASE_INTERFACES.any { it.matches(name) }
}
}

View file

@ -6,9 +6,16 @@ object NetworkInterfacePatterns {
Regex("^tap\\d+"),
Regex("^wg\\d+"),
Regex("^ppp\\d+"),
Regex("^utun\\d*"),
Regex("^zt.*"),
Regex("^tailscale\\d*"),
Regex("^svpn\\d*"),
Regex("^gre\\d+"),
Regex("^l2tp\\d+"),
Regex("^he-ipv6.*"),
)
val IPSEC_INTERFACE_PATTERN: Regex = Regex("^ipsec.*")
val IPSEC_INTERFACE_PATTERN: Regex = Regex("^(ipsec|xfrm).*")
val STANDARD_INTERFACES: List<Regex> = listOf(
Regex("^wlan.*"),
@ -21,6 +28,19 @@ object NetworkInterfacePatterns {
Regex("^dummy\\d+"),
)
// Base interfaces eligible for "v4-" clat de-stacking. Intentionally a strict
// subset of STANDARD_INTERFACES: seth/dummy are excluded so that e.g.
// "v4-seth0" keeps its name, matching the historical normalization behavior.
// Widening this list changes detection results; reconcile with README first.
val STACKED_BASE_INTERFACES: List<Regex> = listOf(
Regex("^wlan.*"),
Regex("^rmnet.*"),
Regex("^eth.*"),
Regex("^lo$"),
Regex("^ccmni.*"),
Regex("^ccemni.*"),
)
fun isVpnInterface(name: String?): Boolean {
val canonical = NetworkInterfaceNameNormalizer.canonicalName(name)
if (canonical.isNullOrBlank()) return false

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

@ -52,6 +52,7 @@ internal data class ResolverHttpRequest(
object ResolverNetworkStack {
internal const val OKHTTP_RETRY_COUNT = 1
internal const val NATIVE_CURL_RETRY_COUNT = 1
internal const val YANDEX_DOH_URL = "https://common.dot.dns.yandex.net/dns-query"
private val lock = Any()
@Volatile
@ -84,6 +85,7 @@ object ResolverNetworkStack {
}
okHttpExecuteOverride = null
ResolverSocketBinder.resetForTests()
WhitelistAwareDnsFailureCounter.reset()
}
fun execute(
@ -295,7 +297,12 @@ object ResolverNetworkStack {
if (binding is ResolverBinding.OsDeviceBinding && binding.dnsMode == ResolverBinding.DnsMode.SYSTEM) {
return Dns.SYSTEM
}
return when (config.mode) {
if (WhitelistAwareDnsFailureCounter.dnsExhausted) {
return createYandexDohFallback(binding, cancellationSignal)
}
val baseDns = when (config.mode) {
DnsResolverMode.SYSTEM -> fallbackDns(binding)
DnsResolverMode.DIRECT -> {
val servers = config.effectiveDirectServers()
@ -334,6 +341,43 @@ object ResolverNetworkStack {
if (cancellationSignal != null) CancellableDns(doh, bootstrapClient, cancellationSignal) else doh
}
}
return CountingDns(baseDns)
}
private fun createYandexDohFallback(
binding: ResolverBinding?,
cancellationSignal: ScanCancellationSignal?,
): Dns {
val yandexBootstrapIps = listOf("77.88.8.8", "77.88.8.1", "77.88.8.88")
.mapNotNull { literalToInetAddress(it) }
val bootstrapClient = OkHttpClient.Builder()
.apply {
when (binding) {
is ResolverBinding.AndroidNetworkBinding -> {
socketFactory(binding.network.socketFactory)
dns(NetworkDns(binding.network))
}
is ResolverBinding.OsDeviceBinding -> {
socketFactory(BindToDeviceSocketFactory(binding.interfaceName))
dns(Dns.SYSTEM)
}
null -> Unit
}
}
.build()
return try {
val builder = DnsOverHttps.Builder()
.client(bootstrapClient)
.url(YANDEX_DOH_URL.toHttpUrl())
if (yandexBootstrapIps.isNotEmpty()) {
builder.bootstrapDnsHosts(yandexBootstrapIps)
}
val doh = builder.build()
if (cancellationSignal != null) CancellableDns(doh, bootstrapClient, cancellationSignal) else doh
} catch (e: Exception) {
// Yandex DoH itself failed — fall back to direct Yandex servers
DirectDns(listOf("77.88.8.8", "77.88.8.1"), binding = binding, cancellationSignal = cancellationSignal)
}
}
private fun literalToInetAddress(value: String): InetAddress? {
@ -485,7 +529,7 @@ internal class DirectDns(
}
}
if (!typeResolved && type == 1 && resolved.isEmpty() && lastFailure is UnknownHostException) {
throw lastFailure as UnknownHostException
throw lastFailure
}
}
@ -633,3 +677,21 @@ internal class DirectDns(
return ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
}
}
internal class CountingDns(
private val delegate: Dns,
) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
return try {
delegate.lookup(hostname)
} catch (e: UnknownHostException) {
WhitelistAwareDnsFailureCounter.recordFailure()
throw e
} catch (e: IOException) {
if (e.message?.contains("No address associated with hostname") == true) {
WhitelistAwareDnsFailureCounter.recordFailure()
}
throw e
}
}
}

View file

@ -0,0 +1,25 @@
package com.notcvnt.rknhardering.network
import java.util.concurrent.atomic.AtomicInteger
object WhitelistAwareDnsFailureCounter {
private const val THRESHOLD = 3
private val count = AtomicInteger(0)
@Volatile
var dnsExhausted: Boolean = false
private set
fun reset() {
count.set(0)
dnsExhausted = false
}
fun recordFailure() {
val current = count.incrementAndGet()
if (current >= THRESHOLD) {
dnsExhausted = true
}
}
}

View file

@ -6,8 +6,6 @@ import com.notcvnt.rknhardering.network.DnsResolverConfig
import com.notcvnt.rknhardering.network.ResolverBinding
import com.notcvnt.rknhardering.network.ResolverNetworkStack
import java.io.IOException
import java.net.Inet4Address
import java.net.Inet6Address
object CdnPullingClient {
@ -112,44 +110,11 @@ object CdnPullingClient {
).takeIf { it.hasUsefulData }
}
internal fun looksLikeIp(value: String): Boolean {
if (value.isBlank() || value.length > 64) return false
val normalized = value.trim()
return when {
':' in normalized -> looksLikeIpv6Literal(normalized)
'.' in normalized -> looksLikeIpv4Literal(normalized)
else -> false
}
}
internal fun looksLikeIp(value: String): Boolean = IpLiterals.isIpLiteral(value)
internal fun looksLikeIpv4(value: String): Boolean {
if (value.isBlank() || value.length > 64) return false
return looksLikeIpv4Literal(value.trim())
}
internal fun looksLikeIpv4(value: String): Boolean = IpLiterals.isIpv4Literal(value)
internal fun looksLikeIpv6(value: String): Boolean {
if (value.isBlank() || value.length > 64) return false
val normalized = value.trim()
return ':' in normalized && looksLikeIpv6Literal(normalized)
}
private fun looksLikeIpv4Literal(value: String): Boolean {
val parts = value.split('.')
if (parts.size != 4 || parts.any { it.isBlank() }) return false
if (parts.any { it.length > 1 && it.startsWith('0') }) return false
if (parts.any { part -> part.any { !it.isDigit() } }) return false
if (parts.any { (it.toIntOrNull() ?: -1) !in 0..255 }) return false
val parsed = runCatching { java.net.InetAddress.getByName(value) }.getOrNull() ?: return false
return parsed is Inet4Address
}
private fun looksLikeIpv6Literal(value: String): Boolean {
if (!value.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' || it == ':' || it == '.' }) {
return false
}
val parsed = runCatching { java.net.InetAddress.getByName(value) }.getOrNull() ?: return false
return parsed is Inet6Address
}
internal fun looksLikeIpv6(value: String): Boolean = IpLiterals.isIpv6Literal(value)
internal fun resetForTests() {
fetchBodyOverride = null

View file

@ -0,0 +1,69 @@
package com.notcvnt.rknhardering.probe
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* Probes the localhost REST management API exposed by Clash / mihomo / sing-box
* proxy cores. These cores listen on loopback (default ports 9090/19090/9091/9097)
* and serve unauthenticated JSON over plain HTTP. The /connections endpoint leaks
* the real destination IPs of active VPN-server tunnels.
*
* Parsing helpers live in the companion object (pure, statically testable);
* networking delegates to a single shared OkHttpClient so all instances share
* one connection pool and dispatcher no per-scan thread-pool churn.
*/
class ClashApiClient(
private val host: String = "127.0.0.1",
) {
fun fetchConfigs(port: Int): String? = httpGet(port, "/configs")
fun fetchConnections(port: Int): String? = httpGet(port, "/connections")
fun fetchProxies(port: Int): String? = httpGet(port, "/proxies")
private fun httpGet(port: Int, path: String): String? {
val bracketHost = if (host.contains(':')) "[$host]" else host
val url = "http://$bracketHost:$port$path"
return runCatching {
SHARED_CLIENT.newCall(Request.Builder().url(url).build()).execute().use { resp ->
if (!resp.isSuccessful) null else resp.body?.string()
}
}.getOrNull()
}
companion object {
private val SHARED_CLIENT: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(600, TimeUnit.MILLISECONDS)
.readTimeout(600, TimeUnit.MILLISECONDS)
.callTimeout(1200, TimeUnit.MILLISECONDS)
.build()
fun isConfigResponseAlive(body: String?): Boolean {
if (body.isNullOrBlank()) return false
return runCatching { JSONObject(body); true }.getOrDefault(false)
}
fun parseConnectionsDestinationIps(body: String?): List<String> {
if (body.isNullOrBlank()) return emptyList()
return runCatching {
val arr = JSONObject(body).optJSONArray("connections") ?: return emptyList()
buildList {
for (i in 0 until arr.length()) {
val meta = arr.optJSONObject(i)?.optJSONObject("metadata") ?: continue
val ip = meta.optString("destinationIP").takeIf { it.isNotBlank() } ?: continue
add(ip)
}
}
}.getOrDefault(emptyList())
}
fun parseProxyNodes(body: String?): List<String> {
if (body.isNullOrBlank()) return emptyList()
return runCatching {
val proxies = JSONObject(body).optJSONObject("proxies") ?: return emptyList()
proxies.keys().asSequence().toList()
}.getOrDefault(emptyList())
}
}
}

View file

@ -0,0 +1,90 @@
package com.notcvnt.rknhardering.probe
import com.notcvnt.rknhardering.ScanExecutionContext
import com.notcvnt.rknhardering.registerSocket
import com.notcvnt.rknhardering.rethrowIfCancellation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.InetSocketAddress
import java.net.Socket
data class ClashApiEndpoint(val host: String, val port: Int)
data class ClashApiScanResult(
val endpoint: ClashApiEndpoint,
val leakedDestIps: List<String>,
val proxyNodes: List<String>,
val configAvailable: Boolean,
)
/**
* Scans loopback for an exposed Clash / mihomo / sing-box REST management API.
* Mirrors [XrayApiScanner]'s structure: a small fixed port list, optional
* override lambdas for testing, and cooperative cancellation via
* [ScanExecutionContext].
*/
class ClashApiScanner(
private val loopbackHosts: List<String> = listOf("127.0.0.1", "::1"),
private val scanPorts: List<Int> = DEFAULT_PORTS,
private val connectTimeoutMs: Int = 200,
private val isTcpPortOpenOverride: ((String, Int) -> Boolean)? = null,
private val probeApiOverride: (suspend (String, Int) -> ClashApiScanResult?)? = null,
) {
companion object {
val DEFAULT_PORTS: List<Int> = listOf(9090, 19090, 9091, 9097)
}
private val clients = loopbackHosts.associateWith { ClashApiClient(it) }
suspend fun findClashApi(
onProgress: suspend (Int, Int) -> Unit,
): ClashApiScanResult? = withContext(Dispatchers.IO) {
val total = scanPorts.size * loopbackHosts.size
var scanned = 0
for (host in loopbackHosts) {
for (port in scanPorts) {
ScanExecutionContext.currentOrDefault().throwIfCancelled()
scanned++
onProgress(scanned, total)
if (!isTcpPortOpen(host, port)) continue
val result = probeApi(host, port)
if (result != null) return@withContext result
}
}
null
}
private suspend fun probeApi(host: String, port: Int): ClashApiScanResult? {
probeApiOverride?.let { return it(host, port) }
val client = clients.getValue(host)
val configs = client.fetchConfigs(port)
if (!ClashApiClient.isConfigResponseAlive(configs)) return null
val connections = client.fetchConnections(port)
val proxies = client.fetchProxies(port)
return ClashApiScanResult(
endpoint = ClashApiEndpoint(host, port),
leakedDestIps = ClashApiClient.parseConnectionsDestinationIps(connections),
proxyNodes = ClashApiClient.parseProxyNodes(proxies),
configAvailable = true,
)
}
private fun isTcpPortOpen(host: String, port: Int): Boolean {
isTcpPortOpenOverride?.let { return it(host, port) }
val executionContext = ScanExecutionContext.currentOrDefault()
return try {
Socket().use { socket ->
val registration = executionContext.cancellationSignal.registerSocket(socket)
try {
socket.connect(InetSocketAddress(host, port), connectTimeoutMs)
} finally {
registration.dispose()
}
}
true
} catch (error: Exception) {
rethrowIfCancellation(error, executionContext)
false
}
}
}

View file

@ -17,12 +17,15 @@ object IfconfigClient {
private const val CURL_COMPATIBLE_UNAVAILABLE_MESSAGE =
"OS device bind fallback is unavailable because interfaceName is missing"
private const val DISABLED_BY_OVERRIDE_MESSAGE = "Disabled by override"
private const val PUBLIC_IP_PROBE_FAILED = "Public IP probe failed"
private const val UNKNOWN_ERROR = "unknown error"
private const val DEFAULT_HTTP_TIMEOUT_MS = 5_000
private val ENDPOINTS = listOf(
IpEndpointSpec("https://ifconfig.me/ip", IpEndpointFamilyHint.IPV4),
IpEndpointSpec("https://checkip.amazonaws.com", IpEndpointFamilyHint.IPV4),
IpEndpointSpec("https://ip.mail.ru", IpEndpointFamilyHint.IPV4),
IpEndpointSpec("https://ipv4-internet.yandex.net/api/v0/ip", IpEndpointFamilyHint.IPV4),
IpEndpointSpec("https://api-ipv4.ip.sb/ip", IpEndpointFamilyHint.IPV4),
IpEndpointSpec("https://api4.ipify.org", IpEndpointFamilyHint.IPV4),
IpEndpointSpec("https://api6.ipify.org", IpEndpointFamilyHint.IPV6),
@ -185,7 +188,7 @@ object IfconfigClient {
curlCompatible = curlCompatible,
fallbackBinding = fallbackBinding,
)
TunProbeModeOverride.STRICT_SAME_PATH -> strict.error ?: "Public IP probe failed"
TunProbeModeOverride.STRICT_SAME_PATH -> strict.error ?: PUBLIC_IP_PROBE_FAILED
TunProbeModeOverride.CURL_COMPATIBLE -> curlCompatible.error ?: CURL_COMPATIBLE_UNAVAILABLE_MESSAGE
}
}
@ -202,6 +205,68 @@ object IfconfigClient {
)
}
// Per-app VPN exclusion probe: no Android Network object available, bind via
// OS SO_BINDTODEVICE directly. CURL_COMPATIBLE (NativeCurl) is the only viable
// transport since the strict path needs AndroidNetworkBinding.
suspend fun fetchIpViaOsDeviceBinding(
binding: ResolverBinding.OsDeviceBinding,
timeoutMs: Int = DEFAULT_HTTP_TIMEOUT_MS,
resolverConfig: DnsResolverConfig = DnsResolverConfig.system(),
modeOverride: TunProbeModeOverride = TunProbeModeOverride.AUTO,
collectTrace: Boolean = false,
targetUrls: List<String>? = null,
okHttpRetryCount: Int = ResolverNetworkStack.OKHTTP_RETRY_COUNT,
nativeCurlRetryCount: Int = ResolverNetworkStack.NATIVE_CURL_RETRY_COUNT,
executionContext: ScanExecutionContext = ScanExecutionContext.currentOrDefault(),
): PublicIpNetworkComparison = withContext(Dispatchers.IO) {
val endpoints = if (!targetUrls.isNullOrEmpty()) {
targetUrls.map(::IpEndpointSpec)
} else {
ENDPOINTS
}
// Strict same-path requires AndroidNetworkBinding; with only OsDeviceBinding available
// it is always skipped. Curl-compatible (NativeCurl) uses SO_BINDTODEVICE which works.
val strict = PublicIpModeProbeResult(
mode = PublicIpProbeMode.STRICT_SAME_PATH,
status = PublicIpProbeStatus.SKIPPED,
error = "AndroidNetworkBinding not available for per-app excluded interface",
)
val curlCompatible = if (modeOverride == TunProbeModeOverride.STRICT_SAME_PATH) {
PublicIpModeProbeResult(
mode = PublicIpProbeMode.CURL_COMPATIBLE,
status = PublicIpProbeStatus.SKIPPED,
error = DISABLED_BY_OVERRIDE_MESSAGE,
)
} else {
fetchModeProbeResult(
mode = PublicIpProbeMode.CURL_COMPATIBLE,
timeoutMs = timeoutMs,
resolverConfig = resolverConfig,
binding = binding,
collectTrace = collectTrace,
endpoints = endpoints,
okHttpRetryCount = okHttpRetryCount,
nativeCurlRetryCount = nativeCurlRetryCount,
executionContext = executionContext,
)
}
val selectedMode = when {
curlCompatible.status == PublicIpProbeStatus.SUCCEEDED -> PublicIpProbeMode.CURL_COMPATIBLE
else -> null
}
val selectedIp = curlCompatible.ip.takeIf { selectedMode != null }
val selectedError = if (selectedIp != null) null else curlCompatible.error ?: strict.error
PublicIpNetworkComparison(
strict = strict,
curlCompatible = curlCompatible,
selectedMode = selectedMode,
selectedIp = selectedIp,
selectedError = selectedError,
dnsPathMismatch = false,
)
}
private suspend fun fetchIpWithFallback(
timeoutMs: Int,
resolverConfig: DnsResolverConfig,
@ -372,12 +437,12 @@ object IfconfigClient {
primaryError = strict.error,
fallbackError = curlCompatible.error,
fallbackBinding = fallbackBinding,
).message ?: "Public IP probe failed"
).message ?: PUBLIC_IP_PROBE_FAILED
PublicIpProbeStatus.SKIPPED -> {
val strictMessage = strict.error ?: "unknown error"
val strictMessage = strict.error ?: UNKNOWN_ERROR
"$strictMessage; ${curlCompatible.error ?: CURL_COMPATIBLE_UNAVAILABLE_MESSAGE}"
}
PublicIpProbeStatus.SUCCEEDED -> strict.error ?: "Public IP probe failed"
PublicIpProbeStatus.SUCCEEDED -> strict.error ?: PUBLIC_IP_PROBE_FAILED
}
}
@ -388,14 +453,14 @@ object IfconfigClient {
): IOException {
val interfaceName = fallbackBinding?.interfaceName ?: "unknown"
return IOException(
"Android Network binding failed: ${primaryError ?: "unknown error"}; " +
"SO_BINDTODEVICE($interfaceName) failed: ${fallbackError ?: "unknown error"}",
"Android Network binding failed: ${primaryError ?: UNKNOWN_ERROR}; " +
"SO_BINDTODEVICE($interfaceName) failed: ${fallbackError ?: UNKNOWN_ERROR}",
)
}
private fun Throwable?.renderMessage(): String {
return this?.message
?: this?.javaClass?.simpleName
?: "unknown error"
?: UNKNOWN_ERROR
}
}

View file

@ -0,0 +1,51 @@
package com.notcvnt.rknhardering.probe
import java.net.Inet4Address
import java.net.Inet6Address
/**
* Strict IP-literal validators (charset checks + real InetAddress parsing).
* Moved verbatim from CdnPullingClient. Not interchangeable with the
* deliberately loose heuristic in PublicIpClient.looksLikeIp.
*/
internal object IpLiterals {
fun isIpLiteral(value: String): Boolean {
if (value.isBlank() || value.length > 64) return false
val normalized = value.trim()
return when {
':' in normalized -> isIpv6Body(normalized)
'.' in normalized -> isIpv4Body(normalized)
else -> false
}
}
fun isIpv4Literal(value: String): Boolean {
if (value.isBlank() || value.length > 64) return false
return isIpv4Body(value.trim())
}
fun isIpv6Literal(value: String): Boolean {
if (value.isBlank() || value.length > 64) return false
val normalized = value.trim()
return ':' in normalized && isIpv6Body(normalized)
}
private fun isIpv4Body(value: String): Boolean {
val parts = value.split('.')
if (parts.size != 4 || parts.any { it.isBlank() }) return false
if (parts.any { it.length > 1 && it.startsWith('0') }) return false
if (parts.any { part -> part.any { !it.isDigit() } }) return false
if (parts.any { (it.toIntOrNull() ?: -1) !in 0..255 }) return false
val parsed = runCatching { java.net.InetAddress.getByName(value) }.getOrNull() ?: return false
return parsed is Inet4Address
}
private fun isIpv6Body(value: String): Boolean {
if (!value.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' || it == ':' || it == '.' }) {
return false
}
val parsed = runCatching { java.net.InetAddress.getByName(value) }.getOrNull() ?: return false
return parsed is Inet6Address
}
}

View file

@ -124,7 +124,7 @@ object LocalSocketInspector {
@Suppress("DEPRECATION")
pm.getApplicationInfo(packageName, 0)
}
pm.getApplicationLabel(appInfo)?.toString()
pm.getApplicationLabel(appInfo).toString()
}.getOrNull()
}

View file

@ -21,6 +21,8 @@ data class ProxyEndpoint(
val port: Int,
val type: ProxyType,
val authRequired: Boolean = false,
val weakAuthCracked: Boolean = false,
val udpAssociateOpen: Boolean = false,
)
data class ScanProgress(

View file

@ -65,11 +65,6 @@ object NativeCurlBridge {
return executeOverride != null || (isLibraryLoaded() && caBundleInfo?.absolutePath?.isNotBlank() == true)
}
fun execute(requestJson: String): String {
val request = NativeCurlRequest.fromJson(requestJson)
return execute(request).toJson()
}
internal fun execute(
request: NativeCurlRequest,
requestId: String = "",
@ -78,7 +73,7 @@ object NativeCurlBridge {
if (!isLibraryLoaded()) {
return NativeCurlResponse(localError = lastLoadErrorMessage() ?: "Native curl bridge is not loaded")
}
val activeCaBundle = request.caBundlePath?.takeIf { it.isNotBlank() } ?: caBundleInfo?.absolutePath
val activeCaBundle = request.caBundlePath.takeIf { it.isNotBlank() } ?: caBundleInfo?.absolutePath
if (activeCaBundle.isNullOrBlank()) {
return NativeCurlResponse(localError = "Native CA bundle is unavailable")
}

View file

@ -11,6 +11,7 @@ data class NativeInterface(
val address: String?,
val netmask: String?,
val mtu: Int,
val ifaceType: Int? = null,
) {
val isUp: Boolean get() = (flags and IFF_UP) != 0L
val isLoopback: Boolean get() = (flags and IFF_LOOPBACK) != 0L
@ -40,6 +41,7 @@ data class NativeRouteEntry(
val scope: String? = null,
val type: String? = null,
val table: Int? = null,
val prefixLen: Int? = null,
) {
enum class RouteSource { PROC, NETLINK }
}
@ -62,6 +64,34 @@ data class NativeRootFinding(
val detail: String?,
)
data class NativeEmulatorFinding(
val kind: String,
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_DEFAULT_DESTINATION = "00000000"
private const val IPV6_DEFAULT_DESTINATION = "00000000000000000000000000000000"
@ -81,6 +111,7 @@ object NativeInterfaceProbe {
val addr = parts[4].takeIf { it.isNotBlank() }
val mask = parts[5].takeIf { it.isNotBlank() }
val mtu = parts[6].toIntOrNull() ?: -1
val ifaceType = parts.getOrNull(7)?.toIntOrNull()?.takeIf { it >= 0 }
return NativeInterface(
name = name,
canonicalName = NetworkInterfaceNameNormalizer.canonicalName(name),
@ -90,6 +121,7 @@ object NativeInterfaceProbe {
address = addr,
netmask = mask,
mtu = mtu,
ifaceType = ifaceType,
)
}
@ -219,6 +251,7 @@ object NativeInterfaceProbe {
scope = scope,
type = type,
table = table,
prefixLen = prefixLen,
),
)
}
@ -358,4 +391,102 @@ object NativeInterfaceProbe {
val rows = NativeSignsBridge.detectRoot()
return parseRootFindings(rows)
}
fun parseEmulatorFindings(rows: Array<String>): List<NativeEmulatorFinding> {
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() }
NativeEmulatorFinding(kind = kind, detail = detail)
}
}
fun collectEmulatorFindings(): List<NativeEmulatorFinding> {
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,7 @@
package com.notcvnt.rknhardering.probe
import com.notcvnt.rknhardering.ScanCancellationSignal
object NativeSignsBridge {
private const val LIBRARY_NAME = "native_signs_probe"
@ -36,6 +38,24 @@ object NativeSignsBridge {
@Volatile
internal var detectRootOverride: (() -> Array<String>)? = null
@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
@ -132,6 +152,42 @@ object NativeSignsBridge {
return runCatching { nativeDetectRoot() }.getOrDefault(emptyArray())
}
fun detectEmulator(): Array<String> {
detectEmulatorOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectEmulator() }.getOrDefault(emptyArray())
}
fun detectVpnProperties(): Array<String> {
detectVpnPropertiesOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectVpnProperties() }.getOrDefault(emptyArray())
}
fun detectVpnLeaks(): Array<String> {
detectVpnLeaksOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectVpnLeaks() }.getOrDefault(emptyArray())
}
fun detectVpnAdvanced(): Array<String> {
detectVpnAdvancedOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectVpnAdvanced() }.getOrDefault(emptyArray())
}
fun detectVpnSyscalls(): Array<String> {
detectVpnSyscallsOverride?.let { return it.invoke() }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectVpnSyscalls() }.getOrDefault(emptyArray())
}
fun detectVpnDetector(cancellationSignal: ScanCancellationSignal? = null): Array<String> {
detectVpnDetectorOverride?.let { return it.invoke(cancellationSignal) }
if (!isLibraryLoaded()) return emptyArray()
return runCatching { nativeDetectVpnDetector(cancellationSignal) }.getOrDefault(emptyArray())
}
internal fun resetForTests() {
isLibraryLoadedOverride = null
getIfAddrsOverride = null
@ -144,6 +200,12 @@ object NativeSignsBridge {
netlinkRouteDumpOverride = null
netlinkSockDiagOverride = null
detectRootOverride = null
detectEmulatorOverride = null
detectVpnPropertiesOverride = null
detectVpnLeaksOverride = null
detectVpnAdvancedOverride = null
detectVpnSyscallsOverride = null
detectVpnDetectorOverride = null
initialized = false
libraryLoaded = false
lastLoadError = null
@ -159,4 +221,10 @@ object NativeSignsBridge {
private external fun nativeNetlinkRouteDump(family: Int): Array<String>
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

@ -0,0 +1,137 @@
package com.notcvnt.rknhardering.probe
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.dnsoverhttps.DnsOverHttps
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.net.InetAddress
import java.util.concurrent.TimeUnit
object OperatorWhitelistProbe {
private const val REQUEST_TIMEOUT_MS = 4_000L
private const val PROBE_TIMEOUT_MS = 6_000L
private val YANDEX_DOH_URL = "https://common.dot.dns.yandex.net/dns-query"
private val YANDEX_BOOTSTRAP_IPS = listOf("77.88.8.8", "77.88.8.1", "77.88.8.88")
@Volatile
internal var executeOverride: ((String, String) -> Pair<Int, String>)? = null
suspend fun probe(): OperatorWhitelistProbeResult {
val startMs = System.currentTimeMillis()
val errors = mutableMapOf<String, String>()
val results = withTimeoutOrNull(PROBE_TIMEOUT_MS) {
coroutineScope {
val googleDeferred = async { checkGoogle(errors) }
val appleDeferred = async { checkApple(errors) }
val firefoxDeferred = async { checkFirefox(errors) }
val ruDeferred = async { checkRussianControl(errors) }
arrayOf(
googleDeferred.await(),
appleDeferred.await(),
firefoxDeferred.await(),
ruDeferred.await(),
)
}
} ?: arrayOf(false, false, false, false)
val googleOk = results[0]
val appleOk = results[1]
val firefoxOk = results[2]
val ruOk = results[3]
val allCaptiveFailed = !googleOk && !appleOk && !firefoxOk
val whitelistDetected = allCaptiveFailed && ruOk
return OperatorWhitelistProbeResult(
whitelistDetected = whitelistDetected,
googleReachable = googleOk,
appleReachable = appleOk,
firefoxReachable = firefoxOk,
russianControlReachable = ruOk,
errors = errors,
durationMs = System.currentTimeMillis() - startMs,
)
}
private fun buildClient(): OkHttpClient {
val bootstrapAddresses = YANDEX_BOOTSTRAP_IPS.mapNotNull { ip ->
runCatching { InetAddress.getByName(ip) }.getOrNull()
}
val baseClient = OkHttpClient.Builder().build()
val doh = DnsOverHttps.Builder()
.client(baseClient)
.url(YANDEX_DOH_URL.toHttpUrl())
.apply {
if (bootstrapAddresses.isNotEmpty()) {
bootstrapDnsHosts(bootstrapAddresses)
}
}
.build()
return OkHttpClient.Builder()
.dns(doh)
.connectTimeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
}
private fun doRequest(url: String, method: String = "GET"): Pair<Int, String> {
executeOverride?.let { return it(url, method) }
val client = buildClient()
val request = Request.Builder()
.url(url)
.method(method, null)
.build()
client.newCall(request).execute().use { response ->
return response.code to (response.body?.string().orEmpty())
}
}
private fun checkGoogle(errors: MutableMap<String, String>): Boolean {
return runCatching {
val (code, body) = doRequest("https://www.google.com/generate_204")
code == 204 && body.isEmpty()
}.getOrElse { e ->
errors["google"] = e.message ?: e.javaClass.simpleName
false
}
}
private fun checkApple(errors: MutableMap<String, String>): Boolean {
return runCatching {
val (code, body) = doRequest("https://www.apple.com/library/test/success.html")
code == 200 &&
body.contains("<TITLE>Success</TITLE>", ignoreCase = false) &&
body.contains("<BODY>Success</BODY>", ignoreCase = false)
}.getOrElse { e ->
errors["apple"] = e.message ?: e.javaClass.simpleName
false
}
}
private fun checkFirefox(errors: MutableMap<String, String>): Boolean {
return runCatching {
val (code, body) = doRequest("https://detectportal.firefox.com/success.txt")
code == 200 && body.startsWith("success")
}.getOrElse { e ->
errors["firefox"] = e.message ?: e.javaClass.simpleName
false
}
}
private fun checkRussianControl(errors: MutableMap<String, String>): Boolean {
return runCatching {
val (code, _) = doRequest("https://yandex.ru/", method = "HEAD")
code in 200..399
}.getOrElse { e ->
errors["yandex_ru"] = e.message ?: e.javaClass.simpleName
false
}
}
}

View file

@ -0,0 +1,11 @@
package com.notcvnt.rknhardering.probe
data class OperatorWhitelistProbeResult(
val whitelistDetected: Boolean,
val googleReachable: Boolean,
val appleReachable: Boolean,
val firefoxReachable: Boolean,
val russianControlReachable: Boolean,
val errors: Map<String, String>,
val durationMs: Long,
)

Some files were not shown because too many files have changed in this diff Show more