mirror of
https://github.com/xtclovver/RKNHardering.git
synced 2026-07-10 01:29:58 +00:00
Compare commits
42 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5faf94563 | ||
|
|
219518c53f | ||
|
|
52b4550427 | ||
|
|
7fa507c60c | ||
|
|
3e1a9cf6e6 | ||
|
|
653a9f6413 | ||
|
|
dfb46b716c | ||
|
|
f58d4f4fc5 | ||
|
|
7142ea1c6c | ||
|
|
f6e9df2a35 | ||
|
|
595e4335f6 | ||
|
|
2832a0571e | ||
|
|
74d256a28d | ||
|
|
a830c52b8e | ||
|
|
8aaa6b5f43 | ||
|
|
2325f1d59b | ||
|
|
63e9b97a36 | ||
|
|
7b101d20e8 | ||
|
|
98876411c6 | ||
|
|
ff6cb6b534 | ||
|
|
e9b7bb6667 | ||
|
|
dd33d2f03f | ||
|
|
562b13e729 | ||
|
|
04179db9a7 | ||
|
|
de9de97ea6 | ||
|
|
7006854473 | ||
|
|
a9f5dacff1 | ||
|
|
88c32fee81 | ||
|
|
350eabb9d6 | ||
|
|
e7d76f9b4b | ||
|
|
e16a78fd5e | ||
|
|
4a9ed45762 | ||
|
|
e2d2d1fb11 | ||
|
|
859f3ca515 | ||
|
|
d49d391469 | ||
|
|
3c5343391f | ||
|
|
2385802479 | ||
|
|
85845fb42e | ||
|
|
20d8255720 | ||
|
|
f69af5e6db | ||
|
|
efab3490a2 | ||
|
|
2ed250d359 |
436 changed files with 36291 additions and 4402 deletions
17
.gitattributes
vendored
Normal file
17
.gitattributes
vendored
Normal 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
|
||||
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
|
|
@ -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
15
.gitignore
vendored
|
|
@ -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/
|
||||
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
|
|
@ -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
1
.idea/gradle.xml
generated
|
|
@ -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
345
README.md
|
|
@ -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 100–200, 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` не влияет на вердикт.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
1
app/src/main/assets/marketplace_pubkey.hex
Normal file
1
app/src/main/assets/marketplace_pubkey.hex
Normal file
|
|
@ -0,0 +1 @@
|
|||
564c6e935dc950b122b6e0323c674f2b127fe35c5c5a06d3e5d854562a9c1a0d
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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(" ")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.notcvnt.rknhardering.checker
|
||||
|
||||
internal data class SignalOutcome(
|
||||
val detected: Boolean = false,
|
||||
val needsReview: Boolean = false,
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
155
app/src/main/java/com/notcvnt/rknhardering/crypto/Ed25519.kt
Normal file
155
app/src/main/java/com/notcvnt/rknhardering/crypto/Ed25519.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -124,7 +124,7 @@ object LocalSocketInspector {
|
|||
@Suppress("DEPRECATION")
|
||||
pm.getApplicationInfo(packageName, 0)
|
||||
}
|
||||
pm.getApplicationLabel(appInfo)?.toString()
|
||||
pm.getApplicationLabel(appInfo).toString()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue