Author SHA1 Message Date
DenozordecandClaude Fable 5.2 26b4edee66 android: version 2.19.9 (177)
CodeQL / pre-check (push) Failing after 3s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 3s
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 3s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 4s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 10:07:10 +07:00
DenozordecandClaude Fable 5.2 a8f32ed7df android: DNS chain diagnostics run at most once per 10 min instead of on every reconnect; Home probes every 30 s instead of 10 s; a DoT worker closes its TLS connection when it exits
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 10:07:09 +07:00
DenozordecandClaude Fable 5.2 8c5752cfbe android: direct TCP relay gets real flow control: a socket at EOF is no longer polled for input (the engine thread spun until the app took the last byte), reading pauses at 64 KB queued for the app instead of aborting the flow at 256 KB, the app's send window reopens only for what the socket accepted (large uploads over a slow uplink were reset), and acknowledged data pulls the rest at once instead of on the 2 s poll
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 10:07:09 +07:00
DenozordecandClaude Fable 5.2 2a81e5f05d android: version 2.19.8 (176)
CodeQL / pre-check (push) Failing after 3s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 3s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 5s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 09:47:47 +07:00
DenozordecandClaude Fable 5.2 f075bf00fe android: a retry the device slept through is resumed by the watchdog alarm: the countdown runs in uptime and stood still in deep sleep, so a tunnel that dropped overnight came back only when the phone was picked up; while a retry is pending the wakeup alarm stays at 10 min instead of backing off to 1 h
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 09:45:56 +07:00
DenozordecandClaude Opus 5.5 b9ea35832c android: DoH/DoT probe asks one.one.one.one (then ya.ru) instead of example.com, which some servers drop (dns.shnt.top closes DoT / returns an empty 200); replies are checked for header, QR bit and id with clear errors («empty reply», «connection closed without a reply»); a failed probe is cached for 1 min, so one connect no longer probes 4 times; dns diag logs the VPN network's private DNS state; version 2.19.7 (175)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 22:11:20 +07:00
DenozordecandClaude Opus 5.5 c5c5f5d461 android: DoT closes the per-worker connection on any read error or id mismatch (a late reply would answer the next query and desync it forever); the connect probe no longer does an untimed host lookup for the log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:26:41 +07:00
DenozordecandClaude Opus 5.5 59d989e956 android: own DoH/DoT DNS server — the tunnel's DNS becomes 198.18.0.53, the native DNS proxy forwards its UDP/53 to a local resolver (127.0.0.1) that sends each query to the user's https:// or tls:// server; probe at connect with fallback to the profile's DNS and an event, SERVFAIL on errors, check on «Save»; decisions, errors and per-period stats go to the diagnostic log without query names; version 2.19.6 (174)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:14:20 +07:00
DenozordecandClaude Opus 5.5 1401cd8087 Merge feature/update-api (2.19.4) into claude/shx-vpn-privacy-stability-a90292; version 2.19.5 (173); the crash event's «Send a report» opens the panel report like the log's button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:48:20 +07:00
DenozordecandClaude Opus 5.5 27345bb24b android: version 2.19.3 (171)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:40:51 +07:00
Denozordec 3953c39a4e Merge commit '4a2581bb1' into claude/shx-vpn-privacy-stability-a90292 2026-11-08 23:34:05 +07:00
DenozordecandClaude Opus 5.5 2b4d0ef12b android: «the app closed unexpectedly» event with «Send a report» after a Java, native (daemon) or ANR death of the previous process; always-on/lockdown read from the running VpnService (the Settings.Global key never existed) with a warning when it gets turned off
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:34:05 +07:00
DenozordecandClaude Opus 5.5 68ee051ab2 android: version 2.19.4 (172) — обновления и отчёты через панель SHX VPN
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 3s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 8s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
- проверка обновлений через API панели (get.shnt.top/api/v1): подписанный запрос (HMAC),
  манифест с подписью Ed25519; старый бакет — только запасной путь при сетевой ошибке
- экраны обновления по макету: страница «Обновления» с каналом и кодом беты, шторки
  «Что нового», «Скачивание» (с отменой), «Обязательное обновление», «Не установлено»
- отправка отчёта из журнала в панель: код отчёта, запись в «Событиях», письмо как запасной путь;
  к отчёту прикладываются результаты проверки сети (checks.json) и diag.log
- исправлен вылет при скачивании обновления на Android 16.0 (setRequestPromotedOngoing есть только с API 36.1)
- кнопки шторок переносят текст на вторую строку, однословные уменьшаются
- задача Gradle uploadShx: сборка и загрузка APK в панель (scripts/upload-apk.mjs)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:38:45 +07:00
DenozordecandClaude Sonnet 5.5 4a2581bb12 android: version 2.19.2 (170)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 17:45:23 +07:00
DenozordecandClaude Sonnet 5.5 ada48f0ca1 android: the diagnostic log records why an established IKE SA went down (reauth, retransmit timeout, network change, user, other) with a per-day drop counter; plan section 8 added
Native signals for retransmit timeout and reauth are diagnostics only and change no state.
docs/energy-plan.md section 8 is the 2.20 plan for connection stability and energy use.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 17:45:23 +07:00
DenozordecandClaude Sonnet 5.5 bd1ad8390a claude: project environment — Sonnet 5.5, three read-only Haiku subagents, advisor rule
.claude/settings.json sets the primary model; .claude/agents/{search,grep,docs}.md are
read-only Haiku 5.5 subagents; CLAUDE.md imports AGENTS.md and holds the advisor rule.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 17:45:22 +07:00
DenozordecandClaude Sonnet 5.5 0e35a794c9 android: version 2.19.1 (169)
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / pre-check (push) Failing after 5s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:55:25 +07:00
DenozordecandClaude Sonnet 5.5 5ffb5a2dbb android: «How traffic goes» no longer says the app is not found on the first try: the check waits for the installed-apps list instead of a fixed 300 ms delay
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:50:50 +07:00
DenozordecandClaude Sonnet 5.5 ae06a58013 android: version 2.19.0 (168)
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
macOS / pre-check (push) Failing after 5s
Linux / alpine (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 7s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:30:09 +07:00
DenozordecandClaude Sonnet 5.5 9109eb8460 android: an unattended service start (watchdog, pause end, FGS retry) no longer wipes the password saved with the profile; smoke-test notes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:21:38 +07:00
DenozordecandClaude Sonnet 5.5 1ef8c983c7 android: keepalive/rekey alarms on elapsedRealtime with coalescing; BootReceiver re-arms pause and scenario alarms; scenarios survive a fresh process and run without network rules; watchdog-restored auto session parks again; NetworkManager tracks a network set; idle native engine goes dormant again; traffic history written every 5 min
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 11:48:27 +07:00
DenozordecandClaude Sonnet 5.5 072674d1a8 android: version 2.18.2 (167)
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / pre-check (push) Failing after 6s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:42:48 +07:00
DenozordecandClaude Sonnet 5.5 228f9f6004 android: deleting or updating a profile no longer crashes when its data source was already closed
A profile keeps the VpnProfileSqlDataSource it was loaded from, and close() nulls its database, so insert/update/delete on that profile hit a NullPointerException and the profile stayed in place. The data source now reopens the database on demand.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:41:29 +07:00
90 changed files with 5791 additions and 460 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
name: docs
description: Looks up API references and library documentation. Read-only.
model: claude-haiku-5-5
tools: Read, Grep, Glob, WebFetch, WebSearch
---
Find the relevant API reference and return signatures and short quotes with sources. Never modify anything.
+7
View File
@@ -0,0 +1,7 @@
---
name: grep
description: Finds call sites and usages of a symbol. Read-only.
model: claude-haiku-5-5
tools: Read, Grep, Glob
---
List every call site of the given symbol as file:line with a short context. Never modify anything.
+7
View File
@@ -0,0 +1,7 @@
---
name: search
description: Repo mapping. Finds files, modules and directory structure. Read-only.
model: claude-haiku-5-5
tools: Read, Glob, Grep
---
Map the repository: locate relevant files, modules and entry points. Return paths and one-line summaries. Never modify anything.
+3
View File
@@ -0,0 +1,3 @@
{
"model": "claude-sonnet-5-5"
}
+18
View File
@@ -40,3 +40,21 @@
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`) `local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows). и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
- Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись. - Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись.
## Android-приложение: выкладка через панель SHX VPN
Когда владелец просит **«собери и выложи»** (или «собери и загрузи в панель»), выкладка идёт через панель
`https://apk.shnt.top`, а не через `aws s3 cp`. Сама просьба и есть подтверждение владельца на загрузку.
- Команда (из `src/frontends/android`, с `ORG_GRADLE_PROJECT_shortNdkCommands=true` при сборке из worktree):
`./gradlew uploadShx` — собирает `assembleRelease` и загружает APK **черновиком** в канал `beta`, тип `phone`.
- Опубликовать сразу — только если владелец сказал «опубликуй»: `./gradlew uploadShx -PshxPublish -PshxRollout=<процент>`.
Без явного процента спросить его. Канал релиза — `-PshxChannel=release`, другой тип — `-PshxType=<id>`.
- Заметки «Что нового» берутся из `NOTES_RU` в `.env`; другие — `-PshxNotes="..."`. Перед выкладкой проверить, что
`NOTES_RU` и `versionCode` в `app/build.gradle` относятся к этому релизу (панель отклонит уже загруженный код версии).
- Нужны `SHX_URL` и `SHX_TOKEN` (заданы у пользователя Windows; скрипт `scripts/upload-apk.mjs` читает их и из реестра,
если процесс запущен раньше). Не печатать значение токена.
- Подпись: release APK подписывается keystore из `.env` (`KEYSTORE_FILE`); неподписанный APK задача не загружает.
- После загрузки сообщить владельцу ссылку на версию из вывода скрипта и статус (черновик или текущая с процентом).
- Сборка для старых приложений 2.19.x (`latest.json` в корне бакета) по-прежнему идёт через `build-local.*` и
требует отдельного подтверждения.
+4
View File
@@ -0,0 +1,4 @@
@AGENTS.md
# Advisor rule
Ping the advisor (Opus 5.5) before locking any plan, as soon as any error repeats a second time, and before the final task handoff.
+46 -2
View File
@@ -45,8 +45,8 @@ android {
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode 166 versionCode 177
versionName "2.18.1" versionName "2.19.9"
/* Base URL of the update storage, trailing slash included */ /* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\"" buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
@@ -74,6 +74,17 @@ android {
buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\"" buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\""
buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\"" buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\""
/* SHX update API (web panel). When UPDATE_API_URL, UPDATE_API_PUBLIC_KEY and UPDATE_API_HMAC_KEY are all set,
the app checks for updates there; the bucket path above stays as a fallback while the API is unreachable.
UPDATE_API_PUBLIC_KEY is the raw Ed25519 public key (base64, 32 bytes) shown on the panel's "API updates" page;
the HMAC key is only a guard against casual requests (an APK can always be unpacked), integrity rests on the
manifest signature and on the SHA-256 plus APK signature checks. */
buildConfigField "String", "UPDATE_API_URL", "\"${envOr('UPDATE_API_URL', '')}\""
buildConfigField "String", "UPDATE_API_PUBLIC_KEY", "\"${envOr('UPDATE_API_PUBLIC_KEY', '')}\""
buildConfigField "String", "UPDATE_API_HMAC_KEY", "\"${xorB64(envOr('UPDATE_API_HMAC_KEY', ''))}\""
/* public download page, opened by «Download from the site» when an update fails */
buildConfigField "String", "UPDATE_DOWNLOAD_PAGE_URL", "\"${envOr('UPDATE_DOWNLOAD_PAGE_URL', 'https://get.shnt.top/get/universal')}\""
externalNativeBuild { externalNativeBuild {
ndkBuild { ndkBuild {
arguments '-j' + Runtime.runtime.availableProcessors() arguments '-j' + Runtime.runtime.availableProcessors()
@@ -175,6 +186,8 @@ dependencies {
implementation 'androidx.activity:activity-compose:1.11.0' implementation 'androidx.activity:activity-compose:1.11.0'
/* QR: scanning profiles (camera) and showing a profile as a QR code */ /* QR: scanning profiles (camera) and showing a profile as a QR code */
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
/* Ed25519 verification of the signed update manifest (java.security Ed25519 needs API 33, minSdk is 24) */
implementation 'net.i2p.crypto:eddsa:0.3.0'
debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-tooling'
implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.ui:ui-tooling-preview'
@@ -182,6 +195,8 @@ dependencies {
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.assertj:assertj-core:3.27.6' testImplementation 'org.assertj:assertj-core:3.27.6'
testImplementation 'org.mockito:mockito-core:5.20.0' testImplementation 'org.mockito:mockito-core:5.20.0'
/* the android.jar org.json stubs throw on the JVM; the real implementation is needed for the update API tests */
testImplementation 'org.json:json:20240303'
androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test:runner:1.7.0' androidTestImplementation 'androidx.test:runner:1.7.0'
@@ -189,3 +204,32 @@ dependencies {
androidTestImplementation composeBom androidTestImplementation composeBom
androidTestImplementation 'androidx.compose.ui:ui-test-junit4' androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
} }
/* Publish to the SHX VPN panel (https://apk.shnt.top): build the release APK and upload it in one step.
./gradlew uploadShx -> draft in the beta channel, type phone
./gradlew uploadShx -PshxPublish -PshxRollout=25
./gradlew uploadShx -PshxChannel=release -PshxType=tv -PshxNotes="..."
Needs Node 18+ and SHX_URL, SHX_TOKEN (a CI token from the panel's Settings -> Tokens).
Release notes default to NOTES_RU from .env; the version comes from the build's output-metadata.json. */
tasks.register('uploadShx', Exec) {
group = 'publishing'
description = 'Build the release APK and upload it to the SHX VPN panel'
dependsOn 'assembleRelease'
def apkFile = layout.buildDirectory.file('outputs/apk/release/app-release.apk')
def script = rootProject.file('scripts/upload-apk.mjs')
doFirst {
def apk = apkFile.get().asFile
if (!apk.exists()) {
throw new GradleException("${apk} not found: the release APK is unsigned (set KEYSTORE_FILE in .env)")
}
def args = ['node', script.absolutePath, apk.absolutePath,
'--type', (findProperty('shxType') ?: envOr('SHX_TYPE', 'phone')).toString(),
'--channel', (findProperty('shxChannel') ?: 'beta').toString()]
def notes = (findProperty('shxNotes') ?: envOr('NOTES_RU', '')).toString()
if (notes) args += ['--notes', notes]
if (project.hasProperty('shxPublish')) {
args += ['--publish', '--rollout', (findProperty('shxRollout') ?: '100').toString()]
}
commandLine args
}
}
@@ -64,3 +64,6 @@
-keep class * extends androidx.room.RoomDatabase { -keep class * extends androidx.room.RoomDatabase {
<init>(); <init>();
} }
# i2p eddsa (manifest signature check) references sun.security.x509 only for JDK interop, absent on Android
-dontwarn sun.security.x509.**
@@ -27,6 +27,7 @@
<!-- SSID is redacted in the background on Android 10+ without this. --> <!-- SSID is redacted in the background on Android 10+ without this. -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" /> <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
@@ -339,6 +340,17 @@
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- after a reboot or a clock change: re-arm the pause and scenario alarms, evaluate the rules again -->
<receiver
android:name=".logic.BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>
@@ -54,6 +54,16 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
return this; return this;
} }
/**
* A profile keeps the data source it was loaded from, which may be closed
* by the time the profile is updated or deleted, so reopen on demand.
*/
private SQLiteDatabase database()
{
open();
return mDatabase;
}
@Override @Override
public void close() public void close()
{ {
@@ -67,7 +77,7 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
public VpnProfile insertProfile(VpnProfile profile) public VpnProfile insertProfile(VpnProfile profile)
{ {
ContentValues values = ContentValuesFromVpnProfile(profile); ContentValues values = ContentValuesFromVpnProfile(profile);
long insertId = mDatabase.insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values); long insertId = database().insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
if (insertId == -1) if (insertId == -1)
{ {
return null; return null;
@@ -82,14 +92,14 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
{ {
final UUID uuid = profile.getUUID(); final UUID uuid = profile.getUUID();
ContentValues values = ContentValuesFromVpnProfile(profile); ContentValues values = ContentValuesFromVpnProfile(profile);
return mDatabase.update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0; return database().update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
} }
@Override @Override
public boolean deleteVpnProfile(VpnProfile profile) public boolean deleteVpnProfile(VpnProfile profile)
{ {
final UUID uuid = profile.getUUID(); final UUID uuid = profile.getUUID();
return mDatabase.delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0; return database().delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
} }
@Override @Override
@@ -0,0 +1,78 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import android.content.Context
import android.net.VpnService
import android.os.Build
import androidx.preference.PreferenceManager
import org.strongswan.android.R
import org.strongswan.android.logic.events.EventAction
import org.strongswan.android.logic.events.EventCategory
import org.strongswan.android.logic.events.EventKind
import org.strongswan.android.logic.events.EventLog
/**
* «Block connections without VPN» as the system reports it. Only the running
* VpnService can ask (Android 10+), so the last answer is kept for the UI.
* When it was on and is off now, the user gets a warning.
*/
object AlwaysOnVpn
{
private const val PREF_LOCKDOWN = "pref_vpn_lockdown"
private const val EVENT_KEY = "lockdown-off"
/** Last value seen by the service; false before the first connection or below Android 10. */
@JvmStatic
fun isLockdown(context: Context): Boolean =
PreferenceManager.getDefaultSharedPreferences(context).getBoolean(PREF_LOCKDOWN, false)
/** Called by the service when the tunnel is up. */
@JvmStatic
fun update(service: VpnService)
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
{
return
}
val lockdown = try
{
service.isLockdownEnabled
}
catch (_: Exception)
{
return
}
val prefs = PreferenceManager.getDefaultSharedPreferences(service)
val was = prefs.getBoolean(PREF_LOCKDOWN, false)
if (lockdown != was)
{
prefs.edit().putBoolean(PREF_LOCKDOWN, lockdown).apply()
DiagLog.i("AlwaysOnVpn", "lockdown ${if (lockdown) "on" else "off"} (always-on ${service.isAlwaysOn})")
}
val events = EventLog.getInstance(service)
if (lockdown)
{
events.resolve(EVENT_KEY)
}
else if (was)
{
events.record(
EventKind.WARN, EventCategory.CONNECTION, service.getString(R.string.event_lockdown_off_title),
service.getString(R.string.event_lockdown_off_text), EventAction.OPEN_VPN_SETTINGS, EVENT_KEY,
)
}
}
}
@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.strongswan.android.logic.autoconnect.AutoConnectManager
import org.strongswan.android.logic.scenarios.ScenarioEngine
/**
* Picks the automation up again after a reboot or a change of the clock.
*
* Starting the process runs the application's start-up (network rules, scenarios),
* but alarms do not survive a reboot, and a time or time zone change moves the
* edges of the scenario time windows. This receiver re-arms what the alarms
* carried and lets the rules decide again. It never starts the tunnel by itself:
* the rules and scenarios do, and a profile without a saved password stays off.
*/
class BootReceiver : BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent?)
{
val action = intent?.action ?: return
DiagLog.i(TAG, "system event: $action")
if (action == Intent.ACTION_BOOT_COMPLETED)
{
PauseManager.rearmAfterRestart(context)
}
/* start() re-arms the time-window alarm for the current clock and zone */
ScenarioEngine.start(context)
AutoConnectManager.getInstance(context).reevaluate()
val pending = goAsync()
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
}
private companion object
{
const val TAG = "BootReceiver"
}
}
@@ -92,6 +92,7 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.SortedSet; import java.util.SortedSet;
import java.util.TimeZone;
import java.util.TreeSet; import java.util.TreeSet;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -171,6 +172,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
private volatile boolean mProfileUpdated; private volatile boolean mProfileUpdated;
private volatile boolean mTerminate; private volatile boolean mTerminate;
private volatile boolean mIsDisconnecting; private volatile boolean mIsDisconnecting;
/* why-did-the-SA-go-down diagnostics (plan 8.1); no effect on behaviour */
private final DailyCounter mSaDropCounter = new DailyCounter();
private volatile long mSaUpSince;
private volatile long mLastNetworkChange;
private volatile boolean mRetransmitTimeout;
private volatile boolean mReauth;
private volatile boolean mShowNotification; private volatile boolean mShowNotification;
/* start of the current CONNECTED session for the notification chronometer */ /* start of the current CONNECTED session for the notification chronometer */
private volatile long mConnectedSince; private volatile long mConnectedSince;
@@ -219,6 +226,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
static final int STATE_UNREACHABLE_ERROR = 6; static final int STATE_UNREACHABLE_ERROR = 6;
static final int STATE_CERTIFICATE_UNAVAILABLE = 7; static final int STATE_CERTIFICATE_UNAVAILABLE = 7;
static final int STATE_GENERIC_ERROR = 8; static final int STATE_GENERIC_ERROR = 8;
static final int STATE_RETRANSMIT_TIMEOUT = 9;
static final int STATE_REAUTH = 10;
@Override @Override
public int onStartCommand(Intent intent, int flags, int startId) public int onStartCommand(Intent intent, int flags, int startId)
@@ -269,7 +278,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
VpnProfile profile = loadProfile(uuid); VpnProfile profile = loadProfile(uuid);
if (profile != null) if (profile != null)
{ {
profile.setPassword(bundle.getString(VpnProfileDataSource.KEY_PASSWORD)); String password = bundle.getString(VpnProfileDataSource.KEY_PASSWORD);
if (password != null)
{ /* an unattended start (watchdog, pause end, FGS retry) carries no
* password: keep the one saved with the profile instead of
* wiping it, or the tunnel never comes back («password unavailable») */
profile.setPassword(password);
}
/* a backup port of F_Profile, for this attempt only */ /* a backup port of F_Profile, for this attempt only */
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0); int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
if (port > 0) if (port > 0)
@@ -940,11 +955,17 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
if (state == State.CONNECTED) if (state == State.CONNECTED)
{ {
TrafficHistory.start(this); TrafficHistory.start(this);
AlwaysOnVpn.update(this);
} }
else else
{ {
TrafficHistory.stop(this); TrafficHistory.stop(this);
} }
if (state == State.DISABLED)
{ /* the local DoH/DoT resolver lives only with a tunnel */
setDnsRedirect(null, 0);
org.strongswan.android.logic.protection.SecureDns.stop("disconnected");
}
synchronized (mServiceLock) synchronized (mServiceLock)
{ {
if (mService != null) if (mService != null)
@@ -1019,12 +1040,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
switch (status) switch (status)
{ {
case STATE_CHILD_SA_DOWN: case STATE_CHILD_SA_DOWN:
logSaDown();
if (!mIsDisconnecting) if (!mIsDisconnecting)
{ {
setState(State.CONNECTING); setState(State.CONNECTING);
} }
break; break;
case STATE_CHILD_SA_UP: case STATE_CHILD_SA_UP:
mSaUpSince = SystemClock.elapsedRealtime();
mRetransmitTimeout = false;
mReauth = false;
setState(State.CONNECTED); setState(State.CONNECTED);
scheduleDnsDiagnostics(); scheduleDnsDiagnostics();
scheduleRouteDiagnostics(); scheduleRouteDiagnostics();
@@ -1053,12 +1078,49 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
case STATE_GENERIC_ERROR: case STATE_GENERIC_ERROR:
setErrorDisconnect(ErrorState.GENERIC_ERROR); setErrorDisconnect(ErrorState.GENERIC_ERROR);
break; break;
case STATE_RETRANSMIT_TIMEOUT:
mRetransmitTimeout = true;
break;
case STATE_REAUTH:
mReauth = true;
break;
default: default:
Log.e(TAG, "Unknown status code received"); Log.e(TAG, "Unknown status code received");
break; break;
} }
} }
/**
* Writes why an established SA went down to the diagnostic log (plan 8.1).
* Does not touch the connection state.
*/
private void logSaDown()
{
long up = mSaUpSince;
boolean timeout = mRetransmitTimeout;
boolean reauth = mReauth;
mRetransmitTimeout = false;
mReauth = false;
if (up == 0)
{ /* no SA was up: a failed first connect is logged elsewhere */
return;
}
mSaUpSince = 0;
long now = SystemClock.elapsedRealtime();
long netAgo = mLastNetworkChange == 0 ? -1 : now - mLastNetworkChange;
SaClose why = SaClose.classify(mIsDisconnecting, reauth, timeout, netAgo);
String count = "";
if (why.getDrop())
{
long wall = System.currentTimeMillis();
count = ", drop #" + mSaDropCounter.next(wall, TimeZone.getDefault().getOffset(wall)) +
" today (resets with the process)";
}
DiagLog.i(TAG, "SA down: " + why.getLabel() + ", was up " + ((now - up) / 1000) + "s, network change " +
(netAgo < 0 ? "none" : (netAgo / 1000) + "s ago") + count +
(why == SaClose.OTHER ? " (no retransmit-timeout alert; MOBIKE path probing gives up without one)" : ""));
}
/** Delay before the DNS chain diagnostics run, so validation settles first. */ /** Delay before the DNS chain diagnostics run, so validation settles first. */
private static final long DNS_DIAG_DELAY_MS = 10_000; private static final long DNS_DIAG_DELAY_MS = 10_000;
private static final String DNS_DIAG_PROBE = "one.one.one.one"; private static final String DNS_DIAG_PROBE = "one.one.one.one";
@@ -1072,6 +1134,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{ {
return; return;
} }
sDnsDiagAt = SystemClock.elapsedRealtime();
mDiagExecutor.execute(() -> { mDiagExecutor.execute(() -> {
try try
{ {
@@ -1129,6 +1192,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
&& vpnCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) && vpnCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
+ ", dns=" + (lp != null ? lp.getDnsServers() : "?") + ", dns=" + (lp != null ? lp.getDnsServers() : "?")
+ ", routes=" + (lp != null ? lp.getRoutes().size() : "?")); + ", routes=" + (lp != null ? lp.getRoutes().size() : "?"));
if (lp != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
{ /* whether Android validated the strict private DNS server over the tunnel */
DiagLog.i(TAG, "dns diag: vpn privateDns active=" + lp.isPrivateDnsActive()
+ ", name=" + lp.getPrivateDnsServerName());
}
if (underlying != null) if (underlying != null)
{ {
NetworkCapabilities uCaps = cm.getNetworkCapabilities(underlying); NetworkCapabilities uCaps = cm.getNetworkCapabilities(underlying);
@@ -1181,8 +1249,17 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
} }
} }
/* a flapping tunnel must not repeat the probes (three lookups and a TCP
* connect per DoT address) on every reconnect: the radio pays for each */
private static final long DNS_DIAG_MIN_GAP_MS = 10 * 60 * 1000;
private static volatile long sDnsDiagAt;
private void scheduleDnsDiagnostics() private void scheduleDnsDiagnostics()
{ {
if (sDnsDiagAt != 0 && SystemClock.elapsedRealtime() - sDnsDiagAt < DNS_DIAG_MIN_GAP_MS)
{
return;
}
mHandler.removeCallbacks(mDnsDiagRunnable); mHandler.removeCallbacks(mDnsDiagRunnable);
mHandler.postDelayed(mDnsDiagRunnable, DNS_DIAG_DELAY_MS); mHandler.postDelayed(mDnsDiagRunnable, DNS_DIAG_DELAY_MS);
} }
@@ -1688,6 +1765,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
*/ */
public native void initiate(String config); public native void initiate(String config);
/**
* Encrypted DNS: UDP/53 to vip (IPv4, 4 bytes) goes to 127.0.0.1:port;
* null or 0 turns it off.
*/
public native void setDnsRedirect(byte[] vip, int port);
/** /**
* Sum inbound/outbound CHILD_SA bytes, provided by libandroidbridge.so. * Sum inbound/outbound CHILD_SA bytes, provided by libandroidbridge.so.
* Static so it can be polled from the UI. * Static so it can be polled from the UI.
@@ -1872,6 +1955,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
return; return;
} }
mApplied = current; mApplied = current;
mLastNetworkChange = SystemClock.elapsedRealtime();
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
{ {
@@ -2311,6 +2395,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
*/ */
public class BuilderCache public class BuilderCache
{ {
/* the user's DoH/DoT resolver when it answered the probe, else null */
private org.strongswan.android.logic.protection.SecureDnsConfig mSecureDns;
private final List<IPRange> mAddresses = new ArrayList<>(); private final List<IPRange> mAddresses = new ArrayList<>();
private final List<IPRange> mRoutesIPv4 = new ArrayList<>(); private final List<IPRange> mRoutesIPv4 = new ArrayList<>();
private final List<IPRange> mRoutesIPv6 = new ArrayList<>(); private final List<IPRange> mRoutesIPv6 = new ArrayList<>();
@@ -2411,6 +2497,19 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
sAppliedGeoExclusionsV6 = mGeoExcludedV6; sAppliedGeoExclusionsV6 = mGeoExcludedV6;
/* F_Protection: encrypted DNS (own DoH/DoT) is used only if it answers
* now; otherwise the profile's DNS, never a silent dead resolver */
org.strongswan.android.logic.protection.SecureDnsConfig secure =
org.strongswan.android.logic.protection.Protection.secureDns(CharonVpnService.this);
if (secure != null)
{
mSecureDns = org.strongswan.android.logic.protection.SecureDns.ensureReady(secure) ? secure : null;
org.strongswan.android.logic.events.Events.secureDnsResult(CharonVpnService.this, mSecureDns != null);
if ("hostname".equals(Settings.Global.getString(getContentResolver(), "private_dns_mode")))
{
DiagLog.i(TAG, "strict private DNS is on: the system resolver bypasses the tunnel DNS, own DoH/DoT has no effect");
}
}
/* F_Protection: a chosen resolver (filtering or not) wins over the profile's */ /* F_Protection: a chosen resolver (filtering or not) wins over the profile's */
java.util.List<String> chosenDns = org.strongswan.android.logic.protection.Protection.dnsServers(CharonVpnService.this); java.util.List<String> chosenDns = org.strongswan.android.logic.protection.Protection.dnsServers(CharonVpnService.this);
String dnsServers = chosenDns != null ? android.text.TextUtils.join(" ", chosenDns) : profile.getDnsServers(); String dnsServers = chosenDns != null ? android.text.TextUtils.join(" ", chosenDns) : profile.getDnsServers();
@@ -2590,7 +2689,26 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{ {
builder.addAddress(address.getFrom(), address.getPrefix()); builder.addAddress(address.getFrom(), address.getPrefix());
} }
if (includeDns) /* encrypted DNS needs an IPv4 address in the tunnel to reach the
* virtual DNS address; otherwise the plain servers stay */
boolean secureDns = mSecureDns != null && mIPv4Seen;
if (mSecureDns != null && !mIPv4Seen)
{
DiagLog.i(TAG, "no IPv4 address in the tunnel, own DoH/DoT not used");
}
int securePort = secureDns ? org.strongswan.android.logic.protection.SecureDns.start(mSecureDns) : 0;
secureDns = securePort > 0;
if (!secureDns)
{
org.strongswan.android.logic.protection.SecureDns.stop("not used for this tunnel");
}
setDnsRedirect(secureDns ? org.strongswan.android.logic.protection.SecureDns.VIP_BYTES : null, securePort);
if (includeDns && secureDns)
{ /* the only server: any plain one would let the resolver bypass DoH/DoT */
builder.addDnsServer(org.strongswan.android.logic.protection.SecureDns.VIP);
builder.addRoute(org.strongswan.android.logic.protection.SecureDns.VIP, 32);
}
else if (includeDns)
{ {
if (mDnsServers.isEmpty()) if (mDnsServers.isEmpty())
{ /* neither the profile nor the server provided DNS servers: { /* neither the profile nor the server provided DNS servers:
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context
import android.os.Build
import androidx.preference.PreferenceManager
import org.strongswan.android.R
import org.strongswan.android.logic.events.EventAction
import org.strongswan.android.logic.events.EventCategory
import org.strongswan.android.logic.events.EventKind
import org.strongswan.android.logic.events.EventLog
/**
* Notices that the previous process died by a crash and leaves an event with
* «Send a report». Android 11+ keeps the exit reasons itself, which also
* covers native crashes of the in-process daemon and ANRs; older versions only
* see Java exceptions, marked by the uncaught exception handler.
*/
object CrashWatch
{
private const val TAG = "CrashWatch"
private const val PREF_SEEN = "pref_crash_seen_at"
private const val PREF_JAVA_CRASH = "pref_java_crash_at"
const val EVENT_KEY = "crash"
/** From the uncaught exception handler: synchronous, the process dies right after. */
@JvmStatic
fun markJavaCrash(context: Context)
{
try
{
PreferenceManager.getDefaultSharedPreferences(context).edit()
.putLong(PREF_JAVA_CRASH, System.currentTimeMillis()).commit()
}
catch (_: Exception)
{
}
}
/** At process start, off the main thread. */
@JvmStatic
fun check(context: Context)
{
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
var at = prefs.getLong(PREF_JAVA_CRASH, 0L)
var what = "java exception"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
{
try
{
val am = context.getSystemService(ActivityManager::class.java)
am?.getHistoricalProcessExitReasons(context.packageName, 0, 10)
?.filter { it.reason in setOf(ApplicationExitInfo.REASON_CRASH, ApplicationExitInfo.REASON_CRASH_NATIVE, ApplicationExitInfo.REASON_ANR) }
?.maxByOrNull { it.timestamp }
?.takeIf { it.timestamp > at }
?.let {
at = it.timestamp
what = "${reasonName(it.reason)}: ${it.description ?: ""}"
}
}
catch (e: Exception)
{
DiagLog.i(TAG, "exit reasons unavailable: $e")
}
}
val seen = prefs.getLong(PREF_SEEN, -1L)
if (seen < 0L)
{ /* first start with this feature: older history is not news */
prefs.edit().putLong(PREF_SEEN, maxOf(at, System.currentTimeMillis())).apply()
return
}
if (at <= seen)
{
return
}
prefs.edit().putLong(PREF_SEEN, at).apply()
DiagLog.i(TAG, "previous process crashed at $at ($what)")
EventLog.getInstance(context).record(
EventKind.WARN, EventCategory.APP, context.getString(R.string.event_crash_title),
context.getString(R.string.event_crash_text), EventAction.SEND_REPORT, EVENT_KEY,
)
}
private fun reasonName(reason: Int): String = when (reason)
{
ApplicationExitInfo.REASON_CRASH_NATIVE -> "native crash"
ApplicationExitInfo.REASON_ANR -> "ANR"
else -> "java crash"
}
}
@@ -25,6 +25,7 @@ import android.net.Network;
import android.net.NetworkRequest; import android.net.NetworkRequest;
import android.os.Build; import android.os.Build;
import java.util.HashSet;
import java.util.LinkedList; import java.util.LinkedList;
public class NetworkManager extends BroadcastReceiver implements Runnable public class NetworkManager extends BroadcastReceiver implements Runnable
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
private volatile boolean mRegistered; private volatile boolean mRegistered;
private ConnectivityManager.NetworkCallback mCallback; private ConnectivityManager.NetworkCallback mCallback;
private Thread mEventNotifier; private Thread mEventNotifier;
private int mConnectedNetworks = 0; /* a set, not a counter: a repeated onAvailable or a missed onLost cannot
* make it drift away from the networks that really exist */
private final HashSet<Network> mConnectedNetworks = new HashSet<>();
private LinkedList<Boolean> mEvents = new LinkedList<>(); private LinkedList<Boolean> mEvents = new LinkedList<>();
public NetworkManager(Context context) public NetworkManager(Context context)
@@ -51,7 +54,7 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
{ {
/* we expect this to be called if connected to at least one network during /* we expect this to be called if connected to at least one network during
* callback registration */ * callback registration */
mConnectedNetworks += 1; mConnectedNetworks.add(network);
mEvents.addLast(true); mEvents.addLast(true);
NetworkManager.this.notifyAll(); NetworkManager.this.notifyAll();
} }
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
synchronized (NetworkManager.this) synchronized (NetworkManager.this)
{ {
/* in particular mobile connections are disconnected overlapping with WiFi */ /* in particular mobile connections are disconnected overlapping with WiFi */
mConnectedNetworks -= 1; mConnectedNetworks.remove(network);
mEvents.addLast(mConnectedNetworks > 0); mEvents.addLast(!mConnectedNetworks.isEmpty());
NetworkManager.this.notifyAll(); NetworkManager.this.notifyAll();
} }
} }
@@ -73,8 +76,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
} }
public void Register() public void Register()
{
synchronized (this)
{ {
mEvents.clear(); mEvents.clear();
mConnectedNetworks.clear();
}
mRegistered = true; mRegistered = true;
mEventNotifier = new Thread(this); mEventNotifier = new Thread(this);
mEventNotifier.start(); mEventNotifier.start();
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
break; break;
} }
connected = mEvents.removeFirst(); connected = mEvents.removeFirst();
/* a burst of identical states (several networks coming up at once)
* is one change for charon, not one reconnect attempt each */
while (!mEvents.isEmpty() && mEvents.getFirst() == connected)
{
mEvents.removeFirst();
}
} }
/* call the native parts without holding the lock */ /* call the native parts without holding the lock */
networkChanged(!connected); networkChanged(!connected);
@@ -229,6 +229,32 @@ object PauseManager
alarmManager(ctx).cancel(alarmIntent(ctx)) alarmManager(ctx).cancel(alarmIntent(ctx))
} }
/**
* After a reboot: alarms do not survive it, so a pause that was running would
* never end. Re-arms the alarm, or ends the pause right away if its time is up.
*/
@JvmStatic
fun rearmAfterRestart(context: Context)
{
val ctx = context.applicationContext
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
val until = prefs.getLong(PREF_UNTIL, 0L)
val profile = prefs.getString(PREF_PROFILE, null)
if (profile == null || until == 0L)
{
return
}
if (until > System.currentTimeMillis())
{
DiagLog.i(TAG, "pause survives the restart, re-arming its alarm")
store(ctx, until, profile)
}
else
{
onAlarm(ctx)
}
}
private fun store(ctx: Context, until: Long, profileUuid: String) private fun store(ctx: Context, until: Long, profileUuid: String)
{ {
PreferenceManager.getDefaultSharedPreferences(ctx).edit() PreferenceManager.getDefaultSharedPreferences(ctx).edit()
@@ -0,0 +1,68 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
/** Why an established IKE SA went down; for the diagnostic log only (plan 8.1). */
enum class SaClose(val label: String, val drop: Boolean)
{
USER("user", false),
REAUTH("reauth", false),
RETRANSMIT_AFTER_NETWORK_CHANGE("retransmit-timeout after network change", true),
RETRANSMIT_TIMEOUT("retransmit-timeout", true),
NETWORK_CHANGE("network change", true),
OTHER("other (server close, rekey failure, MOBIKE probing)", true);
companion object
{
/** a network change this close before the SA went down is blamed first */
const val NETWORK_WINDOW_MS = 20_000L
/** sinceNetworkChangeMs is -1 when there was none */
@JvmStatic
fun classify(disconnecting: Boolean, reauth: Boolean, retransmitTimeout: Boolean,
sinceNetworkChangeMs: Long): SaClose
{
val network = sinceNetworkChangeMs in 0..NETWORK_WINDOW_MS
return when
{
disconnecting -> USER
reauth -> REAUTH
retransmitTimeout && network -> RETRANSMIT_AFTER_NETWORK_CHANGE
retransmitTimeout -> RETRANSMIT_TIMEOUT
network -> NETWORK_CHANGE
else -> OTHER
}
}
}
}
/** Counts events per local calendar day; resets when the process restarts. */
class DailyCounter
{
private var day = Long.MIN_VALUE
private var count = 0
@Synchronized
fun next(nowMs: Long, utcOffsetMs: Long): Int
{
val today = Math.floorDiv(nowMs + utcOffsetMs, 86_400_000L)
if (today != day)
{
day = today
count = 0
}
return ++count
}
}
@@ -0,0 +1,98 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import java.util.PriorityQueue
/**
* The job queue behind [Scheduler]: charon's timers, ordered by due time, and the
* bookkeeping of the single alarm that wakes the device for the earliest one.
* Times are on any monotonic clock (the scheduler passes elapsedRealtime).
*
* Jobs due within [coalesceMs] of each other share one alarm: a job may run up to
* [coalesceMs] early, never late. charon only schedules jobs longer than 3 s ahead,
* so 2 s is harmless, and every alarm saved is one more slot in the Doze quota of
* allow-while-idle alarms that the keepalive needs.
*
* Not thread-safe; the caller synchronises.
*/
class ScheduledJobQueue(private val coalesceMs: Long = COALESCE_MS)
{
private class Job(val id: String, val time: Long) : Comparable<Job>
{
override fun compareTo(other: Job): Int = time.compareTo(other.time)
}
private val jobs = PriorityQueue<Job>()
private var armed = NONE
/** Due time the alarm is currently set for, or [NONE]. */
val armedAt: Long get() = armed
val size: Int get() = jobs.size
/**
* Queues a job.
*
* @return true if the alarm has to be (re)armed for [nextAlarm]; false if the
* armed alarm already covers the job
*/
fun add(id: String, time: Long): Boolean
{
jobs.add(Job(id, time))
if (armed == NONE || time < armed - coalesceMs)
{
armed = time
return true
}
return false
}
/**
* Removes the jobs that are due at [now] (or within the coalescing window).
* The alarm is considered fired: [nextAlarm] says whether to arm another one.
*/
fun popDue(now: Long): List<String>
{
val limit = now + coalesceMs
val due = ArrayList<String>()
while (true)
{
val head = jobs.peek() ?: break
if (head.time > limit)
{
break
}
due.add(jobs.remove().id)
}
armed = jobs.peek()?.time ?: NONE
return due
}
/** Due time of the earliest queued job, or [NONE]; the time to arm after [popDue]. */
fun nextAlarm(): Long = jobs.peek()?.time ?: NONE
fun clear()
{
jobs.clear()
armed = NONE
}
companion object
{
const val NONE = -1L
const val COALESCE_MS = 2000L
}
}
@@ -24,10 +24,10 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter; import android.content.IntentFilter;
import android.os.Build; import android.os.Build;
import android.os.SystemClock;
import android.util.Log; import android.util.Log;
import java.util.ArrayList; import java.util.List;
import java.util.PriorityQueue;
import java.util.UUID; import java.util.UUID;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
private final String TAG = Scheduler.class.getSimpleName(); private final String TAG = Scheduler.class.getSimpleName();
private final Context mContext; private final Context mContext;
private final AlarmManager mManager; private final AlarmManager mManager;
private final PriorityQueue<ScheduledJob> mJobs; /* due times are elapsedRealtime: unlike RTC they do not jump when the clock
* is set (NTP, manual change, time zone), which would delay keepalives and
* rekeyings by hours */
private final ScheduledJobQueue mJobs;
private boolean mExactLogged;
@SuppressLint("UnspecifiedRegisterReceiverFlag") @SuppressLint("UnspecifiedRegisterReceiverFlag")
public Scheduler(Context context) public Scheduler(Context context)
{ {
mContext = context; mContext = context;
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
mJobs = new PriorityQueue<>(); mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
IntentFilter filter = new IntentFilter(); IntentFilter filter = new IntentFilter();
filter.addAction(EXECUTE_JOB); filter.addAction(EXECUTE_JOB);
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
* alarms instead of crashing with a SecurityException (connectivity then * alarms instead of crashing with a SecurityException (connectivity then
* only degrades slightly while timers may be delayed in doze mode). * only degrades slightly while timers may be delayed in doze mode).
* *
* @param time wakeup time based on RTC * @param time wakeup time based on elapsedRealtime
*/ */
@RequiresApi(api = Build.VERSION_CODES.M) @RequiresApi(api = Build.VERSION_CODES.M)
private void scheduleAlarm(long time) private void scheduleAlarm(long time)
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
{ {
exact = mManager.canScheduleExactAlarms(); exact = mManager.canScheduleExactAlarms();
} }
if (!mExactLogged)
{ /* once per run, for support reports */
mExactLogged = true;
DiagLog.i(TAG, "keepalive alarms are " + (exact ? "exact" : "inexact (exact alarms not allowed)"));
}
try try
{ {
if (exact) if (exact)
{ {
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending); mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
} }
else else
{ {
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending); mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
} }
} }
catch (SecurityException e) catch (SecurityException e)
{ /* permission might have been revoked, or OEM restrictions apply */ { /* permission might have been revoked, or OEM restrictions apply */
Log.w(TAG, "Falling back to inexact alarm after SecurityException"); Log.w(TAG, "Falling back to inexact alarm after SecurityException");
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending); mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
} }
} }
@@ -149,12 +158,10 @@ public class Scheduler extends BroadcastReceiver
{ {
synchronized (this) synchronized (this)
{ {
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms); long time = SystemClock.elapsedRealtime() + ms;
mJobs.add(job); if (mJobs.add(id, time))
{ /* the job is due before the armed alarm: move the alarm */
if (job == mJobs.peek()) scheduleAlarm(time);
{ /* update the alarm if the job has to be executed before all others */
scheduleAlarm(job.Time);
} }
} }
} }
@@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver
@Override @Override
public void onReceive(Context context, Intent intent) public void onReceive(Context context, Intent intent)
{ {
ArrayList<ScheduledJob> jobs = new ArrayList<>(); List<String> jobs;
long now = System.currentTimeMillis();
synchronized (this) synchronized (this)
{ {
ScheduledJob job = mJobs.peek(); jobs = mJobs.popDue(SystemClock.elapsedRealtime());
while (job != null) long next = mJobs.nextAlarm();
if (next != ScheduledJobQueue.NONE)
{ {
if (job.Time > now) scheduleAlarm(next);
{
break;
}
jobs.add(mJobs.remove());
job = mJobs.peek();
}
if (job != null)
{
scheduleAlarm(job.Time);
} }
} }
for (ScheduledJob job : jobs) for (String id : jobs)
{ {
executeJob(job.Id); executeJob(id);
} }
} }
@@ -196,25 +194,4 @@ public class Scheduler extends BroadcastReceiver
* @param id job ID * @param id job ID
*/ */
public native void executeJob(String id); public native void executeJob(String id);
/**
* Keep track of scheduled jobs.
*/
private static class ScheduledJob implements Comparable<ScheduledJob>
{
String Id;
long Time;
ScheduledJob(String id, long time)
{
Id = id;
Time = time;
}
@Override
public int compareTo(ScheduledJob o)
{
return Long.compare(Time, o.Time);
}
}
} }
@@ -99,6 +99,7 @@ public class StrongSwanApplication extends Application
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
DiagLog.i(TAG, "UNCAUGHT EXCEPTION on " + thread.getName() + ": " + DiagLog.i(TAG, "UNCAUGHT EXCEPTION on " + thread.getName() + ": " +
Log.getStackTraceString(throwable)); Log.getStackTraceString(throwable));
CrashWatch.markJavaCrash(StrongSwanApplication.this);
if (previousHandler != null) if (previousHandler != null)
{ {
previousHandler.uncaughtException(thread, throwable); previousHandler.uncaughtException(thread, throwable);
@@ -107,6 +108,9 @@ public class StrongSwanApplication extends Application
mDatabaseHelper = new DatabaseHelper(mContext); mDatabaseHelper = new DatabaseHelper(mContext);
/* «the app crashed» event with a report button for the previous process */
mExecutorService.execute(() -> CrashWatch.check(mContext));
mManagedConfigurationService = new ManagedConfigurationService(mContext); mManagedConfigurationService = new ManagedConfigurationService(mContext);
mTrustedCertificateManager = new ManagedTrustedCertificateManager(mContext, mExecutorService, mMainHandler, mTrustedCertificateManager = new ManagedTrustedCertificateManager(mContext, mExecutorService, mMainHandler,
@@ -90,6 +90,9 @@ public class VpnStateService extends Service
private long mRetryIn; private long mRetryIn;
/* true while the countdown is parked because the ISP whitelist blocks the server */ /* true while the countdown is parked because the ISP whitelist blocks the server */
private boolean mRetryFrozen; private boolean mRetryFrozen;
/* when the pending retry is due in elapsedRealtime: the countdown itself
* runs in uptime and stands still while the device sleeps */
private long mRetryDue;
/* timestamp of the last network-change-triggered immediate retry */ /* timestamp of the last network-change-triggered immediate retry */
private long mLastNetworkRetry; private long mLastNetworkRetry;
/* double-start guard: profile and issue time of the last connect() that /* double-start guard: profile and issue time of the last connect() that
@@ -610,6 +613,37 @@ public class VpnStateService extends Service
}); });
} }
/** Whether a retry countdown is pending (an error with an automatic retry). */
public boolean isRetryPending()
{
return mRetryTimeout > 0;
}
/**
* Runs the pending retry now if its time has passed on the wall clock.
* The countdown ticks in uptime, so it stands still while the device
* sleeps; the watchdog's wakeup alarm calls this so a tunnel that dropped
* overnight comes back without the user picking up the phone. Never
* shortens a backoff. Main thread only.
*
* @return true if a reconnect was started
*/
public boolean retryIfOverdue()
{
if (mRetryTimeout <= 0 || mProfile == null || SystemClock.elapsedRealtime() < mRetryDue ||
WhiteListManager.getInstance(this).shouldBlockReconnect())
{
return false;
}
DiagLog.i(TAG, "retry overdue (device slept through the countdown), reconnecting");
mRetryTimeout = 0;
mRetryIn = 0;
mRetryFrozen = false;
mHandler.removeMessages(RETRY_MSG);
connect(null, false);
return true;
}
/** /**
* Update state and notify all listeners about the change. By using a Handler * Update state and notify all listeners about the change. By using a Handler
* this is done from the main UI thread and not the initial reporter thread. * this is done from the main UI thread and not the initial reporter thread.
@@ -826,6 +860,7 @@ public class VpnStateService extends Service
{ {
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error); mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
mRetryFrozen = false; mRetryFrozen = false;
mRetryDue = SystemClock.elapsedRealtime() + mRetryTimeout;
DiagLog.i(TAG, "error " + error + ", retry in " + (mRetryTimeout / 1000) + "s"); DiagLog.i(TAG, "error " + error + ", retry in " + (mRetryTimeout / 1000) + "s");
/* never run two countdown chains in parallel (double-speed countdown, /* never run two countdown chains in parallel (double-speed countdown,
* double notification updates and two reconnects at zero) */ * double notification updates and two reconnects at zero) */
@@ -154,15 +154,16 @@ public class WatchdogReceiver extends BroadcastReceiver
/** /**
* Whether a check counts towards the steady-state backoff of a live * Whether a check counts towards the steady-state backoff of a live
* process: connected, retrying/connecting, or held back by the ISP * process: connected, connecting, or held back by the ISP whitelist. In
* whitelist. In all of these the in-process machinery owns recovery and * all of these the in-process machinery owns recovery and the alarm can
* the alarm can only guard against the process dying - firing every * only guard against the process dying - firing every minute (and waking
* minute (and waking Doze every 10 minutes) for hours of failed retries * Doze every 10 minutes) for hours of whitelist blocking only burns
* or whitelist blocking only burns battery. * battery.
*/ */
static boolean isAliveSteady(Action action, boolean processAlive, boolean parked) static boolean isAliveSteady(Action action, boolean processAlive, boolean parked, boolean retryPending)
{ { /* a pending retry needs the fast wakeup alarm: its countdown stands
return action == Action.NONE && processAlive && !parked; * still while the device sleeps and only this alarm resumes it */
return action == Action.NONE && processAlive && !parked && !retryPending;
} }
@Override @Override
@@ -190,7 +191,13 @@ public class WatchdogReceiver extends BroadcastReceiver
Action action = decideAction(enabled, uuid != null, autoExpected, whitelistBlocks, serviceTracking, Action action = decideAction(enabled, uuid != null, autoExpected, whitelistBlocks, serviceTracking,
processAlive, wifiPresent, autoProfileConfigured); processAlive, wifiPresent, autoProfileConfigured);
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE; boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
boolean alive = isAliveSteady(action, processAlive, parked); /* the whitelist parks retries on purpose, that state may back off */
boolean retryPending = serviceTracking && !whitelistBlocks && service.isRetryPending();
if (retryPending && service.retryIfOverdue())
{
DiagLog.i(TAG, "resumed a retry the device slept through");
}
boolean alive = isAliveSteady(action, processAlive, parked, retryPending);
if (parked) if (parked)
{ {
sParkedChecks++; sParkedChecks++;
@@ -232,6 +239,7 @@ public class WatchdogReceiver extends BroadcastReceiver
return; return;
case RESTART: case RESTART:
{ {
boolean autoRestart = uuid == null;
if (uuid == null) if (uuid == null)
{ {
uuid = resolveAutoProfile(context, prefs); uuid = resolveAutoProfile(context, prefs);
@@ -260,6 +268,10 @@ public class WatchdogReceiver extends BroadcastReceiver
* once, which is harmless (retry flag set, same profile). */ * once, which is harmless (retry flag set, same profile). */
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it"); Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
DiagLog.i(TAG, "restarting the VPN process"); DiagLog.i(TAG, "restarting the VPN process");
if (autoRestart)
{ /* this process does not remember the session was auto-started */
AutoConnectManager.getInstance(context).markAutoSessionRestored();
}
Intent start = new Intent(context, CharonVpnService.class); Intent start = new Intent(context, CharonVpnService.class);
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid); start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
start.putExtra(CharonVpnService.KEY_IS_RETRY, true); start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
@@ -91,6 +91,8 @@ class AutoConnectManager private constructor(
private var currentNetwork: NetworkKind? = null private var currentNetwork: NetworkKind? = null
/* marks the session this manager started itself */ /* marks the session this manager started itself */
private val autoSession = AtomicBoolean(false) private val autoSession = AtomicBoolean(false)
/* an auto session was restored by the watchdog and its tunnel is not up yet */
@Volatile private var awaitingRestoredTunnel = false
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */ /* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
private var lastParkAt = 0L private var lastParkAt = 0L
@@ -123,10 +125,28 @@ class AutoConnectManager private constructor(
} }
} }
/** Network scenarios need the network events even when the network rules are off. */
private fun scenariosNeedNetwork(): Boolean =
org.strongswan.android.logic.scenarios.ScenarioEngine.needsNetworkEvents(context)
/** Called when the scenarios changed: starts or stops the network listening to fit. */
fun scenariosChanged()
{
config = loadConfig()
if (config.enabled || scenariosNeedNetwork())
{
start()
}
else
{
stop()
}
}
fun start() fun start()
{ {
config = loadConfig() config = loadConfig()
if (!config.enabled) if (!config.enabled && !scenariosNeedNetwork())
{ {
return return
} }
@@ -186,8 +206,8 @@ class AutoConnectManager private constructor(
} }
else else
{ {
stop()
autoSession.set(false) autoSession.set(false)
scenariosChanged()
} }
} }
@@ -214,8 +234,8 @@ class AutoConnectManager private constructor(
} }
else else
{ {
stop()
autoSession.set(false) autoSession.set(false)
scenariosChanged()
} }
} }
@@ -236,12 +256,28 @@ class AutoConnectManager private constructor(
override fun stateChanged() override fun stateChanged()
{ {
val state = service?.state ?: return val state = service?.state ?: return
if (state == VpnStateService.State.DISABLED) if (state != VpnStateService.State.DISABLED)
{
awaitingRestoredTunnel = false
}
else if (!awaitingRestoredTunnel)
{ {
autoSession.set(false) autoSession.set(false)
} }
} }
/**
* The watchdog brought an auto session back in a fresh process, whose memory
* knows nothing of it. Mark it as auto-started again, so it parks on a trusted
* Wi-Fi instead of running all night; the DISABLED state of the not yet
* started service must not clear the mark.
*/
fun markAutoSessionRestored()
{
awaitingRestoredTunnel = true
autoSession.set(true)
}
/** /**
* Pure decision function, unit-testable. * Pure decision function, unit-testable.
*/ */
@@ -268,8 +304,8 @@ class AutoConnectManager private constructor(
private fun evaluate() private fun evaluate()
{ {
if (!running.get()) if (!running.get() || !config.enabled)
{ { /* running only for the scenarios: they decide, not the network rules */
return return
} }
val svc = service ?: return val svc = service ?: return
@@ -70,6 +70,10 @@ enum class EventAction
PROFILE_UPDATE, PROFILE_UPDATE,
/** open the server page of the profile [ShxEvent.target] */ /** open the server page of the profile [ShxEvent.target] */
OPEN_SERVER, OPEN_SERVER,
/** share the logs with support (F_Log «Send») */
SEND_REPORT,
/** the Android VPN settings (always-on, block without VPN) */
OPEN_VPN_SETTINGS,
} }
data class ShxEvent( data class ShxEvent(
@@ -143,6 +143,16 @@ object Events
) )
} }
/** A report went to the panel: the code stays here so the user can find it later. */
@JvmStatic
fun reportSent(context: Context, code: String)
{
EventLog.getInstance(context).record(
EventKind.OK, EventCategory.APP, context.getString(R.string.report_event_title, code),
context.getString(R.string.report_event_text), key = "report:$code",
)
}
/** The watchdog did not bring back [name]: a password would be needed. */ /** The watchdog did not bring back [name]: a password would be needed. */
@JvmStatic @JvmStatic
fun restoreNeedsPassword(context: Context, uuid: String, name: String) fun restoreNeedsPassword(context: Context, uuid: String, name: String)
@@ -173,4 +183,20 @@ object Events
context.getString(R.string.event_auto_pw_text), EventAction.OPEN_SERVER, "auto-pw:$uuid", uuid, context.getString(R.string.event_auto_pw_text), EventAction.OPEN_SERVER, "auto-pw:$uuid", uuid,
) )
} }
/** Own DoH/DoT: warn when the probe failed (plain DNS is used), clear it when it works. */
@JvmStatic
fun secureDnsResult(context: Context, ok: Boolean)
{
val events = EventLog.getInstance(context)
if (ok)
{
events.resolve("secure-dns")
return
}
events.record(
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_secure_dns_title),
context.getString(R.string.event_secure_dns_text), EventAction.NONE, "secure-dns",
)
}
} }
@@ -39,7 +39,10 @@ data class ProtectionState(
val blocksTrackers: Boolean get() = choice == DnsChoice.ADGUARD val blocksTrackers: Boolean get() = choice == DnsChoice.ADGUARD
val blocksPhishing: Boolean get() = choice == DnsChoice.ADGUARD || (choice == DnsChoice.CLOUDFLARE && phishingFilter) val blocksPhishing: Boolean get() = choice == DnsChoice.ADGUARD || (choice == DnsChoice.CLOUDFLARE && phishingFilter)
/** null: keep the profile's (or the server's) DNS */ /** CUSTOM with a DoH/DoT address instead of IPs */
val secure: SecureDnsConfig? get() = if (choice == DnsChoice.CUSTOM) SecureDnsConfig.parse(custom) else null
/** null: keep the profile's (or the server's) DNS; a DoH/DoT choice is [secure] instead */
fun servers(): List<String>? = when (choice) fun servers(): List<String>? = when (choice)
{ {
DnsChoice.PROFILE -> null DnsChoice.PROFILE -> null
@@ -110,6 +113,10 @@ object Protection
_state.value = state _state.value = state
} }
/** For CharonVpnService: the user's DoH/DoT resolver, or null. */
@JvmStatic
fun secureDns(context: Context): SecureDnsConfig? = get(context).secure
/** For CharonVpnService: the servers replacing the profile's ones, or null. */ /** For CharonVpnService: the servers replacing the profile's ones, or null. */
@JvmStatic @JvmStatic
fun dnsServers(context: Context): List<String>? = get(context).servers() fun dnsServers(context: Context): List<String>? = get(context).servers()
@@ -0,0 +1,528 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.protection
import android.os.SystemClock
import org.strongswan.android.logic.DiagLog
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.HttpURLConnection
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.net.URI
import java.net.URL
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
/** The user's own encrypted resolver: DoH (`https://host/path`) or DoT (`tls://host[:port]`). */
data class SecureDnsConfig(val doh: Boolean, val host: String, val port: Int, val url: String)
{
/** For the diag log: never the queried names, only where queries go. */
val label: String get() = if (doh) "DoH $url" else "DoT $host:$port"
companion object
{
/** null when the text is not a DoH/DoT address (plain IPs are handled by [ProtectionState.parseCustom]). */
@JvmStatic
fun parse(text: String): SecureDnsConfig?
{
val t = text.trim()
return try
{
when
{
t.startsWith("https://", true) ->
{
val uri = URI(t)
val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null
if (uri.userInfo != null || uri.query != null || uri.fragment != null) return null
val path = uri.rawPath?.takeIf { it.isNotEmpty() && it != "/" } ?: "/dns-query"
val port = if (uri.port > 0) uri.port else 443
val portPart = if (uri.port > 0) ":${uri.port}" else ""
SecureDnsConfig(true, host, port, "https://$host$portPart$path")
}
t.startsWith("tls://", true) ->
{
val uri = URI(t)
val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null
if (uri.userInfo != null || (uri.rawPath ?: "").let { it.isNotEmpty() && it != "/" }) return null
val port = if (uri.port > 0) uri.port else 853
SecureDnsConfig(false, host, port, "tls://$host:$port")
}
else -> null
}
}
catch (_: Exception)
{
null
}
}
}
}
/** DNS wire bits the resolver needs; pure functions, unit tested. */
object SecureDnsCodec
{
/** A minimal query (RD set) for [name] and [type], e.g. 1 = A. */
@JvmStatic
fun query(id: Int, name: String, type: Int): ByteArray
{
val out = ByteArrayOutputStream()
out.write(byteArrayOf((id shr 8).toByte(), id.toByte(), 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0))
name.trimEnd('.').split('.').filter { it.isNotEmpty() }.forEach {
val label = it.toByteArray(Charsets.US_ASCII)
out.write(label.size)
out.write(label)
}
out.write(byteArrayOf(0, (type shr 8).toByte(), type.toByte(), 0, 1))
return out.toByteArray()
}
/**
* SERVFAIL for [query]: header with QR and RA set, RCODE 2, the question
* kept and no records. null if the query is not even a header.
*/
@JvmStatic
fun servfail(query: ByteArray): ByteArray?
{
if (query.size < 12) return null
val end = questionEnd(query) ?: 12
val reply = query.copyOf(end)
reply[2] = ((query[2].toInt() and 0x79) or 0x80).toByte() /* QR, keep opcode and RD */
reply[3] = 0x82.toByte() /* RA, RCODE 2 */
val qd = if (end > 12) 1 else 0
reply[4] = 0; reply[5] = qd.toByte()
for (i in 6 until 12) reply[i] = 0
return reply
}
/** End of the first question, or null if it is malformed / absent. */
private fun questionEnd(msg: ByteArray): Int?
{
if ((msg[4].toInt() or msg[5].toInt()) == 0) return null
var i = 12
while (i < msg.size)
{
val len = msg[i].toInt() and 0xff
if (len == 0) return (i + 5).takeIf { it <= msg.size }
if (len and 0xc0 != 0) return null
i += len + 1
}
return null
}
/** RFC 7858: two-byte length prefix. */
@JvmStatic
fun frame(msg: ByteArray): ByteArray =
byteArrayOf((msg.size shr 8).toByte(), msg.size.toByte()) + msg
@JvmStatic
fun readFrame(input: InputStream): ByteArray
{
val data = DataInputStream(input)
try
{
val len = data.readUnsignedShort()
val msg = ByteArray(len)
data.readFully(msg)
return msg
}
catch (e: java.io.EOFException)
{ /* some servers drop names they refuse to answer by closing the connection */
throw IOException("connection closed without a reply")
}
}
/** Same transaction id: a reply that belongs to the query. */
@JvmStatic
fun sameId(a: ByteArray, b: ByteArray): Boolean = a.size >= 2 && b.size >= 2 && a[0] == b[0] && a[1] == b[1]
/** Throws unless [reply] is a DNS response (header, QR bit) to [query]. */
@JvmStatic
fun checkReply(query: ByteArray, reply: ByteArray)
{
if (reply.isEmpty()) throw IOException("empty reply")
if (reply.size < 12 || (reply[2].toInt() and 0x80) == 0) throw IOException("not a DNS reply")
if (!sameId(query, reply)) throw IOException("reply id mismatch")
}
}
/**
* Local resolver behind the tunnel's DNS address [VIP]. The native DNS proxy
* hands UDP queries for [VIP] to 127.0.0.1:<port>; each one goes to the user's
* DoH/DoT server. Our own app is excluded from the VPN, so the upstream
* connection uses the physical network (encrypted) and its hostname resolves
* there, without looping through this resolver. A failed query is answered
* with SERVFAIL, never by plain DNS.
*/
object SecureDns
{
private const val TAG = "SecureDns"
/**
* The tunnel's DNS address while encrypted DNS is on. 198.18.0.0/15 is
* the benchmarking range (RFC 2544): never routed on the internet and not
* used for VPN pools, so it cannot shadow a real server-pushed subnet.
*/
const val VIP = "198.18.0.53"
@JvmField
val VIP_BYTES = byteArrayOf(198.toByte(), 18, 0, 53)
private const val TIMEOUT_MS = 3000
private const val PROBE_TTL_MS = 10 * 60 * 1000L
private const val PROBE_FAIL_TTL_MS = 60 * 1000L
private const val SUMMARY_EVERY_MS = 15 * 60 * 1000L
private const val WORKERS = 4
private var server: DatagramSocket? = null
private var serverConfig: SecureDnsConfig? = null
private var pool: ThreadPoolExecutor? = null
private val dot = ThreadLocal<DotConnection?>()
private var probedConfig: SecureDnsConfig? = null
private var probedAt = 0L
private var probeOk = false
private val queries = AtomicLong()
private val failures = AtomicLong()
private val totalMs = AtomicLong()
@Volatile private var lastSummary = 0L
@Volatile private var failing = false
private val loggedErrors = java.util.Collections.synchronizedSet(HashSet<String>())
/** Result of a probe: round trip in ms, or the failure. */
class Probe(val ms: Long, val error: String?)
{
val ok: Boolean get() = error == null
}
/**
* Names for the probe; any well-formed reply (any rcode) means the server
* speaks DoH/DoT. Not example.com: some servers silently drop it.
*/
private val PROBE_NAMES = arrayOf("one.one.one.one", "ya.ru")
/** One query (a second name if the first gets no reply); for the connect decision and the «Save» check. */
@JvmStatic
fun probe(config: SecureDnsConfig): Probe
{
val start = SystemClock.elapsedRealtime()
var error: String? = null
for (name in PROBE_NAMES)
{
try
{
val q = SecureDnsCodec.query((SystemClock.elapsedRealtime() and 0xffff).toInt(), name, 1)
SecureDnsCodec.checkReply(q, resolve(config, q))
error = null
break
}
catch (e: Exception)
{
error = describe(e)
if (e is java.net.SocketTimeoutException) break /* a dead server: no point waiting twice */
}
}
val result = Probe(SystemClock.elapsedRealtime() - start, error)
remember(config, result.ok)
return result
}
@Synchronized
private fun remember(config: SecureDnsConfig, ok: Boolean)
{
probedConfig = config
probedAt = SystemClock.elapsedRealtime()
probeOk = ok
}
/**
* Whether [config] works right now. The result of the last probe (also
* the one from «Save») is reused: a success for 10 min, a failure for
* 1 min, so one connect does not probe (and wait) several times. Logs
* the decision when it probes.
*/
@JvmStatic
@Synchronized
fun ensureReady(config: SecureDnsConfig): Boolean
{
val age = SystemClock.elapsedRealtime() - probedAt
if (config == probedConfig && age < if (probeOk) PROBE_TTL_MS else PROBE_FAIL_TTL_MS)
{
return probeOk
}
val result = probe(config)
DiagLog.i(TAG, if (result.ok) "probe ${config.label} ok in ${result.ms} ms"
else "probe ${config.label} failed after ${result.ms} ms: ${result.error}; falling back to plain DNS")
return result.ok
}
/** Starts (or keeps) the local resolver for [config]; returns its port, 0 on failure. */
@JvmStatic
@Synchronized
fun start(config: SecureDnsConfig): Int
{
server?.let { if (config == serverConfig && !it.isClosed) return it.localPort }
stopLocked("restart")
return try
{
val socket = DatagramSocket(InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0))
val executor = ThreadPoolExecutor(WORKERS, WORKERS, 60, TimeUnit.SECONDS, ArrayBlockingQueue(256),
{ r ->
/* a worker that times out or is shut down closes its DoT connection,
* otherwise the TLS socket lingers until the garbage collector finds it */
Thread({ try { r.run() } finally { dot.get()?.close(); dot.remove() } }, "SecureDns-worker").apply { isDaemon = true }
}, ThreadPoolExecutor.DiscardPolicy())
executor.allowCoreThreadTimeOut(true)
server = socket
serverConfig = config
pool = executor
lastSummary = SystemClock.elapsedRealtime()
Thread({ serve(socket, executor, config) }, "SecureDns").apply { isDaemon = true }.start()
DiagLog.i(TAG, "on: ${config.label}, tunnel DNS $VIP -> 127.0.0.1:${socket.localPort}")
socket.localPort
}
catch (e: Exception)
{
DiagLog.i(TAG, "local resolver failed to start: ${describe(e)}")
0
}
}
@JvmStatic
@Synchronized
fun stop(reason: String) = stopLocked(reason)
private fun stopLocked(reason: String)
{
val socket = server ?: return
summary(force = true)
DiagLog.i(TAG, "off ($reason)")
socket.close()
pool?.shutdownNow()
server = null
serverConfig = null
pool = null
}
private fun serve(socket: DatagramSocket, executor: ThreadPoolExecutor, config: SecureDnsConfig)
{
val buffer = ByteArray(4096)
while (!socket.isClosed)
{
val packet = DatagramPacket(buffer, buffer.size)
try
{
socket.receive(packet)
}
catch (_: IOException)
{
break
}
val query = packet.data.copyOfRange(packet.offset, packet.offset + packet.length)
val from = packet.socketAddress
executor.execute { answer(socket, config, query, from) }
}
}
private fun answer(socket: DatagramSocket, config: SecureDnsConfig, query: ByteArray, to: java.net.SocketAddress)
{
val start = SystemClock.elapsedRealtime()
val reply = try
{
resolve(config, query).also {
SecureDnsCodec.checkReply(query, it)
totalMs.addAndGet(SystemClock.elapsedRealtime() - start)
if (failing)
{
failing = false
DiagLog.i(TAG, "upstream answers again")
}
}
}
catch (e: Exception)
{
failures.incrementAndGet()
failing = true
val error = describe(e)
/* the first error of each kind only: a dead upstream would flood the log */
if (loggedErrors.add(e.javaClass.simpleName))
{
DiagLog.i(TAG, "query failed: $error (answered SERVFAIL)")
}
SecureDnsCodec.servfail(query)
}
queries.incrementAndGet()
if (reply != null)
{
try
{
socket.send(DatagramPacket(reply, reply.size, to))
}
catch (_: IOException)
{
}
}
summary(force = false)
}
private fun summary(force: Boolean)
{
val now = SystemClock.elapsedRealtime()
if (!force && now - lastSummary < SUMMARY_EVERY_MS) return
lastSummary = now
val n = queries.getAndSet(0)
val failed = failures.getAndSet(0)
val ms = totalMs.getAndSet(0)
if (n == 0L) return
val ok = n - failed
DiagLog.i(TAG, "stats: $n queries, $failed failed, avg ${if (ok > 0) ms / ok else 0} ms")
}
/** One upstream round trip; a stale pooled connection gets one retry on a fresh one. */
private fun resolve(config: SecureDnsConfig, query: ByteArray): ByteArray = try
{
if (config.doh) doh(config, query) else dot(config, query)
}
catch (e: IOException)
{
if (e is java.net.SocketTimeoutException) throw e /* a slow upstream: retrying would only double the wait */
dot.get()?.close()
dot.set(null)
if (config.doh) doh(config, query) else dot(config, query)
}
/** RFC 8484 POST; HttpsURLConnection verifies the certificate and the hostname. */
private fun doh(config: SecureDnsConfig, query: ByteArray): ByteArray
{
val connection = URL(config.url).openConnection() as HttpsURLConnection
try
{
connection.connectTimeout = TIMEOUT_MS
connection.readTimeout = TIMEOUT_MS
connection.requestMethod = "POST"
connection.doOutput = true
connection.useCaches = false
connection.setRequestProperty("Content-Type", "application/dns-message")
connection.setRequestProperty("Accept", "application/dns-message")
connection.setFixedLengthStreamingMode(query.size)
connection.outputStream.use { it.write(query) }
if (connection.responseCode != HttpURLConnection.HTTP_OK)
{
throw IOException("HTTP ${connection.responseCode}")
}
return connection.inputStream.use { readBounded(it) }
}
catch (e: IOException)
{
connection.disconnect()
throw e
}
}
private fun readBounded(input: InputStream): ByteArray
{
val out = ByteArrayOutputStream()
val buf = ByteArray(4096)
while (true)
{
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
if (out.size() > 65535) throw IOException("reply too large")
}
return out.toByteArray()
}
/** RFC 7858 over a per-worker TLS connection. */
private fun dot(config: SecureDnsConfig, query: ByteArray): ByteArray
{
val conn = dot.get()?.takeIf { it.config == config } ?: DotConnection.open(config).also {
dot.get()?.close()
dot.set(it)
}
try
{
conn.out.write(SecureDnsCodec.frame(query))
conn.out.flush()
val reply = SecureDnsCodec.readFrame(conn.input)
SecureDnsCodec.checkReply(query, reply)
return reply
}
catch (e: IOException)
{ /* a late reply would stay in the stream and answer the next query: never reuse it */
conn.close()
dot.set(null)
throw e
}
}
private class DotConnection(val config: SecureDnsConfig, val socket: SSLSocket)
{
val input: InputStream = socket.inputStream.buffered()
val out: OutputStream = socket.outputStream
fun close()
{
try
{
socket.close()
}
catch (_: IOException)
{
}
}
companion object
{
fun open(config: SecureDnsConfig): DotConnection
{
val raw = Socket()
try
{
raw.connect(InetSocketAddress(config.host, config.port), TIMEOUT_MS)
raw.soTimeout = TIMEOUT_MS
/* the host name sets SNI; the factory does not check it against the certificate */
val tls = (SSLSocketFactory.getDefault() as SSLSocketFactory).createSocket(raw, config.host, config.port, true) as SSLSocket
tls.startHandshake()
if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(config.host, tls.session))
{
tls.close()
throw javax.net.ssl.SSLPeerUnverifiedException("certificate does not match ${config.host}")
}
return DotConnection(config, tls)
}
catch (e: IOException)
{
raw.close()
throw e
}
}
}
}
private fun describe(e: Exception): String = "${e.javaClass.simpleName}: ${e.message ?: ""}".trimEnd(' ', ':')
}
@@ -25,10 +25,12 @@ import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.VpnService import android.net.VpnService
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock
import android.telephony.TelephonyManager import android.telephony.TelephonyManager
import android.util.Log import android.util.Log
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@@ -162,6 +164,9 @@ object ScenarioEngine
private const val TAG = "Scenarios" private const val TAG = "Scenarios"
private const val FILE_NAME = "scenarios.json" private const val FILE_NAME = "scenarios.json"
private const val POLL_MS = 4000L private const val POLL_MS = 4000L
/* how long a broadcast waits for the VPN state service of a fresh process */
private const val SERVICE_WAIT_MS = 8000L
private const val SERVICE_POLL_MS = 200L
/* the foreground app did not change for a minute: poll half as often */ /* the foreground app did not change for a minute: poll half as often */
private const val SLOW_AFTER_POLLS = 15 private const val SLOW_AFTER_POLLS = 15
private const val SLOW_POLL_MS = 8000L private const val SLOW_POLL_MS = 8000L
@@ -193,9 +198,20 @@ object ScenarioEngine
_list.value = list _list.value = list
persist(context) persist(context)
start(context) start(context)
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
evaluate(context) evaluate(context)
} }
/** Whether an enabled scenario reacts to Wi-Fi, mobile network or roaming changes. */
@JvmStatic
fun needsNetworkEvents(context: Context): Boolean
{
load(context.applicationContext)
return _list.value.any {
it.enabled && (it.trigger == ScenarioTrigger.WIFI || it.trigger == ScenarioTrigger.MOBILE || it.trigger == ScenarioTrigger.ROAMING)
}
}
/** Starts listening (from the application); idempotent. */ /** Starts listening (from the application); idempotent. */
@JvmStatic @JvmStatic
fun start(context: Context) fun start(context: Context)
@@ -289,6 +305,39 @@ object ScenarioEngine
} }
} }
/**
* Decides again once the VPN state service exists. A broadcast that starts a
* fresh process arrives before the managers bound the service; evaluating
* then would silently drop the event. Gives up after [SERVICE_WAIT_MS]
* (the next network or alarm event evaluates again) and always calls [done].
*/
@JvmStatic
fun evaluateWhenReady(context: Context, done: Runnable?)
{
val app = context.applicationContext
val deadline = SystemClock.elapsedRealtime() + SERVICE_WAIT_MS
val check = object : Runnable
{
override fun run()
{
if (VpnStateService.getLiveInstance() == null && SystemClock.elapsedRealtime() < deadline)
{
handler.postDelayed(this, SERVICE_POLL_MS)
return
}
try
{
evaluate(app)
}
finally
{
done?.run()
}
}
}
handler.post(check)
}
/** Network, whitelist or alarm: decide again. */ /** Network, whitelist or alarm: decide again. */
@JvmStatic @JvmStatic
fun evaluate(context: Context) fun evaluate(context: Context)
@@ -431,10 +480,12 @@ object ScenarioEngine
{ {
return return
} }
/* without a live service nothing can be put back: keep the record so the
* next evaluation (the receiver waits for the service) still restores */
val service = VpnStateService.getLiveInstance() ?: return
val wasOn = prefs.getBoolean(PREF_WAS_ON, false) val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null) val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply() prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
val service = VpnStateService.getLiveInstance() ?: return
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)") DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
if (PauseManager.isPaused(context)) if (PauseManager.isPaused(context))
{ {
@@ -562,12 +613,22 @@ object ScenarioEngine
{ {
c.add(Calendar.DAY_OF_YEAR, 1) c.add(Calendar.DAY_OF_YEAR, 1)
} }
/* same choice as PauseManager: exact when the grant allows it */
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
try try
{
if (exact)
{
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
}
else
{ {
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
} }
}
catch (_: SecurityException) catch (_: SecurityException)
{ { /* revoked in between: an inexact alarm is still better than none */
runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) }
} }
} }
@@ -673,6 +734,7 @@ class ScenarioReceiver : BroadcastReceiver()
override fun onReceive(context: Context, intent: Intent?) override fun onReceive(context: Context, intent: Intent?)
{ {
ScenarioEngine.start(context) ScenarioEngine.start(context)
ScenarioEngine.evaluate(context) val pending = goAsync()
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
} }
} }
@@ -15,6 +15,8 @@
package org.strongswan.android.logic.stats package org.strongswan.android.logic.stats
import android.content.Context import android.content.Context
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -37,7 +39,8 @@ data class DayTraffic(val day: String, val vpn: Long, val direct: Long)
/** /**
* Daily traffic history (F_Stats). While the tunnel is up the daemon * Daily traffic history (F_Stats). While the tunnel is up the daemon
* counters are sampled every [SAMPLE_S] seconds and the growth is added to * counters are sampled every [SAMPLE_S] seconds (every [SAMPLE_OFF_S] with
* the screen off: the counters are cumulative, so nothing is lost) and the growth is added to
* the current day; the counters restart with every connection, so a drop * the current day; the counters restart with every connection, so a drop
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json. * starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
*/ */
@@ -46,12 +49,21 @@ object TrafficHistory
private const val TAG = "TrafficHistory" private const val TAG = "TrafficHistory"
private const val FILE_NAME = "traffic.json" private const val FILE_NAME = "traffic.json"
private const val SAMPLE_S = 15L private const val SAMPLE_S = 15L
private const val SAMPLE_OFF_S = 60L
/* the history lives in memory and is written out this often (not on every
* sample: a full JSON rewrite every 15 s is flash wear for nothing); a
* killed process loses at most this much of the statistics */
internal const val SAVE_EVERY_MS = 5 * 60 * 1000L
private const val KEEP_DAYS = 120 private const val KEEP_DAYS = 120
private val executor = Executors.newSingleThreadScheduledExecutor() private val executor = Executors.newSingleThreadScheduledExecutor()
private var task: ScheduledFuture<*>? = null private var task: ScheduledFuture<*>? = null
private var lastVpn = -1L private var lastVpn = -1L
private var lastDirect = -1L private var lastDirect = -1L
private var lastSampleAt = 0L
private var dirty = false
private var lastSavedAt = 0L
private var lastSavedDay: String? = null
@Volatile private var loaded = false @Volatile private var loaded = false
private val days = LinkedHashMap<String, LongArray>() private val days = LinkedHashMap<String, LongArray>()
private var firstDay: String? = null private var firstDay: String? = null
@@ -86,11 +98,38 @@ object TrafficHistory
val running = task ?: return val running = task ?: return
running.cancel(false) running.cancel(false)
task = null task = null
executor.execute { sample(app) } executor.execute {
sample(app, force = true)
flush(app)
}
} }
private fun sample(context: Context) /** Writes the history out now if it changed since the last write. */
@Synchronized
fun flush(context: Context)
{ {
if (dirty)
{
save(context)
}
}
/** Whether a changed history has to be written now: periodically and when the day turns. */
internal fun saveDue(dirty: Boolean, now: Long, lastSavedAt: Long, dayChanged: Boolean): Boolean =
dirty && (dayChanged || now - lastSavedAt >= SAVE_EVERY_MS)
private fun screenOn(context: Context): Boolean =
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive != false
private fun sample(context: Context, force: Boolean = false)
{
val now = SystemClock.elapsedRealtime()
val screen = screenOn(context)
if (!force && !screen && now - lastSampleAt < SAMPLE_OFF_S * 1000L - 1000L)
{ /* screen off: a sample a minute is enough, the counters accumulate */
return
}
lastSampleAt = now
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull() val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
/* direct bytes only exist with the userspace router (s[3]) */ /* direct bytes only exist with the userspace router (s[3]) */
@@ -121,10 +160,17 @@ object TrafficHistory
firstDay = key firstDay = key
} }
prune() prune()
dirty = true
if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key))
{
save(context) save(context)
}
_changes.value = System.currentTimeMillis() _changes.value = System.currentTimeMillis()
if (screenOn(context))
{ /* nobody sees the widget with the screen off */
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context) org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
} }
}
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */ /** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
@Synchronized @Synchronized
@@ -205,6 +251,9 @@ object TrafficHistory
file.writeText(json.toString()) file.writeText(json.toString())
tmp.delete() tmp.delete()
} }
dirty = false
lastSavedAt = SystemClock.elapsedRealtime()
lastSavedDay = dayKey(System.currentTimeMillis())
} }
catch (e: Exception) catch (e: Exception)
{ {
@@ -0,0 +1,177 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import java.io.File
import java.io.RandomAccessFile
/**
* What goes into a report sent from the log screen: today's part of charon.log and,
* on request, the app's diag.log. No Android types here, so it runs in unit tests.
*/
object ReportContent
{
/* the panel unpacks a report with a 5 MB cap; 3 + 0.75 MB of text leaves room and stays far below 2 MB gzip */
const val CHARON_MAX_BYTES = 3 * 1024 * 1024
const val DIAG_MAX_BYTES = 768 * 1024
const val CUT_MARK = "... (earlier lines cut)\n"
/** Why a report was not sent; each maps to a reason line of the "not sent" sheet */
enum class Failure { NO_NETWORK, SERVER_DOWN, SIGNATURE, TOO_OFTEN, TOO_LARGE, REJECTED }
fun failure(httpCode: Int): Failure = when
{
httpCode == 0 -> Failure.NO_NETWORK
httpCode == 401 -> Failure.SIGNATURE
httpCode == 413 -> Failure.TOO_LARGE
httpCode == 429 -> Failure.TOO_OFTEN
httpCode >= 500 || httpCode == 404 -> Failure.SERVER_DOWN
else -> Failure.REJECTED
}
private val MONTHS = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
/* charon: «Nov 8 14:02:11 06[IKE] ...» (strftime %b %e, C locale) */
private val CHARON_DATE = Regex("""^(\w{3})\s+(\d{1,2})\s+\d{2}:\d{2}:\d{2}\s""")
/**
* Lines of [month] (1-12) / [day] only; lines without a date stay with the dated line before them.
* When nothing is from today the whole text is kept: an old log still helps support.
*/
fun todayOnly(text: String, month: Int, day: Int): String
{
var keep = true
var today = false
val out = StringBuilder()
for (line in text.lineSequence())
{
CHARON_DATE.find(line)?.let { m ->
keep = MONTHS.indexOf(m.groupValues[1]) + 1 == month && m.groupValues[2].toInt() == day
today = today || keep
}
if (keep && line.isNotEmpty())
{
out.append(line).append('\n')
}
}
/* undated lines alone (e.g. the cut mark of readTail) do not count as today's log */
return if (today) out.toString() else text
}
/** The last [maxBytes] of UTF-8 [text], cut at a line start; the cut is marked */
fun tailBytes(text: String, maxBytes: Int): String
{
val bytes = text.toByteArray(Charsets.UTF_8)
if (bytes.size <= maxBytes)
{
return text
}
var start = bytes.size - maxBytes
/* '\n' is never part of a multi-byte UTF-8 sequence, so cutting after it keeps characters whole */
while (start < bytes.size && bytes[start - 1] != '\n'.code.toByte())
{
start++
}
if (start == bytes.size)
{ /* one huge line: cut anywhere, but not inside a character (skip 10xxxxxx continuation bytes) */
start = bytes.size - maxBytes
while (start < bytes.size && (bytes[start].toInt() and 0xC0) == 0x80)
{
start++
}
}
return CUT_MARK + String(bytes, start, bytes.size - start, Charsets.UTF_8)
}
/** Up to [maxBytes] from the end of [file] ("" when missing); a partial first line is dropped */
fun readTail(file: File, maxBytes: Int): String
{
if (!file.exists())
{
return ""
}
return try
{
RandomAccessFile(file, "r").use { f ->
val from = maxOf(0L, f.length() - maxBytes)
val buf = ByteArray((f.length() - from).toInt())
f.seek(from)
f.readFully(buf)
val text = String(buf, Charsets.UTF_8)
if (from > 0) CUT_MARK + text.substringAfter('\n') else text
}
}
catch (e: Exception)
{
""
}
}
/** One network check of the diagnostics screen: the panel shows them as a check list */
data class Check(val title: String, val value: String, val ok: Boolean?)
/** JSON array of the checks (no org.json: this object stays testable without Android) */
fun checksJson(checks: List<Check>): String = checks.joinToString(",", "[", "]") { c ->
"{\"title\":${jsonString(c.title)},\"value\":${jsonString(c.value)},\"ok\":${c.ok?.toString() ?: "null"}}"
}
private fun jsonString(s: String): String = buildString {
append('"')
for (ch in s)
{
when
{
ch == '"' -> append("\\\"")
ch == '\\' -> append("\\\\")
ch == '\n' -> append("\\n")
ch == '\r' -> append("\\r")
ch == '\t' -> append("\\t")
ch < ' ' -> append("\\u%04x".format(ch.code))
else -> append(ch)
}
}
append('"')
}
/** The report text: the connection log, the network checks and diag.log when asked for */
fun build(charon: String, diag: String?, checks: List<Check>? = null): String = buildString {
append("=== charon.log ===\n")
append(tailBytes(charon, CHARON_MAX_BYTES).ifBlank { "(empty)\n" })
if (!checks.isNullOrEmpty())
{
append("\n=== checks.json ===\n")
append(checksJson(checks)).append('\n')
}
if (diag != null)
{
append("\n=== diag.log ===\n")
append(tailBytes(diag, DIAG_MAX_BYTES).ifBlank { "(empty)\n" })
}
}
/** Meta of a report from the log screen; [network] is "Wi-Fi", "mobile" or "none" */
fun meta(
versionName: String, versionCode: Long, manufacturer: String, model: String, release: String, sdk: Int,
network: String, comment: String, profile: String,
) = ReportUploader.Meta(
versionName = versionName,
versionCode = versionCode,
device = if (model.startsWith(manufacturer, ignoreCase = true)) model else "$manufacturer $model",
os = "Android $release (API $sdk)",
network = network,
source = "log",
comment = comment.trim(),
profile = profile,
)
}
@@ -0,0 +1,139 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.GZIPOutputStream
/**
* Sends a diagnostic log to the web panel (POST /api/v1/reports) and returns the report
* code the user can quote to support. The request is signed like the update check.
* The "Send report" sheet of the log screen (LogActivity) calls it, see [ReportContent].
*/
class ReportUploader(
private val baseUrl: String,
private val hmacKey: String,
private val installId: String,
private val clock: () -> Long = { System.currentTimeMillis() },
)
{
data class Meta(
val versionName: String,
val versionCode: Long,
val device: String,
val os: String,
val network: String,
val source: String,
val comment: String,
val profile: String,
)
sealed class Result
{
data class Sent(val code: String) : Result()
data class Failed(val httpCode: Int, val detail: String) : Result()
}
companion object
{
/** Server limit: the whole request body must stay below 2 MB */
const val MAX_BYTES = 2 * 1024 * 1024
const val BOUNDARY = "----shxreport"
private val CODE = Regex("^SHX-[0-9A-Z]{5}$")
/** The panel serves the app API under /api/v1 (the proxy routes only that prefix); the build may give just the host */
fun apiBase(url: String): String
{
val base = url.trim().trimEnd('/')
return if (base.endsWith("/api/v1")) base else "$base/api/v1"
}
/** 201 with {"code":"SHX-XXXXX"} is the only success; a broken 201 is not "no network" */
fun parseResponse(httpCode: Int, body: String): Result
{
if (httpCode != 201)
{
return Result.Failed(httpCode, body.take(200))
}
val code = try { JSONObject(body).optString("code", "") } catch (e: Exception) { "" }
return if (CODE.matches(code)) Result.Sent(code) else Result.Failed(httpCode, "bad response: ${body.take(100)}")
}
fun gzip(text: String): ByteArray
{
val out = ByteArrayOutputStream()
GZIPOutputStream(out).use { it.write(text.toByteArray(Charsets.UTF_8)) }
return out.toByteArray()
}
fun metaJson(m: Meta): String = JSONObject()
.put("versionName", m.versionName).put("versionCode", m.versionCode)
.put("device", m.device.take(120)).put("os", m.os.take(60)).put("network", m.network.take(40))
.put("source", m.source).put("comment", m.comment.take(1000)).put("profile", m.profile.take(200))
.toString()
/** multipart/form-data with the gzip log and the meta JSON */
fun multipartBody(logGz: ByteArray, meta: String): ByteArray
{
val out = ByteArrayOutputStream()
fun w(s: String) = out.write(s.toByteArray(Charsets.UTF_8))
w("--$BOUNDARY\r\nContent-Disposition: form-data; name=\"log\"; filename=\"log.gz\"\r\nContent-Type: application/gzip\r\n\r\n")
out.write(logGz)
/* a plain text field: @fastify/multipart turns an application/json field into an object and the server then reads "[object Object]" */
w("\r\n--$BOUNDARY\r\nContent-Disposition: form-data; name=\"meta\"\r\n\r\n")
w(meta)
w("\r\n--$BOUNDARY--\r\n")
return out.toByteArray()
}
}
fun send(logText: String, meta: Meta): Result
{
val body = multipartBody(gzip(logText), metaJson(meta))
if (body.size > MAX_BYTES)
{
return Result.Failed(413, "report is larger than 2 MB")
}
val url = apiBase(baseUrl) + "/reports"
val ts = clock() / 1000
val conn = URL(url).openConnection() as HttpURLConnection
return try
{
conn.requestMethod = "POST"
conn.doOutput = true
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY")
conn.setRequestProperty("X-Install-Id", installId)
conn.setRequestProperty("X-Timestamp", ts.toString())
conn.setRequestProperty("X-Signature", UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("POST", URL(url).path, ts)))
conn.outputStream.use { it.write(body) }
val code = conn.responseCode
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)?.use { it.readBytes().decodeToString() } ?: ""
parseResponse(code, text)
}
catch (e: Exception)
{
Result.Failed(0, e.message ?: e.javaClass.simpleName)
}
finally
{
conn.disconnect()
}
}
}
@@ -0,0 +1,254 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import net.i2p.crypto.eddsa.EdDSAEngine
import net.i2p.crypto.eddsa.EdDSAPublicKey
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Client of the SHX update API (/api/v1/updates/check).
*
* The wire contract is frozen in the web panel repository: docs/openapi.v1.json
* and packages/contracts/vectors/signing.json. [UpdateSigning] builds exactly
* the strings the server signs; UpdateApiTest checks it against those vectors.
*/
@OptIn(ExperimentalEncodingApi::class)
object UpdateSigning
{
const val SIGNING_VERSION = "shx-manifest-v1"
/** The fields covered by the manifest signature (notes are display-only and not signed) */
data class Fields(
val update: Boolean,
val mandatory: Boolean,
val versionCode: Long,
val versionName: String,
val sha256: String,
val size: Long,
val apkUrl: String,
val issuedAt: Long,
val checkHours: Int,
val wifiOnly: Boolean,
)
/** Fields joined by '\n' in a strict order; absent values are 0 / empty (same as the server) */
fun manifestInput(f: Fields): String
{
return listOf(
SIGNING_VERSION,
if (f.update) "1" else "0",
if (f.mandatory) "1" else "0",
f.versionCode.toString(),
f.versionName,
f.sha256,
f.size.toString(),
f.apkUrl,
f.issuedAt.toString(),
f.checkHours.toString(),
if (f.wifiOnly) "1" else "0",
).joinToString("\n")
}
fun requestInput(method: String, path: String, timestamp: Long): String
{
return "${method.uppercase()}|$path|$timestamp"
}
fun hmacBase64(key: String, input: String): String
{
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key.toByteArray(Charsets.UTF_8), "HmacSHA256"))
return Base64.Default.encode(mac.doFinal(input.toByteArray(Charsets.UTF_8)))
}
/**
* Ed25519 verification on API 24+ (java.security Ed25519 exists only from API 33),
* therefore a small pure-Java implementation is used
*/
fun verifyEd25519(publicKeyRaw: ByteArray, input: String, signatureBase64: String): Boolean
{
return try
{
val spec = EdDSAPublicKeySpec(publicKeyRaw, EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519))
val engine = EdDSAEngine(MessageDigest.getInstance("SHA-512"))
engine.initVerify(EdDSAPublicKey(spec))
engine.update(input.toByteArray(Charsets.UTF_8))
engine.verify(Base64.Default.decode(signatureBase64))
}
catch (e: Exception)
{
false
}
}
}
/** Where the HTTP is delegated to, so the client can be tested without a network */
interface UpdateHttp
{
data class Response(val code: Int, val body: String)
fun get(url: String, headers: Map<String, String>): Response
}
class HttpUrlConnectionHttp(private val timeoutMs: Int = 10_000) : UpdateHttp
{
override fun get(url: String, headers: Map<String, String>): UpdateHttp.Response
{
val conn = URL(url).openConnection(java.net.Proxy.NO_PROXY) as HttpURLConnection
conn.connectTimeout = timeoutMs
conn.readTimeout = timeoutMs
conn.setRequestProperty("Connection", "close")
conn.setRequestProperty("Cache-Control", "no-cache")
for ((k, v) in headers)
{
conn.setRequestProperty(k, v)
}
return try
{
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
UpdateHttp.Response(code, stream?.use { it.readBytes().decodeToString() } ?: "")
}
finally
{
conn.disconnect()
}
}
}
/** Policy delivered inside the signed manifest; the user's own settings stay the default */
data class UpdatePolicy(val checkHours: Int, val wifiOnly: Boolean)
sealed class UpdateApiResult
{
data class Update(val info: UpdateInfo, val mandatory: Boolean, val policy: UpdatePolicy, val issuedAt: Long) : UpdateApiResult()
data class NoUpdate(val policy: UpdatePolicy) : UpdateApiResult()
/** [kind]: NETWORK (try the legacy path), SIGNATURE (never fall back), OTHER (rate limit, bad request) */
data class Failure(val kind: UpdateError, val detail: String) : UpdateApiResult()
}
@OptIn(ExperimentalEncodingApi::class)
class UpdateApiClient(
private val baseUrl: String,
private val hmacKey: String,
private val publicKeyBase64: String,
private val installId: String,
private val http: UpdateHttp = HttpUrlConnectionHttp(),
private val clock: () -> Long = { System.currentTimeMillis() },
)
{
companion object
{
/** The manifest is rejected when its issue time is far from the device clock (replay of a stale "no update") */
const val MAX_SKEW_SECONDS = 6 * 3600L
fun isConfigured(baseUrl: String, hmacKey: String, publicKey: String): Boolean
{
return baseUrl.isNotBlank() && hmacKey.isNotBlank() && publicKey.isNotBlank()
}
}
fun check(packageId: String, versionCode: Long, channel: String, abi: String?, sdk: Int, betaToken: String?): UpdateApiResult
{
val path = URL(ReportUploader.apiBase(baseUrl) + "/updates/check").path
val query = buildString {
append("package=").append(enc(packageId))
append("&versionCode=").append(versionCode)
append("&channel=").append(enc(channel))
if (!abi.isNullOrBlank()) append("&abi=").append(enc(abi))
append("&sdk=").append(sdk)
if (!betaToken.isNullOrBlank()) append("&betaToken=").append(enc(betaToken))
}
val ts = clock() / 1000
val headers = mapOf(
"X-Install-Id" to installId,
"X-Timestamp" to ts.toString(),
"X-Signature" to UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", path, ts)),
)
val response = try
{
http.get(ReportUploader.apiBase(baseUrl) + "/updates/check?" + query, headers)
}
catch (e: Exception)
{
return UpdateApiResult.Failure(UpdateError.NETWORK, e.message ?: e.javaClass.simpleName)
}
return when
{
response.code in 500..599 || response.code == 404 -> UpdateApiResult.Failure(UpdateError.NETWORK, "HTTP ${response.code}")
response.code != 200 -> UpdateApiResult.Failure(UpdateError.OTHER, "HTTP ${response.code}")
else -> parse(response.body)
}
}
private fun parse(body: String): UpdateApiResult
{
val json = try { JSONObject(body) } catch (e: Exception) { return UpdateApiResult.Failure(UpdateError.OTHER, "bad json") }
val update = json.optBoolean("update", false)
val fields = UpdateSigning.Fields(
update = update,
mandatory = json.optBoolean("mandatory", false),
versionCode = if (update) json.optLong("versionCode", 0L) else 0L,
versionName = if (update) json.optString("versionName", "") else "",
sha256 = if (update) json.optString("sha256", "").lowercase() else "",
size = if (update) json.optLong("size", 0L) else 0L,
apkUrl = if (update) json.optString("apkUrl", "") else "",
issuedAt = json.optLong("issuedAt", 0L),
checkHours = json.optInt("checkHours", 12),
wifiOnly = json.optBoolean("wifiOnly", true),
)
val key = try { Base64.Default.decode(publicKeyBase64) } catch (e: Exception) { ByteArray(0) }
if (key.size != 32 || !UpdateSigning.verifyEd25519(key, UpdateSigning.manifestInput(fields), json.optString("signature", "")))
{
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest signature mismatch")
}
val skew = Math.abs(clock() / 1000 - fields.issuedAt)
if (skew > MAX_SKEW_SECONDS)
{
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest is too old or the device clock is off ($skew s)")
}
val policy = UpdatePolicy(fields.checkHours, fields.wifiOnly)
if (!update)
{
return UpdateApiResult.NoUpdate(policy)
}
if (fields.apkUrl.isBlank() || fields.sha256.length != 64)
{
return UpdateApiResult.Failure(UpdateError.OTHER, "incomplete manifest")
}
val notes = HashMap<String, String>()
json.optJSONObject("notes")?.let { n -> for (k in n.keys()) notes[k.lowercase()] = n.optString(k, "") }
val info = UpdateInfo(
versionCode = fields.versionCode, versionName = fields.versionName, apkUrl = fields.apkUrl,
sha256 = fields.sha256, size = fields.size, notes = notes, fromApi = true, issuedAt = fields.issuedAt,
)
return UpdateApiResult.Update(info, fields.mandatory, policy, fields.issuedAt)
}
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
}
@@ -62,6 +62,7 @@ import java.net.Proxy
import java.net.URL import java.net.URL
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.cancellation.CancellationException
/** Categories of update failures, mapped to localized messages by the UI */ /** Categories of update failures, mapped to localized messages by the UI */
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER } enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
@@ -76,6 +77,10 @@ data class UpdateInfo(
val sha256: String, val sha256: String,
val size: Long, val size: Long,
val notes: Map<String, String>, val notes: Map<String, String>,
/* set for manifests from the SHX update API: the file URL is presigned and short-lived */
val fromApi: Boolean = false,
val issuedAt: Long = 0L,
val mandatory: Boolean = false,
) )
sealed class UpdateState sealed class UpdateState
@@ -117,6 +122,17 @@ class UpdateManager private constructor(private val context: Context)
private const val UPDATES_CHANNEL = "org.strongswan.android.updates" private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
private const val UPDATES_NOTIFICATION_ID = 1001 private const val UPDATES_NOTIFICATION_ID = 1001
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002 private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
private const val PREF_INSTALL_ID = "pref_update_install_id"
private const val PREF_UPDATE_CHANNEL = "pref_update_channel"
private const val PREF_BETA_TOKEN = "pref_update_beta_token"
private const val PREF_API_CHECK_HOURS = "pref_update_api_check_hours"
private const val PREF_API_WIFI_ONLY = "pref_update_api_wifi_only"
/* a mandatory update the last check found: keeps the blocking sheet up across restarts and failed checks */
private const val PREF_MANDATORY_CODE = "pref_update_mandatory_code"
private const val PREF_MANDATORY_NAME = "pref_update_mandatory_name"
private const val PREF_MANDATORY_SIZE = "pref_update_mandatory_size"
/* refresh the manifest when it is older than this before downloading (the URL lives 10 minutes) */
private const val URL_MAX_AGE_SECONDS = 8 * 60L
@Volatile @Volatile
private var mInstance: UpdateManager? = null private var mInstance: UpdateManager? = null
@@ -166,6 +182,11 @@ class UpdateManager private constructor(private val context: Context)
* was known in this process: install as soon as the check finds it */ * was known in this process: install as soon as the check finds it */
@Volatile @Volatile
private var mInstallPending = false private var mInstallPending = false
/* set by cancelDownload(); the copy loop and the install step check it */
@Volatile
private var mCancelRequested = false
@Volatile
private var mDownloadConn: HttpURLConnection? = null
private var mVpnService: VpnStateService? = null private var mVpnService: VpnStateService? = null
private val mConnection = object : ServiceConnection private val mConnection = object : ServiceConnection
@@ -191,7 +212,8 @@ class UpdateManager private constructor(private val context: Context)
{ {
cleanup() cleanup()
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE) context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(12, TimeUnit.HOURS) /* the interval comes from the server policy (signed manifest), 12 h until it is known */
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(checkIntervalHours(), TimeUnit.HOURS)
.setConstraints(Constraints.Builder() .setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) .setRequiredNetworkType(NetworkType.CONNECTED)
/* background checks only run while charging - zero battery impact */ /* background checks only run while charging - zero battery impact */
@@ -224,7 +246,7 @@ class UpdateManager private constructor(private val context: Context)
return null return null
} }
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L) val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
if (System.currentTimeMillis() - last >= Constants.UPDATE_CHECK_INTERVAL_MS) if (System.currentTimeMillis() - last >= TimeUnit.HOURS.toMillis(checkIntervalHours()))
{ {
return check() return check()
} }
@@ -244,6 +266,57 @@ class UpdateManager private constructor(private val context: Context)
check() check()
} }
/** True while the update channel is "beta" */
fun isBetaChannel(): Boolean = mPrefs.getString(PREF_UPDATE_CHANNEL, "release") == "beta"
/** Switches the channel (leaving beta forgets the invitation code) and checks right away */
fun setChannel(beta: Boolean, token: String?)
{
val edit = mPrefs.edit().putString(PREF_UPDATE_CHANNEL, if (beta) "beta" else "release")
if (beta) edit.putString(PREF_BETA_TOKEN, token?.trim()) else edit.remove(PREF_BETA_TOKEN)
edit.apply()
check()
}
/** Version name and size of a mandatory update newer than the installed app, or null */
fun pendingMandatory(): Pair<String, Long>?
{
if (mPrefs.getLong(PREF_MANDATORY_CODE, 0L) <= currentVersionCode())
{
return null
}
return (mPrefs.getString(PREF_MANDATORY_NAME, null) ?: "") to mPrefs.getLong(PREF_MANDATORY_SIZE, -1L)
}
/** Interrupts a running download, deletes the partial file and goes back to Idle */
@Synchronized
fun cancelDownload()
{
if (mDownloadJob?.isActive != true)
{
return
}
mInstallPending = false
mCancelRequested = true
mDownloadJob?.cancel()
/* unblocks a read waiting for the network */
try
{
mDownloadConn?.disconnect()
}
catch (_: Exception)
{
}
}
private fun onDownloadCancelled()
{
DiagLog.i(TAG, "update: download cancelled")
cleanup()
cancelDownloadProgress()
mState.value = UpdateState.Idle
}
@Synchronized @Synchronized
private fun check(): Job? private fun check(): Job?
{ {
@@ -257,7 +330,12 @@ class UpdateManager private constructor(private val context: Context)
mInstallPending = false mInstallPending = false
return@launch return@launch
} }
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply() val mandatory = info.mandatory && info.versionCode > currentVersionCode()
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis())
.putLong(PREF_MANDATORY_CODE, if (mandatory) info.versionCode else 0L)
.putString(PREF_MANDATORY_NAME, info.versionName)
.putLong(PREF_MANDATORY_SIZE, info.size)
.apply()
if (info.versionCode > currentVersionCode()) if (info.versionCode > currentVersionCode())
{ {
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " + DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
@@ -300,7 +378,8 @@ class UpdateManager private constructor(private val context: Context)
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install") DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
return return
} }
if (isNetworkMetered()) /* server policy "Wi-Fi only" (default true); a policy of false allows the silent download on metered networks too */
if (mPrefs.getBoolean(PREF_API_WIFI_ONLY, true) && isNetworkMetered())
{ {
DiagLog.i(TAG, "update: silent download deferred (network metered)") DiagLog.i(TAG, "update: silent download deferred (network metered)")
return return
@@ -309,12 +388,17 @@ class UpdateManager private constructor(private val context: Context)
{ {
return return
} }
mCancelRequested = false
mDownloadJob = mScope.launch { mDownloadJob = mScope.launch {
try try
{ {
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)") DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
val apk = downloadApk(info) val apk = downloadApk(info)
verifySignature(apk) verifySignature(apk)
if (mCancelRequested)
{
throw CancellationException("update download cancelled")
}
if (isVpnDown() && canInstallSilently()) if (isVpnDown() && canInstallSilently())
{ {
Log.i(TAG, "Applying the downloaded update silently") Log.i(TAG, "Applying the downloaded update silently")
@@ -340,6 +424,10 @@ class UpdateManager private constructor(private val context: Context)
} }
} }
} }
catch (e: CancellationException)
{
onDownloadCancelled()
}
catch (e: Exception) catch (e: Exception)
{ {
Log.e(TAG, "Silent update download failed", e) Log.e(TAG, "Silent update download failed", e)
@@ -350,6 +438,9 @@ class UpdateManager private constructor(private val context: Context)
} }
} }
/** Check interval from the server policy, kept between 1 hour and a week */
fun checkIntervalHours(): Long = mPrefs.getInt(PREF_API_CHECK_HOURS, 12).coerceIn(1, 168).toLong()
private fun isSilentUpdatesEnabled(): Boolean private fun isSilentUpdatesEnabled(): Boolean
{ {
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false) return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
@@ -408,7 +499,7 @@ class UpdateManager private constructor(private val context: Context)
val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
{ {
Notification.Builder(context, UPDATES_CHANNEL) val builder = Notification.Builder(context, UPDATES_CHANNEL)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .setContentTitle(title)
.setContentText("$progress%") .setContentText("$progress%")
@@ -416,11 +507,16 @@ class UpdateManager private constructor(private val context: Context)
.setStyle(Notification.ProgressStyle() .setStyle(Notification.ProgressStyle()
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100))) .setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
.setProgress(progress)) .setProgress(progress))
.setRequestPromotedOngoing(true)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentIntent(pending) .setContentIntent(pending)
.build() /* setRequestPromotedOngoing appeared in Android 16 QPR2 (API 36.1): on 36.0 it is a
* NoSuchMethodError that killed the app as soon as an update download started */
if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1)
{
builder.setRequestPromotedOngoing(true)
}
builder.build()
} }
else else
{ {
@@ -519,16 +615,25 @@ class UpdateManager private constructor(private val context: Context)
check() check()
return return
} }
mCancelRequested = false
mDownloadJob = mScope.launch { mDownloadJob = mScope.launch {
try try
{ {
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)") DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
val apk = downloadApk(info) val apk = downloadApk(info)
verifySignature(apk) verifySignature(apk)
if (mCancelRequested)
{
throw CancellationException("update download cancelled")
}
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)") DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
UpdateInstaller.install(context, apk) UpdateInstaller.install(context, apk)
mState.value = UpdateState.Idle mState.value = UpdateState.Idle
} }
catch (e: CancellationException)
{
onDownloadCancelled()
}
catch (e: UpdateException) catch (e: UpdateException)
{ {
Log.e(TAG, "Update failed: ${e.kind}", e) Log.e(TAG, "Update failed: ${e.kind}", e)
@@ -560,6 +665,26 @@ class UpdateManager private constructor(private val context: Context)
private fun fetchManifest(): UpdateInfo? private fun fetchManifest(): UpdateInfo?
{ {
if (apiConfigured())
{
when (val r = callApi())
{
is UpdateApiResult.Update -> return r.info.copy(mandatory = r.mandatory)
is UpdateApiResult.NoUpdate -> return upToDateMarker()
is UpdateApiResult.Failure ->
{
/* only a network or server failure falls back to the legacy bucket path; a bad
* signature or a rate limit must not be bypassed by asking the bucket */
if (r.kind != UpdateError.NETWORK || !legacyConfigured())
{
DiagLog.i(TAG, "update: API check failed: ${r.kind} ${r.detail}")
mState.value = UpdateState.Failed(r.kind, r.detail)
return null
}
DiagLog.i(TAG, "update: API unreachable (${r.detail}), using the legacy manifest")
}
}
}
try try
{ {
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE) val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
@@ -592,6 +717,58 @@ class UpdateManager private constructor(private val context: Context)
} }
} }
/** also signs report uploads (ReportUploader) */
internal fun apiHmacKey(): String = decodeUpdateSecret(BuildConfig.UPDATE_API_HMAC_KEY)
private fun apiConfigured(): Boolean =
UpdateApiClient.isConfigured(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY)
/* the legacy path needs the bucket URL; without it there is nothing to fall back to */
private fun legacyConfigured(): Boolean = BuildConfig.UPDATE_BASE_URL.isNotBlank()
/** Stable random installation id (no personal data); the server stores only its hash */
internal fun installId(): String
{
mPrefs.getString(PREF_INSTALL_ID, null)?.let { return it }
val bytes = ByteArray(18).also { java.security.SecureRandom().nextBytes(it) }
val id = android.util.Base64.encodeToString(bytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING)
mPrefs.edit().putString(PREF_INSTALL_ID, id).apply()
return id
}
private fun callApi(): UpdateApiResult
{
val client = UpdateApiClient(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY, installId())
val abi = Build.SUPPORTED_ABIS.firstOrNull()
val result = client.check(context.packageName, currentVersionCode(), mPrefs.getString(PREF_UPDATE_CHANNEL, "release") ?: "release",
abi, Build.VERSION.SDK_INT, mPrefs.getString(PREF_BETA_TOKEN, null))
val policy = when (result)
{
is UpdateApiResult.Update -> result.policy
is UpdateApiResult.NoUpdate -> result.policy
else -> null
}
/* server hints are kept for the scheduler; the user's own settings stay in charge */
policy?.let { mPrefs.edit().putInt(PREF_API_CHECK_HOURS, it.checkHours).putBoolean(PREF_API_WIFI_ONLY, it.wifiOnly).apply() }
return result
}
/** An UpdateInfo that sends check() into the "up to date" branch */
private fun upToDateMarker() = UpdateInfo(currentVersionCode(), currentVersionName(), "", "", -1L, emptyMap(), fromApi = true)
/**
* The presigned file URL lives ~10 minutes while a download may wait for Wi-Fi or charging:
* ask the API again when the manifest is old
*/
private fun freshen(info: UpdateInfo, force: Boolean = false): UpdateInfo
{
if (!info.fromApi || info.apkUrl.isBlank()) return info
val age = System.currentTimeMillis() / 1000 - info.issuedAt
if (!force && age in 0..URL_MAX_AGE_SECONDS) return info
val r = callApi()
return if (r is UpdateApiResult.Update && r.info.versionCode == info.versionCode && r.info.sha256 == info.sha256) r.info.copy(mandatory = r.mandatory) else info
}
private fun parseNotes(notes: JSONObject?): Map<String, String> private fun parseNotes(notes: JSONObject?): Map<String, String>
{ {
if (notes == null) if (notes == null)
@@ -606,8 +783,9 @@ class UpdateManager private constructor(private val context: Context)
return result return result
} }
private fun downloadApk(info: UpdateInfo): File private fun downloadApk(original: UpdateInfo, retried: Boolean = false): File
{ {
val info = freshen(original, force = retried)
val dir = File(context.filesDir, UPDATE_DIR) val dir = File(context.filesDir, UPDATE_DIR)
if (!dir.exists() && !dir.mkdirs()) if (!dir.exists() && !dir.mkdirs())
{ {
@@ -620,36 +798,32 @@ class UpdateManager private constructor(private val context: Context)
} }
apk.delete() apk.delete()
val conn = openConnection(info.apkUrl) /* a presigned URL already carries its own signature: adding S3 auth headers makes S3 reject it */
val conn = openConnection(info.apkUrl, signS3 = !info.fromApi)
conn.readTimeout = 30_000 conn.readTimeout = 30_000
try try
{ {
val code = conn.responseCode val code = conn.responseCode
if (code == HttpURLConnection.HTTP_FORBIDDEN && info.fromApi && !retried)
{ /* the link expired while waiting: fetch a fresh one once */
conn.disconnect()
return downloadApk(original, retried = true)
}
if (code != HttpURLConnection.HTTP_OK) if (code != HttpURLConnection.HTTP_OK)
{ {
throw UpdateException(UpdateError.NETWORK, "HTTP $code") throw UpdateException(UpdateError.NETWORK, "HTTP $code")
} }
val expected = if (info.size > 0) info.size else -1L val expected = if (info.size > 0) info.size else -1L
val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
val buffer = ByteArray(64 * 1024)
var downloaded = 0L
var lastProgress = -1 var lastProgress = -1
conn.inputStream.use { input -> mDownloadConn = conn
val downloaded = conn.inputStream.use { input ->
FileOutputStream(apk).use { output -> FileOutputStream(apk).use { output ->
while (true) copyDownload(input, output, digest, { mCancelRequested }) { done ->
{
val len = input.read(buffer)
if (len < 0)
{
break
}
output.write(buffer, 0, len)
digest.update(buffer, 0, len)
downloaded += len
if (expected > 0) if (expected > 0)
{ {
val progress = (downloaded * 100 / expected).toInt().coerceIn(0, 100) val progress = (done * 100 / expected).toInt().coerceIn(0, 100)
if (progress != lastProgress) if (progress != lastProgress)
{ {
lastProgress = progress lastProgress = progress
@@ -674,6 +848,7 @@ class UpdateManager private constructor(private val context: Context)
} }
finally finally
{ {
mDownloadConn = null
conn.disconnect() conn.disconnect()
cancelDownloadProgress() cancelDownloadProgress()
} }
@@ -771,14 +946,17 @@ class UpdateManager private constructor(private val context: Context)
} }
} }
private fun openConnection(url: String): HttpURLConnection private fun openConnection(url: String, signS3: Boolean = true): HttpURLConnection
{ {
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
conn.connectTimeout = CONNECT_TIMEOUT_MS conn.connectTimeout = CONNECT_TIMEOUT_MS
conn.readTimeout = READ_TIMEOUT_MS conn.readTimeout = READ_TIMEOUT_MS
conn.setRequestProperty("Connection", "close") conn.setRequestProperty("Connection", "close")
conn.setRequestProperty("Cache-Control", "no-cache") conn.setRequestProperty("Cache-Control", "no-cache")
if (signS3)
{
applyS3Auth(conn, url) applyS3Auth(conn, url)
}
return conn return conn
} }
@@ -0,0 +1,71 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
/**
* Release notes of the app language: the full tag ("zh-tw"), then the language ("zh"),
* then Russian, then English. Keys of [notes] are lowercase (see UpdateManager.parseNotes).
*/
fun pickNotes(notes: Map<String, String>, languageTag: String, language: String): String?
{
return listOf(languageTag.lowercase(), language.lowercase(), "ru", "en")
.firstNotNullOfOrNull { key -> notes[key]?.takeIf { it.isNotBlank() } }
}
/** Every non-empty line of the notes is one list item; a leading "-", "*" or "•" is dropped. */
fun noteLines(text: String?): List<String>
{
return text.orEmpty().lines().map { it.trim().trimStart('-', '*', '•').trim() }.filter { it.isNotEmpty() }
}
/**
* Copies the download into [output] and the digest; [isCancelled] is checked between reads and
* when a read fails (cancelling disconnects the connection, which breaks a blocked read).
*/
internal fun copyDownload(input: InputStream, output: OutputStream, digest: MessageDigest,
isCancelled: () -> Boolean, onProgress: (Long) -> Unit): Long
{
val buffer = ByteArray(64 * 1024)
var total = 0L
while (true)
{
if (isCancelled())
{
throw CancellationException("update download cancelled")
}
val len = try
{
input.read(buffer)
}
catch (e: IOException)
{
if (isCancelled()) throw CancellationException("update download cancelled") else throw e
}
if (len < 0)
{
return total
}
output.write(buffer, 0, len)
digest.update(buffer, 0, len)
total += len
onProgress(total)
}
}
@@ -70,6 +70,14 @@ class InboxActivity : AppCompatActivity()
EventAction.PROFILE_UPDATE -> event.target?.let { startActivity(ServerActivity.intent(this, it, checkUpdate = true)) } EventAction.PROFILE_UPDATE -> event.target?.let { startActivity(ServerActivity.intent(this, it, checkUpdate = true)) }
EventAction.OPEN_SERVER -> event.target?.let { startActivity(ServerActivity.intent(this, it)) } EventAction.OPEN_SERVER -> event.target?.let { startActivity(ServerActivity.intent(this, it)) }
EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java)) EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java))
EventAction.SEND_REPORT -> startActivity(Intent(this, LogActivity::class.java).putExtra(LogActivity.EXTRA_SEND, true))
EventAction.OPEN_VPN_SETTINGS -> try
{
startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
}
catch (_: android.content.ActivityNotFoundException)
{
}
} }
if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE) if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE)
{ {
@@ -9,7 +9,11 @@
package org.strongswan.android.ui package org.strongswan.android.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
@@ -77,6 +81,7 @@ import org.strongswan.android.R
import org.strongswan.android.data.LogContentProvider import org.strongswan.android.data.LogContentProvider
import org.strongswan.android.logic.CharonVpnService import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.DiagLog import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.ui.compose.SecondaryScaffold import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxTheme import org.strongswan.android.ui.compose.theme.ShxTheme
import java.io.BufferedReader import java.io.BufferedReader
@@ -87,18 +92,61 @@ class LogActivity : AppCompatActivity()
{ {
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1) private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
/* the active profile goes into the report meta */
private var vpn: VpnStateService? = null
private val connection = object : ServiceConnection
{
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
{
vpn = (binder as VpnStateService.LocalBinder).service
}
override fun onServiceDisconnected(name: ComponentName?)
{
vpn = null
}
}
override fun onCreate(savedInstanceState: Bundle?) override fun onCreate(savedInstanceState: Bundle?)
{ {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
bindService(Intent(this, VpnStateService::class.java), connection, Context.BIND_AUTO_CREATE)
/* «Send a report» from the crash event opens the report right away */
val autoSend = savedInstanceState == null && intent.getBooleanExtra(EXTRA_SEND, false)
setContent { setContent {
ShxTheme { ShxTheme {
LogScreen(onBack = { finish() }, onSend = { sendLog() }, messages = messages) val report: ReportViewModel = viewModel()
/* without the panel API in this build the report goes by email, as before */
val send = { if (ReportViewModel.apiReady(this)) report.open() else sendLog("") }
LogScreen(onBack = { finish() }, onSend = send, messages = messages)
ReportSheet(report, profile = ::activeProfile, onMail = { comment -> report.close(); sendLog(comment) })
if (autoSend)
{
androidx.compose.runtime.LaunchedEffect(Unit) { send() }
}
} }
} }
} }
private fun sendLog() companion object
{
const val EXTRA_SEND = "org.strongswan.android.extra.SEND_LOG"
}
override fun onDestroy()
{
unbindService(connection)
super.onDestroy()
}
private fun activeProfile(): String
{
val profile = vpn?.profile ?: return ""
return listOfNotNull(profile.name, profile.gateway).filter { it.isNotBlank() }.distinct().joinToString(" · ")
}
private fun sendLog(comment: String)
{ {
/* the daemon log plus the lifecycle log; the latter survives process /* the daemon log plus the lifecycle log; the latter survives process
* deaths and documents watchdog/reconnect decisions */ * deaths and documents watchdog/reconnect decisions */
@@ -124,6 +172,10 @@ class LogActivity : AppCompatActivity()
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL)) intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version)) intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
intent.type = "text/plain" intent.type = "text/plain"
if (comment.isNotBlank())
{
intent.putExtra(Intent.EXTRA_TEXT, comment)
}
if (uris.size > 1) if (uris.size > 1)
{ {
intent.putExtra(Intent.EXTRA_STREAM, uris) intent.putExtra(Intent.EXTRA_STREAM, uris)
@@ -0,0 +1,380 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui
import android.app.Application
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.strongswan.android.ui.compose.runDiagnostics
import org.strongswan.android.BuildConfig
import org.strongswan.android.R
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
import org.strongswan.android.logic.events.Events
import org.strongswan.android.logic.updates.ReportContent
import org.strongswan.android.logic.updates.ReportUploader
import org.strongswan.android.logic.updates.UpdateManager
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxFormField
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxIntro
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.design.ShxSheetTitle
import org.strongswan.android.ui.compose.design.ShxToggleRow
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import java.io.File
import java.util.Calendar
/** State of the "Send report" sheet (mockups App8-App10); the upload survives a rotation */
class ReportViewModel(app: Application) : AndroidViewModel(app)
{
sealed class Ui
{
data object Hidden : Ui()
data class Form(val sending: Boolean = false) : Ui()
data class Sent(val code: String) : Ui()
data class Failed(val failure: ReportContent.Failure) : Ui()
}
var ui by mutableStateOf<Ui>(Ui.Hidden)
private set
var comment by mutableStateOf("")
var attachDiag by mutableStateOf(true)
/* a swipe may hide the sheet mid-upload: the result then opens it again */
private var job: Job? = null
fun open()
{
ui = Ui.Form(sending = job?.isActive == true)
}
fun close()
{
ui = Ui.Hidden
}
fun send(profile: String)
{
if (job?.isActive == true)
{
return
}
ui = Ui.Form(sending = true)
val app = getApplication<Application>()
val note = comment
val diag = attachDiag
job = viewModelScope.launch {
/* the same checks as the Diagnostics screen (~3 s); a hang must not block the report */
val checks = if (!diag) null else withTimeoutOrNull(10_000) {
runCatching { runDiagnostics(app) }.getOrNull()?.steps?.map { ReportContent.Check(it.title, it.value, it.ok) }
}
val result = withContext(Dispatchers.IO) {
val now = Calendar.getInstance()
val charon = ReportContent.todayOnly(
ReportContent.readTail(File(app.filesDir, CharonVpnService.LOG_FILE), ReportContent.CHARON_MAX_BYTES * 2),
now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH),
)
val text = ReportContent.build(charon, if (diag) ReportContent.readTail(DiagLog.file(app), ReportContent.DIAG_MAX_BYTES) else null, checks)
val meta = ReportContent.meta(
BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toLong(), Build.MANUFACTURER, Build.MODEL,
Build.VERSION.RELEASE, Build.VERSION.SDK_INT, networkLabel(app), note, profile,
)
val updates = UpdateManager.getInstance(app)
ReportUploader(BuildConfig.UPDATE_API_URL, updates.apiHmacKey(), updates.installId()).send(text, meta)
}
when (result)
{
is ReportUploader.Result.Sent ->
{
Events.reportSent(app, result.code)
comment = ""
ui = Ui.Sent(result.code)
}
is ReportUploader.Result.Failed ->
{
DiagLog.i("Report", "upload failed: HTTP ${result.httpCode} ${result.detail}")
ui = Ui.Failed(ReportContent.failure(result.httpCode))
}
}
}
}
companion object
{
/** Only the address and the HMAC key are needed (the manifest key is for updates) */
fun apiReady(context: Context): Boolean =
BuildConfig.UPDATE_API_URL.isNotBlank() && UpdateManager.getInstance(context).apiHmacKey().isNotBlank()
/** The network under the tunnel: with the VPN up the active network is the VPN itself */
private fun networkLabel(context: Context): String
{
val network = UnderlyingNetwork.resolve(context) ?: return "none"
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "none"
val caps = try { cm.getNetworkCapabilities(network) } catch (_: Exception) { null } ?: return "none"
return when
{
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "mobile"
else -> "other"
}
}
}
}
@Composable
fun ReportSheet(model: ReportViewModel, profile: () -> String, onMail: (String) -> Unit)
{
val ui = model.ui
if (ui == ReportViewModel.Ui.Hidden)
{
return
}
ShxSheet(onDismiss = model::close)
{
when (ui)
{
is ReportViewModel.Ui.Form -> ReportForm(model, ui.sending, onSend = { model.send(profile()) }, onMail = { onMail(model.comment) })
is ReportViewModel.Ui.Sent -> ReportSent(ui.code, onDone = model::close)
is ReportViewModel.Ui.Failed -> ReportFailed(ui.failure, onRetry = model::open, onMail = { onMail(model.comment) })
ReportViewModel.Ui.Hidden -> Unit
}
}
}
/* App8 */
@Composable
private fun ReportForm(model: ReportViewModel, sending: Boolean, onSend: () -> Unit, onMail: () -> Unit)
{
val c = LocalShxColors.current
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
ShxSheetTitle(stringResource(R.string.log_send_report))
ShxIntro(stringResource(R.string.report_intro))
}
ReasonCard(
stringResource(R.string.report_contents), c.accent,
listOf(R.string.report_item_log, R.string.report_item_device, R.string.report_item_network, R.string.report_item_ips).map { stringResource(it) to null },
)
ShxPanel()
{
ShxToggleRow(
stringResource(R.string.report_diag_title), model.attachDiag, { model.attachDiag = it },
subtitle = stringResource(R.string.report_diag_sub), enabled = !sending,
)
}
ShxFormField(
label = stringResource(R.string.report_comment_label),
value = model.comment,
onValueChange = { model.comment = it.take(1000) },
singleLine = false,
enabled = !sending,
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.report_cancel), model::close, style = ShxButtonStyle.OUTLINE, enabled = !sending, modifier = Modifier.weight(1f))
Box(contentAlignment = Alignment.Center, modifier = Modifier.weight(2f))
{
ShxButton(if (sending) "" else stringResource(R.string.report_send), { if (!sending) onSend() }, modifier = Modifier.fillMaxWidth())
if (sending)
{
CircularProgressIndicator(color = c.onAccent, strokeWidth = 2.5.dp, modifier = Modifier.size(22.dp))
}
}
}
if (!sending)
{
TextLink(stringResource(R.string.report_by_mail), onMail)
}
}
/* App9 */
@Composable
private fun ReportSent(code: String, onDone: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
val copied = stringResource(R.string.report_code_copied)
ResultHeader(ShxIcons.Check, c.successContainer, c.success, stringResource(R.string.report_sent_title), stringResource(R.string.report_sent_sub))
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(16.dp))
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.report_code_label).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
Text(code, style = ShxType.mono.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, letterSpacing = 0.9.sp), color = c.text)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile).clickable(role = Role.Button) {
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(ClipData.newPlainText("report", code))
Toast.makeText(context, copied, Toast.LENGTH_SHORT).show()
},
)
{
Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.report_copy_code), tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
}
}
}
ShxNote(stringResource(R.string.report_saved_note))
ShxButton(stringResource(R.string.report_done), onDone, modifier = Modifier.fillMaxWidth())
}
/* App10 */
@Composable
private fun ReportFailed(failure: ReportContent.Failure, onRetry: () -> Unit, onMail: () -> Unit)
{
val c = LocalShxColors.current
val noAnswer = failure == ReportContent.Failure.NO_NETWORK || failure == ReportContent.Failure.SERVER_DOWN
ResultHeader(
ShxIcons.Alert, c.errorContainer, c.error, stringResource(R.string.report_failed_title),
stringResource(if (noAnswer) R.string.report_failed_no_answer else R.string.report_failed_refused),
)
val reasons = when (failure)
{
ReportContent.Failure.NO_NETWORK -> listOf(R.string.report_reason_offline to R.string.report_reason_offline_d, R.string.report_reason_server to R.string.report_reason_server_d)
ReportContent.Failure.SERVER_DOWN -> listOf(R.string.report_reason_server to R.string.report_reason_server_d)
ReportContent.Failure.SIGNATURE -> listOf(R.string.report_reason_clock to R.string.report_reason_clock_d)
ReportContent.Failure.TOO_OFTEN -> listOf(R.string.report_reason_often to R.string.report_reason_often_d)
ReportContent.Failure.TOO_LARGE -> listOf(R.string.report_reason_large to R.string.report_reason_large_d)
ReportContent.Failure.REJECTED -> listOf(R.string.report_reason_rejected to R.string.report_reason_rejected_d)
}
ReasonCard(stringResource(R.string.report_reasons), c.error, reasons.map { (t, d) -> stringResource(t) to stringResource(d) })
ShxNote(stringResource(R.string.report_mail_note))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.report_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.report_by_letter), onMail, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
}
}
/** 52dp round icon, 22/800 title, 13 sub (as F_ErrImport) */
@Composable
private fun ResultHeader(icon: ImageVector, background: Color, tint: Color, title: String, sub: String)
{
val c = LocalShxColors.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(CircleShape).background(background))
{
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text)
Text(sub, style = ShxType.sub, color = c.textSub)
}
}
}
/** White card with a section label and dotted lines: «bold — muted» when [items] carry a second part */
@Composable
private fun ReasonCard(title: String, dot: Color, items: List<Pair<String, String?>>)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(22.dp)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp),
)
{
Text(title.uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
items.forEach { (head, tail) ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
Text(
buildAnnotatedString {
if (tail == null)
{
append(head)
}
else
{
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(head) }
append(" — ")
withStyle(SpanStyle(color = c.textSub)) { append(tail) }
}
},
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
)
}
}
}
}
@Composable
private fun TextLink(text: String, onClick: () -> Unit)
{
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
{
Text(
text, style = ShxType.chip, color = LocalShxColors.current.accentText,
modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onClick).padding(8.dp),
)
}
}
@@ -55,6 +55,7 @@ class SettingsActivity : AppCompatActivity()
Box(Modifier.padding(padding)) { Box(Modifier.padding(padding)) {
SettingsScreen(profiles = profiles, showTitle = false) SettingsScreen(profiles = profiles, showTitle = false)
} }
org.strongswan.android.ui.compose.UpdateSheetHost()
} }
} }
} }
@@ -92,7 +92,7 @@ data class DiagReport(val steps: List<DiagStep>, val at: Long, val vpnUp: Boolea
val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true } val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true }
} }
private suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope { internal suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
val vpn = VpnStateService.getLiveInstance() val vpn = VpnStateService.getLiveInstance()
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
val gateway = (vpn?.profile)?.gateway val gateway = (vpn?.profile)?.gateway
@@ -857,7 +857,8 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
companion object companion object
{ {
private const val TRAFFIC_POLL_MS = 1000L private const val TRAFFIC_POLL_MS = 1000L
private const val PROBE_INTERVAL_MS = 10_000L /* 10 s kept the cellular radio out of its idle state for as long as Home was open */
private const val PROBE_INTERVAL_MS = 30_000L
private const val EXIT_REFRESH_MS = 60_000L private const val EXIT_REFRESH_MS = 60_000L
private const val EXIT_TIMEOUT_MS = 5000 private const val EXIT_TIMEOUT_MS = 5000
private const val FASTEST_TIMEOUT_MS = 1500 private const val FASTEST_TIMEOUT_MS = 1500
@@ -309,5 +309,7 @@ private fun EventAction.labelRes(): Int = when (this)
EventAction.SET_APP_DIRECT -> R.string.events_action_direct EventAction.SET_APP_DIRECT -> R.string.events_action_direct
EventAction.PROFILE_UPDATE -> R.string.events_action_profile_update EventAction.PROFILE_UPDATE -> R.string.events_action_profile_update
EventAction.OPEN_SERVER -> R.string.events_action_open_server EventAction.OPEN_SERVER -> R.string.events_action_open_server
EventAction.SEND_REPORT -> R.string.diag_send_report
EventAction.OPEN_VPN_SETTINGS -> R.string.home_lockdown_enable
EventAction.NONE -> R.string.events_action_open EventAction.NONE -> R.string.events_action_open
} }
@@ -124,10 +124,23 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
var result by remember { mutableStateOf<CheckResult?>(null) } var result by remember { mutableStateOf<CheckResult?>(null) }
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) } var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) } val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
val apps by produceState<List<InstalledApp>>(emptyList(), mode) { var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
if (mode == RouteCheckMode.APP && value.isEmpty())
/** The installed apps; loads them on first use, so a check never runs against a list that is still empty. */
suspend fun loadApps(): List<InstalledApp>
{ {
value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) } if (apps.isEmpty())
{
apps = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
}
return apps
}
LaunchedEffect(mode)
{
if (mode == RouteCheckMode.APP)
{
loadApps()
} }
} }
@@ -150,7 +163,7 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
{ {
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive) RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive) RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
RouteCheckMode.APP -> apps.firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) } RouteCheckMode.APP -> loadApps().firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
?.let { CheckResult.App(it) } ?: CheckResult.BadInput ?.let { CheckResult.App(it) } ?: CheckResult.BadInput
} }
if (result !is CheckResult.BadInput) if (result !is CheckResult.BadInput)
@@ -173,10 +186,6 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
{ {
if (initialQuery.isNotBlank()) if (initialQuery.isNotBlank())
{ {
if (mode == RouteCheckMode.APP)
{
delay(300)
}
run() run()
} }
} }
@@ -52,11 +52,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.strongswan.android.R import org.strongswan.android.R
import org.strongswan.android.logic.updates.UpdateState
import org.strongswan.android.ui.compose.design.ShxButton import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxTone
import kotlinx.coroutines.launch
import org.strongswan.android.ui.compose.design.ShxPanel import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSectionHeader import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxSwitch import org.strongswan.android.ui.compose.design.ShxSwitch
@@ -221,91 +222,6 @@ fun KillSwitchPage(lockdown: Boolean)
} }
} }
/** Update card (F_Update): app icon, version, what is new, progress, Later / Install. */
@Composable
fun UpdateCard(state: UpdateState, installed: String, onCheck: () -> Unit, onInstall: () -> Unit)
{
val c = LocalShxColors.current
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
val shape = RoundedCornerShape(24.dp)
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(16.dp),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
{
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(
if (info != null) "SHX VPN ${info.versionName}" else stringResource(R.string.update_card_uptodate),
style = ShxType.cardHeading.copy(fontSize = 20.sp), color = c.text,
)
Text(
when
{
info != null -> stringResource(R.string.update_card_have, installed, formatBytes(info.size))
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
else -> stringResource(R.string.update_card_version, installed)
},
style = ShxType.sub, color = c.textSub,
)
}
}
if (info != null)
{
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull()
if (!notes.isNullOrBlank())
{
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
notes.split(Regex("(?<=[.;])\\s+")).map { it.trim().trimEnd('.', ';') }.filter { it.isNotEmpty() }.forEach { line ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.accent))
Text(line, style = ShxType.note.copy(fontSize = 14.sp), color = c.text)
}
}
}
}
if (state is UpdateState.Downloading)
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
Row(Modifier.fillMaxWidth())
{
Text(stringResource(R.string.update_card_downloading), style = ShxType.meta, color = c.textSub, modifier = Modifier.weight(1f))
Text("${state.progress}%", style = ShxType.meta, color = c.textSub)
}
Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.tile))
{
Box(Modifier.fillMaxWidth(state.progress.coerceIn(0, 100) / 100f).height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.accent))
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
if (info != null)
{
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.update_card_install), onInstall, modifier = Modifier.weight(2f), enabled = state !is UpdateState.Downloading)
}
else
{
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth(), enabled = state !is UpdateState.Checking)
}
}
Text(stringResource(R.string.update_card_footer), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
}
}
/** /**
* «Help» (F_Help): two big buttons (check the connection, contact support), * «Help» (F_Help): two big buttons (check the connection, contact support),
* the frequent questions as an accordion, and «About the app». * the frequent questions as an accordion, and «About the app».
@@ -566,14 +482,7 @@ internal fun InstallPermissionSheet(onOpen: () -> Unit, onDismiss: () -> Unit)
Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft) Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
} }
} }
Row( UpdateSafeNote(stringResource(R.string.install_perm_safe))
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = 12.dp),
)
{
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
Text(stringResource(R.string.install_perm_safe), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onSuccess)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{ {
ShxButton(stringResource(R.string.install_perm_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f)) ShxButton(stringResource(R.string.install_perm_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
@@ -613,6 +522,15 @@ internal fun ProtectionPage()
val stateNullable by remember { org.strongswan.android.logic.protection.Protection.flow(context) }.collectAsStateWithLifecycle() val stateNullable by remember { org.strongswan.android.logic.protection.Protection.flow(context) }.collectAsStateWithLifecycle()
val state = stateNullable ?: return val state = stateNullable ?: return
var custom by remember { mutableStateOf(state.custom) } var custom by remember { mutableStateOf(state.custom) }
/* IPs, or an own DoH/DoT server */
fun customValid(text: String) = org.strongswan.android.logic.protection.ProtectionState.parseCustom(text).isNotEmpty() ||
org.strongswan.android.logic.protection.SecureDnsConfig.parse(text) != null
/* result of the check on «Save» for DoH/DoT: (ok, text) */
var probe by remember { mutableStateOf<Pair<Boolean, String>?>(null) }
var probing by remember { mutableStateOf(false) }
val scope = androidx.compose.runtime.rememberCoroutineScope()
val probeOk = stringResource(R.string.prot_secure_ok)
val probeFail = stringResource(R.string.prot_secure_fail)
val applies = stringResource(R.string.prot_applies) val applies = stringResource(R.string.prot_applies)
fun update(next: org.strongswan.android.logic.protection.ProtectionState) fun update(next: org.strongswan.android.logic.protection.ProtectionState)
{ {
@@ -666,7 +584,7 @@ internal fun ProtectionPage()
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(62.dp).selectable(selected = state.choice == choice, role = Role.RadioButton) { modifier = Modifier.fillMaxWidth().height(62.dp).selectable(selected = state.choice == choice, role = Role.RadioButton) {
if (choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM && org.strongswan.android.logic.protection.ProtectionState.parseCustom(custom).isEmpty()) if (choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM && !customValid(custom))
{ /* the field below takes the address first */ { /* the field below takes the address first */
org.strongswan.android.logic.protection.Protection.set(context, state.copy(choice = choice)) org.strongswan.android.logic.protection.Protection.set(context, state.copy(choice = choice))
} }
@@ -690,12 +608,33 @@ internal fun ProtectionPage()
{ {
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp))
{ {
org.strongswan.android.ui.compose.design.ShxFormField(label = stringResource(R.string.prot_custom), value = custom, onValueChange = { custom = it }, placeholder = "9.9.9.9") org.strongswan.android.ui.compose.design.ShxFormField(label = stringResource(R.string.prot_custom), value = custom, onValueChange = { custom = it; probe = null }, placeholder = "9.9.9.9")
val secure = org.strongswan.android.logic.protection.SecureDnsConfig.parse(custom)
ShxButton( ShxButton(
stringResource(R.string.server_save), { update(state.copy(custom = custom)) }, stringResource(R.string.server_save),
{
if (secure == null) update(state.copy(custom = custom))
else
{ /* check the server first, so a typo shows here and not as a dead tunnel */
probing = true
scope.launch {
val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { org.strongswan.android.logic.protection.SecureDns.probe(secure) }
org.strongswan.android.logic.DiagLog.i("SecureDns", "check on save: ${secure.label} " +
if (result.ok) "ok in ${result.ms} ms" else "failed: ${result.error}")
probing = false
probe = if (result.ok) true to probeOk.format(result.ms) else false to probeFail.format(result.error ?: "")
update(state.copy(custom = custom))
}
}
},
style = ShxButtonStyle.TONAL, height = 44.dp, modifier = Modifier.fillMaxWidth(), style = ShxButtonStyle.TONAL, height = 44.dp, modifier = Modifier.fillMaxWidth(),
enabled = org.strongswan.android.logic.protection.ProtectionState.parseCustom(custom).isNotEmpty(), enabled = customValid(custom) && !probing,
) )
probe?.let { (ok, text) -> ShxNote(text, tone = if (ok) ShxTone.SUCCESS else ShxTone.WARNING) }
if (secure != null)
{
Text(stringResource(R.string.prot_secure_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub)
}
} }
} }
} }
@@ -105,8 +105,6 @@ import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.WatchdogReceiver import org.strongswan.android.logic.WatchdogReceiver
import org.strongswan.android.logic.connectivity.WhiteListManager import org.strongswan.android.logic.connectivity.WhiteListManager
import org.strongswan.android.logic.shortcuts.VpnShortcutManager import org.strongswan.android.logic.shortcuts.VpnShortcutManager
import org.strongswan.android.logic.updates.UpdateError
import org.strongswan.android.logic.updates.UpdateInstaller
import org.strongswan.android.logic.updates.UpdateManager import org.strongswan.android.logic.updates.UpdateManager
import org.strongswan.android.logic.updates.UpdateState import org.strongswan.android.logic.updates.UpdateState
import org.strongswan.android.ui.LogActivity import org.strongswan.android.ui.LogActivity
@@ -151,43 +149,9 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
val updateManager = remember { UpdateManager.getInstance(context) } val updateManager = remember { UpdateManager.getInstance(context) }
val updateState by updateManager.state.collectAsStateWithLifecycle() val updateState by updateManager.state.collectAsStateWithLifecycle()
var showUpdateConfirm by remember { mutableStateOf(false) }
var showInstallPermission by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val unknownSourcesLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (UpdateInstaller.canInstall(context))
{
updateManager.downloadAndInstall()
}
else
{
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.update_permission_denied)) }
}
}
val startUpdate: () -> Unit = {
if (UpdateInstaller.canInstall(context))
{
updateManager.downloadAndInstall()
}
else
{ /* F_UpdateInstall explains the system page first */
showInstallPermission = true
}
}
if (showInstallPermission)
{
InstallPermissionSheet(
onOpen = {
showInstallPermission = false
unknownSourcesLauncher.launch(UpdateInstaller.createInstallPermissionIntent(context))
},
onDismiss = { showInstallPermission = false },
)
}
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) } var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle() val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle()
@@ -419,18 +383,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
if (page == SettingsPage.UPDATES) if (page == SettingsPage.UPDATES)
{ {
UpdateCard( UpdatesPage()
state = updateState,
installed = versionName,
onCheck = {
when (updateState)
{
is UpdateState.Checking, is UpdateState.Downloading -> Unit
else -> updateManager.checkNow()
}
},
onInstall = { showUpdateConfirm = true },
)
} }
if (page == SettingsPage.KILLSWITCH) if (page == SettingsPage.KILLSWITCH)
@@ -445,75 +398,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() }) KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() })
} }
if (page == SettingsPage.UPDATES) if (page == SettingsPage.ROOT) Text(
{
ShxSection(title = stringResource(R.string.update_settings))
{
var autoUpdate by remember {
mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true))
}
ShxSettingToggleRow(
icon = ShxIcons.Refresh,
title = stringResource(R.string.update_auto_check_title),
subtitle = stringResource(R.string.update_auto_check_summary),
checked = autoUpdate,
onCheckedChange = {
autoUpdate = it
prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply()
},
)
ShxSectionDivider()
var silentUpdates by remember {
mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false))
}
ShxSettingToggleRow(
icon = ShxIcons.Refresh,
title = stringResource(R.string.update_silent_title),
subtitle = stringResource(R.string.update_silent_summary),
checked = silentUpdates,
onCheckedChange = {
silentUpdates = it
prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply()
},
)
ShxSectionDivider()
ShxSettingRow(
icon = ShxIcons.Download,
title = stringResource(R.string.update_check_title),
subtitle = updateStatusText(updateState),
onClick = {
when (updateState)
{
is UpdateState.Checking, is UpdateState.Downloading -> Unit
else -> updateManager.checkNow()
}
},
)
val updateInfo = (updateState as? UpdateState.Available)?.info
?: (updateState as? UpdateState.Downloading)?.info
if (updateInfo != null)
{
ShxSectionDivider()
ShxSettingRow(
icon = ShxIcons.Download,
title = stringResource(R.string.update_download_install),
subtitle = when (val state = updateState)
{
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
else -> stringResource(R.string.update_available_fmt, updateInfo.versionName)
},
onClick = {
if (updateState !is UpdateState.Downloading)
{
showUpdateConfirm = true
}
},
)
}
}
}
if (page == SettingsPage.ROOT || page == SettingsPage.UPDATES) Text(
text = stringResource(R.string.app_version_fmt, versionName), text = stringResource(R.string.app_version_fmt, versionName),
style = ShxType.meta, style = ShxType.meta,
color = LocalShxColors.current.textSub, color = LocalShxColors.current.textSub,
@@ -600,82 +485,13 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
) )
} }
if (showUpdateConfirm && updateState is UpdateState.Available)
{
val info = (updateState as UpdateState.Available).info
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull() ?: ""
ShxAlertDialog(
onDismissRequest = { showUpdateConfirm = false },
title = { Text(stringResource(R.string.update_confirm_title, info.versionName)) },
text = { if (notes.isNotEmpty()) { Text(notes) } },
confirmButton = {
ShxDialogButton(primary = true, onClick = {
showUpdateConfirm = false
startUpdate()
}) { Text(stringResource(R.string.update_action)) }
},
dismissButton = {
ShxDialogButton(onClick = { showUpdateConfirm = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@Composable
private fun updateStatusText(state: UpdateState): String
{
return when (state)
{
is UpdateState.Checking -> stringResource(R.string.update_checking)
is UpdateState.Idle -> stringResource(R.string.update_status_idle)
is UpdateState.UpToDate -> stringResource(R.string.update_status_uptodate)
is UpdateState.Available ->
{
if (state.info.size > 0)
{
stringResource(R.string.update_available_fmt, state.info.versionName) + " • " +
stringResource(R.string.update_size_mb_fmt, (state.info.size / (1024 * 1024)).toInt())
}
else
{
stringResource(R.string.update_available_fmt, state.info.versionName)
}
}
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
is UpdateState.Failed -> stringResource(
when (state.error)
{
UpdateError.NETWORK -> R.string.update_error_network
UpdateError.HASH -> R.string.update_error_hash
UpdateError.SIGNATURE -> R.string.update_error_signature
UpdateError.INSTALL -> R.string.update_error_install
UpdateError.OTHER -> R.string.update_error_generic
}
)
}
} }
/** /**
* Checks whether the system-level lockdown ("Block connections without VPN") is * Whether the system-level lockdown ("Block connections without VPN") is on for
* active for our package. Returns false if not determinable. * us, as the VPN service last saw it (false before the first connection).
*/ */
internal fun Context.isVpnLockdownEnabled(): Boolean internal fun Context.isVpnLockdownEnabled(): Boolean = org.strongswan.android.logic.AlwaysOnVpn.isLockdown(this)
{
return try
{
Settings.Global.getInt(
contentResolver,
"vpn_lockdown_$packageName",
0
) != 0
}
catch (_: Exception)
{
false
}
}
/** /**
* Opens the Samsung battery settings where the user can exclude the app from * Opens the Samsung battery settings where the user can exclude the app from
@@ -260,6 +260,8 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
{ {
AddServerSheet(onDismiss = { addSheetOpen = false }) AddServerSheet(onDismiss = { addSheetOpen = false })
} }
/* update sheets, the mandatory update included, over every tab */
UpdateSheetHost()
} }
} }
} }
@@ -0,0 +1,658 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.text.format.DateUtils
import android.widget.Toast
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import org.strongswan.android.BuildConfig
import org.strongswan.android.R
import org.strongswan.android.logic.updates.UpdateError
import org.strongswan.android.logic.updates.UpdateInfo
import org.strongswan.android.logic.updates.UpdateInstaller
import org.strongswan.android.logic.updates.UpdateManager
import org.strongswan.android.logic.updates.UpdateState
import org.strongswan.android.logic.updates.noteLines
import org.strongswan.android.logic.updates.pickNotes
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxChoiceCard
import org.strongswan.android.ui.compose.design.ShxFormField
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxIntro
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.design.ShxSheetTitle
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.design.ShxToggleRow
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.utils.Constants
/** Which update sheet the user opened: «What is new» (App2) or the install attempt (App3 / App5). */
enum class UpdateSheet { REVIEW, ATTEMPT }
/** Shared between the Updates page and the one sheet host of the activity. */
object UpdateSheets
{
val shown = MutableStateFlow<UpdateSheet?>(null)
fun show(sheet: UpdateSheet?)
{
shown.value = sheet
}
}
/**
* All update sheets of an activity (ShxApp and SettingsActivity call it once):
* «What is new» (App2), the download (App3), the mandatory update (App4),
* the failure (App5) and the install permission (App6).
*/
@Composable
fun UpdateSheetHost()
{
val context = LocalContext.current
val manager = remember { UpdateManager.getInstance(context) }
val state by manager.state.collectAsStateWithLifecycle()
val sheet by UpdateSheets.shown.collectAsStateWithLifecycle()
val mandatory = remember(state) { manager.pendingMandatory() }
var askPermission by remember { mutableStateOf(false) }
val install = {
UpdateSheets.show(UpdateSheet.ATTEMPT)
manager.downloadAndInstall()
}
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult())
{
if (UpdateInstaller.canInstall(context)) install()
else Toast.makeText(context, R.string.update_permission_denied, Toast.LENGTH_LONG).show()
}
val start: () -> Unit = {
if (UpdateInstaller.canInstall(context)) install() else askPermission = true
}
LaunchedEffect(Unit)
{ /* a mandatory update found earlier (e.g. before a restart): make sure it is still current */
if (manager.pendingMandatory() != null && state !is UpdateState.Available && state !is UpdateState.Downloading)
{
manager.checkNow()
}
}
LaunchedEffect(state)
{ /* the installer took over, or the download was cancelled */
if (sheet == UpdateSheet.ATTEMPT && (state is UpdateState.Idle || state is UpdateState.UpToDate))
{
UpdateSheets.show(null)
}
}
when
{
askPermission -> InstallPermissionSheet(
onOpen = {
askPermission = false
launcher.launch(UpdateInstaller.createInstallPermissionIntent(context))
},
onDismiss = { askPermission = false },
)
sheet == UpdateSheet.ATTEMPT -> ShxSheet(onDismiss = { UpdateSheets.show(null) })
{
when (val s = state)
{
is UpdateState.Failed -> UpdateFailedContent(s, onRetry = start)
else -> UpdateProgressContent(s, onCancel = { manager.cancelDownload() }, onInstall = start)
}
}
mandatory != null -> MandatoryUpdateSheet(mandatory.first, mandatory.second, onUpdate = start)
sheet == UpdateSheet.REVIEW ->
{
val info = (state as? UpdateState.Available)?.info
if (info != null) UpdateReviewSheet(info, onInstall = start, onDismiss = { UpdateSheets.show(null) })
else androidx.compose.runtime.SideEffect { UpdateSheets.show(null) }
}
}
}
/** «Updates» page (App1): the available version, automatic mode, channel, safety note, manual check. */
@Composable
fun UpdatesPage()
{
val context = LocalContext.current
val c = LocalShxColors.current
val manager = remember { UpdateManager.getInstance(context) }
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
val state by manager.state.collectAsStateWithLifecycle()
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
val busy = state is UpdateState.Checking || state is UpdateState.Downloading
var beta by remember { mutableStateOf(manager.isBetaChannel()) }
var askCode by remember { mutableStateOf(false) }
Column(Modifier.padding(horizontal = ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(c.accentContainer).padding(ShxSpacing.lg),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
{
Icon(ShxIcons.Refresh, contentDescription = null, tint = c.onAccent, modifier = Modifier.size(26.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(
when
{
info != null -> stringResource(R.string.update_available_fmt, info.versionName)
state is UpdateState.UpToDate -> stringResource(R.string.update_card_uptodate)
else -> "SHX VPN ${BuildConfig.VERSION_NAME}"
},
style = ShxType.cardHeading, color = c.onAccentContainerStrong,
)
Text(
when
{
info != null -> stringResource(R.string.updates_have_size, BuildConfig.VERSION_NAME, sizeText(info.size))
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
state is UpdateState.UpToDate -> stringResource(R.string.update_card_version, BuildConfig.VERSION_NAME)
else -> stringResource(R.string.update_status_idle)
},
style = ShxType.sub, color = c.onAccentContainer,
)
}
}
if (info != null)
{
val downloading = state as? UpdateState.Downloading
ShxButton(
if (downloading != null) stringResource(R.string.update_downloading_fmt, downloading.progress) else stringResource(R.string.updates_review),
{ UpdateSheets.show(if (downloading != null) UpdateSheet.ATTEMPT else UpdateSheet.REVIEW) },
height = 52.dp, modifier = Modifier.fillMaxWidth(),
)
}
}
ShxSectionHeader(stringResource(R.string.updates_section_auto))
ShxPanel()
{
var autoCheck by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) }
var silent by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) }
val hours = remember { manager.checkIntervalHours().toInt() }
ShxToggleRow(
stringResource(R.string.updates_auto_check), autoCheck,
{ autoCheck = it; prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply() },
subtitle = pluralStringResource(R.plurals.updates_auto_check_sub, hours, hours), divider = true,
)
ShxToggleRow(
stringResource(R.string.updates_silent), silent,
{ silent = it; prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply() },
subtitle = stringResource(R.string.updates_silent_sub),
)
}
ShxSectionHeader(stringResource(R.string.updates_section_channel))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxChoiceCard(
stringResource(R.string.updates_channel_stable), stringResource(R.string.updates_channel_stable_sub), !beta,
{
if (beta)
{
beta = false
manager.setChannel(beta = false, token = null)
}
},
modifier = Modifier.weight(1f),
)
ShxChoiceCard(
stringResource(R.string.updates_channel_beta), stringResource(R.string.updates_channel_beta_sub), beta,
{ if (!beta) askCode = true },
modifier = Modifier.weight(1f),
)
}
Text(stringResource(R.string.updates_channel_hint), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
UpdateSafeNote(stringResource(R.string.install_perm_safe))
ShxButton(
stringResource(R.string.updates_check_now), { manager.checkNow() },
style = ShxButtonStyle.SOFT, height = 52.dp, enabled = !busy, modifier = Modifier.fillMaxWidth(),
)
val last = remember(state) { prefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L) }
Text(
when (val s = state)
{
is UpdateState.Checking -> stringResource(R.string.update_card_checking)
is UpdateState.Failed -> stringResource(updateErrorText(s.error))
else -> if (last > 0) stringResource(R.string.updates_last_check, lastCheckText(context, last))
else stringResource(R.string.updates_never_checked)
},
style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
)
}
if (askCode)
{
BetaCodeSheet(
onEnable = { code ->
askCode = false
beta = true
manager.setChannel(beta = true, token = code)
},
onDismiss = { askCode = false },
)
}
}
/** Invitation code of the beta channel. */
@Composable
private fun BetaCodeSheet(onEnable: (String) -> Unit, onDismiss: () -> Unit)
{
var code by remember { mutableStateOf("") }
ShxSheet(onDismiss = onDismiss)
{
ShxSheetTitle(stringResource(R.string.updates_beta_title))
ShxIntro(stringResource(R.string.updates_beta_text))
ShxFormField(label = stringResource(R.string.updates_beta_code), value = code, onValueChange = { code = it }, mono = true)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.updates_beta_enable), { onEnable(code.trim()) }, enabled = code.isNotBlank(), modifier = Modifier.weight(2f))
}
}
}
/** «A new version is available» (App2): version, what is new from the release notes, Later / Download and install. */
@Composable
private fun UpdateReviewSheet(info: UpdateInfo, onInstall: () -> Unit, onDismiss: () -> Unit)
{
val c = LocalShxColors.current
val locale = LocalConfiguration.current.locales[0]
val lines = noteLines(pickNotes(info.notes, "${locale.language}-${locale.country}", locale.language))
ShxSheet(onDismiss = onDismiss)
{
UpdateSheetHeader(
"SHX VPN ${info.versionName}",
stringResource(R.string.updates_review_sub, BuildConfig.VERSION_NAME, sizeText(info.size)),
)
if (lines.isNotEmpty())
{
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(ShxSpacing.lg))
{
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
{
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section, color = c.accentText)
lines.forEach { Bullet(it, c.accent) }
}
}
}
UpdateSafeNote(stringResource(R.string.updates_review_safe))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.cert_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.update_download_install), onInstall, modifier = Modifier.weight(2f))
}
}
}
/** Download (App3): progress and the four steps of the update. */
@Composable
private fun UpdateProgressContent(state: UpdateState, onCancel: () -> Unit, onInstall: () -> Unit)
{
val c = LocalShxColors.current
val info = (state as? UpdateState.Downloading)?.info ?: (state as? UpdateState.Available)?.info
val progress = (state as? UpdateState.Downloading)?.progress ?: 0
val downloading = state is UpdateState.Downloading
UpdateSheetHeader(
stringResource(R.string.updates_downloading_title, info?.versionName ?: ""),
stringResource(R.string.updates_downloading_sub),
)
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
{
Row(Modifier.fillMaxWidth())
{
Text(stringResource(R.string.update_card_downloading), style = ShxType.chip, color = c.textSoft, modifier = Modifier.weight(1f))
if (info != null && info.size > 0)
{
Text(
stringResource(R.string.updates_progress, progress, sizeText(info.size * progress / 100), sizeText(info.size)),
style = ShxType.chip, color = c.textSoft,
)
}
}
Box(Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.cardBorder))
{
Box(Modifier.fillMaxWidth(progress / 100f).height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.accent))
}
}
/* steps: the list is known once a version is shown; at 100% the file is being checked */
val verifying = downloading && progress >= 100
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 6.dp))
{
UpdateStep(StepState.DONE, stringResource(R.string.updates_step_list), stringResource(R.string.updates_step_list_sub), divider = true)
UpdateStep(
if (verifying) StepState.DONE else StepState.RUNNING, stringResource(R.string.updates_step_download),
if (info != null && info.size > 0) sizeText(info.size) else stringResource(R.string.update_card_downloading), divider = true,
)
UpdateStep(if (verifying) StepState.RUNNING else StepState.WAITING, stringResource(R.string.updates_step_verify), stringResource(R.string.updates_step_verify_sub), divider = true)
UpdateStep(StepState.WAITING, stringResource(R.string.updates_step_install), stringResource(R.string.updates_step_install_sub), divider = false)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.updates_cancel), onCancel, style = ShxButtonStyle.OUTLINE, enabled = downloading, modifier = Modifier.weight(1f))
/* while downloading the app installs on its own; Available here means a background download
* finished or failed without an install request, so the button starts it */
ShxButton(stringResource(R.string.update_card_install), onInstall, enabled = state is UpdateState.Available, modifier = Modifier.weight(2f))
}
}
private enum class StepState { DONE, RUNNING, WAITING }
@Composable
private fun UpdateStep(step: StepState, title: String, sub: String, divider: Boolean)
{
val c = LocalShxColors.current
Column()
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier.fillMaxWidth().padding(vertical = ShxSpacing.md),
)
{
val shape = CircleShape
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(24.dp).clip(shape).then(
when (step)
{
StepState.DONE -> Modifier.background(c.success)
StepState.RUNNING -> Modifier.background(c.accentContainer).border(2.dp, c.accent, shape)
StepState.WAITING -> Modifier.background(c.card).border(1.dp, c.outline, shape)
}
),
)
{
when (step)
{
StepState.DONE -> Icon(ShxIcons.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp))
StepState.RUNNING -> Text("…", style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
StepState.WAITING -> Unit
}
}
Column(verticalArrangement = Arrangement.spacedBy(1.dp))
{
Text(title, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (step == StepState.WAITING) c.iconMuted else c.text)
Text(sub, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
}
if (divider)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
/** Mandatory update (App4): cannot be closed; update now or leave the app. */
@Composable
private fun MandatoryUpdateSheet(versionName: String, size: Long, onUpdate: () -> Unit)
{
val c = LocalShxColors.current
val activity = LocalActivity.current
ShxSheet(onDismiss = {}, dismissible = false)
{
UpdateSheetHeader(
stringResource(R.string.updates_mandatory_title),
stringResource(R.string.updates_mandatory_sub, BuildConfig.VERSION_NAME),
icon = ShxIcons.Alert, iconBackground = c.warningContainer, iconTint = c.onWarningContainer, round = true,
)
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
{
Text(stringResource(R.string.updates_mandatory_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
Bullet(stringResource(R.string.updates_mandatory_reason), c.warning)
Bullet(stringResource(R.string.updates_mandatory_install, versionName, sizeText(size)), c.accent)
}
}
ShxNote(stringResource(R.string.updates_mandatory_locked), tone = ShxTone.WARNING)
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.cert_update_now), onUpdate, modifier = Modifier.fillMaxWidth())
ShxButton(stringResource(R.string.updates_exit), { activity?.finishAndRemoveTask() }, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.fillMaxWidth())
}
}
}
/** Update not installed (App5): reasons by error kind, retry, the download page, details for support. */
@Composable
private fun UpdateFailedContent(state: UpdateState.Failed, onRetry: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
val checkFailed = state.error == UpdateError.HASH || state.error == UpdateError.SIGNATURE
UpdateSheetHeader(
stringResource(R.string.updates_failed_title),
stringResource(
when (state.error)
{
UpdateError.HASH, UpdateError.SIGNATURE -> R.string.updates_failed_sub_check
UpdateError.NETWORK -> R.string.updates_failed_sub_network
UpdateError.INSTALL -> R.string.updates_failed_sub_install
UpdateError.OTHER -> R.string.updates_failed_sub_other
}
),
icon = ShxIcons.Shield, iconBackground = c.errorContainer, iconTint = c.error, round = true,
)
val (title, detail) = when (state.error)
{
UpdateError.HASH -> R.string.updates_reason_hash to R.string.updates_reason_hash_d
UpdateError.SIGNATURE -> R.string.updates_reason_signature to R.string.updates_reason_signature_d
UpdateError.NETWORK -> R.string.updates_reason_network to R.string.updates_reason_network_d
UpdateError.INSTALL -> R.string.updates_reason_install to R.string.updates_reason_install_d
UpdateError.OTHER -> R.string.updates_reason_other to R.string.updates_reason_other_d
}
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
{
Text(stringResource(R.string.updates_failed_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
Reason(stringResource(title), stringResource(detail), if (checkFailed) c.error else c.accent)
Reason(stringResource(R.string.updates_reason_same), stringResource(R.string.updates_reason_same_d, BuildConfig.VERSION_NAME), c.accent)
Reason(stringResource(R.string.updates_reason_later), stringResource(R.string.updates_reason_later_d), c.accent)
}
}
ShxNote(stringResource(R.string.updates_manual_note))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.updates_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
ShxButton(
stringResource(R.string.updates_from_site),
{ runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.UPDATE_DOWNLOAD_PAGE_URL))) } },
style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f),
)
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
{
Text(
stringResource(R.string.updates_support),
style = ShxType.chip, color = c.accentText,
modifier = Modifier.clip(RoundedCornerShape(8.dp))
.clickable(role = Role.Button) { copySupportDetails(context, state) }.padding(horizontal = ShxSpacing.sm, vertical = 6.dp),
)
}
}
/** Error kind, message and versions for support; the beta code stays out. */
private fun copySupportDetails(context: Context, state: UpdateState.Failed)
{
val manager = UpdateManager.getInstance(context)
val text = buildString {
appendLine("SHX VPN update error: ${state.error}")
appendLine("Detail: ${state.detail ?: "-"}")
appendLine("Installed: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
appendLine("Channel: ${if (manager.isBetaChannel()) "beta" else "release"}")
appendLine("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}), ${Build.MANUFACTURER} ${Build.MODEL}, ${Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}")
}
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
clipboard.setPrimaryClip(ClipData.newPlainText("SHX VPN", text))
/* Android 13+ confirms the copy itself */
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
Toast.makeText(context, R.string.updates_copied, Toast.LENGTH_SHORT).show()
}
}
/** Icon well, sheet title and subtitle of the update sheets. */
@Composable
private fun UpdateSheetHeader(
title: String,
subtitle: String,
icon: ImageVector? = null,
iconBackground: Color = LocalShxColors.current.accent,
iconTint: Color = Color.White,
round: Boolean = false,
)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp),
)
{
val size = if (round) 52.dp else 56.dp
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(if (round) CircleShape else RoundedCornerShape(16.dp)).background(iconBackground))
{
if (icon != null) Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(24.dp))
else Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = iconTint, modifier = Modifier.size(36.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.pageTitle, color = c.text)
Text(subtitle, style = ShxType.sub, color = c.textSub)
}
}
}
/** Green «safe» box with a shield (App1, App2, App6). */
@Composable
internal fun UpdateSafeNote(text: String)
{
val c = LocalShxColors.current
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = ShxSpacing.md),
)
{
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
Text(text, style = ShxType.note, color = c.onSuccess)
}
}
@Composable
private fun Bullet(text: String, dot: Color)
{
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.text)
}
}
/** «Title — detail» line of App5. */
@Composable
private fun Reason(title: String, detail: String, dot: Color)
{
val c = LocalShxColors.current
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = c.text)) { append(title) }
append(" — ")
withStyle(SpanStyle(color = c.textSoft)) { append(detail) }
},
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
)
}
}
private fun sizeText(bytes: Long): String = if (bytes > 0) formatBytes(bytes) else "—"
private fun lastCheckText(context: Context, time: Long): String =
DateUtils.getRelativeDateTimeString(context, time, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
internal fun updateErrorText(error: UpdateError): Int = when (error)
{
UpdateError.NETWORK -> R.string.update_error_network
UpdateError.HASH -> R.string.update_error_hash
UpdateError.SIGNATURE -> R.string.update_error_signature
UpdateError.INSTALL -> R.string.update_error_install
UpdateError.OTHER -> R.string.update_error_generic
}
@@ -331,7 +331,7 @@ fun ShxButton(
.background(if (enabled) bg else bg.copy(alpha = 0.38f)) .background(if (enabled) bg else bg.copy(alpha = 0.38f))
.then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier) .then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier)
.clickable(enabled = enabled, role = Role.Button, onClick = onClick) .clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(horizontal = if (height >= 44.dp) 16.dp else 14.dp), .padding(horizontal = if (height >= 44.dp) 10.dp else 14.dp),
) )
{ {
if (icon != null) if (icon != null)
@@ -339,7 +339,17 @@ fun ShxButton(
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp)) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
Text(text, style = textStyle, color = if (enabled) fg else fg.copy(alpha = 0.6f), maxLines = 1, overflow = TextOverflow.Ellipsis) /* two lines on large buttons: on 320dp screens «Скачать и установить» beside «Позже» was cut to «Скачать и установ…» */
Text(
text,
style = textStyle,
color = if (enabled) fg else fg.copy(alpha = 0.6f),
/* a single word must not wrap mid-word («Отмени / ть»): it shrinks instead */
maxLines = if (height >= 52.dp && text.trim().contains(' ')) 2 else 1,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
autoSize = androidx.compose.foundation.text.TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = textStyle.fontSize),
)
} }
} }
@@ -555,14 +565,18 @@ fun ShxTag(text: String, background: Color, color: Color)
) )
} }
/** Bottom sheet of the mockup: radius 32, 36x4 handle, page background. */ /**
* Bottom sheet of the mockup: radius 32, 36x4 handle, page background.
* [dismissible] = false: swipe, scrim tap and «Back» do not close it (a mandatory update).
*/
@Composable @Composable
fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) fun ShxSheet(onDismiss: () -> Unit, dismissible: Boolean = true, content: @Composable ColumnScope.() -> Unit)
{ {
val c = LocalShxColors.current val c = LocalShxColors.current
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = { if (dismissible) onDismiss() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true, confirmValueChange = { dismissible || it != androidx.compose.material3.SheetValue.Hidden }),
properties = androidx.compose.material3.ModalBottomSheetProperties(shouldDismissOnBackPress = dismissible, shouldDismissOnClickOutside = dismissible),
containerColor = c.background, containerColor = c.background,
scrimColor = c.scrim, scrimColor = c.scrim,
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
@@ -571,6 +585,8 @@ fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
}, },
) )
{ {
/* belt and braces for a mandatory sheet: «Back» is swallowed even if the dialog forwards it */
androidx.activity.compose.BackHandler(enabled = !dismissible) {}
Column( Column(
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp), modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
@@ -35,6 +35,21 @@
typedef struct private_android_dns_proxy_t private_android_dns_proxy_t; typedef struct private_android_dns_proxy_t private_android_dns_proxy_t;
/**
* Encrypted DNS redirect: the virtual address (network order) and the local
* resolver port (0 = off). Written by JNI, read on the TUN reader and
* watcher threads; the port is stored last and read first.
*/
static uint32_t redirect_vip;
static uint16_t redirect_port;
void android_dns_proxy_set_redirect(uint32_t vip, uint16_t port)
{
__atomic_store_n(&redirect_port, 0, __ATOMIC_RELEASE);
__atomic_store_n(&redirect_vip, vip, __ATOMIC_RELEASE);
__atomic_store_n(&redirect_port, port, __ATOMIC_RELEASE);
}
struct private_android_dns_proxy_t { struct private_android_dns_proxy_t {
/** /**
@@ -173,6 +188,19 @@ CALLBACK(handle_response, bool,
DBG1(DBG_NET, "failed to parse source address"); DBG1(DBG_NET, "failed to parse source address");
return TRUE; return TRUE;
} }
if (addr.ss_family == AF_INET &&
((struct sockaddr_in*)&addr)->sin_addr.s_addr == htonl(INADDR_LOOPBACK))
{ /* the app's encrypted DNS resolver: the reply must come from the
* address the query went to, or the system resolver drops it */
uint32_t vip = __atomic_load_n(&redirect_vip, __ATOMIC_ACQUIRE);
src->destroy(src);
src = host_create_from_chunk(AF_INET, chunk_from_thing(vip), 53);
if (!src)
{
return TRUE;
}
}
packet = ip_packet_create_udp_from_data(src, this->src, packet = ip_packet_create_udp_from_data(src, this->src,
chunk_create(buf, len)); chunk_create(buf, len));
src->destroy(src); src->destroy(src);
@@ -302,29 +330,16 @@ static bool check_hostname(private_android_dns_proxy_t *this, chunk_t data)
return success; return success;
} }
METHOD(android_dns_proxy_t, handle, bool, /**
private_android_dns_proxy_t *this, ip_packet_t *packet) * Send the DNS payload of packet to dst through the per-source socket; takes
* the packet over on success.
*/
static bool forward(private_android_dns_proxy_t *this, ip_packet_t *packet,
chunk_t data, host_t *dst)
{ {
proxy_socket_t *skt; proxy_socket_t *skt;
host_t *dst, *src; host_t *src;
chunk_t data;
if (packet->get_next_header(packet) != IPPROTO_UDP)
{
return FALSE;
}
dst = packet->get_destination(packet);
if (dst->get_port(dst) != 53)
{ /* no DNS packet */
return FALSE;
}
data = packet->get_payload(packet);
/* remove UDP header */
data = chunk_skip(data, 8);
if (!check_hostname(this, data))
{
return FALSE;
}
src = packet->get_source(packet); src = packet->get_source(packet);
this->lock->write_lock(this->lock); this->lock->write_lock(this->lock);
skt = this->sockets->get(this->sockets, src); skt = this->sockets->get(this->sockets, src);
@@ -354,6 +369,65 @@ METHOD(android_dns_proxy_t, handle, bool,
return TRUE; return TRUE;
} }
METHOD(android_dns_proxy_t, handle, bool,
private_android_dns_proxy_t *this, ip_packet_t *packet)
{
host_t *dst;
chunk_t data;
if (packet->get_next_header(packet) != IPPROTO_UDP)
{
return FALSE;
}
dst = packet->get_destination(packet);
if (dst->get_port(dst) != 53)
{ /* no DNS packet */
return FALSE;
}
data = packet->get_payload(packet);
/* remove UDP header */
data = chunk_skip(data, 8);
if (!check_hostname(this, data))
{
return FALSE;
}
return forward(this, packet, data, dst);
}
METHOD(android_dns_proxy_t, redirect, bool,
private_android_dns_proxy_t *this, ip_packet_t *packet)
{
uint16_t port = __atomic_load_n(&redirect_port, __ATOMIC_ACQUIRE);
uint32_t vip;
host_t *dst, *local;
chunk_t addr;
if (!port)
{ /* the common case: encrypted DNS is off */
return FALSE;
}
if (packet->get_version(packet) != 4 ||
packet->get_next_header(packet) != IPPROTO_UDP)
{
return FALSE;
}
vip = __atomic_load_n(&redirect_vip, __ATOMIC_ACQUIRE);
dst = packet->get_destination(packet);
addr = dst->get_address(dst);
if (dst->get_port(dst) != 53 || addr.len != 4 || !memeq(addr.ptr, &vip, 4))
{
return FALSE;
}
local = host_create_from_string("127.0.0.1", port);
if (!local || !forward(this, packet,
chunk_skip(packet->get_payload(packet), 8), local))
{ /* the virtual address leads nowhere else: drop, do not tunnel it */
packet->destroy(packet);
}
DESTROY_IF(local);
return TRUE;
}
METHOD(android_dns_proxy_t, register_cb, void, METHOD(android_dns_proxy_t, register_cb, void,
private_android_dns_proxy_t *this, dns_proxy_response_cb_t cb, void *data) private_android_dns_proxy_t *this, dns_proxy_response_cb_t cb, void *data)
{ {
@@ -408,6 +482,7 @@ android_dns_proxy_t *android_dns_proxy_create()
.register_cb = _register_cb, .register_cb = _register_cb,
.unregister_cb = _unregister_cb, .unregister_cb = _unregister_cb,
.add_hostname = _add_hostname, .add_hostname = _add_hostname,
.redirect = _redirect,
.destroy = _destroy, .destroy = _destroy,
}, },
.sockets = hashtable_create((hashtable_hash_t)socket_hash, .sockets = hashtable_create((hashtable_hash_t)socket_hash,
@@ -72,6 +72,15 @@ struct android_dns_proxy_t {
*/ */
void (*add_hostname)(android_dns_proxy_t *this, char *hostname); void (*add_hostname)(android_dns_proxy_t *this, char *hostname);
/**
* Forward a UDP/53 packet for the encrypted DNS address (see
* android_dns_proxy_set_redirect()) to the app's local resolver.
*
* @param packet packet to handle
* @return TRUE if handled (taken over), FALSE otherwise
*/
bool (*redirect)(android_dns_proxy_t *this, ip_packet_t *packet);
/** /**
* Destroy an instance. * Destroy an instance.
*/ */
@@ -83,5 +92,15 @@ struct android_dns_proxy_t {
*/ */
android_dns_proxy_t *android_dns_proxy_create(); android_dns_proxy_t *android_dns_proxy_create();
/**
* Encrypted DNS (DoH/DoT): UDP/53 queries to the IPv4 address vip go to
* 127.0.0.1:port, where the app resolves them; port 0 turns it off. Global,
* as the proxy instance is re-created with every connection.
*
* @param vip IPv4 address in network order
* @param port local resolver port, 0 to disable
*/
void android_dns_proxy_set_redirect(uint32_t vip, uint16_t port);
#endif /** ANDROID_DNS_PROXY_H_ @}*/ #endif /** ANDROID_DNS_PROXY_H_ @}*/
@@ -257,6 +257,10 @@ static job_requeue_t handle_plain(private_android_service_t *this)
{ {
/* handled by the DNS proxy */ /* handled by the DNS proxy */
} }
else if (this->dns_proxy->redirect(this->dns_proxy, packet))
{
/* encrypted DNS: answered by the app's local resolver */
}
else else
{ {
direct_engine_t *engine = direct_engine_get(); direct_engine_t *engine = direct_engine_get();
@@ -591,6 +595,10 @@ METHOD(listener_t, ike_reestablish_pre, bool,
{ {
if (this->ike_sa == old) if (this->ike_sa == old)
{ {
if (old->has_condition(old, COND_REAUTHENTICATING))
{ /* planned reauthentication, not a drop */
charonservice->update_status(charonservice, CHARONSERVICE_REAUTH);
}
/* enable DNS proxy so hosts are properly resolved while the TUN device /* enable DNS proxy so hosts are properly resolved while the TUN device
* is still active */ * is still active */
this->lock->write_lock(this->lock); this->lock->write_lock(this->lock);
@@ -674,6 +682,12 @@ METHOD(listener_t, alert, bool,
{ {
switch (alert) switch (alert)
{ {
case ALERT_RETRANSMIT_SEND_TIMEOUT:
/* fires before the reestablish that reports CHILD_STATE_DOWN;
* break, not FALSE: returning FALSE unregisters the listener */
charonservice->update_status(charonservice,
CHARONSERVICE_RETRANSMIT_TIMEOUT);
break;
case ALERT_PEER_ADDR_FAILED: case ALERT_PEER_ADDR_FAILED:
charonservice->update_status(charonservice, charonservice->update_status(charonservice,
CHARONSERVICE_LOOKUP_ERROR); CHARONSERVICE_LOOKUP_ERROR);
@@ -35,6 +35,7 @@
#include "android_jni.h" #include "android_jni.h"
#include "backend/android_attr.h" #include "backend/android_attr.h"
#include "backend/android_creds.h" #include "backend/android_creds.h"
#include "backend/android_dns_proxy.h"
#include "backend/android_fetcher.h" #include "backend/android_fetcher.h"
#include "backend/android_private_key.h" #include "backend/android_private_key.h"
#include "backend/android_scheduler.h" #include "backend/android_scheduler.h"
@@ -1320,6 +1321,28 @@ JNI_METHOD(CharonVpnService, setUserspaceRoutingData, void,
pthread_mutex_unlock(&charon_mutex); pthread_mutex_unlock(&charon_mutex);
} }
/**
* Encrypted DNS (DoH/DoT): UDP/53 queries to vip (4 bytes, IPv4) go to the
* app's resolver on 127.0.0.1:port. A null vip or port 0 turns it off. Only
* stores two globals, so it is safe at any time; pushed on every connect.
*/
JNI_METHOD(CharonVpnService, setDnsRedirect, void,
jbyteArray jvip, jint port)
{
uint32_t vip = 0;
if (jvip && port > 0 && port < 65536 &&
(*env)->GetArrayLength(env, jvip) == sizeof(vip))
{
(*env)->GetByteArrayRegion(env, jvip, 0, sizeof(vip), (jbyte*)&vip);
android_dns_proxy_set_redirect(vip, port);
}
else
{
android_dns_proxy_set_redirect(0, 0);
}
}
/** /**
* Snapshot of the userspace routing engine counters (mode B diagnostics). * Snapshot of the userspace routing engine counters (mode B diagnostics).
*/ */
@@ -63,6 +63,9 @@ enum android_vpn_state_t {
CHARONSERVICE_UNREACHABLE_ERROR, CHARONSERVICE_UNREACHABLE_ERROR,
CHARONSERVICE_CERTIFICATE_UNAVAILABLE, CHARONSERVICE_CERTIFICATE_UNAVAILABLE,
CHARONSERVICE_GENERIC_ERROR, CHARONSERVICE_GENERIC_ERROR,
/* diagnostics only, no state change: the next CHILD_STATE_DOWN is caused by it */
CHARONSERVICE_RETRANSMIT_TIMEOUT,
CHARONSERVICE_REAUTH,
}; };
/** /**
@@ -797,10 +797,9 @@ static void *engine_thread(private_direct_engine_t *this)
{ {
active = TRUE; active = TRUE;
this->udp->reprotect(this->udp); this->udp->reprotect(this->udp);
if (this->tcp_active) /* also while lwIP is dormant: this clears the negative cache of
{ * failed direct connects, which must not survive a roam */
this->tcp->reprotect(this->tcp); this->tcp->reprotect(this->tcp);
}
/* the network changed: whatever was dead for direct connects /* the network changed: whatever was dead for direct connects
* may work again - drop the tunnel fallbacks with the rest of * may work again - drop the tunnel fallbacks with the rest of
* the failure state (tcp->reprotect clears the neg table) */ * the failure state (tcp->reprotect clears the neg table) */
@@ -812,8 +811,22 @@ static void *engine_thread(private_direct_engine_t *this)
this->bind_lock->unlock(this->bind_lock); this->bind_lock->unlock(this->bind_lock);
if (!bound && this->unbound_flushed) if (!bound && this->unbound_flushed)
{ /* TUN still gone and already flushed */ { /* TUN still gone and already flushed: nothing to do until the
* re-bind wakes us, so back off like an idle round instead of
* polling at the active cadence all through a re-authentication */
drain_queue_nobind(this); drain_queue_nobind(this);
if (active)
{
poll_ms = ENGINE_POLL_MS;
}
else
{
poll_ms *= 2;
if (poll_ms > ENGINE_IDLE_POLL_MS)
{
poll_ms = ENGINE_IDLE_POLL_MS;
}
}
continue; continue;
} }
@@ -862,6 +875,12 @@ static void *engine_thread(private_direct_engine_t *this)
{ {
this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n); this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
this->tcp->check_timers(this->tcp); this->tcp->check_timers(this->tcp);
if (!active && this->tcp->flow_count(this->tcp) == 0)
{ /* a full idle round with no TCP flow left: let lwIP go dormant
* again (the next TCP packet wakes it, its timers catch up),
* so the idle cadence stretches back to ENGINE_IDLE_POLL_MS */
this->tcp_active = FALSE;
}
} }
{ /* adapt the cadence after the work of this round is known */ { /* adapt the cadence after the work of this round is known */
@@ -59,6 +59,13 @@
*/ */
#define MAX_BUFFERED (256 * 1024) #define MAX_BUFFERED (256 * 1024)
/**
* Stop reading from the remote socket while this much is waiting for the
* app: the data stays in the kernel socket and TCP throttles the sender,
* instead of the flow hitting MAX_BUFFERED and being aborted
*/
#define RELAY_HIGH_WATER (64 * 1024)
/** /**
* Give up on a direct connect after this many milliseconds (a destination * Give up on a direct connect after this many milliseconds (a destination
* that silently drops SYNs would otherwise hang the app's connection * that silently drops SYNs would otherwise hang the app's connection
@@ -126,6 +133,7 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err);
static lwip_err_t on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, lwip_err_t err); static lwip_err_t on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, lwip_err_t err);
static void on_err(void *arg, lwip_err_t err); static void on_err(void *arg, lwip_err_t err);
static lwip_err_t on_poll(void *arg, struct tcp_pcb *pcb); static lwip_err_t on_poll(void *arg, struct tcp_pcb *pcb);
static lwip_err_t on_sent(void *arg, struct tcp_pcb *pcb, u16_t len);
/** /**
* One relayed connection * One relayed connection
@@ -330,13 +338,20 @@ static void relay_flush_to_sock(tcp_relay_t *relay)
{ {
while (relay->to_sock_len) while (relay->to_sock_len)
{ {
ssize_t n = write(relay->fd, relay->to_sock, relay->to_sock_len); ssize_t n = write(relay->fd, relay->to_sock,
min(relay->to_sock_len, 0xffff));
if (n < 0) if (n < 0)
{ {
return; return;
} }
buf_consume(relay->to_sock, &relay->to_sock_len, n); buf_consume(relay->to_sock, &relay->to_sock_len, n);
if (relay->pcb)
{ /* reopen the app's send window only for what the socket took:
* acknowledging on receipt let a fast app outrun a slow uplink
* until the flow hit MAX_BUFFERED and was aborted */
tcp_recved(relay->pcb, n);
}
} }
} }
@@ -729,6 +744,7 @@ static lwip_err_t on_accept(void *arg, struct tcp_pcb *newpcb, lwip_err_t err)
tcp_arg(newpcb, relay); tcp_arg(newpcb, relay);
tcp_recv(newpcb, on_recv); tcp_recv(newpcb, on_recv);
tcp_err(newpcb, on_err); tcp_err(newpcb, on_err);
tcp_sent(newpcb, on_sent);
tcp_poll(newpcb, on_poll, 4); tcp_poll(newpcb, on_poll, 4);
this->relays->insert_last(this->relays, relay); this->relays->insert_last(this->relays, relay);
DIRECT_STATS_ADD(this->stats->tcp_flows, 1); DIRECT_STATS_ADD(this->stats->tcp_flows, 1);
@@ -767,7 +783,6 @@ static lwip_err_t on_recv(void *arg, struct tcp_pcb *pcb, struct pbuf *p, lwip_e
relay->dead = TRUE; relay->dead = TRUE;
return ERR_ABRT; return ERR_ABRT;
} }
tcp_recved(pcb, p->tot_len);
pbuf_free(p); pbuf_free(p);
relay_flush_to_sock(relay); relay_flush_to_sock(relay);
return ERR_OK; return ERR_OK;
@@ -802,6 +817,22 @@ static lwip_err_t on_poll(void *arg, struct tcp_pcb *pcb)
return ERR_OK; return ERR_OK;
} }
/**
* lwIP sent callback: the app acknowledged data, so the send buffer has
* room again. Without it the rest of a response only moved on the next
* socket read or the 2 s poll callback.
*/
static lwip_err_t on_sent(void *arg, struct tcp_pcb *pcb, u16_t len)
{
tcp_relay_t *relay = arg;
if (relay && relay->pcb && !relay->dead)
{
relay_flush_to_pcb(relay);
}
return ERR_OK;
}
/** /**
* netif output: a finished IP packet for the kernel. lwIP keeps ownership * netif output: a finished IP packet for the kernel. lwIP keeps ownership
* of the pbuf (unacked segments reference it for retransmission and free * of the pbuf (unacked segments reference it for retransmission and free
@@ -966,6 +997,12 @@ METHOD(direct_tcp_t, check_timers, void,
enumerator->destroy(enumerator); enumerator->destroy(enumerator);
} }
METHOD(direct_tcp_t, flow_count, size_t,
private_direct_tcp_t *this)
{
return this->relays->get_count(this->relays);
}
METHOD(direct_tcp_t, set_mtu, void, METHOD(direct_tcp_t, set_mtu, void,
private_direct_tcp_t *this, uint16_t mtu) private_direct_tcp_t *this, uint16_t mtu)
{ {
@@ -1014,7 +1051,16 @@ METHOD(direct_tcp_t, fill_poll, size_t,
while (n < cap && enumerator->enumerate(enumerator, &relay)) while (n < cap && enumerator->enumerate(enumerator, &relay))
{ {
fds[n].fd = relay->fd; fds[n].fd = relay->fd;
fds[n].events = POLLIN | (relay->connected ? 0 : POLLOUT); fds[n].events = 0;
if (!relay->connected || relay->to_sock_len)
{ /* connect result, or room for data the socket refused before */
fds[n].events |= POLLOUT;
}
if (!relay->remote_eof && relay->to_pcb_len < RELAY_HIGH_WATER)
{ /* a socket at EOF stays readable forever: polling it for input
* made the engine thread spin until the app took the last byte */
fds[n].events |= POLLIN;
}
fds[n].revents = 0; fds[n].revents = 0;
n++; n++;
} }
@@ -1051,6 +1097,13 @@ METHOD(direct_tcp_t, process_poll, void,
relay_destroy(this, relay, TRUE); relay_destroy(this, relay, TRUE);
continue; continue;
} }
if (relay->remote_eof && (re & (POLLERR | POLLHUP)))
{ /* fully closed: HUP is reported whatever we ask for, so drop the
* socket (poll ignores fd -1); what is buffered still goes to the app */
close(relay->fd);
relay->fd = -1;
re = 0;
}
if (!relay->connected) if (!relay->connected)
{ {
uint64_t now = now_ms(); uint64_t now = now_ms();
@@ -1136,13 +1189,16 @@ METHOD(direct_tcp_t, process_poll, void,
} }
} }
} }
if (re & (POLLIN | POLLHUP)) if (re & (POLLIN | POLLHUP | POLLERR))
{ {
uint8_t buf[16384]; uint8_t buf[16384];
ssize_t n; ssize_t n = 1;
bool failed = FALSE; bool failed = FALSE;
while ((n = read(relay->fd, buf, sizeof(buf))) > 0) /* a broken socket is read out whatever is buffered: its error
* must surface, or poll would report it round after round */
while ((relay->to_pcb_len < RELAY_HIGH_WATER || (re & (POLLHUP | POLLERR))) &&
(n = read(relay->fd, buf, sizeof(buf))) > 0)
{ {
if (!buf_append(relay, FALSE, buf, n)) if (!buf_append(relay, FALSE, buf, n))
{ {
@@ -1290,6 +1346,7 @@ direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
.public = { .public = {
.handle_packet = _handle_packet, .handle_packet = _handle_packet,
.check_timers = _check_timers, .check_timers = _check_timers,
.flow_count = _flow_count,
.set_mtu = _set_mtu, .set_mtu = _set_mtu,
.reprotect = _reprotect, .reprotect = _reprotect,
.fill_poll = _fill_poll, .fill_poll = _fill_poll,
@@ -1251,7 +1251,7 @@
<string name="prot_cloudflare_sub">1.1.1.1 · durch den Tunnel</string> <string name="prot_cloudflare_sub">1.1.1.1 · durch den Tunnel</string>
<string name="prot_adguard_sub">Mit eingebautem Filter</string> <string name="prot_adguard_sub">Mit eingebautem Filter</string>
<string name="prot_custom">Eigene Adresse</string> <string name="prot_custom">Eigene Adresse</string>
<string name="prot_custom_sub">IPv4 oder IPv6, durch Komma getrennt</string> <string name="prot_custom_sub">IPs, DoH (https://) oder DoT (tls://)</string>
<string name="prot_note">Anfragen an diesen DNS gehen durch den Tunnel — der Anbieter kann die Antwort nicht verfälschen. Apps, die am VPN vorbeigehen, nutzen den DNS des Netzes.</string> <string name="prot_note">Anfragen an diesen DNS gehen durch den Tunnel — der Anbieter kann die Antwort nicht verfälschen. Apps, die am VPN vorbeigehen, nutzen den DNS des Netzes.</string>
<string name="prot_applies">Gilt nach einer Neuverbindung</string> <string name="prot_applies">Gilt nach einer Neuverbindung</string>
<string name="prot_list_ads">Werbung</string> <string name="prot_list_ads">Werbung</string>
@@ -1318,4 +1318,13 @@
<item quantity="one">%d vertrauenswürdiges Zertifikat</item> <item quantity="one">%d vertrauenswürdiges Zertifikat</item>
<item quantity="other">%d vertrauenswürdige Zertifikate</item> <item quantity="other">%d vertrauenswürdige Zertifikate</item>
</plurals> </plurals>
<string name="event_crash_title">Die App wurde unerwartet beendet</string>
<string name="event_crash_text">SHX VPN ist zuletzt abgestürzt. Senden Sie einen Bericht, damit wir die Ursache finden.</string>
<string name="event_lockdown_off_title">Durchgehend aktives VPN wurde ausgeschaltet</string>
<string name="event_lockdown_off_text">Es war vorher an. Jetzt gehen Apps bei jedem VPN-Abbruch für einige Sekunden direkt ins Netz. Schalten Sie es in den VPN-Einstellungen von Android wieder ein.</string>
<string name="prot_secure_ok">Der Server hat in %1$d ms geantwortet. DNS-Anfragen werden verschlüsselt.</string>
<string name="prot_secure_fail">Der Server hat nicht geantwortet (%1$s). Bis dahin nutzt das VPN das DNS des Profils.</string>
<string name="prot_secure_note">DoH/DoT-Anfragen gehen verschlüsselt über das normale Netz an Ihren Server, nicht durch den Tunnel. Antwortet er nicht, nutzt das VPN das DNS des Profils. Bei strengem privatem DNS nutzt Android seinen eigenen Server.</string>
<string name="event_secure_dns_title">Eigener DoH/DoT-Server antwortet nicht</string>
<string name="event_secure_dns_text">Das VPN hat sich mit dem DNS des Profils verbunden. Prüfen Sie die Adresse unter „Einstellungen → DNS und Werbung“.</string>
</resources> </resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Der Bericht geht über einen geschützten Kanal an das SHX-Team. Mit dem Berichtscode findet der Support Ihr Protokoll.</string>
<string name="report_contents">Inhalt des Berichts</string>
<string name="report_item_log">Verbindungsprotokoll von heute</string>
<string name="report_item_device">App-Version und Telefonmodell</string>
<string name="report_item_network">Netzwerktyp und aktiver Server</string>
<string name="report_item_ips">IP-Adressen der Server aus dem Protokoll</string>
<string name="report_diag_title">Diagnose anhängen</string>
<string name="report_diag_sub">Ergebnisse der Netzwerkprüfung und erweitertes App-Protokoll</string>
<string name="report_comment_label">Was ist passiert? (optional)</string>
<string name="report_cancel">Abbrechen</string>
<string name="report_send">Senden</string>
<string name="report_by_mail">Per E-Mail senden</string>
<string name="report_sent_title">Bericht gesendet</string>
<string name="report_sent_sub">Nennen Sie diesen Code dem Support</string>
<string name="report_code_label">Berichtscode</string>
<string name="report_copy_code">Code kopieren</string>
<string name="report_code_copied">Code kopiert</string>
<string name="report_saved_note">Der Code ist unter „Ereignisse“ gespeichert und lässt sich später finden.</string>
<string name="report_done">Fertig</string>
<string name="report_failed_title">Bericht nicht gesendet</string>
<string name="report_failed_no_answer">Der Server hat nicht geantwortet</string>
<string name="report_failed_refused">Der Server hat den Bericht abgelehnt</string>
<string name="report_reasons">Mögliche Ursachen</string>
<string name="report_reason_offline">Kein Internet</string>
<string name="report_reason_offline_d">Netzwerk prüfen und wiederholen</string>
<string name="report_reason_server">Server nicht erreichbar</string>
<string name="report_reason_server_d">in ein paar Minuten wiederholen</string>
<string name="report_reason_clock">Telefonuhr geht falsch</string>
<string name="report_reason_clock_d">automatische Zeit einschalten und wiederholen</string>
<string name="report_reason_often">Zu häufig</string>
<string name="report_reason_often_d">in einer Stunde erneut versuchen</string>
<string name="report_reason_large">Protokoll zu groß</string>
<string name="report_reason_large_d">per E-Mail senden</string>
<string name="report_reason_rejected">Anfrage abgelehnt</string>
<string name="report_reason_rejected_d">Protokoll per E-Mail senden</string>
<string name="report_mail_note">Das Protokoll lässt sich wie bisher auch per E-Mail senden.</string>
<string name="report_retry">Wiederholen</string>
<string name="report_by_letter">Per E-Mail senden</string>
<string name="report_event_title">Bericht gesendet, Code %1$s</string>
<string name="report_event_text">Nennen Sie diesen Code dem Support, damit er Ihr Protokoll findet.</string>
</resources>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">Installiert: %1$s · %2$s</string>
<string name="updates_review">Ansehen und installieren</string>
<string name="updates_section_auto">Automatisch</string>
<string name="updates_auto_check">Selbst prüfen</string>
<string name="updates_silent">Still installieren</string>
<string name="updates_silent_sub">Über WLAN laden und installieren, wenn das VPN aus ist</string>
<string name="updates_section_channel">Kanal</string>
<string name="updates_channel_stable">Stabil</string>
<string name="updates_channel_stable_sub">Geprüfte Versionen</string>
<string name="updates_channel_beta">Beta</string>
<string name="updates_channel_beta_sub">Früher, aber mit Fehlern</string>
<string name="updates_channel_hint">Die Beta wird mit einem Einladungscode des Administrators freigeschaltet.</string>
<string name="updates_beta_title">Einladungscode für die Beta</string>
<string name="updates_beta_text">Gib den Code ein, den du vom Administrator erhalten hast. Ohne gültigen Code bleibt die App bei stabilen Versionen.</string>
<string name="updates_beta_code">Einladungscode</string>
<string name="updates_beta_enable">Beta einschalten</string>
<string name="updates_check_now">Jetzt prüfen</string>
<string name="updates_last_check">Letzte Prüfung: %1$s</string>
<string name="updates_never_checked">Noch nicht geprüft</string>
<string name="updates_review_sub">Installiert: %1$s · %2$s · die Datei wird vor der Installation geprüft</string>
<string name="updates_review_safe">Die App prüft die Prüfsumme und die SHX-Signatur. Eine gefälschte Datei wird nicht installiert.</string>
<string name="updates_downloading_title">%1$s wird geladen</string>
<string name="updates_downloading_sub">Du kannst dieses Fenster schließen: Der Download läuft weiter</string>
<string name="updates_progress">%1$d%% · %2$s von %3$s</string>
<string name="updates_step_list">Versionsliste erhalten</string>
<string name="updates_step_list_sub">Über einen geschützten Kanal</string>
<string name="updates_step_download">Datei wird geladen</string>
<string name="updates_step_verify">Datei wird geprüft</string>
<string name="updates_step_verify_sub">Prüfsumme und SHX-Signatur</string>
<string name="updates_step_install">Installation</string>
<string name="updates_step_install_sub">Android fragt nach einer Bestätigung</string>
<string name="updates_cancel">Abbrechen</string>
<string name="updates_mandatory_title">SHX VPN muss aktualisiert werden</string>
<string name="updates_mandatory_sub">Version %1$s wird nicht mehr unterstützt</string>
<string name="updates_mandatory_what">Was das bedeutet</string>
<string name="updates_mandatory_reason">Der Server nimmt keine Verbindungen der alten Version mehr an</string>
<string name="updates_mandatory_install">Installiere %1$s (%2$s), deine Server und Einstellungen bleiben erhalten</string>
<string name="updates_mandatory_locked">Dieses Fenster lässt sich erst nach dem Update schließen.</string>
<string name="updates_exit">App beenden</string>
<string name="updates_failed_title">Update nicht installiert</string>
<string name="updates_failed_sub_check">Die Datei hat die Prüfung nicht bestanden</string>
<string name="updates_failed_sub_network">Die Datei wurde nicht geladen</string>
<string name="updates_failed_sub_install">Android hat die Datei nicht installiert</string>
<string name="updates_failed_sub_other">Etwas ist schiefgelaufen</string>
<string name="updates_failed_what">Was passiert ist</string>
<string name="updates_reason_hash">Prüfsumme stimmt nicht</string>
<string name="updates_reason_hash_d">die Datei wurde unterwegs beschädigt, die Kopie ist gelöscht</string>
<string name="updates_reason_signature">Signatur stimmt nicht</string>
<string name="updates_reason_signature_d">die Datei ist nicht von SHX signiert, die Kopie ist gelöscht</string>
<string name="updates_reason_network">Keine Verbindung zum Update-Server</string>
<string name="updates_reason_network_d">der Download wurde unterbrochen</string>
<string name="updates_reason_install">Android hat die Installation abgelehnt</string>
<string name="updates_reason_install_d">das Installationsprogramm meldete einen Fehler</string>
<string name="updates_reason_other">Unerwarteter Fehler</string>
<string name="updates_reason_other_d">Details für den Support findest du unten</string>
<string name="updates_reason_same">Version unverändert</string>
<string name="updates_reason_same_d">du hast weiterhin %1$s</string>
<string name="updates_reason_later">Versuche es später erneut</string>
<string name="updates_reason_later_d">oder wechsle das Netz: WLAN statt Mobilfunk</string>
<string name="updates_manual_note">Du kannst die App manuell über die Download-Seite installieren. Deine Server und Einstellungen bleiben erhalten.</string>
<string name="updates_retry">Wiederholen</string>
<string name="updates_from_site">Von der Website laden</string>
<string name="updates_support">Details für den Support</string>
<string name="updates_copied">Kopiert</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Alle %d Stunde, während das Telefon lädt</item>
<item quantity="other">Alle %d Stunden, während das Telefon lädt</item>
</plurals>
</resources>
@@ -1141,7 +1141,7 @@
<string name="prot_cloudflare_sub">1.1.1.1 · przez tunel</string> <string name="prot_cloudflare_sub">1.1.1.1 · przez tunel</string>
<string name="prot_adguard_sub">Z wbudowanym filtrowaniem</string> <string name="prot_adguard_sub">Z wbudowanym filtrowaniem</string>
<string name="prot_custom">Własny adres</string> <string name="prot_custom">Własny adres</string>
<string name="prot_custom_sub">IPv4 lub IPv6 oddzielone przecinkami</string> <string name="prot_custom_sub">IP, DoH (https://) lub DoT (tls://)</string>
<string name="prot_note">Zapytania do tego DNS idą przez tunel — dostawca nie podmieni odpowiedzi. Aplikacje z pominięciem VPN używają DNS sieci.</string> <string name="prot_note">Zapytania do tego DNS idą przez tunel — dostawca nie podmieni odpowiedzi. Aplikacje z pominięciem VPN używają DNS sieci.</string>
<string name="prot_applies">Zostanie zastosowane po ponownym połączeniu</string> <string name="prot_applies">Zostanie zastosowane po ponownym połączeniu</string>
<string name="prot_list_ads">reklamy</string> <string name="prot_list_ads">reklamy</string>
@@ -1236,4 +1236,13 @@
<item quantity="many">%d zaufanych certyfikatów</item> <item quantity="many">%d zaufanych certyfikatów</item>
<item quantity="other">%d zaufanego certyfikatu</item> <item quantity="other">%d zaufanego certyfikatu</item>
</plurals> </plurals>
<string name="event_crash_title">Aplikacja nieoczekiwanie się zamknęła</string>
<string name="event_crash_text">Ostatnio SHX VPN uległ awarii. Wyślij raport, abyśmy znaleźli przyczynę.</string>
<string name="event_lockdown_off_title">Stały VPN został wyłączony</string>
<string name="event_lockdown_off_text">Wcześniej był włączony. Teraz przy każdym zerwaniu VPN aplikacje na kilka sekund łączą się bezpośrednio. Włącz go ponownie w ustawieniach VPN Androida.</string>
<string name="prot_secure_ok">Serwer odpowiedział w %1$d ms. Zapytania DNS będą szyfrowane.</string>
<string name="prot_secure_fail">Serwer nie odpowiedział (%1$s). Dopóki jest niedostępny, VPN używa DNS z profilu.</string>
<string name="prot_secure_note">Zapytania DoH/DoT trafiają do Twojego serwera zaszyfrowane przez zwykłą sieć, nie przez tunel. Jeśli serwer nie odpowiada, VPN używa DNS z profilu. Przy ścisłym prywatnym DNS Android używa własnego serwera.</string>
<string name="event_secure_dns_title">Własny serwer DoH/DoT nie odpowiada</string>
<string name="event_secure_dns_text">VPN połączył się z DNS z profilu. Sprawdź adres w „Ustawienia → DNS i reklamy”.</string>
</resources> </resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Raport trafi do zespołu SHX bezpiecznym kanałem. Po kodzie raportu wsparcie znajdzie Twój dziennik.</string>
<string name="report_contents">Co trafi do raportu</string>
<string name="report_item_log">Dziennik połączenia z dzisiaj</string>
<string name="report_item_device">Wersja aplikacji i model telefonu</string>
<string name="report_item_network">Typ sieci i aktywny serwer</string>
<string name="report_item_ips">Adresy IP serwerów z dziennika</string>
<string name="report_diag_title">Dołącz diagnostykę</string>
<string name="report_diag_sub">Wyniki sprawdzenia sieci i rozszerzony dziennik aplikacji</string>
<string name="report_comment_label">Co się stało (opcjonalnie)</string>
<string name="report_cancel">Anuluj</string>
<string name="report_send">Wyślij</string>
<string name="report_by_mail">Wyślij e-mailem</string>
<string name="report_sent_title">Raport wysłany</string>
<string name="report_sent_sub">Podaj ten kod wsparciu</string>
<string name="report_code_label">Kod raportu</string>
<string name="report_copy_code">Kopiuj kod</string>
<string name="report_code_copied">Kod skopiowany</string>
<string name="report_saved_note">Kod zapisano w „Zdarzeniach”, można go znaleźć później.</string>
<string name="report_done">Gotowe</string>
<string name="report_failed_title">Raport nie został wysłany</string>
<string name="report_failed_no_answer">Serwer nie odpowiedział</string>
<string name="report_failed_refused">Serwer odrzucił raport</string>
<string name="report_reasons">Możliwe przyczyny</string>
<string name="report_reason_offline">Brak internetu</string>
<string name="report_reason_offline_d">sprawdź sieć i ponów</string>
<string name="report_reason_server">Serwer niedostępny</string>
<string name="report_reason_server_d">ponów za kilka minut</string>
<string name="report_reason_clock">Zły czas w telefonie</string>
<string name="report_reason_clock_d">włącz automatyczny czas i ponów</string>
<string name="report_reason_often">Zbyt często</string>
<string name="report_reason_often_d">spróbuj za godzinę</string>
<string name="report_reason_large">Dziennik jest za duży</string>
<string name="report_reason_large_d">wyślij go e-mailem</string>
<string name="report_reason_rejected">Żądanie odrzucone</string>
<string name="report_reason_rejected_d">wyślij dziennik e-mailem</string>
<string name="report_mail_note">Dziennik można też wysłać e-mailem, jak dotąd.</string>
<string name="report_retry">Ponów</string>
<string name="report_by_letter">Wyślij e-mailem</string>
<string name="report_event_title">Raport wysłany, kod %1$s</string>
<string name="report_event_text">Podaj ten kod wsparciu, aby znalazło Twój dziennik.</string>
</resources>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">Masz %1$s · %2$s</string>
<string name="updates_review">Zobacz i zainstaluj</string>
<string name="updates_section_auto">Automatycznie</string>
<string name="updates_auto_check">Sprawdzaj samodzielnie</string>
<string name="updates_silent">Instaluj po cichu</string>
<string name="updates_silent_sub">Pobierz przez Wi-Fi i zainstaluj, gdy VPN jest wyłączony</string>
<string name="updates_section_channel">Kanał</string>
<string name="updates_channel_stable">Stabilny</string>
<string name="updates_channel_stable_sub">Sprawdzone wersje</string>
<string name="updates_channel_beta">Beta</string>
<string name="updates_channel_beta_sub">Wcześniej, ale z błędami</string>
<string name="updates_channel_hint">Beta jest dostępna z kodem zaproszenia od administratora.</string>
<string name="updates_beta_title">Kod zaproszenia do bety</string>
<string name="updates_beta_text">Wpisz kod od administratora. Bez prawidłowego kodu aplikacja zostanie na stabilnych wersjach.</string>
<string name="updates_beta_code">Kod zaproszenia</string>
<string name="updates_beta_enable">Włącz betę</string>
<string name="updates_check_now">Sprawdź teraz</string>
<string name="updates_last_check">Ostatnie sprawdzenie: %1$s</string>
<string name="updates_never_checked">Jeszcze nie sprawdzano</string>
<string name="updates_review_sub">Masz %1$s · %2$s · plik sprawdzimy przed instalacją</string>
<string name="updates_review_safe">Aplikacja sprawdzi sumę kontrolną i podpis SHX. Podrobiony plik się nie zainstaluje.</string>
<string name="updates_downloading_title">Pobieramy %1$s</string>
<string name="updates_downloading_sub">Możesz zamknąć to okno: pobieranie będzie trwać</string>
<string name="updates_progress">%1$d%% · %2$s z %3$s</string>
<string name="updates_step_list">Lista wersji pobrana</string>
<string name="updates_step_list_sub">Przez chroniony kanał</string>
<string name="updates_step_download">Pobieranie pliku</string>
<string name="updates_step_verify">Sprawdzanie pliku</string>
<string name="updates_step_verify_sub">Suma kontrolna i podpis SHX</string>
<string name="updates_step_install">Instalacja</string>
<string name="updates_step_install_sub">Android poprosi o potwierdzenie</string>
<string name="updates_cancel">Anuluj</string>
<string name="updates_mandatory_title">SHX VPN wymaga aktualizacji</string>
<string name="updates_mandatory_sub">Wersja %1$s nie jest już obsługiwana</string>
<string name="updates_mandatory_what">Co to oznacza</string>
<string name="updates_mandatory_reason">Serwer nie przyjmuje już połączeń ze starej wersji</string>
<string name="updates_mandatory_install">Zainstaluj %1$s (%2$s), Twoje serwery i ustawienia zostaną zachowane</string>
<string name="updates_mandatory_locked">Tego okna nie można zamknąć, dopóki aplikacja nie zostanie zaktualizowana.</string>
<string name="updates_exit">Zamknij aplikację</string>
<string name="updates_failed_title">Aktualizacja nie została zainstalowana</string>
<string name="updates_failed_sub_check">Plik nie przeszedł weryfikacji</string>
<string name="updates_failed_sub_network">Nie udało się pobrać pliku</string>
<string name="updates_failed_sub_install">Android nie zainstalował pliku</string>
<string name="updates_failed_sub_other">Coś poszło nie tak</string>
<string name="updates_failed_what">Co się stało</string>
<string name="updates_reason_hash">Suma kontrolna się nie zgadza</string>
<string name="updates_reason_hash_d">plik uszkodził się po drodze, kopia została usunięta</string>
<string name="updates_reason_signature">Podpis się nie zgadza</string>
<string name="updates_reason_signature_d">plik nie jest podpisany przez SHX, kopia została usunięta</string>
<string name="updates_reason_network">Brak połączenia z serwerem aktualizacji</string>
<string name="updates_reason_network_d">pobieranie zostało przerwane</string>
<string name="updates_reason_install">Android odrzucił instalację</string>
<string name="updates_reason_install_d">instalator systemowy zwrócił błąd</string>
<string name="updates_reason_other">Nieoczekiwany błąd</string>
<string name="updates_reason_other_d">szczegóły dla wsparcia znajdziesz poniżej</string>
<string name="updates_reason_same">Wersja się nie zmieniła</string>
<string name="updates_reason_same_d">nadal masz %1$s</string>
<string name="updates_reason_later">Spróbuj później</string>
<string name="updates_reason_later_d">lub zmień sieć: Wi-Fi zamiast komórkowej</string>
<string name="updates_manual_note">Możesz zainstalować ręcznie ze strony pobierania. Twoje serwery i ustawienia pozostaną bez zmian.</string>
<string name="updates_retry">Ponów</string>
<string name="updates_from_site">Pobierz ze strony</string>
<string name="updates_support">Szczegóły dla wsparcia</string>
<string name="updates_copied">Skopiowano</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Co %d godzinę, gdy telefon się ładuje</item>
<item quantity="few">Co %d godziny, gdy telefon się ładuje</item>
<item quantity="many">Co %d godzin, gdy telefon się ładuje</item>
<item quantity="other">Co %d godziny, gdy telefon się ładuje</item>
</plurals>
</resources>
@@ -1312,7 +1312,7 @@
<string name="prot_cloudflare_sub">1.1.1.1 · через туннель</string> <string name="prot_cloudflare_sub">1.1.1.1 · через туннель</string>
<string name="prot_adguard_sub">Со встроенной фильтрацией</string> <string name="prot_adguard_sub">Со встроенной фильтрацией</string>
<string name="prot_custom">Свой адрес</string> <string name="prot_custom">Свой адрес</string>
<string name="prot_custom_sub">IPv4 или IPv6 через запятую</string> <string name="prot_custom_sub">IP, DoH (https://) или DoT (tls://)</string>
<string name="prot_note">Запросы к этому DNS идут через туннель — провайдер не сможет подменить ответ. Приложения, которые идут мимо VPN, используют DNS сети.</string> <string name="prot_note">Запросы к этому DNS идут через туннель — провайдер не сможет подменить ответ. Приложения, которые идут мимо VPN, используют DNS сети.</string>
<string name="prot_applies">Применится при переподключении</string> <string name="prot_applies">Применится при переподключении</string>
<string name="prot_list_ads">реклама</string> <string name="prot_list_ads">реклама</string>
@@ -1347,4 +1347,13 @@
<string name="server_needs_password">Нет сохранённого пароля: переключиться на этот сервер не получится. Сохраните пароль в его профиле.</string> <string name="server_needs_password">Нет сохранённого пароля: переключиться на этот сервер не получится. Сохраните пароль в его профиле.</string>
<string name="event_auto_pw_title">«%1$s» не подключён автоматически</string> <string name="event_auto_pw_title">«%1$s» не подключён автоматически</string>
<string name="event_auto_pw_text">Сетевое правило или сценарий хотели включить VPN, но в профиле нет сохранённого пароля. Сохраните пароль в профиле.</string> <string name="event_auto_pw_text">Сетевое правило или сценарий хотели включить VPN, но в профиле нет сохранённого пароля. Сохраните пароль в профиле.</string>
<string name="event_crash_title">Приложение аварийно завершилось</string>
<string name="event_crash_text">В прошлый раз SHX VPN закрылся из-за сбоя. Отправьте отчёт, чтобы мы нашли причину.</string>
<string name="event_lockdown_off_title">Постоянный VPN выключили</string>
<string name="event_lockdown_off_text">Раньше он был включён. Теперь при обрыве VPN приложения на секунды выходят в сеть напрямую. Включите его снова в настройках VPN Android.</string>
<string name="prot_secure_ok">Сервер ответил за %1$d мс. DNS-запросы будут шифроваться.</string>
<string name="prot_secure_fail">Сервер не ответил (%1$s). Пока он недоступен, VPN использует DNS профиля.</string>
<string name="prot_secure_note">Запросы DoH/DoT идут к вашему серверу в зашифрованном виде через обычную сеть, а не через туннель. Если сервер не отвечает, VPN использует DNS профиля. При строгом «Частном DNS» Android использует свой сервер.</string>
<string name="event_secure_dns_title">Свой DoH/DoT-сервер не отвечает</string>
<string name="event_secure_dns_text">VPN подключился с DNS профиля. Проверьте адрес в «Настройки → DNS и реклама».</string>
</resources> </resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Отчёт уйдёт команде SHX по защищённому каналу. По коду отчёта поддержка найдёт ваш журнал.</string>
<string name="report_contents">Что войдёт в отчёт</string>
<string name="report_item_log">Журнал подключения за сегодня</string>
<string name="report_item_device">Версия приложения и модель телефона</string>
<string name="report_item_network">Тип сети и активный сервер</string>
<string name="report_item_ips">IP-адреса серверов из журнала</string>
<string name="report_diag_title">Приложить диагностику</string>
<string name="report_diag_sub">Результаты проверки сети и расширенный журнал приложения</string>
<string name="report_comment_label">Что случилось (необязательно)</string>
<string name="report_cancel">Отмена</string>
<string name="report_send">Отправить</string>
<string name="report_by_mail">Отправить по почте</string>
<string name="report_sent_title">Отчёт отправлен</string>
<string name="report_sent_sub">Назовите этот код поддержке</string>
<string name="report_code_label">Код отчёта</string>
<string name="report_copy_code">Скопировать код</string>
<string name="report_code_copied">Код скопирован</string>
<string name="report_saved_note">Код сохранён в «Событиях», его можно найти позже.</string>
<string name="report_done">Готово</string>
<string name="report_failed_title">Отчёт не отправлен</string>
<string name="report_failed_no_answer">Сервер не ответил</string>
<string name="report_failed_refused">Сервер отклонил отчёт</string>
<string name="report_reasons">Возможные причины</string>
<string name="report_reason_offline">Нет интернета</string>
<string name="report_reason_offline_d">проверьте сеть и повторите</string>
<string name="report_reason_server">Сервер недоступен</string>
<string name="report_reason_server_d">повторите через несколько минут</string>
<string name="report_reason_clock">Часы телефона сбиты</string>
<string name="report_reason_clock_d">включите автоматическое время и повторите</string>
<string name="report_reason_often">Слишком часто</string>
<string name="report_reason_often_d">попробуйте через час</string>
<string name="report_reason_large">Журнал слишком большой</string>
<string name="report_reason_large_d">отправьте его письмом</string>
<string name="report_reason_rejected">Запрос отклонён</string>
<string name="report_reason_rejected_d">отправьте журнал письмом</string>
<string name="report_mail_note">Журнал можно отправить и письмом, как раньше.</string>
<string name="report_retry">Повторить</string>
<string name="report_by_letter">Отправить письмом</string>
<string name="report_event_title">Отчёт отправлен, код %1$s</string>
<string name="report_event_text">Назовите этот код поддержке, чтобы она нашла ваш журнал.</string>
</resources>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">У вас %1$s · %2$s</string>
<string name="updates_review">Посмотреть и установить</string>
<string name="updates_section_auto">Автоматически</string>
<string name="updates_auto_check">Проверять сами</string>
<string name="updates_silent">Ставить тихо</string>
<string name="updates_silent_sub">Скачать по Wi-Fi и установить, когда VPN выключен</string>
<string name="updates_section_channel">Канал</string>
<string name="updates_channel_stable">Стабильный</string>
<string name="updates_channel_stable_sub">Проверенные версии</string>
<string name="updates_channel_beta">Бета</string>
<string name="updates_channel_beta_sub">Раньше, но с ошибками</string>
<string name="updates_channel_hint">Бета открывается по коду приглашения от администратора.</string>
<string name="updates_beta_title">Код приглашения в бету</string>
<string name="updates_beta_text">Введите код, который дал администратор. Без верного кода приложение останется на стабильных версиях.</string>
<string name="updates_beta_code">Код приглашения</string>
<string name="updates_beta_enable">Включить бету</string>
<string name="updates_check_now">Проверить сейчас</string>
<string name="updates_last_check">Последняя проверка: %1$s</string>
<string name="updates_never_checked">Ещё не проверяли</string>
<string name="updates_review_sub">У вас %1$s · %2$s · файл проверим перед установкой</string>
<string name="updates_review_safe">Приложение сверит контрольную сумму и подпись SHX. Подделанный файл не установится.</string>
<string name="updates_downloading_title">Скачиваем %1$s</string>
<string name="updates_downloading_sub">Можно закрыть шторку: загрузка продолжится</string>
<string name="updates_progress">%1$d%% · %2$s из %3$s</string>
<string name="updates_step_list">Список версий получен</string>
<string name="updates_step_list_sub">По защищённому каналу</string>
<string name="updates_step_download">Скачивание файла</string>
<string name="updates_step_verify">Проверка файла</string>
<string name="updates_step_verify_sub">Контрольная сумма и подпись SHX</string>
<string name="updates_step_install">Установка</string>
<string name="updates_step_install_sub">Android спросит подтверждение</string>
<string name="updates_cancel">Отменить</string>
<string name="updates_mandatory_title">Нужно обновить SHX VPN</string>
<string name="updates_mandatory_sub">Версия %1$s больше не поддерживается</string>
<string name="updates_mandatory_what">Что это значит</string>
<string name="updates_mandatory_reason">Сервер больше не принимает подключения со старой версии</string>
<string name="updates_mandatory_install">Установите %1$s (%2$s), ваши серверы и настройки сохранятся</string>
<string name="updates_mandatory_locked">Закрыть эту шторку нельзя, пока приложение не обновлено.</string>
<string name="updates_exit">Выйти из приложения</string>
<string name="updates_failed_title">Обновление не установлено</string>
<string name="updates_failed_sub_check">Файл не прошёл проверку</string>
<string name="updates_failed_sub_network">Файл не скачался</string>
<string name="updates_failed_sub_install">Android не установил файл</string>
<string name="updates_failed_sub_other">Что-то пошло не так</string>
<string name="updates_failed_what">Что произошло</string>
<string name="updates_reason_hash">Контрольная сумма не совпала</string>
<string name="updates_reason_hash_d">файл повреждён по дороге, копия удалена</string>
<string name="updates_reason_signature">Подпись не совпала</string>
<string name="updates_reason_signature_d">файл подписан не SHX, копия удалена</string>
<string name="updates_reason_network">Нет связи с сервером обновлений</string>
<string name="updates_reason_network_d">загрузка прервалась</string>
<string name="updates_reason_install">Android отклонил установку</string>
<string name="updates_reason_install_d">системный установщик вернул ошибку</string>
<string name="updates_reason_other">Непредвиденная ошибка</string>
<string name="updates_reason_other_d">подробности для поддержки — ниже</string>
<string name="updates_reason_same">Версия не изменилась</string>
<string name="updates_reason_same_d">у вас по-прежнему %1$s</string>
<string name="updates_reason_later">Попробуйте позже</string>
<string name="updates_reason_later_d">или смените сеть: Wi-Fi вместо мобильной</string>
<string name="updates_manual_note">Установить вручную можно со страницы скачивания. Ваши серверы и настройки останутся на месте.</string>
<string name="updates_retry">Повторить</string>
<string name="updates_from_site">Скачать с сайта</string>
<string name="updates_support">Подробности для поддержки</string>
<string name="updates_copied">Скопировано</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Раз в %d час, когда телефон на зарядке</item>
<item quantity="few">Раз в %d часа, когда телефон на зарядке</item>
<item quantity="many">Раз в %d часов, когда телефон на зарядке</item>
<item quantity="other">Раз в %d часа, когда телефон на зарядке</item>
</plurals>
</resources>
@@ -1151,7 +1151,7 @@
<string name="prot_cloudflare_sub">1.1.1.1 · через тунель</string> <string name="prot_cloudflare_sub">1.1.1.1 · через тунель</string>
<string name="prot_adguard_sub">З вбудованою фільтрацією</string> <string name="prot_adguard_sub">З вбудованою фільтрацією</string>
<string name="prot_custom">Своя адреса</string> <string name="prot_custom">Своя адреса</string>
<string name="prot_custom_sub">IPv4 або IPv6 через кому</string> <string name="prot_custom_sub">IP, DoH (https://) або DoT (tls://)</string>
<string name="prot_note">Запити до цього DNS ідуть через тунель — провайдер не зможе підмінити відповідь. Застосунки, що йдуть повз VPN, використовують DNS мережі.</string> <string name="prot_note">Запити до цього DNS ідуть через тунель — провайдер не зможе підмінити відповідь. Застосунки, що йдуть повз VPN, використовують DNS мережі.</string>
<string name="prot_applies">Застосується після перепідключення</string> <string name="prot_applies">Застосується після перепідключення</string>
<string name="prot_list_ads">реклама</string> <string name="prot_list_ads">реклама</string>
@@ -1246,4 +1246,13 @@
<item quantity="many">%d довірених сертифікатів</item> <item quantity="many">%d довірених сертифікатів</item>
<item quantity="other">%d довіреного сертифіката</item> <item quantity="other">%d довіреного сертифіката</item>
</plurals> </plurals>
<string name="event_crash_title">Застосунок аварійно завершився</string>
<string name="event_crash_text">Минулого разу SHX VPN закрився через збій. Надішліть звіт, щоб ми знайшли причину.</string>
<string name="event_lockdown_off_title">Постійну VPN вимкнули</string>
<string name="event_lockdown_off_text">Раніше її було ввімкнено. Тепер у разі обриву VPN застосунки на секунди виходять у мережу напряму. Увімкніть її знову в налаштуваннях VPN Android.</string>
<string name="prot_secure_ok">Сервер відповів за %1$d мс. DNS-запити шифруватимуться.</string>
<string name="prot_secure_fail">Сервер не відповів (%1$s). Поки він недоступний, VPN використовує DNS профілю.</string>
<string name="prot_secure_note">Запити DoH/DoT ідуть до вашого сервера в зашифрованому вигляді через звичайну мережу, а не через тунель. Якщо сервер не відповідає, VPN використовує DNS профілю. За суворого «Приватного DNS» Android використовує свій сервер.</string>
<string name="event_secure_dns_title">Власний DoH/DoT-сервер не відповідає</string>
<string name="event_secure_dns_text">VPN підключився з DNS профілю. Перевірте адресу в «Налаштування → DNS і реклама».</string>
</resources> </resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Звіт піде команді SHX захищеним каналом. За кодом звіту підтримка знайде ваш журнал.</string>
<string name="report_contents">Що увійде у звіт</string>
<string name="report_item_log">Журнал підключення за сьогодні</string>
<string name="report_item_device">Версія застосунку й модель телефона</string>
<string name="report_item_network">Тип мережі й активний сервер</string>
<string name="report_item_ips">IP-адреси серверів із журналу</string>
<string name="report_diag_title">Додати діагностику</string>
<string name="report_diag_sub">Результати перевірки мережі та розширений журнал застосунку</string>
<string name="report_comment_label">Що сталося (необов’язково)</string>
<string name="report_cancel">Скасувати</string>
<string name="report_send">Надіслати</string>
<string name="report_by_mail">Надіслати поштою</string>
<string name="report_sent_title">Звіт надіслано</string>
<string name="report_sent_sub">Назвіть цей код підтримці</string>
<string name="report_code_label">Код звіту</string>
<string name="report_copy_code">Скопіювати код</string>
<string name="report_code_copied">Код скопійовано</string>
<string name="report_saved_note">Код збережено в «Подіях», його можна знайти пізніше.</string>
<string name="report_done">Готово</string>
<string name="report_failed_title">Звіт не надіслано</string>
<string name="report_failed_no_answer">Сервер не відповів</string>
<string name="report_failed_refused">Сервер відхилив звіт</string>
<string name="report_reasons">Можливі причини</string>
<string name="report_reason_offline">Немає інтернету</string>
<string name="report_reason_offline_d">перевірте мережу й повторіть</string>
<string name="report_reason_server">Сервер недоступний</string>
<string name="report_reason_server_d">повторіть за кілька хвилин</string>
<string name="report_reason_clock">Годинник телефона збитий</string>
<string name="report_reason_clock_d">увімкніть автоматичний час і повторіть</string>
<string name="report_reason_often">Надто часто</string>
<string name="report_reason_often_d">спробуйте за годину</string>
<string name="report_reason_large">Журнал завеликий</string>
<string name="report_reason_large_d">надішліть його листом</string>
<string name="report_reason_rejected">Запит відхилено</string>
<string name="report_reason_rejected_d">надішліть журнал листом</string>
<string name="report_mail_note">Журнал можна надіслати й листом, як раніше.</string>
<string name="report_retry">Повторити</string>
<string name="report_by_letter">Надіслати листом</string>
<string name="report_event_title">Звіт надіслано, код %1$s</string>
<string name="report_event_text">Назвіть цей код підтримці, щоб вона знайшла ваш журнал.</string>
</resources>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">У вас %1$s · %2$s</string>
<string name="updates_review">Переглянути й установити</string>
<string name="updates_section_auto">Автоматично</string>
<string name="updates_auto_check">Перевіряти самостійно</string>
<string name="updates_silent">Установлювати тихо</string>
<string name="updates_silent_sub">Завантажити через Wi-Fi й установити, коли VPN вимкнено</string>
<string name="updates_section_channel">Канал</string>
<string name="updates_channel_stable">Стабільний</string>
<string name="updates_channel_stable_sub">Перевірені версії</string>
<string name="updates_channel_beta">Бета</string>
<string name="updates_channel_beta_sub">Раніше, але з помилками</string>
<string name="updates_channel_hint">Бета відкривається за кодом запрошення від адміністратора.</string>
<string name="updates_beta_title">Код запрошення до бети</string>
<string name="updates_beta_text">Введіть код, який дав адміністратор. Без правильного коду застосунок залишиться на стабільних версіях.</string>
<string name="updates_beta_code">Код запрошення</string>
<string name="updates_beta_enable">Увімкнути бету</string>
<string name="updates_check_now">Перевірити зараз</string>
<string name="updates_last_check">Остання перевірка: %1$s</string>
<string name="updates_never_checked">Ще не перевіряли</string>
<string name="updates_review_sub">У вас %1$s · %2$s · файл перевіримо перед установленням</string>
<string name="updates_review_safe">Застосунок звірить контрольну суму й підпис SHX. Підроблений файл не встановиться.</string>
<string name="updates_downloading_title">Завантажуємо %1$s</string>
<string name="updates_downloading_sub">Можна закрити шторку: завантаження триватиме</string>
<string name="updates_progress">%1$d%% · %2$s з %3$s</string>
<string name="updates_step_list">Список версій отримано</string>
<string name="updates_step_list_sub">Через захищений канал</string>
<string name="updates_step_download">Завантаження файлу</string>
<string name="updates_step_verify">Перевірка файлу</string>
<string name="updates_step_verify_sub">Контрольна сума й підпис SHX</string>
<string name="updates_step_install">Установлення</string>
<string name="updates_step_install_sub">Android попросить підтвердження</string>
<string name="updates_cancel">Скасувати</string>
<string name="updates_mandatory_title">Потрібно оновити SHX VPN</string>
<string name="updates_mandatory_sub">Версія %1$s більше не підтримується</string>
<string name="updates_mandatory_what">Що це означає</string>
<string name="updates_mandatory_reason">Сервер більше не приймає підключення зі старої версії</string>
<string name="updates_mandatory_install">Установіть %1$s (%2$s), ваші сервери й налаштування збережуться</string>
<string name="updates_mandatory_locked">Цю шторку не можна закрити, доки застосунок не оновлено.</string>
<string name="updates_exit">Вийти із застосунку</string>
<string name="updates_failed_title">Оновлення не встановлено</string>
<string name="updates_failed_sub_check">Файл не пройшов перевірку</string>
<string name="updates_failed_sub_network">Файл не завантажився</string>
<string name="updates_failed_sub_install">Android не встановив файл</string>
<string name="updates_failed_sub_other">Щось пішло не так</string>
<string name="updates_failed_what">Що сталося</string>
<string name="updates_reason_hash">Контрольна сума не збіглася</string>
<string name="updates_reason_hash_d">файл пошкоджено дорогою, копію видалено</string>
<string name="updates_reason_signature">Підпис не збігся</string>
<string name="updates_reason_signature_d">файл підписано не SHX, копію видалено</string>
<string name="updates_reason_network">Немає зв’язку із сервером оновлень</string>
<string name="updates_reason_network_d">завантаження перервалося</string>
<string name="updates_reason_install">Android відхилив установлення</string>
<string name="updates_reason_install_d">системний установник повернув помилку</string>
<string name="updates_reason_other">Непередбачена помилка</string>
<string name="updates_reason_other_d">подробиці для підтримки — нижче</string>
<string name="updates_reason_same">Версія не змінилася</string>
<string name="updates_reason_same_d">у вас і далі %1$s</string>
<string name="updates_reason_later">Спробуйте пізніше</string>
<string name="updates_reason_later_d">або змініть мережу: Wi-Fi замість мобільної</string>
<string name="updates_manual_note">Установити вручну можна зі сторінки завантаження. Ваші сервери й налаштування залишаться на місці.</string>
<string name="updates_retry">Повторити</string>
<string name="updates_from_site">Завантажити із сайту</string>
<string name="updates_support">Подробиці для підтримки</string>
<string name="updates_copied">Скопійовано</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Раз на %d годину, коли телефон заряджається</item>
<item quantity="few">Раз на %d години, коли телефон заряджається</item>
<item quantity="many">Раз на %d годин, коли телефон заряджається</item>
<item quantity="other">Раз на %d години, коли телефон заряджається</item>
</plurals>
</resources>
@@ -1150,7 +1150,7 @@
<string name="prot_cloudflare_sub">1.1.1.1 · 经隧道</string> <string name="prot_cloudflare_sub">1.1.1.1 · 经隧道</string>
<string name="prot_adguard_sub">内置过滤</string> <string name="prot_adguard_sub">内置过滤</string>
<string name="prot_custom">自定义地址</string> <string name="prot_custom">自定义地址</string>
<string name="prot_custom_sub">IPv4 或 IPv6,以逗号分隔</string> <string name="prot_custom_sub">IP、DoH (https://) 或 DoT (tls://)</string>
<string name="prot_note">对此 DNS 的查询经隧道发送,运营商无法篡改应答。绕过 VPN 的应用使用网络自身的 DNS。</string> <string name="prot_note">对此 DNS 的查询经隧道发送,运营商无法篡改应答。绕过 VPN 的应用使用网络自身的 DNS。</string>
<string name="prot_applies">重新连接后生效</string> <string name="prot_applies">重新连接后生效</string>
<string name="prot_list_ads">广告</string> <string name="prot_list_ads">广告</string>
@@ -1215,4 +1215,13 @@
<plurals name="settings_certs_n"> <plurals name="settings_certs_n">
<item quantity="other">%d 个受信任的证书</item> <item quantity="other">%d 个受信任的证书</item>
</plurals> </plurals>
<string name="event_crash_title">应用意外关闭</string>
<string name="event_crash_text">SHX VPN 上次发生崩溃。请发送报告,帮助我们找到原因。</string>
<string name="event_lockdown_off_title">始终开启的 VPN 已被关闭</string>
<string name="event_lockdown_off_text">此前它处于开启状态。现在每次 VPN 断开时,应用都会直接联网几秒钟。请在 Android 的 VPN 设置中重新开启。</string>
<string name="prot_secure_ok">服务器在 %1$d 毫秒内响应。DNS 查询将被加密。</string>
<string name="prot_secure_fail">服务器没有响应(%1$s)。在它恢复之前,VPN 使用配置文件中的 DNS。</string>
<string name="prot_secure_note">DoH/DoT 查询会经由普通网络加密发送到你的服务器,而不经过隧道。如果服务器没有响应,VPN 会使用配置文件中的 DNS。启用严格的私人 DNS 时,Android 会使用其自己的服务器。</string>
<string name="event_secure_dns_title">自定义 DoH/DoT 服务器没有响应</string>
<string name="event_secure_dns_text">VPN 已使用配置文件中的 DNS 连接。请在“设置 → DNS 与广告”中检查地址。</string>
</resources> </resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">报告将通过加密通道发送给 SHX 团队。客服凭报告代码即可找到你的日志。</string>
<string name="report_contents">报告包含的内容</string>
<string name="report_item_log">今天的连接日志</string>
<string name="report_item_device">应用版本和手机型号</string>
<string name="report_item_network">网络类型和当前服务器</string>
<string name="report_item_ips">日志中的服务器 IP 地址</string>
<string name="report_diag_title">附加诊断信息</string>
<string name="report_diag_sub">网络检查结果和应用的详细日志</string>
<string name="report_comment_label">发生了什么(可选)</string>
<string name="report_cancel">取消</string>
<string name="report_send">发送</string>
<string name="report_by_mail">通过邮件发送</string>
<string name="report_sent_title">报告已发送</string>
<string name="report_sent_sub">请把此代码告诉客服</string>
<string name="report_code_label">报告代码</string>
<string name="report_copy_code">复制代码</string>
<string name="report_code_copied">代码已复制</string>
<string name="report_saved_note">代码已保存在“事件”中,之后可以在那里找到。</string>
<string name="report_done">完成</string>
<string name="report_failed_title">报告未发送</string>
<string name="report_failed_no_answer">服务器没有响应</string>
<string name="report_failed_refused">服务器拒绝了报告</string>
<string name="report_reasons">可能的原因</string>
<string name="report_reason_offline">没有网络</string>
<string name="report_reason_offline_d">检查网络后重试</string>
<string name="report_reason_server">服务器不可用</string>
<string name="report_reason_server_d">几分钟后重试</string>
<string name="report_reason_clock">手机时间不准</string>
<string name="report_reason_clock_d">开启自动时间后重试</string>
<string name="report_reason_often">过于频繁</string>
<string name="report_reason_often_d">一小时后再试</string>
<string name="report_reason_large">日志过大</string>
<string name="report_reason_large_d">请通过邮件发送</string>
<string name="report_reason_rejected">请求被拒绝</string>
<string name="report_reason_rejected_d">请通过邮件发送日志</string>
<string name="report_mail_note">也可以像以前一样通过邮件发送日志。</string>
<string name="report_retry">重试</string>
<string name="report_by_letter">通过邮件发送</string>
<string name="report_event_title">报告已发送,代码 %1$s</string>
<string name="report_event_text">把此代码告诉客服,以便找到你的日志。</string>
</resources>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">当前 %1$s · %2$s</string>
<string name="updates_review">查看并安装</string>
<string name="updates_section_auto">自动</string>
<string name="updates_auto_check">自动检查</string>
<string name="updates_silent">静默安装</string>
<string name="updates_silent_sub">通过 Wi-Fi 下载,并在 VPN 关闭时安装</string>
<string name="updates_section_channel">渠道</string>
<string name="updates_channel_stable">稳定版</string>
<string name="updates_channel_stable_sub">经过测试的版本</string>
<string name="updates_channel_beta">测试版</string>
<string name="updates_channel_beta_sub">更早获得,但可能有错误</string>
<string name="updates_channel_hint">测试版需要管理员提供的邀请码。</string>
<string name="updates_beta_title">测试版邀请码</string>
<string name="updates_beta_text">输入管理员提供的邀请码。没有有效的邀请码时,应用仍使用稳定版。</string>
<string name="updates_beta_code">邀请码</string>
<string name="updates_beta_enable">启用测试版</string>
<string name="updates_check_now">立即检查</string>
<string name="updates_last_check">上次检查:%1$s</string>
<string name="updates_never_checked">尚未检查</string>
<string name="updates_review_sub">当前 %1$s · %2$s · 安装前会校验文件</string>
<string name="updates_review_safe">应用会校验校验和与 SHX 签名,伪造的文件无法安装。</string>
<string name="updates_downloading_title">正在下载 %1$s</string>
<string name="updates_downloading_sub">可以关闭此面板,下载会继续</string>
<string name="updates_progress">%1$d%% · %2$s / %3$s</string>
<string name="updates_step_list">已获取版本列表</string>
<string name="updates_step_list_sub">通过受保护的通道</string>
<string name="updates_step_download">下载文件</string>
<string name="updates_step_verify">校验文件</string>
<string name="updates_step_verify_sub">校验和与 SHX 签名</string>
<string name="updates_step_install">安装</string>
<string name="updates_step_install_sub">Android 会请求确认</string>
<string name="updates_cancel">取消</string>
<string name="updates_mandatory_title">需要更新 SHX VPN</string>
<string name="updates_mandatory_sub">版本 %1$s 已不再受支持</string>
<string name="updates_mandatory_what">这意味着什么</string>
<string name="updates_mandatory_reason">服务器不再接受旧版本的连接</string>
<string name="updates_mandatory_install">安装 %1$s(%2$s),你的服务器和设置会保留</string>
<string name="updates_mandatory_locked">应用更新前无法关闭此面板。</string>
<string name="updates_exit">退出应用</string>
<string name="updates_failed_title">更新未安装</string>
<string name="updates_failed_sub_check">文件未通过校验</string>
<string name="updates_failed_sub_network">文件未能下载</string>
<string name="updates_failed_sub_install">Android 未安装该文件</string>
<string name="updates_failed_sub_other">出现问题</string>
<string name="updates_failed_what">发生了什么</string>
<string name="updates_reason_hash">校验和不匹配</string>
<string name="updates_reason_hash_d">文件在传输中损坏,副本已删除</string>
<string name="updates_reason_signature">签名不匹配</string>
<string name="updates_reason_signature_d">文件不是由 SHX 签名的,副本已删除</string>
<string name="updates_reason_network">无法连接更新服务器</string>
<string name="updates_reason_network_d">下载已中断</string>
<string name="updates_reason_install">Android 拒绝了安装</string>
<string name="updates_reason_install_d">系统安装程序返回了错误</string>
<string name="updates_reason_other">意外错误</string>
<string name="updates_reason_other_d">可在下方复制给支持人员的详细信息</string>
<string name="updates_reason_same">版本未变化</string>
<string name="updates_reason_same_d">当前仍为 %1$s</string>
<string name="updates_reason_later">请稍后再试</string>
<string name="updates_reason_later_d">或切换网络:用 Wi-Fi 代替移动数据</string>
<string name="updates_manual_note">也可以从下载页面手动安装,你的服务器和设置会保留。</string>
<string name="updates_retry">重试</string>
<string name="updates_from_site">从网站下载</string>
<string name="updates_support">复制给支持人员的详细信息</string>
<string name="updates_copied">已复制</string>
<plurals name="updates_auto_check_sub">
<item quantity="other">手机充电时每 %d 小时检查一次</item>
</plurals>
</resources>
@@ -1135,7 +1135,7 @@
<string name="prot_cloudflare_sub">1.1.1.1 · 經通道</string> <string name="prot_cloudflare_sub">1.1.1.1 · 經通道</string>
<string name="prot_adguard_sub">內建過濾</string> <string name="prot_adguard_sub">內建過濾</string>
<string name="prot_custom">自訂位址</string> <string name="prot_custom">自訂位址</string>
<string name="prot_custom_sub">IPv4 或 IPv6,以逗號分隔</string> <string name="prot_custom_sub">IP、DoH (https://) 或 DoT (tls://)</string>
<string name="prot_note">對此 DNS 的查詢經通道傳送,電信業者無法竄改回應。繞過 VPN 的應用程式使用網路本身的 DNS。</string> <string name="prot_note">對此 DNS 的查詢經通道傳送,電信業者無法竄改回應。繞過 VPN 的應用程式使用網路本身的 DNS。</string>
<string name="prot_applies">重新連線後生效</string> <string name="prot_applies">重新連線後生效</string>
<string name="prot_list_ads">廣告</string> <string name="prot_list_ads">廣告</string>
@@ -1200,4 +1200,13 @@
<plurals name="settings_certs_n"> <plurals name="settings_certs_n">
<item quantity="other">%d 個信任的憑證</item> <item quantity="other">%d 個信任的憑證</item>
</plurals> </plurals>
<string name="event_crash_title">應用程式意外關閉</string>
<string name="event_crash_text">SHX VPN 上次發生當機。請傳送報告,協助我們找出原因。</string>
<string name="event_lockdown_off_title">永久連線的 VPN 已被關閉</string>
<string name="event_lockdown_off_text">先前它處於開啟狀態。現在每次 VPN 中斷時,應用程式都會直接連網幾秒鐘。請在 Android 的 VPN 設定中重新開啟。</string>
<string name="prot_secure_ok">伺服器在 %1$d 毫秒內回應。DNS 查詢將會加密。</string>
<string name="prot_secure_fail">伺服器沒有回應(%1$s)。在恢復之前,VPN 會使用設定檔中的 DNS。</string>
<string name="prot_secure_note">DoH/DoT 查詢會經由一般網路加密傳送到你的伺服器,而不經過通道。如果伺服器沒有回應,VPN 會使用設定檔中的 DNS。啟用嚴格的私人 DNS 時,Android 會使用自己的伺服器。</string>
<string name="event_secure_dns_title">自訂 DoH/DoT 伺服器沒有回應</string>
<string name="event_secure_dns_text">VPN 已使用設定檔中的 DNS 連線。請在「設定 → DNS 與廣告」中檢查位址。</string>
</resources> </resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">報告將透過加密通道傳送給 SHX 團隊。客服憑報告代碼即可找到你的日誌。</string>
<string name="report_contents">報告包含的內容</string>
<string name="report_item_log">今天的連線日誌</string>
<string name="report_item_device">應用程式版本和手機型號</string>
<string name="report_item_network">網路類型和目前伺服器</string>
<string name="report_item_ips">日誌中的伺服器 IP 位址</string>
<string name="report_diag_title">附加診斷資訊</string>
<string name="report_diag_sub">網路檢查結果和應用程式的詳細日誌</string>
<string name="report_comment_label">發生了什麼(選填)</string>
<string name="report_cancel">取消</string>
<string name="report_send">傳送</string>
<string name="report_by_mail">透過電子郵件傳送</string>
<string name="report_sent_title">報告已傳送</string>
<string name="report_sent_sub">請把此代碼告訴客服</string>
<string name="report_code_label">報告代碼</string>
<string name="report_copy_code">複製代碼</string>
<string name="report_code_copied">代碼已複製</string>
<string name="report_saved_note">代碼已儲存在「事件」中,之後可以在那裡找到。</string>
<string name="report_done">完成</string>
<string name="report_failed_title">報告未傳送</string>
<string name="report_failed_no_answer">伺服器沒有回應</string>
<string name="report_failed_refused">伺服器拒絕了報告</string>
<string name="report_reasons">可能的原因</string>
<string name="report_reason_offline">沒有網路</string>
<string name="report_reason_offline_d">檢查網路後重試</string>
<string name="report_reason_server">伺服器無法使用</string>
<string name="report_reason_server_d">幾分鐘後重試</string>
<string name="report_reason_clock">手機時間不準</string>
<string name="report_reason_clock_d">開啟自動時間後重試</string>
<string name="report_reason_often">過於頻繁</string>
<string name="report_reason_often_d">一小時後再試</string>
<string name="report_reason_large">日誌過大</string>
<string name="report_reason_large_d">請透過電子郵件傳送</string>
<string name="report_reason_rejected">要求被拒絕</string>
<string name="report_reason_rejected_d">請透過電子郵件傳送日誌</string>
<string name="report_mail_note">也可以像以前一樣透過電子郵件傳送日誌。</string>
<string name="report_retry">重試</string>
<string name="report_by_letter">透過電子郵件傳送</string>
<string name="report_event_title">報告已傳送,代碼 %1$s</string>
<string name="report_event_text">把此代碼告訴客服,以便找到你的日誌。</string>
</resources>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">目前 %1$s · %2$s</string>
<string name="updates_review">檢視並安裝</string>
<string name="updates_section_auto">自動</string>
<string name="updates_auto_check">自動檢查</string>
<string name="updates_silent">靜默安裝</string>
<string name="updates_silent_sub">透過 Wi-Fi 下載,並在 VPN 關閉時安裝</string>
<string name="updates_section_channel">頻道</string>
<string name="updates_channel_stable">穩定版</string>
<string name="updates_channel_stable_sub">經過測試的版本</string>
<string name="updates_channel_beta">測試版</string>
<string name="updates_channel_beta_sub">更早取得,但可能有錯誤</string>
<string name="updates_channel_hint">測試版需要管理員提供的邀請碼。</string>
<string name="updates_beta_title">測試版邀請碼</string>
<string name="updates_beta_text">輸入管理員提供的邀請碼。沒有有效的邀請碼時,應用程式仍使用穩定版。</string>
<string name="updates_beta_code">邀請碼</string>
<string name="updates_beta_enable">啟用測試版</string>
<string name="updates_check_now">立即檢查</string>
<string name="updates_last_check">上次檢查:%1$s</string>
<string name="updates_never_checked">尚未檢查</string>
<string name="updates_review_sub">目前 %1$s · %2$s · 安裝前會驗證檔案</string>
<string name="updates_review_safe">應用程式會驗證檢查碼與 SHX 簽章,偽造的檔案無法安裝。</string>
<string name="updates_downloading_title">正在下載 %1$s</string>
<string name="updates_downloading_sub">可以關閉此面板,下載會繼續</string>
<string name="updates_progress">%1$d%% · %2$s / %3$s</string>
<string name="updates_step_list">已取得版本清單</string>
<string name="updates_step_list_sub">透過受保護的通道</string>
<string name="updates_step_download">下載檔案</string>
<string name="updates_step_verify">驗證檔案</string>
<string name="updates_step_verify_sub">檢查碼與 SHX 簽章</string>
<string name="updates_step_install">安裝</string>
<string name="updates_step_install_sub">Android 會要求確認</string>
<string name="updates_cancel">取消</string>
<string name="updates_mandatory_title">需要更新 SHX VPN</string>
<string name="updates_mandatory_sub">版本 %1$s 已不再支援</string>
<string name="updates_mandatory_what">這代表什麼</string>
<string name="updates_mandatory_reason">伺服器不再接受舊版本的連線</string>
<string name="updates_mandatory_install">安裝 %1$s(%2$s),你的伺服器和設定會保留</string>
<string name="updates_mandatory_locked">應用程式更新前無法關閉此面板。</string>
<string name="updates_exit">結束應用程式</string>
<string name="updates_failed_title">更新未安裝</string>
<string name="updates_failed_sub_check">檔案未通過驗證</string>
<string name="updates_failed_sub_network">檔案未能下載</string>
<string name="updates_failed_sub_install">Android 未安裝該檔案</string>
<string name="updates_failed_sub_other">發生問題</string>
<string name="updates_failed_what">發生了什麼</string>
<string name="updates_reason_hash">檢查碼不相符</string>
<string name="updates_reason_hash_d">檔案在傳輸中損壞,副本已刪除</string>
<string name="updates_reason_signature">簽章不相符</string>
<string name="updates_reason_signature_d">檔案不是由 SHX 簽署,副本已刪除</string>
<string name="updates_reason_network">無法連線到更新伺服器</string>
<string name="updates_reason_network_d">下載已中斷</string>
<string name="updates_reason_install">Android 拒絕了安裝</string>
<string name="updates_reason_install_d">系統安裝程式傳回了錯誤</string>
<string name="updates_reason_other">非預期的錯誤</string>
<string name="updates_reason_other_d">可在下方複製給支援人員的詳細資訊</string>
<string name="updates_reason_same">版本未變更</string>
<string name="updates_reason_same_d">目前仍為 %1$s</string>
<string name="updates_reason_later">請稍後再試</string>
<string name="updates_reason_later_d">或切換網路:用 Wi-Fi 取代行動數據</string>
<string name="updates_manual_note">也可以從下載頁面手動安裝,你的伺服器和設定會保留。</string>
<string name="updates_retry">重試</string>
<string name="updates_from_site">從網站下載</string>
<string name="updates_support">複製給支援人員的詳細資訊</string>
<string name="updates_copied">已複製</string>
<plurals name="updates_auto_check_sub">
<item quantity="other">手機充電時每 %d 小時檢查一次</item>
</plurals>
</resources>
@@ -1298,7 +1298,7 @@
<string name="prot_cloudflare_sub">1.1.1.1 · through the tunnel</string> <string name="prot_cloudflare_sub">1.1.1.1 · through the tunnel</string>
<string name="prot_adguard_sub">With built-in filtering</string> <string name="prot_adguard_sub">With built-in filtering</string>
<string name="prot_custom">Own address</string> <string name="prot_custom">Own address</string>
<string name="prot_custom_sub">IPv4 or IPv6, comma separated</string> <string name="prot_custom_sub">IPs, DoH (https://) or DoT (tls://)</string>
<string name="prot_note">Queries to this DNS go through the tunnel — the provider cannot change the answer. Apps that bypass the VPN use the network\'s DNS.</string> <string name="prot_note">Queries to this DNS go through the tunnel — the provider cannot change the answer. Apps that bypass the VPN use the network\'s DNS.</string>
<string name="prot_applies">Applies on reconnect</string> <string name="prot_applies">Applies on reconnect</string>
<string name="prot_list_ads">ads</string> <string name="prot_list_ads">ads</string>
@@ -1333,4 +1333,13 @@
<string name="server_needs_password">No saved password: switching to it will not work. Save it in its profile.</string> <string name="server_needs_password">No saved password: switching to it will not work. Save it in its profile.</string>
<string name="event_auto_pw_title">«%1$s» was not connected automatically</string> <string name="event_auto_pw_title">«%1$s» was not connected automatically</string>
<string name="event_auto_pw_text">A network rule or a scenario wanted the VPN on, but the profile has no saved password. Save it in the profile.</string> <string name="event_auto_pw_text">A network rule or a scenario wanted the VPN on, but the profile has no saved password. Save it in the profile.</string>
<string name="event_crash_title">The app closed unexpectedly</string>
<string name="event_crash_text">SHX VPN crashed last time. Send a report so we can find the cause.</string>
<string name="event_lockdown_off_title">Always-on VPN was turned off</string>
<string name="event_lockdown_off_text">It was on before. Now apps go online directly for a few seconds whenever the VPN drops. Turn it back on in the Android VPN settings.</string>
<string name="prot_secure_ok">The server answered in %1$d ms. DNS queries will be encrypted.</string>
<string name="prot_secure_fail">The server did not answer (%1$s). Until it does, the VPN uses the profile’s DNS.</string>
<string name="prot_secure_note">DoH/DoT queries go to your server encrypted over the regular network, not through the tunnel. If it does not answer, the VPN uses the profile’s DNS. With strict Private DNS, Android uses its own server.</string>
<string name="event_secure_dns_title">Own DoH/DoT server is not answering</string>
<string name="event_secure_dns_text">The VPN connected with the profile’s DNS. Check the address in Settings → DNS and ads.</string>
</resources> </resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">The report goes to the SHX team over a secure channel. With the report code, support will find your log.</string>
<string name="report_contents">What the report includes</string>
<string name="report_item_log">Today’s connection log</string>
<string name="report_item_device">App version and phone model</string>
<string name="report_item_network">Network type and active server</string>
<string name="report_item_ips">Server IP addresses from the log</string>
<string name="report_diag_title">Attach diagnostics</string>
<string name="report_diag_sub">Network check results and the app’s extended log</string>
<string name="report_comment_label">What happened (optional)</string>
<string name="report_cancel">Cancel</string>
<string name="report_send">Send</string>
<string name="report_by_mail">Send by email</string>
<string name="report_sent_title">Report sent</string>
<string name="report_sent_sub">Tell this code to support</string>
<string name="report_code_label">Report code</string>
<string name="report_copy_code">Copy code</string>
<string name="report_code_copied">Code copied</string>
<string name="report_saved_note">The code is saved in “Events”, you can find it there later.</string>
<string name="report_done">Done</string>
<string name="report_failed_title">Report not sent</string>
<string name="report_failed_no_answer">The server did not answer</string>
<string name="report_failed_refused">The server refused the report</string>
<string name="report_reasons">Possible reasons</string>
<string name="report_reason_offline">No internet</string>
<string name="report_reason_offline_d">check the network and retry</string>
<string name="report_reason_server">Server unavailable</string>
<string name="report_reason_server_d">retry in a few minutes</string>
<string name="report_reason_clock">Phone clock is off</string>
<string name="report_reason_clock_d">turn on automatic time and retry</string>
<string name="report_reason_often">Too often</string>
<string name="report_reason_often_d">try again in an hour</string>
<string name="report_reason_large">Log is too large</string>
<string name="report_reason_large_d">send it by email</string>
<string name="report_reason_rejected">Request refused</string>
<string name="report_reason_rejected_d">send the log by email</string>
<string name="report_mail_note">The log can also be sent by email, as before.</string>
<string name="report_retry">Retry</string>
<string name="report_by_letter">Send by email</string>
<string name="report_event_title">Report sent, code %1$s</string>
<string name="report_event_text">Tell this code to support so they can find your log.</string>
</resources>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">You have %1$s · %2$s</string>
<string name="updates_review">View and install</string>
<string name="updates_section_auto">Automatically</string>
<string name="updates_auto_check">Check by itself</string>
<string name="updates_silent">Install quietly</string>
<string name="updates_silent_sub">Download over Wi-Fi and install while the VPN is off</string>
<string name="updates_section_channel">Channel</string>
<string name="updates_channel_stable">Stable</string>
<string name="updates_channel_stable_sub">Tested versions</string>
<string name="updates_channel_beta">Beta</string>
<string name="updates_channel_beta_sub">Earlier, but with bugs</string>
<string name="updates_channel_hint">Beta opens with an invitation code from the administrator.</string>
<string name="updates_beta_title">Beta invitation code</string>
<string name="updates_beta_text">Enter the code the administrator gave you. Without a valid code the app stays on stable versions.</string>
<string name="updates_beta_code">Invitation code</string>
<string name="updates_beta_enable">Turn on beta</string>
<string name="updates_check_now">Check now</string>
<string name="updates_last_check">Last check: %1$s</string>
<string name="updates_never_checked">Not checked yet</string>
<string name="updates_review_sub">You have %1$s · %2$s · the file is checked before installing</string>
<string name="updates_review_safe">The app checks the checksum and the SHX signature. A forged file will not install.</string>
<string name="updates_downloading_title">Downloading %1$s</string>
<string name="updates_downloading_sub">You can close this sheet: the download goes on</string>
<string name="updates_progress">%1$d%% · %2$s of %3$s</string>
<string name="updates_step_list">Version list received</string>
<string name="updates_step_list_sub">Over a protected channel</string>
<string name="updates_step_download">Downloading the file</string>
<string name="updates_step_verify">Checking the file</string>
<string name="updates_step_verify_sub">Checksum and SHX signature</string>
<string name="updates_step_install">Installation</string>
<string name="updates_step_install_sub">Android will ask for confirmation</string>
<string name="updates_cancel">Cancel</string>
<string name="updates_mandatory_title">SHX VPN needs an update</string>
<string name="updates_mandatory_sub">Version %1$s is no longer supported</string>
<string name="updates_mandatory_what">What this means</string>
<string name="updates_mandatory_reason">The server no longer accepts connections from the old version</string>
<string name="updates_mandatory_install">Install %1$s (%2$s): your servers and settings stay</string>
<string name="updates_mandatory_locked">This sheet can’t be closed until the app is updated.</string>
<string name="updates_exit">Exit the app</string>
<string name="updates_failed_title">Update not installed</string>
<string name="updates_failed_sub_check">The file did not pass the check</string>
<string name="updates_failed_sub_network">The file did not download</string>
<string name="updates_failed_sub_install">Android did not install the file</string>
<string name="updates_failed_sub_other">Something went wrong</string>
<string name="updates_failed_what">What happened</string>
<string name="updates_reason_hash">Checksum mismatch</string>
<string name="updates_reason_hash_d">the file was damaged on the way, the copy is deleted</string>
<string name="updates_reason_signature">Signature mismatch</string>
<string name="updates_reason_signature_d">the file is not signed by SHX, the copy is deleted</string>
<string name="updates_reason_network">No connection to the update server</string>
<string name="updates_reason_network_d">the download was interrupted</string>
<string name="updates_reason_install">Android rejected the installation</string>
<string name="updates_reason_install_d">the system installer returned an error</string>
<string name="updates_reason_other">Unexpected error</string>
<string name="updates_reason_other_d">the details for support are below</string>
<string name="updates_reason_same">Version unchanged</string>
<string name="updates_reason_same_d">you still have %1$s</string>
<string name="updates_reason_later">Try again later</string>
<string name="updates_reason_later_d">or switch networks: Wi-Fi instead of mobile data</string>
<string name="updates_manual_note">You can install it manually from the download page. Your servers and settings stay in place.</string>
<string name="updates_retry">Retry</string>
<string name="updates_from_site">Download from the site</string>
<string name="updates_support">Details for support</string>
<string name="updates_copied">Copied</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Every %d hour while the phone is charging</item>
<item quantity="other">Every %d hours while the phone is charging</item>
</plurals>
</resources>
@@ -0,0 +1,92 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
class SaCloseReasonTest
{
private fun classify(disconnecting: Boolean = false, reauth: Boolean = false,
timeout: Boolean = false, netAgo: Long = -1) =
SaClose.classify(disconnecting, reauth, timeout, netAgo)
@Test
fun userStopWinsOverEverything()
{
assertThat(classify(disconnecting = true, reauth = true, timeout = true, netAgo = 0)).isEqualTo(SaClose.USER)
assertThat(SaClose.USER.drop).isFalse()
}
@Test
fun reauthIsNotADrop()
{
assertThat(classify(reauth = true, timeout = true, netAgo = 1000)).isEqualTo(SaClose.REAUTH)
assertThat(SaClose.REAUTH.drop).isFalse()
}
@Test
fun retransmitTimeoutWithAndWithoutNetworkChange()
{
assertThat(classify(timeout = true, netAgo = 5000)).isEqualTo(SaClose.RETRANSMIT_AFTER_NETWORK_CHANGE)
assertThat(classify(timeout = true)).isEqualTo(SaClose.RETRANSMIT_TIMEOUT)
assertThat(classify(timeout = true, netAgo = 21_000)).isEqualTo(SaClose.RETRANSMIT_TIMEOUT)
}
@Test
fun networkWindowBoundary()
{
assertThat(classify(netAgo = 0)).isEqualTo(SaClose.NETWORK_CHANGE)
assertThat(classify(netAgo = SaClose.NETWORK_WINDOW_MS)).isEqualTo(SaClose.NETWORK_CHANGE)
assertThat(classify(netAgo = SaClose.NETWORK_WINDOW_MS + 1)).isEqualTo(SaClose.OTHER)
}
@Test
fun noSignalsIsOther()
{
assertThat(classify()).isEqualTo(SaClose.OTHER)
assertThat(SaClose.OTHER.drop).isTrue()
}
@Test
fun counterCountsWithinADayAndResetsAtLocalMidnight()
{
val c = DailyCounter()
val day = 86_400_000L
assertThat(c.next(1000, 0)).isEqualTo(1)
assertThat(c.next(2000, 0)).isEqualTo(2)
assertThat(c.next(day - 1, 0)).isEqualTo(3)
assertThat(c.next(day, 0)).isEqualTo(1)
}
@Test
fun counterUsesTheLocalOffset()
{
val c = DailyCounter()
val hour = 3_600_000L
/* 23:30 UTC: past midnight at UTC+3 (21:30 and 23:30 UTC are one local day, the next hour is not),
* still the same local day an hour later at UTC-5 */
val t = 24 * hour - hour / 2
assertThat(c.next(t - 2 * hour, 3 * hour)).isEqualTo(1)
assertThat(c.next(t, 3 * hour)).isEqualTo(2)
assertThat(c.next(t + hour, 3 * hour)).isEqualTo(3)
val d = DailyCounter()
assertThat(d.next(t, -5 * hour)).isEqualTo(1)
assertThat(d.next(t + hour, -5 * hour)).isEqualTo(2)
val e = DailyCounter()
assertThat(e.next(t, 0)).isEqualTo(1)
assertThat(e.next(t + hour, 0)).isEqualTo(1)
}
}
@@ -0,0 +1,100 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
class ScheduledJobQueueTest
{
@Test
fun firstJobArmsTheAlarm()
{
val q = ScheduledJobQueue(2000)
assertThat(q.add("a", 10_000)).isTrue()
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun laterJobDoesNotRearm()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.add("b", 50_000)).isFalse()
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun jobJustBeforeTheArmedAlarmReusesIt()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.add("b", 8_500)).isFalse()
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun clearlyEarlierJobRearms()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.add("b", 5_000)).isTrue()
assertThat(q.armedAt).isEqualTo(5_000)
}
@Test
fun popDueRunsJobsInOrderIncludingTheCoalescingWindow()
{
val q = ScheduledJobQueue(2000)
q.add("c", 11_900)
q.add("a", 10_000)
q.add("b", 10_500)
q.add("late", 30_000)
assertThat(q.popDue(10_000)).containsExactly("a", "b", "c")
assertThat(q.nextAlarm()).isEqualTo(30_000)
assertThat(q.armedAt).isEqualTo(30_000)
}
@Test
fun earlyOrSpuriousWakeupKeepsTheQueue()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.popDue(5_000)).isEmpty()
assertThat(q.nextAlarm()).isEqualTo(10_000)
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun emptyQueueLeavesNothingArmed()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.popDue(10_000)).containsExactly("a")
assertThat(q.nextAlarm()).isEqualTo(ScheduledJobQueue.NONE)
assertThat(q.armedAt).isEqualTo(ScheduledJobQueue.NONE)
assertThat(q.add("b", 20_000)).isTrue()
}
@Test
fun clearForgetsTheArmedAlarm()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
q.clear()
assertThat(q.size).isEqualTo(0)
assertThat(q.add("b", 40_000)).isTrue()
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.strongswan.android.logic.stats.TrafficHistory
class TrafficHistoryTest
{
private val every = TrafficHistory.SAVE_EVERY_MS
@Test
fun cleanHistoryIsNeverWritten()
{
assertThat(TrafficHistory.saveDue(false, 10 * every, 0, true)).isFalse()
}
@Test
fun changedHistoryWaitsForTheInterval()
{
assertThat(TrafficHistory.saveDue(true, every - 1, 0, false)).isFalse()
assertThat(TrafficHistory.saveDue(true, every, 0, false)).isTrue()
}
@Test
fun dayChangeWritesAtOnce()
{
assertThat(TrafficHistory.saveDue(true, 1000, 900, true)).isTrue()
}
}
@@ -132,21 +132,23 @@ class WatchdogReceiverTest
/* retrying/connecting: the service tracks the connection */ /* retrying/connecting: the service tracks the connection */
val retrying = decide(expectedProfileSet = true, serviceTracking = true, processAlive = true) val retrying = decide(expectedProfileSet = true, serviceTracking = true, processAlive = true)
assertThat(retrying).isEqualTo(Action.NONE) assertThat(retrying).isEqualTo(Action.NONE)
assertThat(WatchdogReceiver.isAliveSteady(retrying, true, false)).isTrue() assertThat(WatchdogReceiver.isAliveSteady(retrying, true, false, false)).isTrue()
/* a pending retry stands still while the device sleeps: keep the fast wakeup alarm */
assertThat(WatchdogReceiver.isAliveSteady(retrying, true, false, true)).isFalse()
/* whitelist blocks the server while the process is alive */ /* whitelist blocks the server while the process is alive */
val blocked = decide(expectedProfileSet = true, whitelistBlocks = true, processAlive = true) val blocked = decide(expectedProfileSet = true, whitelistBlocks = true, processAlive = true)
assertThat(WatchdogReceiver.isAliveSteady(blocked, true, false)).isTrue() assertThat(WatchdogReceiver.isAliveSteady(blocked, true, false, false)).isTrue()
} }
@Test @Test
fun deadProcessOrRestartNeverBacksOff() fun deadProcessOrRestartNeverBacksOff()
{ {
val restart = decide(expectedProfileSet = true, processAlive = false) val restart = decide(expectedProfileSet = true, processAlive = false)
assertThat(WatchdogReceiver.isAliveSteady(restart, false, false)).isFalse() assertThat(WatchdogReceiver.isAliveSteady(restart, false, false, false)).isFalse()
/* whitelist blocks, but no live process: keep the fast cadence */ /* whitelist blocks, but no live process: keep the fast cadence */
val blocked = decide(expectedProfileSet = true, whitelistBlocks = true, processAlive = false) val blocked = decide(expectedProfileSet = true, whitelistBlocks = true, processAlive = false)
assertThat(WatchdogReceiver.isAliveSteady(blocked, false, false)).isFalse() assertThat(WatchdogReceiver.isAliveSteady(blocked, false, false, false)).isFalse()
/* parked sessions use their own backoff */ /* parked sessions use their own backoff */
assertThat(WatchdogReceiver.isAliveSteady(Action.NONE, true, true)).isFalse() assertThat(WatchdogReceiver.isAliveSteady(Action.NONE, true, true, false)).isFalse()
} }
} }
@@ -0,0 +1,129 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.protection
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.Test
import java.io.ByteArrayInputStream
class SecureDnsTest
{
@Test
fun parsesDoh()
{
assertThat(SecureDnsConfig.parse(" https://dns.example.com/dns-query "))
.isEqualTo(SecureDnsConfig(true, "dns.example.com", 443, "https://dns.example.com/dns-query"))
/* no path: the RFC 8484 default */
assertThat(SecureDnsConfig.parse("https://dns.example.com")!!.url).isEqualTo("https://dns.example.com/dns-query")
assertThat(SecureDnsConfig.parse("https://dns.example.com:8443/q")!!.port).isEqualTo(8443)
}
@Test
fun parsesDot()
{
assertThat(SecureDnsConfig.parse("tls://dot.example.com")).isEqualTo(SecureDnsConfig(false, "dot.example.com", 853, "tls://dot.example.com:853"))
assertThat(SecureDnsConfig.parse("TLS://1.1.1.1:8853")!!.port).isEqualTo(8853)
}
@Test
fun rejectsPlainAndBroken()
{
assertThat(SecureDnsConfig.parse("http://dns.example.com/dns-query")).isNull()
assertThat(SecureDnsConfig.parse("9.9.9.9")).isNull()
assertThat(SecureDnsConfig.parse("https://")).isNull()
assertThat(SecureDnsConfig.parse("https://user:pw@dns.example.com/q")).isNull()
assertThat(SecureDnsConfig.parse("https://dns.example.com/q?dns=x")).isNull()
assertThat(SecureDnsConfig.parse("tls://dot.example.com/path")).isNull()
}
@Test
fun customChoiceUsesSecureOnlyForUrls()
{
assertThat(ProtectionState(DnsChoice.CUSTOM, custom = "tls://dot.example.com").secure).isNotNull()
assertThat(ProtectionState(DnsChoice.CUSTOM, custom = "tls://dot.example.com").servers()).isNull()
assertThat(ProtectionState(DnsChoice.CUSTOM, custom = "9.9.9.9").secure).isNull()
assertThat(ProtectionState(DnsChoice.CLOUDFLARE, custom = "tls://dot.example.com").secure).isNull()
}
@Test
fun queryEncodesName()
{
val q = SecureDnsCodec.query(0x1234, "example.com", 1)
assertThat(q.copyOfRange(0, 6)).containsExactly(0x12, 0x34, 0x01, 0x00, 0, 1)
assertThat(q.copyOfRange(12, q.size)).containsExactly(
7, 'e'.code.toByte(), 'x'.code.toByte(), 'a'.code.toByte(), 'm'.code.toByte(), 'p'.code.toByte(), 'l'.code.toByte(), 'e'.code.toByte(),
3, 'c'.code.toByte(), 'o'.code.toByte(), 'm'.code.toByte(), 0, 0, 1, 0, 1)
}
@Test
fun servfailKeepsIdAndQuestion()
{
val q = SecureDnsCodec.query(0xbeef, "example.com", 28)
val r = SecureDnsCodec.servfail(q)!!
assertThat(r.size).isEqualTo(q.size)
assertThat(SecureDnsCodec.sameId(q, r)).isTrue()
assertThat(r[2].toInt() and 0x80).isEqualTo(0x80) /* QR */
assertThat(r[2].toInt() and 0x01).isEqualTo(0x01) /* RD kept */
assertThat(r[3].toInt() and 0x0f).isEqualTo(2) /* SERVFAIL */
assertThat(r.copyOfRange(4, 12)).containsExactly(0, 1, 0, 0, 0, 0, 0, 0)
assertThat(r.copyOfRange(12, r.size)).isEqualTo(q.copyOfRange(12, q.size))
}
@Test
fun servfailDropsAdditionalRecords()
{
/* EDNS OPT record after the question must not survive with ARCOUNT 0 */
val q = SecureDnsCodec.query(1, "a.b", 1).also { it[11] = 1 } + byteArrayOf(0, 0, 41, 4, 0xd0.toByte(), 0, 0, 0, 0, 0, 0)
val r = SecureDnsCodec.servfail(q)!!
assertThat(r.size).isEqualTo(q.size - 11)
}
@Test
fun servfailNeedsHeader()
{
assertThat(SecureDnsCodec.servfail(ByteArray(5))).isNull()
/* header only, no question */
assertThat(SecureDnsCodec.servfail(ByteArray(12))!!.size).isEqualTo(12)
}
@Test
fun checkReplyRejectsEmptyAndForeign()
{
val q = SecureDnsCodec.query(0x1234, "ya.ru", 1)
assertThatThrownBy { SecureDnsCodec.checkReply(q, ByteArray(0)) }.hasMessage("empty reply")
/* the query itself has no QR bit */
assertThatThrownBy { SecureDnsCodec.checkReply(q, q) }.hasMessage("not a DNS reply")
val other = SecureDnsCodec.servfail(SecureDnsCodec.query(0x4321, "ya.ru", 1))!!
assertThatThrownBy { SecureDnsCodec.checkReply(q, other) }.hasMessage("reply id mismatch")
SecureDnsCodec.checkReply(q, SecureDnsCodec.servfail(q)!!) /* any rcode is a reply */
}
@Test
fun dotClosedConnectionIsReported()
{
assertThatThrownBy { SecureDnsCodec.readFrame(ByteArrayInputStream(ByteArray(0))) }
.hasMessage("connection closed without a reply")
}
@Test
fun dotFramingRoundTrip()
{
val msg = ByteArray(300) { it.toByte() }
val framed = SecureDnsCodec.frame(msg)
assertThat(framed.copyOfRange(0, 2)).containsExactly(1, 44)
assertThat(SecureDnsCodec.readFrame(ByteArrayInputStream(framed))).isEqualTo(msg)
}
}
@@ -0,0 +1,172 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import org.assertj.core.api.Assertions.assertThat
import org.json.JSONObject
import org.junit.Test
import java.io.File
class ReportContentTest
{
@Test
fun checksGoBetweenTheLogAndDiagAsAJsonArray()
{
val checks = listOf(
ReportContent.Check("Интернет", "Wi-Fi", true),
ReportContent.Check("Сервер \"nl1\"", "нет ответа\n15 с", false),
ReportContent.Check("DNS", "проверяется", null),
)
val text = ReportContent.build("a\n", "d\n", checks)
assertThat(text).isEqualTo(
"=== charon.log ===\na\n\n=== checks.json ===\n" +
"[{\"title\":\"Интернет\",\"value\":\"Wi-Fi\",\"ok\":true}," +
"{\"title\":\"Сервер \\\"nl1\\\"\",\"value\":\"нет ответа\\n15 с\",\"ok\":false}," +
"{\"title\":\"DNS\",\"value\":\"проверяется\",\"ok\":null}]\n" +
"\n=== diag.log ===\nd\n"
)
assertThat(ReportContent.build("a\n", null, emptyList())).doesNotContain("checks.json")
}
@Test
fun apiBaseAddsTheApiPrefixOnlyWhenMissing()
{
/* the proxy routes only /api/v1/ and the HMAC covers the full path */
assertThat(ReportUploader.apiBase("https://get.shnt.top")).isEqualTo("https://get.shnt.top/api/v1")
assertThat(ReportUploader.apiBase("https://get.shnt.top/")).isEqualTo("https://get.shnt.top/api/v1")
assertThat(ReportUploader.apiBase("https://get.shnt.top/api/v1/")).isEqualTo("https://get.shnt.top/api/v1")
assertThat(java.net.URL(ReportUploader.apiBase("http://127.0.0.1:4000") + "/reports").path).isEqualTo("/api/v1/reports")
}
@Test
fun parseResponseAcceptsOnlyA201WithAValidCode()
{
assertThat(ReportUploader.parseResponse(201, """{"code":"SHX-7F3K2"}""")).isEqualTo(ReportUploader.Result.Sent("SHX-7F3K2"))
val broken = ReportUploader.parseResponse(201, "<html>proxy</html>")
assertThat(broken).isInstanceOf(ReportUploader.Result.Failed::class.java)
assertThat((broken as ReportUploader.Result.Failed).httpCode).isEqualTo(201)
assertThat(ReportUploader.parseResponse(201, """{"code":"nope"}""")).isInstanceOf(ReportUploader.Result.Failed::class.java)
val limited = ReportUploader.parseResponse(429, """{"error":{"code":"rate_limited"}}""") as ReportUploader.Result.Failed
assertThat(limited.httpCode).isEqualTo(429)
assertThat(limited.detail).contains("rate_limited")
}
@Test
fun failureMapsServerAnswersToReasons()
{
assertThat(ReportContent.failure(0)).isEqualTo(ReportContent.Failure.NO_NETWORK)
assertThat(ReportContent.failure(401)).isEqualTo(ReportContent.Failure.SIGNATURE)
assertThat(ReportContent.failure(413)).isEqualTo(ReportContent.Failure.TOO_LARGE)
assertThat(ReportContent.failure(429)).isEqualTo(ReportContent.Failure.TOO_OFTEN)
assertThat(ReportContent.failure(502)).isEqualTo(ReportContent.Failure.SERVER_DOWN)
assertThat(ReportContent.failure(404)).isEqualTo(ReportContent.Failure.SERVER_DOWN)
assertThat(ReportContent.failure(400)).isEqualTo(ReportContent.Failure.REJECTED)
assertThat(ReportContent.failure(201)).isEqualTo(ReportContent.Failure.REJECTED)
}
@Test
fun todayOnlyKeepsTodaysLinesWithTheirContinuations()
{
val log = """
Nov 7 23:59:58 06[IKE] old line
old continuation
Nov 8 00:00:01 06[IKE] initiating IKE_SA
payload continuation
Nov 8 14:02:30 07[IKE] CHILD_SA established
""".trimIndent()
assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo(
"Nov 8 00:00:01 06[IKE] initiating IKE_SA\n payload continuation\nNov 8 14:02:30 07[IKE] CHILD_SA established\n"
)
assertThat(ReportContent.todayOnly("Dec 18 10:00:00 01[IKE] two-digit day\n", 12, 18)).contains("two-digit day")
}
@Test
fun todayOnlyFallsBackToTheWholeLogWhenNothingIsFromToday()
{
val log = "Nov 7 10:00:00 06[IKE] yesterday\n"
assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo(log)
/* a cut log starts with an undated mark: it alone must not count as "today" */
val cut = ReportContent.CUT_MARK + log
assertThat(ReportContent.todayOnly(cut, 11, 8)).isEqualTo(cut)
}
@Test
fun tailBytesCutsTheStartAtALineBoundaryAndKeepsCyrillicWhole()
{
val text = (1..200).joinToString("") { "строка $it: нет ответа\n" }
val cut = ReportContent.tailBytes(text, 1000)
assertThat(cut).startsWith(ReportContent.CUT_MARK)
val body = cut.removePrefix(ReportContent.CUT_MARK)
assertThat(body.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(1000)
assertThat(body).startsWith("строка ").endsWith("строка 200: нет ответа\n")
assertThat(body).doesNotContain("�")
assertThat(ReportContent.tailBytes("short\n", 1000)).isEqualTo("short\n")
}
@Test
fun tailBytesOfOneHugeLineDoesNotSplitACharacter()
{
val cut = ReportContent.tailBytes("ж".repeat(1000), 101).removePrefix(ReportContent.CUT_MARK)
assertThat(cut).doesNotContain("�")
assertThat(cut).isEqualTo("ж".repeat(50))
}
@Test
fun readTailDropsThePartialFirstLine()
{
val file = File.createTempFile("charon", ".log")
try
{
file.writeText("first line\nsecond line\nthird line\n")
assertThat(ReportContent.readTail(file, 1000)).isEqualTo("first line\nsecond line\nthird line\n")
assertThat(ReportContent.readTail(file, 15)).isEqualTo(ReportContent.CUT_MARK + "third line\n")
assertThat(ReportContent.readTail(File(file.path + ".missing"), 100)).isEmpty()
}
finally
{
file.delete()
}
}
@Test
fun buildAddsDiagOnlyWhenAskedAndStaysWithinTheLimits()
{
val plain = ReportContent.build("Nov 8 14:02:31 08[IKE] tunnel up\n", null)
assertThat(plain).isEqualTo("=== charon.log ===\nNov 8 14:02:31 08[IKE] tunnel up\n")
val withDiag = ReportContent.build("", "11-08 14:02:00.000 Watchdog: ok\n")
assertThat(withDiag).contains("=== charon.log ===\n(empty)").contains("=== diag.log ===\n11-08 14:02:00.000 Watchdog: ok")
val huge = "x".repeat(99) + "\n"
val big = ReportContent.build(huge.repeat(40_000), huge.repeat(10_000))
assertThat(big.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(ReportContent.CHARON_MAX_BYTES + ReportContent.DIAG_MAX_BYTES + 200)
/* what the panel unpacks for the report card (5 MB cap) */
assertThat(big.length).isLessThan(5 * 1024 * 1024)
}
@Test
fun metaDescribesThePhoneAndPassesTheServerSchema()
{
val meta = ReportContent.meta("2.19.2", 170, "Google", "Pixel 7", "14", 34, "Wi-Fi", " Не подключается в метро ", "nl1 · nl1.shx.one")
assertThat(meta.device).isEqualTo("Google Pixel 7")
assertThat(ReportContent.meta("2.19.2", 170, "samsung", "SAMSUNG SM-S911B", "14", 34, "mobile", "", "").device).isEqualTo("SAMSUNG SM-S911B")
assertThat(meta.os).isEqualTo("Android 14 (API 34)")
assertThat(meta.comment).isEqualTo("Не подключается в метро")
val json = JSONObject(ReportUploader.metaJson(meta))
/* ReportMetaSchema: source is "log" | "diagnostics", versionCode an integer */
assertThat(json.getString("source")).isEqualTo("log")
assertThat(json.getInt("versionCode")).isEqualTo(170)
assertThat(json.getString("network")).isEqualTo("Wi-Fi")
assertThat(json.getString("profile")).isEqualTo("nl1 · nl1.shx.one")
}
}
@@ -0,0 +1,69 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import org.assertj.core.api.Assertions.assertThat
import org.json.JSONObject
import org.junit.Test
import java.io.ByteArrayInputStream
import java.util.zip.GZIPInputStream
class ReportUploaderTest
{
private val meta = ReportUploader.Meta("2.19.1", 169, "Pixel 7", "Android 14", "LTE", "log", "Не подключается", "nl1")
@Test
fun gzipRoundTripsTheLogAndStartsWithTheGzipMagic()
{
val gz = ReportUploader.gzip("09:38:11 IKE_SA_INIT → nl1 :4500\n")
assertThat(gz[0]).isEqualTo(0x1f.toByte())
assertThat(gz[1]).isEqualTo(0x8b.toByte())
assertThat(GZIPInputStream(ByteArrayInputStream(gz)).readBytes().decodeToString()).isEqualTo("09:38:11 IKE_SA_INIT → nl1 :4500\n")
}
@Test
fun metaJsonCarriesTheFieldsTheServerValidatesAndTruncatesLongOnes()
{
val json = JSONObject(ReportUploader.metaJson(meta.copy(comment = "x".repeat(2000), device = "d".repeat(500))))
assertThat(json.getString("versionName")).isEqualTo("2.19.1")
assertThat(json.getInt("versionCode")).isEqualTo(169)
assertThat(json.getString("comment")).hasSize(1000)
assertThat(json.getString("device")).hasSize(120)
assertThat(json.getString("source")).isEqualTo("log")
}
@Test
fun multipartBodyHasTheLogAndMetaPartsInTheExpectedShape()
{
val body = ReportUploader.multipartBody(byteArrayOf(0x1f, 0x8b.toByte(), 1, 2), "{\"a\":1}").decodeToString(throwOnInvalidSequence = false)
assertThat(body).contains("name=\"log\"; filename=\"log.gz\"")
assertThat(body).contains("name=\"meta\"")
assertThat(body).contains("{\"a\":1}")
/* the meta part must stay a plain field (see multipartBody) */
assertThat(body.substringAfter("name=\"meta\"").substringBefore("{")).doesNotContain("Content-Type")
assertThat(body).endsWith("--${ReportUploader.BOUNDARY}--\r\n")
}
@Test
fun anOversizedReportIsRefusedBeforeAnyRequest()
{
/* 62 symbols are ~6 bits each: 4 MB of text stays above 2 MB after gzip */
val alphabet = ('a'..'z') + ('A'..'Z') + ('0'..'9')
val huge = java.util.Random(1).let { r -> buildString { repeat(4 * 1024 * 1024) { append(alphabet[r.nextInt(alphabet.size)]) } } }
val result = ReportUploader("https://api.example/api/v1", "k", "install-0001").send(huge, meta)
assertThat(result).isInstanceOf(ReportUploader.Result.Failed::class.java)
assertThat((result as ReportUploader.Result.Failed).httpCode).isEqualTo(413)
}
}
@@ -0,0 +1,196 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import org.assertj.core.api.Assertions.assertThat
import org.json.JSONObject
import org.junit.Test
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Checks the client against the shared vectors of the web panel
* (packages/contracts/vectors/signing.json). If the server contract changes,
* the vectors are regenerated there and copied to src/test/resources.
*/
@OptIn(ExperimentalEncodingApi::class)
class UpdateApiTest
{
private val vectors = JSONObject(
javaClass.classLoader!!.getResourceAsStream("shx-signing-vectors.json")!!.use { it.readBytes().decodeToString() })
private val publicKeyRaw = vectors.getJSONObject("ed25519").getString("publicKeyRawBase64")
private val manifest = vectors.getJSONObject("manifest")
private val hmacKey = vectors.getJSONObject("request").getString("hmacKey")
private fun fields(o: JSONObject) = UpdateSigning.Fields(
update = o.getBoolean("update"),
mandatory = o.getBoolean("mandatory"),
versionCode = o.optLong("versionCode", 0L),
versionName = o.optString("versionName", ""),
sha256 = o.optString("sha256", ""),
size = o.optLong("size", 0L),
apkUrl = o.optString("apkUrl", ""),
issuedAt = o.getLong("issuedAt"),
checkHours = o.getInt("checkHours"),
wifiOnly = o.getBoolean("wifiOnly"),
)
private fun responseJson(v: JSONObject, patch: (JSONObject) -> Unit = {}): String
{
val f = v.getJSONObject("fields")
val body = JSONObject(f.toString())
body.put("signature", v.getString("signatureBase64"))
if (f.getBoolean("update"))
{
body.put("notes", JSONObject().put("ru", "Исправления").put("en", "Fixes"))
}
patch(body)
return body.toString()
}
private class FakeHttp(private val response: UpdateHttp.Response) : UpdateHttp
{
var lastUrl: String? = null
var lastHeaders: Map<String, String> = emptyMap()
override fun get(url: String, headers: Map<String, String>): UpdateHttp.Response
{
lastUrl = url
lastHeaders = headers
return response
}
}
private fun client(http: UpdateHttp, nowSeconds: Long = manifest.getJSONObject("fields").getLong("issuedAt")) =
UpdateApiClient("https://api.example/api/v1", hmacKey, publicKeyRaw, "install-0001", http) { nowSeconds * 1000 }
@Test
fun manifestSigningInputMatchesTheServerVector()
{
assertThat(UpdateSigning.manifestInput(fields(manifest.getJSONObject("fields")))).isEqualTo(manifest.getString("signingInput"))
val none = vectors.getJSONObject("noUpdate")
assertThat(UpdateSigning.manifestInput(fields(none.getJSONObject("fields")))).isEqualTo(none.getString("signingInput"))
}
@Test
fun ed25519SignatureFromTheServerVerifiesAndTamperingIsRejected()
{
val key = Base64.Default.decode(publicKeyRaw)
val input = manifest.getString("signingInput")
val signature = manifest.getString("signatureBase64")
assertThat(UpdateSigning.verifyEd25519(key, input, signature)).isTrue()
assertThat(UpdateSigning.verifyEd25519(key, input + "x", signature)).isFalse()
assertThat(UpdateSigning.verifyEd25519(key, input.replace("250003", "250004"), signature)).isFalse()
assertThat(UpdateSigning.verifyEd25519(ByteArray(32), input, signature)).isFalse()
}
@Test
fun requestSignatureMatchesTheServerVector()
{
val r = vectors.getJSONObject("request")
val input = UpdateSigning.requestInput(r.getString("method"), r.getString("path"), r.getString("timestamp").toLong())
assertThat(input).isEqualTo(r.getString("signingInput"))
assertThat(UpdateSigning.hmacBase64(hmacKey, input)).isEqualTo(r.getString("signatureBase64"))
}
@Test
fun aBareHostGetsTheApiPrefix()
{
/* UPDATE_API_URL=https://get.shnt.top (no /api/v1) used to hit /updates/check -> 404 -> silent legacy fallback */
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
val bare = UpdateApiClient("https://api.example/", hmacKey, publicKeyRaw, "install-0001", http) {
manifest.getJSONObject("fields").getLong("issuedAt") * 1000
}
assertThat(bare.check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null)).isInstanceOf(UpdateApiResult.Update::class.java)
assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?")
}
@Test
fun clientReturnsTheOfferedUpdateAndSendsASignedRequest()
{
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
val result = client(http).check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null)
assertThat(result).isInstanceOf(UpdateApiResult.Update::class.java)
val update = result as UpdateApiResult.Update
assertThat(update.info.versionName).isEqualTo("2.5.0-beta.3")
assertThat(update.info.versionCode).isEqualTo(250003L)
assertThat(update.info.fromApi).isTrue()
assertThat(update.info.notes["ru"]).isEqualTo("Исправления")
assertThat(update.policy).isEqualTo(UpdatePolicy(12, true))
assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?package=one.shx.strongswan.ext&versionCode=168")
assertThat(http.lastHeaders["X-Install-Id"]).isEqualTo("install-0001")
val ts = http.lastHeaders["X-Timestamp"]!!.toLong()
assertThat(http.lastHeaders["X-Signature"]).isEqualTo(
UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", "/api/v1/updates/check", ts)))
}
@Test
fun clientAcceptsASignedNoUpdateAnswer()
{
val http = FakeHttp(UpdateHttp.Response(200, responseJson(vectors.getJSONObject("noUpdate"))))
val result = client(http).check("one.shx.strongswan.ext", 169, "release", null, 34, null)
assertThat(result).isInstanceOf(UpdateApiResult.NoUpdate::class.java)
}
@Test
fun tamperedFieldsAreRejectedAsASignatureFailureAndNeverFallBack()
{
for (field in listOf("sha256", "apkUrl", "versionCode", "versionName", "size", "mandatory", "checkHours", "wifiOnly"))
{
val body = responseJson(manifest) { o ->
when (field)
{
"mandatory", "wifiOnly" -> o.put(field, !o.getBoolean(field))
"versionCode", "size", "checkHours" -> o.put(field, o.getLong(field) + 1)
"sha256" -> o.put(field, "cd".repeat(32))
else -> o.put(field, o.getString(field) + "x")
}
}
val result = client(FakeHttp(UpdateHttp.Response(200, body))).check("one.shx.strongswan.ext", 1, "release", null, 34, null)
assertThat(result).describedAs(field).isInstanceOf(UpdateApiResult.Failure::class.java)
assertThat((result as UpdateApiResult.Failure).kind).describedAs(field).isEqualTo(UpdateError.SIGNATURE)
}
}
@Test
fun aStaleManifestIsRejectedEvenWithAValidSignature()
{
val issued = manifest.getJSONObject("fields").getLong("issuedAt")
val result = client(FakeHttp(UpdateHttp.Response(200, responseJson(manifest))), nowSeconds = issued + UpdateApiClient.MAX_SKEW_SECONDS + 60)
.check("one.shx.strongswan.ext", 1, "release", null, 34, null)
assertThat((result as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE)
}
@Test
fun aWrongPublicKeyFailsAndServerErrorsAreClassified()
{
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
val wrongKey = UpdateApiClient("https://api.example/api/v1", hmacKey, Base64.Default.encode(ByteArray(32) { 7 }), "install-0001", http) { 1760438401_000L }
assertThat((wrongKey.check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE)
// 404/5xx: сеть или сервер, допускается запасной путь; 401/429: нет
assertThat((client(FakeHttp(UpdateHttp.Response(503, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK)
assertThat((client(FakeHttp(UpdateHttp.Response(404, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK)
assertThat((client(FakeHttp(UpdateHttp.Response(401, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER)
assertThat((client(FakeHttp(UpdateHttp.Response(429, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER)
}
@Test
fun theClientIsConfiguredOnlyWithAllThreeValues()
{
assertThat(UpdateApiClient.isConfigured("https://api", "key", "pub")).isTrue()
assertThat(UpdateApiClient.isConfigured("", "key", "pub")).isFalse()
assertThat(UpdateApiClient.isConfigured("https://api", "", "pub")).isFalse()
assertThat(UpdateApiClient.isConfigured("https://api", "key", "")).isFalse()
}
}
@@ -0,0 +1,84 @@
package org.strongswan.android.logic.updates
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
class UpdateNotesTest
{
private val notes = mapOf("ru" to "Русский", "en" to "English", "zh-tw" to "繁體", "zh" to "中文")
@Test
fun picksAppLanguageThenRussianThenEnglish()
{
assertThat(pickNotes(notes, "en-US", "en")).isEqualTo("English")
assertThat(pickNotes(notes, "zh-TW", "zh")).isEqualTo("繁體")
assertThat(pickNotes(notes, "zh-CN", "zh")).isEqualTo("中文")
assertThat(pickNotes(notes, "de-DE", "de")).isEqualTo("Русский")
assertThat(pickNotes(mapOf("en" to "English", "ru" to " "), "de-DE", "de")).isEqualTo("English")
assertThat(pickNotes(emptyMap(), "ru-RU", "ru")).isNull()
}
@Test
fun everyNonEmptyLineIsAnItem()
{
assertThat(noteLines("Серверы\n\n - История трафика \r\n• Исправлен сбой\n")).containsExactly("Серверы", "История трафика", "Исправлен сбой")
assertThat(noteLines("Одна строка. Без переносов.")).containsExactly("Одна строка. Без переносов.")
assertThat(noteLines(null)).isEmpty()
}
@Test
fun copiesWithDigestAndProgress()
{
val data = ByteArray(200_000) { it.toByte() }
val out = ByteArrayOutputStream()
val digest = MessageDigest.getInstance("SHA-256")
var last = 0L
val total = copyDownload(ByteArrayInputStream(data), out, digest, { false }) { last = it }
assertThat(total).isEqualTo(data.size.toLong())
assertThat(last).isEqualTo(data.size.toLong())
assertThat(out.toByteArray()).isEqualTo(data)
assertThat(digest.digest()).isEqualTo(MessageDigest.getInstance("SHA-256").digest(data))
}
@Test
fun cancelStopsBetweenReads()
{
var cancelled = false
val out = ByteArrayOutputStream()
assertThatThrownBy {
copyDownload(ByteArrayInputStream(ByteArray(300_000)), out, MessageDigest.getInstance("SHA-256"), { cancelled }) { cancelled = true }
}.isInstanceOf(CancellationException::class.java)
/* one 64 KiB chunk, then the loop stopped */
assertThat(out.size()).isEqualTo(64 * 1024)
}
@Test
fun failedReadAfterCancelIsACancellation()
{
var cancelled = false
/* what disconnect() does to a blocked read */
val broken = object : InputStream()
{
override fun read(): Int = throw IOException("socket closed")
override fun read(b: ByteArray, off: Int, len: Int): Int
{
cancelled = true
throw IOException("socket closed")
}
}
assertThatThrownBy {
copyDownload(broken, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { cancelled }) {}
}.isInstanceOf(CancellationException::class.java)
/* without a cancel the network error stays a network error */
assertThatThrownBy {
copyDownload(object : InputStream() { override fun read(): Int = throw IOException("reset") }, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { false }) {}
}.isInstanceOf(IOException::class.java)
}
}
@@ -0,0 +1,43 @@
{
"note": "ТЕСТОВЫЙ ключ, только для проверки формата подписи.",
"ed25519": {
"privateKeyPem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIA9Mac/nzlc6O6s6/5p7z3BfaggTulVsGYSLJyiG9/H8\n-----END PRIVATE KEY-----\n",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEASE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY=\n-----END PUBLIC KEY-----\n",
"publicKeyRawBase64": "SE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY="
},
"manifest": {
"fields": {
"update": true,
"mandatory": false,
"versionCode": 250003,
"versionName": "2.5.0-beta.3",
"checkHours": 12,
"wifiOnly": true,
"sha256": "abababababababababababababababababababababababababababababababab",
"size": 38900000,
"apkUrl": "https://s3.example/app.apk?sig=1",
"issuedAt": 1760438401
},
"signingInput": "shx-manifest-v1\n1\n0\n250003\n2.5.0-beta.3\nabababababababababababababababababababababababababababababababab\n38900000\nhttps://s3.example/app.apk?sig=1\n1760438401\n12\n1",
"signatureBase64": "KkPqAQX4DwqhgNWdJecOePHsnPzOGCYAGNG25mQganJGQiD2MTl9YSrcHHeO5hoGeLoOa4rEU40rV++YtODGCw=="
},
"noUpdate": {
"fields": {
"update": false,
"mandatory": false,
"issuedAt": 1760438401,
"checkHours": 12,
"wifiOnly": true
},
"signingInput": "shx-manifest-v1\n0\n0\n0\n\n\n0\n\n1760438401\n12\n1",
"signatureBase64": "8Q5+fb7t6ot5iXYBPbv9CCspNzJOEVmQqHLh/6k2lNNtMxeyuTfbldKaKFzQV4orknVKijbf5ChgYDlC5pNvDQ=="
},
"request": {
"hmacKey": "test-hmac-key",
"method": "GET",
"path": "/api/v1/updates/check",
"timestamp": "1760438400",
"signingInput": "GET|/api/v1/updates/check|1760438400",
"signatureBase64": "m6eQoNLWfDApDH2E1/q3aW/qG06t01/2zvxL1RD+Ckw="
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
# SHX VPN для Android # SHX VPN для Android
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.18.1 (versionCode 166)**. Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.9 (versionCode 177)**.
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само. Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду. - Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
@@ -14,10 +14,10 @@
| Серверы | Один сервер = один профиль `.sswan`. Добавление по QR, ссылке, файлу или вручную; проверка профиля перед добавлением; «Поделиться» QR-кодом без пароля; страница сервера: источник профиля, ежедневная проверка ссылки и лист изменений, запасные порты, резервный сервер, мост при белых списках, DNS, MTU, фрагментация IKE | | Серверы | Один сервер = один профиль `.sswan`. Добавление по QR, ссылке, файлу или вручную; проверка профиля перед добавлением; «Поделиться» QR-кодом без пароля; страница сервера: источник профиля, ежедневная проверка ссылки и лист изменений, запасные порты, резервный сервер, мост при белых списках, DNS, MTU, фрагментация IKE |
| Маршруты | Раздельный туннель, сайты РФ напрямую (база адресов, режимы «Стабильно»/«Гибко»), приложения напрямую, правила (сайт, адрес/сеть, шаблон пакета), недавние приложения, недоступные хосты, «Как ходит трафик» | | Маршруты | Раздельный туннель, сайты РФ напрямую (база адресов, режимы «Стабильно»/«Гибко»), приложения напрямую, правила (сайт, адрес/сеть, шаблон пакета), недавние приложения, недоступные хосты, «Как ходит трафик» |
| Сценарии | Правила для сетей (доверенная Wi-Fi / мобильная или чужая сеть) и свои сценарии: Wi-Fi, мобильная сеть, приложение открыто, время, роуминг, белые списки → включить, выключить, пауза, сменить сервер. Общий порядок, верхний важнее; ручное действие перекрывает сценарий до его окончания | | Сценарии | Правила для сетей (доверенная Wi-Fi / мобильная или чужая сеть) и свои сценарии: Wi-Fi, мобильная сеть, приложение открыто, время, роуминг, белые списки → включить, выключить, пауза, сменить сервер. Общий порядок, верхний важнее; ручное действие перекрывает сценарий до его окончания |
| Защита | DNS через туннель: из профиля, Cloudflare, AdGuard (реклама и трекеры), Cloudflare 1.1.1.2 (фишинг), свой адрес; «Без VPN — без сети» (инструкция к системному Always-on) | | Защита | DNS через туннель: из профиля, Cloudflare, AdGuard (реклама и трекеры), Cloudflare 1.1.1.2 (фишинг), свой адрес; свой DoH (`https://…`) или DoT (`tls://…`): DNS туннеля — виртуальный `198.18.0.53`, нативный DNS-прокси отдаёт запросы локальному резолверу `SecureDns` (127.0.0.1), тот шифрует их к серверу пользователя через физическую сеть; проба при подключении, иначе DNS профиля и событие; ошибки → SERVFAIL; всё пишется в diag.log без имён запросов; «Без VPN — без сети» (инструкция к системному Always-on) |
| Статистика | Трафик по дням/неделям/месяцам: через VPN и напрямую, по приложениям (нужен доступ к статистике использования), итоги недели | | Статистика | Трафик по дням/неделям/месяцам: через VPN и напрямую, по приложениям (нужен доступ к статистике использования), итоги недели |
| Диагностика | Интернет, сайты РФ и зарубежные, сервер, DNS, белые списки за ~10 секунд; проверка сайта; отчёт в поддержку | | Диагностика | Интернет, сайты РФ и зарубежные, сервер, DNS, белые списки за ~10 секунд; проверка сайта; отчёт в поддержку |
| События | Центр событий в приложении и push по каналам (статус, проблемы, новые приложения, обновления, итоги недели) | | События | Центр событий в приложении и push по каналам (статус, проблемы, новые приложения, обновления, итоги недели); «Приложение аварийно завершилось» с кнопкой «Отправить отчёт» (`CrashWatch`: Java, нативный демон, ANR — на Android 11+ по истории завершений процесса); «Постоянный VPN выключили», если блокировка без VPN была включена (`AlwaysOnVpn`: статус спрашивает работающий `VpnService`, Android 10+) |
| Система | Виджеты трёх размеров (Glance), плитка быстрых настроек, ярлыки профилей, автообновление с проверкой SHA-256 и подписи | | Система | Виджеты трёх размеров (Glance), плитка быстрых настроек, ярлыки профилей, автообновление с проверкой SHA-256 и подписи |
Профиль без сохранённого пароля никогда не запускается сам (watchdog, правила, сценарии, резерв, мост): вместо этого приходит событие со ссылкой на сервер. Профиль без сохранённого пароля никогда не запускается сам (watchdog, правила, сценарии, резерв, мост): вместо этого приходит событие со ссылкой на сервер.
@@ -63,7 +63,7 @@ a{color:#B69DFF}a:hover{color:#D0C2FF}
class Component extends DCLogic { class Component extends DCLogic {
renderVals() { renderVals() {
var t = [['Рекламу', 'Баннеры в браузере и приложениях', true], ['Трекеры', 'Аналитика и сбор данных', true], ['Фишинг', 'Поддельные сайты банков и сервисов', true]]; var t = [['Рекламу', 'Баннеры в браузере и приложениях', true], ['Трекеры', 'Аналитика и сбор данных', true], ['Фишинг', 'Поддельные сайты банков и сервисов', true]];
var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IPv4 или IPv6 через запятую', false]]; var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IP, DoH (https://) или DoT (tls://)', false]];
return { return {
toggles: t.map(function (x, i) { return { title: x[0], sub: x[1], on: x[2] ? 'true' : 'false', track: x[2] ? '#6D4AFF' : '#48454E', align: x[2] ? 'flex-end' : 'flex-start', divider: i < t.length - 1 ? '1px solid #2B2930' : '0' }; }), toggles: t.map(function (x, i) { return { title: x[0], sub: x[1], on: x[2] ? 'true' : 'false', track: x[2] ? '#6D4AFF' : '#48454E', align: x[2] ? 'flex-end' : 'flex-start', divider: i < t.length - 1 ? '1px solid #2B2930' : '0' }; }),
dns: d.map(function (x, i) { return { title: x[0], sub: x[1], checked: x[2], divider: i < d.length - 1 ? '1px solid #2B2930' : '0' }; }) dns: d.map(function (x, i) { return { title: x[0], sub: x[1], checked: x[2], divider: i < d.length - 1 ? '1px solid #2B2930' : '0' }; })
@@ -63,7 +63,7 @@ a{color:#6D4AFF}a:hover{color:#4B2FD1}
class Component extends DCLogic { class Component extends DCLogic {
renderVals() { renderVals() {
var t = [['Рекламу', 'Баннеры в браузере и приложениях', true], ['Трекеры', 'Аналитика и сбор данных', true], ['Фишинг', 'Поддельные сайты банков и сервисов', true]]; var t = [['Рекламу', 'Баннеры в браузере и приложениях', true], ['Трекеры', 'Аналитика и сбор данных', true], ['Фишинг', 'Поддельные сайты банков и сервисов', true]];
var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IPv4 или IPv6 через запятую', false]]; var d = [['Из профиля сервера', 'Как настроил администратор', false], ['Cloudflare', '1.1.1.1 · через туннель', false], ['AdGuard DNS', 'Со встроенной фильтрацией', true], ['Свой адрес', 'IP, DoH (https://) или DoT (tls://)', false]];
return { return {
toggles: t.map(function (x, i) { return { title: x[0], sub: x[1], on: x[2] ? 'true' : 'false', track: x[2] ? '#6D4AFF' : '#C9C5D0', align: x[2] ? 'flex-end' : 'flex-start', divider: i < t.length - 1 ? '1px solid #F0ECF4' : '0' }; }), toggles: t.map(function (x, i) { return { title: x[0], sub: x[1], on: x[2] ? 'true' : 'false', track: x[2] ? '#6D4AFF' : '#C9C5D0', align: x[2] ? 'flex-end' : 'flex-start', divider: i < t.length - 1 ? '1px solid #F0ECF4' : '0' }; }),
dns: d.map(function (x, i) { return { title: x[0], sub: x[1], checked: x[2], divider: i < d.length - 1 ? '1px solid #F0ECF4' : '0' }; }) dns: d.map(function (x, i) { return { title: x[0], sub: x[1], checked: x[2], divider: i < d.length - 1 ? '1px solid #F0ECF4' : '0' }; })
+88
View File
@@ -139,3 +139,91 @@
- minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать. - minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать.
- Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения). - Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения).
- Строки UI, если появятся (подсказки в «Батарее»), добавлять сразу во все семь `strings.xml`. - Строки UI, если появятся (подсказки в «Батарее»), добавлять сразу во все семь `strings.xml`.
## 7. Версия 2.19.0: надёжность и лишняя работа (по результатам разбора багов)
**Стабильность соединения**
- `Scheduler` (будильники keepalive и rekey): `ELAPSED_REALTIME_WAKEUP` вместо `RTC_WAKEUP` — сдвиг часов (NTP, ручная смена, пояс)
больше не уводит срабатывание на часы вперёд. Очередь вынесена в `ScheduledJobQueue` (тесты): задания в окне 2 с делят один будильник
(раньше срабатывает не более чем на 2 с, поздно — никогда; charon ставит задания дольше 3 с). Каждый сэкономленный будильник — слот в квоте
allow-while-idle в Doze. Интервалы и keepalive 45 с не менялись. В `diag.log` один раз пишется, точные ли будильники.
- `BootReceiver` (`BOOT_COMPLETED`, `TIME_SET`, `TIMEZONE_CHANGED`): после перезагрузки и смены времени заново взводит будильник пауз
(раньше пауза после перезагрузки не заканчивалась никогда) и будильник окон сценариев, правила решают заново. Туннель сам не стартует;
профиль без сохранённого пароля по-прежнему не запускается.
- `ScenarioReceiver` ждёт привязки `VpnStateService` (до 8 с) через `goAsync`; раньше срабатывание свежего процесса терялось. `restoreIfApplied`
стирает запись о применённом сценарии только когда сервис есть.
- Будильник окон сценариев: точный, если разрешено (как у паузы).
- Сетевые сценарии (Wi-Fi, мобильная сеть, роуминг) слушают сеть и без включённых правил сетей (`AutoConnectManager.scenariosChanged`).
- Watchdog, поднявший туннель авто-сессии в свежем процессе, помечает сессию авто (`markAutoSessionRestored`): туннель паркуется на доверенной
Wi-Fi, а не работает всю ночь. Постоянный флаг не вводился сознательно: устаревший флаг запарковал бы туннель, запущенный вручную.
- `NetworkManager`: набор сетей вместо счётчика (повтор `onAvailable` или пропуск `onLost` не сдвигают состояние); подряд идущие одинаковые
события схлопываются в одно.
**Расход ресурсов**
- `direct_engine.c`: `tcp_active` сбрасывается после холостого раунда без TCP-потоков (`flow_count` в `direct_tcp.c` объявлен, но не был
реализован — теперь реализован), холостой интервал снова растягивается до 4 с; ветка «TUN не привязан» наращивает интервал, а не опрашивает
4 раза в секунду; `reprotect` очищает отрицательный кэш и при спящем lwIP.
- `TrafficHistory`: история в памяти, на диск раз в 5 мин, при остановке и смене дня; с погашенным экраном опрос раз в минуту; виджет обновляется
только при включённом экране. Теряется не более 5 мин статистики при убийстве процесса.
**Сознательно не делали (нет выигрыша или он не стоит риска)**
- Проверка здоровья уже растягивает паузы до штатного интервала и выключена по умолчанию, отдельная «остановка на мёртвом туннеле» не нужна.
- Проверка белых списков: цепочка идёт только пока белый список обнаружен, шаг не больше 5 мин; удлинение при погашенном экране отложило бы
возврат туннеля на минуты ради долей процента.
- Сеть без процесса (`registerNetworkCallback` на `PendingIntent`) — гипотеза не проверена на устройстве.
- `UnderlyingNetworkTracker` и SSID для сценариев из кэша `NetworkMonitor` — нужен лог с реальных сетей.
**Известное ограничение:** без «Будильников и напоминаний» keepalive в глубоком Doze остаётся под квотой; склейка заданий лишь освобождает слоты.
**Найдено при smoke-тесте 2.19.0:** `CharonVpnService` при старте по `Intent` без пароля (watchdog, конец паузы, `FgsStartRetry`) затирал сохранённый в профиле пароль значением `null`;
туннель после смерти процесса не возвращался («Password unavailable»). Теперь пароль из `Intent` подставляется, только если он там есть.
Проверено на эмуляторе `shx36` (профиль на 192.0.2.1, туннель не поднимается, проверялась логика): `am crash` → watchdog поднимает сервис (~80 с, окно неточного
будильника); сценарий «Wi-Fi → включить» срабатывает по событию сети при выключенных правилах сетей (~10 с); будильник сценария по времени будит убитый процесс,
`ScenarioReceiver` ждёт сервис и возвращает прежнее состояние VPN. Без реального сервера не проверены keepalive/rekey, NAT-таймауты и энергопотребление.
## 8. Версия 2.20: стабильность соединения и расход энергии (разбор 2.19.1)
Основа — чтение `NetworkManager`, `UnderlyingNetworkTracker`, `VpnStateService`, `android_net.c`, `charonservice.c`, `android_service.c`
и исходников charon. `CharonVpnService` и `direct_engine.c` целиком не разбирались: пункты ниже — гипотезы, пока их не подтвердит лог с телефона.
Условие раздела 5 (туннель не падает в фоне) действует для каждого пункта.
### 8.1. Шаг 0 — наблюдаемость (поведение не меняется)
- В `DiagLog` писать причину закрытия IKE SA: DPD/ретрансмиты, смена сети, закрыто сервером, действие пользователя; считать реконнекты в сутки.
Новых строк интерфейса нет. Без этих данных 8.2–8.4 решаются вслепую.
- Замеры на телефоне делает владелец: ночь с погашенным экраном на Wi-Fi и на мобильной сети; до и после — `dumpsys batterystats --reset` /
`dumpsys batterystats one.shx.strongswan.ext`, `dumpsys alarm`, `dumpsys deviceidle force-idle`.
### 8.2. Окно ретрансмитов (стабильность и энергия)
- Сейчас `retransmit_tries=3`, `timeout=2,0`, `base=1,4` (`charonservice.c:59-61`): первая отправка и 3 повтора, после ожидания третьего
повтора (`task_manager_v2.c:403`) IKE SA закрывается. Итого 2 + 2,8 + 3,9 + 5,5 ≈ 14 с без ответа; 5 повторов дали бы ≈ 33 с.
- Это относится к обычным запросам (DPD, rekey). У MOBIKE-проверки пути свой бюджет (`ROUTABILITY_CHECK_TRIES`), он от этих настроек не зависит.
- После закрытия SA charon поднимает его заново сам: `dpd_action` и `close_action` = `ACTION_START`, `keyingtries = 6` (`android_service.c:885-896`).
Java-backoff (5 с × 2ⁿ, до 120 с) включается, только когда попытки исчерпаны. Цена короткого провала связи — полный IKE_SA_INIT + AUTH
(радио, CPU, туннель недоступен на время переустановки). Как Java реагирует на `CHILD_STATE_DOWN` при этом, не разбиралось.
- Настройки читаются при создании IKE SA: «больше попыток только на мобильной сети» при роуминге не включится — либо выбор по транспорту
при подключении, либо одно значение для всех.
- Решать по логу 8.1: много падений по DPD на коротких провалах — поднять; мало — не трогать. Цена: дольше обнаружение реально мёртвого пути.
### 8.3. Привязка сокета IKE после смены сети (гипотеза, только по логу)
- Состояние `connected` из `NetworkManager` в нативе нужно одному: `android_net.c` ставит повторную проверку через 1 с после `ENETUNREACH`.
Roam-событие поднимается при любом изменении, поэтому версия «Wi-Fi без интернета считается связью и IKE уходит в пустоту» из кода не следует.
- Остаётся вопрос, на какую сеть привязан сокет IKE после roam (`bypass_socket` → `protect`) и совпадает ли она с `UnderlyingNetworkTracker`,
который требует `VALIDATED`. Риск: в сетях с белыми списками (`WhiteListManager`) проверка системы может не проходить при доступном сервере.
`NetworkManager` и условия сети не менять без лога с реальным случаем; приёмка по разделу 5.
### 8.4. Keepalive по транспорту (энергия, слоты квоты Doze) — пересматривает решение «45 с»
- 45 с ≈ 80 будильников/ч при квоте 72/ч (7/ч без точных будильников). На Wi-Fi 90–120 с вдвое сократили бы число будильников.
- `keep_alive` читается при создании IKE SA (`ike_sa.c:3283`): после смены Wi-Fi ↔ LTE значение не обновится без переподключения.
- Риск: домашние роутеры с UDP-таймаутом 30 с. Только после замера, как необязательная настройка профиля; умолчание не менять.
### 8.5. Нативный роутер и логи (после Perfetto)
- `direct_engine.c`: при погашенном экране растянуть холостой интервал с 4 до 10–30 с (пробуждение через `wake`-fd), не вызывать
`check_timers` вхолостую. Только при включённом режиме «сайты РФ напрямую».
- `DiagLog`: при погашенном экране не писать повторяющиеся строки (watchdog, смена сети); `flush` оставить.
### 8.6. Порядок и граница
1. Релиз 1: шаг 0 (8.1), поведение не меняется. Владелец проверяет сборку из S3 и присылает `diag.log` и цифры.
2. Релизы 2–3: 8.2 и 8.3 — только если лог это оправдывает; каждый отдельно, с приёмкой раздела 5.
3. 8.4 — после замера на Wi-Fi и мобильной сети. 8.5 — после Perfetto.
- Не трогать: keepalive 45 с на мобильной сети, foreground-сервис, будильники `Scheduler`/`WatchdogReceiver`, DPD,
`NetworkManager`/`UnderlyingNetworkTracker` без приёмки.
@@ -0,0 +1,107 @@
#!/usr/bin/env node
// Загрузка собранного APK в панель SHX VPN с компьютера сборки. Нужен только Node 18+, без зависимостей.
//
// node upload-apk.mjs app-release.apk --type phone --channel beta [--publish] [--rollout 100] [--notes "Что нового"]
//
// Окружение:
// SHX_URL адрес консоли, например https://apk.shnt.top
// SHX_TOKEN токен CI из «Настройки → Токены» (upload — только черновик, publish — можно публиковать)
//
// versionName и versionCode берутся из output-metadata.json, который Gradle кладёт рядом с APK,
// либо из --version-name / --version-code.
//
// Шаги: билет на загрузку → файл напрямую в S3 по presigned PUT → сервер проверяет SHA-256 и оформляет версию.
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { parseArgs } from "node:util";
const { values: o, positionals } = parseArgs({
allowPositionals: true,
options: {
type: { type: "string" },
channel: { type: "string", default: "beta" },
"version-name": { type: "string" },
"version-code": { type: "string" },
notes: { type: "string", default: "" },
"notes-en": { type: "string", default: "" },
publish: { type: "boolean", default: false },
rollout: { type: "string", default: "100" },
help: { type: "boolean", short: "h", default: false },
},
});
const die = (msg) => { console.error("✗ " + msg); process.exit(1); };
if (o.help || positionals.length !== 1) {
console.log("node upload-apk.mjs <файл.apk> --type <id типа> [--channel beta|release] [--publish] [--rollout 1-100] [--notes текст] [--version-name 2.20.0 --version-code 220]");
process.exit(o.help ? 0 : 1);
}
// Windows: переменные, заданные через [Environment]::SetEnvironmentVariable(..., "User"), видны только новым
// процессам. Если терминал (или Gradle daemon) открыт раньше, читаем их из реестра пользователя.
function userEnv(name) {
if (process.env[name]) return process.env[name];
if (process.platform !== "win32") return "";
try {
const out = execFileSync("reg", ["query", "HKCU\\Environment", "/v", name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
return /REG_(?:EXPAND_)?SZ\s+(.*)$/m.exec(out)?.[1]?.trim() ?? "";
} catch {
return "";
}
}
const base = userEnv("SHX_URL").replace(/\/$/, "");
const token = userEnv("SHX_TOKEN");
if (!base) die("Задайте SHX_URL (адрес консоли)");
if (!token) die("Задайте SHX_TOKEN (Настройки → Токены в консоли)");
if (!o.type) die("Укажите --type (id типа приложения из раздела «Типы приложений»)");
if (!["beta", "release"].includes(o.channel)) die("--channel: beta или release");
const rolloutPct = Number(o.rollout);
if (!Number.isInteger(rolloutPct) || rolloutPct < 1 || rolloutPct > 100) die("--rollout: целое от 1 до 100");
const file = positionals[0];
if (!existsSync(file)) die("Нет файла " + file);
let versionName = o["version-name"];
let versionCode = o["version-code"] ? Number(o["version-code"]) : undefined;
const meta = join(dirname(file), "output-metadata.json");
if ((!versionName || !versionCode) && existsSync(meta)) {
const el = JSON.parse(readFileSync(meta, "utf8")).elements?.[0];
versionName ??= el?.versionName;
versionCode ??= el?.versionCode;
}
if (!versionName || !Number.isInteger(versionCode)) die("Не удалось определить версию: передайте --version-name и --version-code");
const bytes = readFileSync(file);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const mb = (statSync(file).size / 1048576).toFixed(1);
async function api(path, body) {
const r = await fetch(base + path, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) die(`${path}: ${r.status} ${data?.error?.message ?? r.statusText}`);
return data;
}
console.log(`→ ${o.type} / ${o.channel} / ${versionName} (${versionCode}), ${mb} МБ, sha256 ${sha256.slice(0, 12)}…`);
const ticket = await api("/api/admin/versions/uploads", { typeId: o.type, channel: o.channel, versionName, versionCode });
if (bytes.length > ticket.maxBytes) die("Файл больше допустимого размера");
const put = await fetch(ticket.url, { method: ticket.method, headers: ticket.headers, body: bytes });
if (!put.ok) die(`Загрузка в хранилище: ${put.status} ${await put.text().catch(() => "")}`.slice(0, 300));
console.log("✓ файл в хранилище");
const v = await api("/api/admin/versions", {
uploadId: ticket.uploadId,
sha256,
notes: { ru: o.notes, en: o["notes-en"] },
publish: o.publish,
rolloutPct,
});
console.log(`✓ версия оформлена: ${v.versionName}, статус ${v.status}${o.publish ? `, раскатка ${rolloutPct}%` : " (черновик: опубликуйте в консоли)"}`);
console.log(` ${base}/versions/${v.id}`);