92 Commits
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
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 3s
SonarCloud / pre-check (push) Failing after 3s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / pre-check (push) Failing after 4s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (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
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
SonarCloud / pre-check (push) Failing after 3s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (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 (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (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
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
SonarCloud / pre-check (push) Failing after 3s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (push) Skipped
Android / pre-check (push) Failing after 8s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (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
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (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 (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (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
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
macOS / pre-check (push) Failing after 5s
Linux / alpine (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (push) Skipped
Android / pre-check (push) Failing after 7s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (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
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (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 (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (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
DenozordecandClaude Sonnet 5.5 6756d2fca5 android: version 2.18.1 (166)
CodeQL / pre-check (push) Failing after 5s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (push) Skipped
Android / pre-check (push) Failing after 5s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:52:12 +07:00
DenozordecandClaude Sonnet 5.5 5998c0fc46 android: Home view model no longer re-reads the Wi-Fi SSID on every signal-strength tick (state recomputed only when the transports change); energy plan: no shared network-callback hub, why
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:44:03 +07:00
DenozordecandClaude Sonnet 5.5 f5b15a0394 android: health check skips probes while inbound tunnel traffic proves liveness (adaptive mode, real probe at least every 5 min) and probes a single target with the screen off; unit tests; energy plan notes keepalive stays at 45 s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:22:09 +07:00
DenozordecandClaude Sonnet 5.5 38e5fd84bb android: less idle CPU work: TUN reader waits on poll+eventfd instead of a 1 s select; scenario app polling stops with the screen and slows down when idle; site DNS refresh waits for the screen; ServerActivity and rememberNow loops follow the lifecycle; energy plan
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:18:15 +07:00
DenozordecandClaude Opus 5.5 e0113e3413 android docs: the Final 2026 mockup (143 artboard sources and canvas.json) with an artboard-to-code map; app overview, code map, localization and release guide; AGENTS.md updated; old UI plan marked as archive
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (push) Skipped
Android / pre-check (push) Failing after 6s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:31:05 +07:00
DenozordecandClaude Opus 5.5 637b8061ed android: version 2.18.0 (165)
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 3s
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (push) Skipped
Android / pre-check (push) Failing after 6s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:31:57 +07:00
DenozordecandClaude Opus 5.5 29095e087f android: de, pl, uk, zh-CN and zh-TW translations of all new strings and plurals; old English leftovers in pl/uk/zh-TW translated; short German and Ukrainian tab labels on one line; trusted-network hint centred; German keeps noun capitals in mid-sentence labels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:29:05 +07:00
DenozordecandClaude Opus 5.5 e3e6d51f6f android: no unattended start of a profile without a saved password (watchdog, network rules, scenarios, reserve, bridge): an event that opens the server instead; «password missing» ends the watchdog expectation; resume and restore keep a typed password; warning on the server page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:01:41 +07:00
DenozordecandClaude Opus 5.5 df1575fe27 android: one order for the network rules and the scenarios (top wins), hand actions beat a running scenario until it ends
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:35:17 +07:00
DenozordecandClaude Opus 5.5 a39993075e android: the rest of the UI in the Final 2026 style: setting rows as on the sub pages, kit colours and type everywhere, server picker labels, shortcut picker and remediation screens; the rules server moves to Scenarios, the old Trusted networks screen goes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:51:18 +07:00
DenozordecandClaude Opus 5.5 9ed278b440 android: mockup line icons everywhere, mockup dialog buttons, server picker sheet as on F_Servers; drop the unused old Home and routing parts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:33:42 +07:00
DenozordecandClaude Opus 5.5 16d4c72920 android: bridge question back in Help
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:11:51 +07:00
DenozordecandClaude Opus 5.5 1e32410fca android: system notifications as in the Final 2026 mockup: connecting with Stop, answers in suggestion pushes, Update / What is new, weekly summary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:09:51 +07:00
DenozordecandClaude Opus 5.5 8880601ef6 android: DNS and ads as in the Final 2026 mockup: filtering resolvers (AdGuard, Cloudflare 1.1.1.2), own DNS, applied to the tunnel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:04:01 +07:00
DenozordecandClaude Opus 5.5 dcf8ace6e1 android: Home scenario hints as in the Final 2026 mockup (trusted network pill, rule suggestion), profile update from the error and expiry cards, a stop button on errors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:58:57 +07:00
DenozordecandClaude Opus 5.5 1a6fb85259 android: scenarios as in the Final 2026 mockup: network rules with own switches, user scenarios (Wi-Fi, mobile, app, time, roaming, whitelists) with an engine, app picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:47:53 +07:00
DenozordecandClaude Opus 5.5 5ca60d7066 android: resilience of F_Profile at work: backup ports, reserve server, bridge while whitelists are on
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:33:36 +07:00
DenozordecandClaude Opus 5.5 e7b801d584 android: server page as in the Final 2026 mockup; profile source, link updates with a diff sheet and a daily check, per-profile IKE fragmentation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:22:53 +07:00
DenozordecandClaude Opus 5.5 638c58f0e4 android: daily traffic history and the statistics screen as in the Final 2026 mockup; today's traffic on Home and the widget
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:08:13 +07:00
DenozordecandClaude Opus 5.5 92627da459 android: remove the pre-mockup Home parts (hero, banners, availability card, traffic tiles, glow)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:47:33 +07:00
DenozordecandClaude Opus 5.5 15ad5c1151 android: install-permission sheet before updating as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:42:18 +07:00
DenozordecandClaude Opus 5.5 92e2f4f069 android: connection steps on Home as in the Final 2026 mockup, read from the daemon log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:39:26 +07:00
DenozordecandClaude Opus 5.5 a08c193a90 android: hint behind the system VPN consent window as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:36:09 +07:00
DenozordecandClaude Opus 5.5 8fbebed140 android: battery saving instructions screen as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:07:20 +07:00
DenozordecandClaude Opus 5.5 9c96c36fc6 android: all dialogs in the Final 2026 look (28dp card, 22/800 title, 50% scrim), login fields as form fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:01:25 +07:00
DenozordecandClaude Opus 5.5 ffa38d5f28 android: certificate expiry card on Home as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:55:39 +07:00
DenozordecandClaude Opus 5.5 e465797b5f android: certificates screen as in the Final 2026 mockup (from profiles, added, system; expiry colors)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:51:13 +07:00
DenozordecandClaude Opus 5.5 b4a311c84f android: notification settings as in the Final 2026 mockup (problems, new apps, updates)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:47:24 +07:00
DenozordecandClaude Opus 5.5 739068ccb9 android: help screen as in the Final 2026 mockup; log opens directly from settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:43:54 +07:00
DenozordecandClaude Opus 5.5 8cd704ed08 android: log screen as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:35:42 +07:00
DenozordecandClaude Opus 5.5 eb5a89623b android: server delete confirmation as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:31:32 +07:00
DenozordecandClaude Opus 5.5 f193661e68 android: quick settings tile states and widget sizes as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:25:38 +07:00
DenozordecandClaude Opus 5.5 53623ebb4a android: home screen widget in three sizes as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:18:47 +07:00
DenozordecandClaude Opus 5.5 db6c806d58 android: VPN notification as in the Final 2026 shade: protected title, pause and change actions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:12:41 +07:00
DenozordecandClaude Opus 5.5 6652970925 android: first-run onboarding and permissions screen as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:08:57 +07:00
DenozordecandClaude Opus 5.5 dc8f5a75e1 android: appearance, kill switch and update pages as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:04:12 +07:00
DenozordecandClaude Opus 5.5 fecef1135c android: import preview/error sheet and profile form as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:00:09 +07:00
DenozordecandClaude Opus 5.5 200697216d android: add-server sheet with QR scan and link, share-by-QR screen (Final 2026)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:49:51 +07:00
DenozordecandClaude Opus 5.5 434e5bad4e android: Servers, Settings and Apps direct screens as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:43:13 +07:00
DenozordecandClaude Opus 5.5 4ff5ed11bb android: routing phase 3 and the Final 2026 design system (WIP)
- Routing: site and direct-address rules (sites resolved to addresses,
  refreshed on connect and every 30 min), package patterns can be
  switched off; unified Rules screen with add sheet and empty state;
  "How traffic goes" for a site, IP or app with reasons, reachability and
  one-tap rules; Recent apps (usage access) with failing apps; new-app
  "go direct?" suggestions in Events.
- Diagnostics screen (all-in-one check), "Fastest" server, pause until
  morning / until the network changes.
- Design system of the mockup: Manrope font, mockup palette for light and
  dark, ShxKit components (cards, rows, switch, buttons, chips, tabs,
  fields, sheets), mockup line icons; Home, nav bar, Routing, Rules,
  Recent, Events, Diagnostics and route check rebuilt on it; shared
  components restyled so the remaining screens pick up the look.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:46:27 +07:00
DenozordecandClaude Opus 5.5 60252437ac android: home phase 2 - events inbox and problem notifications
- EventLog keeps what the app noticed and did by itself for 30 days on the
  device (events.json); repeats of the same event within 30 minutes are
  merged (x3), problems that go away are marked resolved.
- Recorded: connection errors and recovery, carrier whitelists on/off,
  health-check reconnects, auto-connect starting/parking the tunnel, the
  end of a pause, available updates.
- Problems are pushed on a new "Connection problems" channel, but only
  while the app is in the background (Home shows them already).
- «Events» screen (InboxActivity): filters (all, need attention,
  connection, routes, app), grouped by day, one action per event; opened
  from a bell with an unread badge in the Home header.
- Amber warning colours in the status palette, used for whitelists, no
  network and an unresponsive server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:54:01 +07:00
DenozordecandClaude Opus 5.5 75849beb90 android: home phase 1 - header, quick actions, pause, problem cards
- Connect tab gets the Shield-S header and, while connected, three quick
  actions: pause, switch server, "site not loading?" (route check).
- Pause VPN for 5/15/30/60 min (PauseManager + PauseReceiver): disconnects
  like a manual stop and reconnects on an alarm; auto-connect waits, any
  connect request cancels the pause. Home shows a countdown, "turn on now"
  and "+15 minutes".
- Connection errors become plain-language cards with one fixing action:
  no internet (tracked via a non-VPN network callback), server not
  responding (with the retry countdown and "other server"), server not
  found, sign-in rejected, server not verified, generic failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:36:34 +07:00
DenozordecandClaude Opus 5.5 8f21e16277 android: 2.17.1 - Shield-S launcher, shortcut and status bar icon
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 3s
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (push) Skipped
Android / pre-check (push) Failing after 6s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
- adaptive launcher icon as vectors: purple background, white shield with
  the S cut out (even-odd), plus a monochrome layer for Android 13+ themed
  icons; profile shortcuts use the same icon
- vector legacy icon for Android 7.x in mipmap-anydpi, old strongSwan PNGs
  removed
- connected status bar icon is the Shield-S silhouette; connecting,
  warning and disconnected states keep their outlined shields

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:17:24 +07:00
DenozordecandClaude Opus 5.5 12a95d1e89 android: 2.17.0 - stability and battery fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:28:46 +07:00
DenozordecandClaude Opus 5.5 66688031cd android: small cleanups - DB close in finally, single profile reload
- HomeViewModel closes the profile database in finally blocks and relies
  on the VPN_PROFILES_CHANGED broadcast instead of reloading twice
- READ_EXTERNAL_STORAGE only up to API 32; drop a dead settings flag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:20:28 +07:00
DenozordecandClaude Opus 5.5 7abac87140 android: run DNS/route diagnostics on their own executor
They can block for 10-30 s on a degraded network and delayed any profile
load (connect, retry, profile switch) queued behind them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:20:28 +07:00
DenozordecandClaude Opus 5.5 1b4fdc83d8 android: tile/widget/log fixes
- tile: query the configured profile once per listening session instead
  of on every state notification
- widget: DISCONNECTING is shown as such, with a Disconnect button
- log viewer: bounded to 5000 lines, tails only while visible, picks up
  a log created or restarted after opening, and no longer yanks the user
  back to the bottom while they scroll up

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:19:33 +07:00
DenozordecandClaude Opus 5.5 ce80c36be1 android: routing - package scans off the main thread, no needless reconnects
- computeExcludedPackages() (full PackageManager scan) runs on Default,
  not Main.immediate, on config changes and package add/remove
- updateConfig() ignores no-op transforms and reconnects when routing is
  switched off too (direct apps kept bypassing the tunnel until then)
- config is volatile and read after its version, so an old config can no
  longer be cached under a new version
- Direct apps loads the app list once per entry, without GET_META_DATA

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:19:33 +07:00
DenozordecandClaude Opus 5.5 07fb02790c android: CharonVpnService - handover race, cheaper network/notification updates
- underlying-network bookkeeping is guarded by one lock and re-applied
  after establish(), so a handover racing with it no longer leaves the
  tunnel attributed to a lost network
- use the capabilities passed to onCapabilitiesChanged (no IPC per
  signal-strength update); mCurrentProfile is volatile
- countdown-only notification re-posts are limited to one per 5 s
- the connected chronometer keeps its start time across re-posts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:17:36 +07:00
DenozordecandClaude Opus 5.5 b0f353af90 android: whitelist - working recheck backoff, fewer probe rounds
- scheduled rechecks are forced: the 15/30 s steps were below FRESH_MS,
  so the unforced recheck returned early and the backoff chain died
- signal-strength-only capability changes are no longer network events
  (each one reset the backoff and launched a check)
- tunnel state transitions force a new round at most every 15 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:17:36 +07:00
DenozordecandClaude Opus 5.5 514b15b1ca android: home/routing UI - poll only while visible, no idle animation
- traffic (1 s) and probe (10 s) loops run only while the Home screen is
  shown and the VPN is connected; no 1 s idle tick otherwise
- the refresh button no longer runs a second exit-IP trace
- the glow pulse is not composed at all while disconnected or with
  animations off (it redrew a transparent circle every frame)
- RTT sparkline appends each probe result once (it duplicated stale ones)
- Routing dead-host poll is lifecycle-aware

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:15:10 +07:00
DenozordecandClaude Opus 5.5 5f8b813eb4 android: updates - honour the charging constraint, real metered check
- drop the in-process 12 h loop: the VPN keeps the process alive, so it
  checked and pre-downloaded on battery, defeating the worker constraint
- the worker now waits for the check and a silent download it starts
- metered is judged by the physical network: the VPN reports itself
  unmetered, so pre-downloads ran over cellular while connected

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:15:10 +07:00
DenozordecandClaude Opus 5.5 6668dcfb56 android: health check - ping the server only when it can matter
- WITH_TUNNEL mode pings the server only once the external targets fail
  (no ping fork + DNS lookup outside the tunnel on every healthy cycle;
  an ICMP-blocking server no longer forces fast polls)
- initial battery-low state from the sticky ACTION_BATTERY_CHANGED
- non-state notifications no longer restart a pending cycle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:12:22 +07:00
DenozordecandClaude Opus 5.5 1eb1dfe317 android: RU list - no reconnect for an unchanged list, failure backoff
- same SHA-256 as the applied list: only bump the timestamp, no parse,
  save or tunnel reconnect (it tore a healthy tunnel down daily)
- a failed download is not retried on every cold start (3 h backoff)
- the saved list is read and parsed off the main thread at startup
- switching the split off in system mode reconnects to drop the routes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:12:22 +07:00
DenozordecandClaude Opus 5.5 cf89cec20e android: watchdog backs off whenever a live process owns recovery
Retrying, connecting or whitelist-blocked sessions kept the 60 s alarm and
the 10 min Doze wakeup for hours although a live process makes the alarm
useless. They now share the connected backoff (5 min / 60 min).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:12:22 +07:00
DenozordecandClaude Opus 5.5 24e536b9b9 android: updates - no stuck Downloading state, thread-safe singleton
After a silent pre-download the state stayed Downloading, so tapping the
"update ready" notification did nothing. It now returns to Available;
an install request without a known update (e.g. after a process restart)
re-checks and installs when found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:40 +07:00
DenozordecandClaude Opus 5.5 e6c893219c android: DiagLog thread-safe timestamps; auto-connect survives stop/start
- SimpleDateFormat is now used under the lock; rotation counts bytes
- AutoConnectManager.stop() clears pendingEvaluate, whose reset runnable
  it removes, so later connects are not silently swallowed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:40 +07:00
DenozordecandClaude Opus 5.5 01c398f2f7 android: tile/control activity - no bind leaks, no callbacks after destroy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:40 +07:00
DenozordecandClaude Opus 5.5 f2e4ffe860 android: VpnStateService - single retry countdown, isolated listeners
- remove pending RETRY_MSG on reset/set: a Retry tap during a backoff
  started a second countdown chain (double-speed ticks, two reconnects)
- notify listeners over a copy with per-listener error handling
- disconnect() tolerates the VPN service not running

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:40 +07:00
DenozordecandClaude Opus 5.5 358dcb94b2 android: CharonVpnService - safer foreground and teardown handling
- DISCONNECT (plain startService) no longer promotes to the foreground,
  and startForeground() refusals on Android 12+ are logged, not fatal
- drop the placeholder notification when nothing was running, so the
  service does not stay in the foreground with a stale Disabled state
- onDestroy: shutdownNow/await the profile executor before closing the
  database; profile loads catch errors instead of killing the process
- unbind by bind result, not by mService (binding leaked if destroyed
  before onServiceConnected)
- the connection thread's crash guard can no longer throw itself

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:39 +07:00
DenozordecandClaude Opus 5.5 2b40eebf5b android: close TCP probe sockets right after connecting
tcpPing never closed its socket: every health/whitelist/home probe leaked
an fd until GC and the peer's later FIN/RST woke the radio again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:09:39 +07:00
DenozordecandClaude Opus 5.5 793ca1b410 android: native - hold charon_mutex while reading routing engine stats
getRoutingStats/getDeadDirectTargets are polled from the UI once a second;
a concurrent disconnect frees the engine under charon_mutex, so reading it
without the lock was a use-after-free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:05:09 +07:00
DenozordecandClaude Opus 5.5 5e38fc2f76 android: keep the BETA badge on one line on narrow mode cards
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
SonarCloud / pre-check (push) Failing after 3s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (push) Skipped
Android / pre-check (push) Failing after 5s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:55:19 +07:00
DenozordecandClaude Opus 5.5 c23d4dad8b android: 2.16.0 - Routing 2.0: one direct-apps list, inline RU mode, no per-profile app picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:48:33 +07:00
DenozordecandClaude Opus 5.5 e2ce8420a7 android: remove per-profile app picker, rule-set UI and SelectedApplicationsActivity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:48:00 +07:00
DenozordecandClaude Opus 5.5 488c0aa210 android: Routing tab redesign - RU sites card, direct apps screen, advanced pages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:47:59 +07:00
DenozordecandClaude Opus 5.5 48f1ed7fb8 android: routing 2.0 strings (en/ru/de), drop obsolete rule-set strings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:47:59 +07:00
DenozordecandClaude Opus 5.5 389712bba3 android: global direct-apps routing model, migration, single-source CharonVpnService excludes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:47:58 +07:00
DenozordecandClaude Opus 5.5 584004a25c android: 2.15.1 - fix startup crash: R8 stripped WorkDatabase_Impl constructor
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
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-testable-ke) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 3s
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win64) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (clang, no-dbg) (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 / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / cross-compile (win32) (push) Skipped
Android / pre-check (push) Failing after 4s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 3s
CIFuzz / fuzzing (undefined) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no, all) (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-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
macOS / macos (macos-14) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
AGP 9 strict full-mode keep rules no longer keep no-arg constructors of
-keep'd classes, so WorkManager's reflectively created Room database
lost its constructor and the app crashed in InitializationProvider.
Restore AGP 8 keep-rule semantics and keep RoomDatabase constructors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:50:59 +07:00
361 changed files with 45247 additions and 7348 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"
}
+45 -1
View File
@@ -1,6 +1,25 @@
# Память проекта
## Android-приложение: сборка и выкладка (src/frontends/android)
Основная работа в репозитории — Android-приложение SHX VPN (`src/frontends/android`), форк клиента strongSwan.
Описание приложения, кода, локализации и релиза: `src/frontends/android/docs/README.md`.
Дизайн: `src/frontends/android/docs/design/README.md` (макет «Финал 2026»).
## Android-приложение: дизайн и UI
- **Эталон — макет «Финал 2026»** (артборды `F_*`, тёмная тема `D_*`): живой холст https://claude.ai/artifact/UCC88C9hkU3hDU2YwMck1a,
копия исходников в `docs/design/final-2026/`. Экраны делаются строго по макету; новый экран сначала появляется на холсте.
- UI собирается только из компонентов макета: `ui/compose/design/ShxKit.kt`, `ShxIcons.kt`, цвета и шрифты `ui/compose/theme/ShxDesign.kt`
(`LocalShxColors`, `ShxType`). Сторонние цвета, размеры и стандартные M3-диалоги не использовать.
- Изменили экран в коде — обновите `F_*` и парный `D_*` на холсте и копию в `docs/design/final-2026/`.
## Android-приложение: строки и переводы
- Языки: en (база), ru, de, pl, uk, zh-rCN, zh-rTW. Новая строка добавляется сразу во все семь `values*/strings.xml`.
- Плейсхолдеры и `\n` как в английской строке; кавычки и plurals по языку — см. раздел «Локализация» в `docs/README.md`.
- После правок строк смотреть de и uk на эмуляторе (`adb shell cmd locale set-app-locales one.shx.strongswan.ext --locales de-DE`):
длинные слова не должны переноситься в навигации и заголовках. Сброс — `--locales ""`.
## Android-приложение: сборка и выкладка
- **Бакет S3 (Selectel, `shx-app`) ПРИВАТНЫЙ.** Публичного чтения нет и включать его не нужно:
`GET https://s3.ru-7.storage.selcloud.ru/shx-app/latest.json` всегда отдаёт `AccessDenied` — это норма, а не ошибка выкладки.
@@ -8,9 +27,34 @@
- Сборка и публикация — скриптом `build-local.sh` (Git Bash) или `build-local.ps1` в `src/frontends/android`:
он читает `.env`, собирает `assembleRelease`, подписывает keystore'ом из `.env`, генерирует `latest.json`
(sha256/size/versionCode) и заливает `shx-vpn.apk` + `latest.json` в корень бакета с `Cache-Control: no-cache`.
- **Порядок релиза:** `--skip-upload` → smoke-тест APK на эмуляторе (AVD `shx36`, все вкладки, `adb logcat -b crash`) →
выгрузка именно проверенного `build/publish/shx-vpn.apk` и `latest.json` через `aws s3 cp` → сверка `latest.json` в бакете.
Выгрузка в S3 — только с подтверждения владельца.
- **`.env` нельзя парсить через `source`/`eval`**: значения `NOTES_RU` и `AWS_CA_BUNDLE` содержат пробелы и
обратные слэши. Разбирать только построчно `KEY=...` (как в `build-local.sh`).
- aws cli установлен через pip: `%APPDATA%\Python\Python313\Scripts\aws` (скрипт сам добавляет путь в PATH).
- `versionCode` растёт вручную в `app/build.gradle`; перед сборкой сверять с `versionCode` в `latest.json` бакета.
- `NOTES_RU` в `.env` — заметки релиза (попадают в `latest.json` → диалог обновления в приложении); обновлять под релиз.
`.env` не в git: в worktree лежит своя копия, после релиза перенести `NOTES_RU` в `.env` основного checkout.
- **Сборка из worktree:** скопировать игнорируемые входы из основного checkout (`.env` с абсолютным `KEYSTORE_FILE`,
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
- Автообновление приложения: `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.
+48 -2
View File
@@ -45,8 +45,8 @@ android {
minSdk = 24
targetSdk = 36
versionCode 160
versionName "2.15.0"
versionCode 177
versionName "2.19.9"
/* Base URL of the update storage, trailing slash included */
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_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 {
ndkBuild {
arguments '-j' + Runtime.runtime.availableProcessors()
@@ -173,6 +184,10 @@ dependencies {
implementation 'androidx.glance:glance-appwidget:1.2.0'
implementation 'androidx.glance:glance-material3:1.2.0'
implementation 'androidx.activity:activity-compose:1.11.0'
/* QR: scanning profiles (camera) and showing a profile as a QR code */
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'
implementation 'androidx.compose.ui:ui-tooling-preview'
@@ -180,6 +195,8 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.assertj:assertj-core:3.27.6'
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:runner:1.7.0'
@@ -187,3 +204,32 @@ dependencies {
androidTestImplementation composeBom
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
}
}
@@ -58,3 +58,12 @@
# --- squash warnings for optional dependencies referenced only from XML ---
-dontwarn org.bouncycastle.jsse.util.**
# --- Room databases are instantiated reflectively (WorkManager's WorkDatabase_Impl) ---
-keep class * extends androidx.room.RoomDatabase {
<init>();
}
# i2p eddsa (manifest signature check) references sun.security.x509 only for JDK interop, absent on Android
-dontwarn sun.security.x509.**
@@ -0,0 +1,91 @@
/*
* 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.compose
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.strongswan.android.R
import org.strongswan.android.logic.routing.TemplateMatch
import org.strongswan.android.ui.compose.routing.AppReason
import org.strongswan.android.ui.compose.routing.AppsFilter
import org.strongswan.android.ui.compose.routing.DirectAppRow
import org.strongswan.android.ui.compose.routing.DirectAppsContent
import org.strongswan.android.ui.compose.routing.DirectAppsGroup
import org.strongswan.android.ui.compose.routing.DirectAppsUiState
import org.strongswan.android.ui.compose.routing.QuickChipState
import org.strongswan.android.ui.compose.theme.ShxTheme
/** Smoke test of the stateless "Apps direct" screen: filter chips, quick-add chips and an app row. */
class DirectAppsScreenTest
{
@get:Rule
val composeRule = createComposeRule()
private fun string(id: Int, vararg args: Any): String =
InstrumentationRegistry.getInstrumentation().targetContext.getString(id, *args)
private val state = DirectAppsUiState(
loading = false,
groups = listOf(
DirectAppsGroup(
category = "banks",
rows = listOf(
DirectAppRow("com.example.bank", "Example Bank", true, AppReason.Template(TemplateMatch(category = "banks"))),
),
),
DirectAppsGroup(
category = null,
rows = listOf(DirectAppRow("com.example.chat", "Example Chat", false, AppReason.Tunnel)),
),
),
directCount = 1,
tunnelCount = 1,
filter = AppsFilter.ALL,
quickChips = listOf(QuickChipState("banks", true), QuickChipState("shop", false)),
)
@Test
fun showsFiltersQuickChipsAndRows()
{
var toggled: Pair<String, Boolean>? = null
composeRule.setContent {
ShxTheme {
DirectAppsContent(
state = state,
onBack = {},
onQuery = {},
onFilter = {},
onShowSystem = {},
onToggleApp = { pkg, direct -> toggled = pkg to direct },
onToggleCategory = { _, _ -> },
onAllRf = {},
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText(string(R.string.apps_filter_direct, 1)).assertIsDisplayed()
composeRule.onNodeWithText(string(R.string.apps_filter_vpn, 1)).assertIsDisplayed()
composeRule.onNodeWithText(string(R.string.routing_apps_add_all_rf)).assertIsDisplayed()
composeRule.onNodeWithText("Example Bank").assertIsDisplayed()
composeRule.onNodeWithText("Example Chat").performClick()
assertEquals("com.example.chat" to true, toggled)
}
}
@@ -27,9 +27,14 @@
<!-- 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.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_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- «Recent apps»: per-app traffic totals from the system network statistics;
granted by the user in Settings > Usage access, never requested silently -->
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
<!-- required to install downloaded updates of the app itself -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@@ -39,7 +44,8 @@
<!-- required to claim update ownership (setRequestUpdateOwnership) so our
own updates keep being applied silently on Android 14+ -->
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- Required to reliably schedule NAT keepalives/rekeyings via the AlarmManager.
@@ -74,6 +80,18 @@
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<activity
android:name=".ui.ScenariosActivity"
android:exported="false" />
<activity
android:name=".ui.ServerActivity"
android:exported="false" />
<activity
android:name=".ui.StatsActivity"
android:exported="false" />
<activity
android:name=".ui.BatteryActivity"
android:exported="false" />
<activity
android:name=".ui.VpnProfileControlActivity"
android:theme="@style/TransparentActivity"
@@ -108,10 +126,6 @@
android:name=".ui.TrustedCertificatesActivity"
android:label="@string/trusted_certs_title" >
</activity>
<activity
android:name=".ui.SelectedApplicationsActivity"
android:label="@string/profile_select_apps" >
</activity>
<activity
android:name=".ui.LogActivity"
android:label="@string/log_title" >
@@ -120,10 +134,6 @@
android:name=".ui.SettingsActivity"
android:label="@string/pref_title">
</activity>
<activity
android:name=".ui.TrustedNetworksActivity"
android:label="@string/auto_connect_title">
</activity>
<activity
android:name=".ui.ConnectivityCheckActivity"
android:label="@string/health_check_title">
@@ -132,6 +142,18 @@
android:name=".ui.RouteCheckActivity"
android:label="@string/route_check_title">
</activity>
<activity
android:name=".ui.InboxActivity"
android:label="@string/events_title">
</activity>
<activity
android:name=".ui.DiagnosticsActivity"
android:label="@string/diag_title">
</activity>
<activity
android:name=".ui.OnboardingActivity"
android:label="@string/app_name">
</activity>
<activity
android:name=".ui.RemediationInstructionsActivity"
android:label="@string/remediation_instructions_title" >
@@ -296,6 +318,18 @@
<receiver
android:name=".logic.WatchdogReceiver"
android:exported="false" />
<!-- «Yes» / «No» of the suggestion notifications -->
<receiver
android:name=".logic.events.EventAnswerReceiver"
android:exported="false" />
<!-- edges of the time windows of the user scenarios -->
<receiver
android:name=".logic.scenarios.ScenarioReceiver"
android:exported="false" />
<!-- ends a "pause VPN for N minutes" and reconnects the paused profile -->
<receiver
android:name=".logic.PauseReceiver"
android:exported="false" />
<!-- runs after the app has updated itself (the system kills the
process during the install): cleans up and lets auto-connect
restore the VPN -->
@@ -306,6 +340,17 @@
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</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>
</manifest>
@@ -54,6 +54,16 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
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
public void close()
{
@@ -67,7 +77,7 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
public VpnProfile insertProfile(VpnProfile 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)
{
return null;
@@ -82,14 +92,14 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
{
final UUID uuid = profile.getUUID();
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
public boolean deleteVpnProfile(VpnProfile profile)
{
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
@@ -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"
}
}
@@ -51,13 +51,13 @@ import android.util.Log;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnProfileSource;
import org.strongswan.android.data.VpnType.VpnTypeFeature;
import org.strongswan.android.logic.VpnStateService.ErrorState;
import org.strongswan.android.logic.VpnStateService.State;
import org.strongswan.android.logic.imc.ImcState;
import org.strongswan.android.logic.stats.TrafficHistory;
import org.strongswan.android.logic.imc.RemediationInstruction;
import org.strongswan.android.logic.routing.RouteCheck;
import org.strongswan.android.logic.routing.RoutingManager;
@@ -92,8 +92,11 @@ import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.SortedSet;
import java.util.TimeZone;
import java.util.TreeSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
@@ -156,19 +159,37 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
private VpnProfileDataSource mDataSource;
/* profile lookups are SQLite queries and must not run on the main thread */
private final ExecutorService mProfileExecutor = Executors.newSingleThreadExecutor();
/* DNS/route diagnostics and private DNS lookups can block for many seconds
* on a bad network: keep them off the profile executor, or a connect
* queued behind them is delayed by that much */
private final ExecutorService mDiagExecutor = Executors.newSingleThreadExecutor();
private Thread mConnectionHandler;
private VpnProfile mCurrentProfile;
/* read by the ConnectivityThread (UnderlyingNetworkTracker) too */
private volatile VpnProfile mCurrentProfile;
private volatile String mCurrentCertificateAlias;
private volatile String mCurrentUserCertificateAlias;
private VpnProfile mNextProfile;
private volatile boolean mProfileUpdated;
private volatile boolean mTerminate;
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;
/* start of the current CONNECTED session for the notification chronometer */
private volatile long mConnectedSince;
/* rate limit for countdown-only notification updates (main thread only) */
private static final long NOTIFICATION_MIN_INTERVAL_MS = 5000;
private String mLastNotifiedKey;
private long mLastNotifiedAt;
private final BuilderAdapter mBuilderAdapter = new BuilderAdapter();
private UnderlyingNetworkTracker mUnderlyingTracker;
private Handler mHandler;
private VpnStateService mService;
private boolean mBound;
private final Object mServiceLock = new Object();
private final ServiceConnection mServiceConnection = new ServiceConnection()
{
@@ -205,6 +226,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
static final int STATE_UNREACHABLE_ERROR = 6;
static final int STATE_CERTIFICATE_UNAVAILABLE = 7;
static final int STATE_GENERIC_ERROR = 8;
static final int STATE_RETRANSMIT_TIMEOUT = 9;
static final int STATE_REAUTH = 10;
@Override
public int onStartCommand(Intent intent, int flags, int startId)
@@ -217,8 +240,14 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
* first millisecond, and it avoids the system killing a fresh process
* when the profile fails to load before addNotification() runs. The
* state-specific notification replaces this one (via stateChanged()). */
mShowNotification = true;
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
boolean disconnect = intent != null && DISCONNECT_ACTION.equals(intent.getAction());
if (!disconnect)
{ /* DISCONNECT arrives via plain startService(): promoting to the
* foreground from the background is not allowed there (Android 12+)
* and would crash, and there is nothing to keep alive anyway */
mShowNotification = true;
startForegroundSafe();
}
if (intent != null)
{
if (VPN_SERVICE_ACTION.equals(intent.getAction()))
@@ -249,7 +278,19 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
VpnProfile profile = loadProfile(uuid);
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 */
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
if (port > 0)
{
profile.setPort(port);
}
PreferenceManager.getDefaultSharedPreferences(this).edit()
.putString(Constants.PREF_MRU_VPN_PROFILE, profile.getUUID().toString())
.apply();
@@ -282,7 +323,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
return null;
}
return mDataSource.getVpnProfile(uuid);
try
{
return mDataSource.getVpnProfile(uuid);
}
catch (RuntimeException e)
{ /* e.g. the service is being destroyed and closed the database;
* an uncaught exception on the executor would kill the process */
DiagLog.i(TAG, "loading profile failed: " + e);
return null;
}
}
/**
@@ -391,8 +441,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
/* use a separate thread as main thread for charon */
mConnectionHandler = new Thread(this);
/* the thread is started when the service is bound */
bindService(new Intent(this, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
mBound = bindService(new Intent(this, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
createNotificationChannel();
}
@@ -418,12 +468,29 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
e.printStackTrace();
}
if (mService != null)
synchronized (mServiceLock)
{
mService.unregisterListener(this);
if (mService != null)
{
mService.unregisterListener(this);
}
}
if (mBound)
{ /* unbind even if onServiceConnected() never ran, or the binding leaks */
unbindService(mServiceConnection);
mBound = false;
}
mDiagExecutor.shutdownNow();
/* queued profile loads must not run against the closed database */
mProfileExecutor.shutdownNow();
try
{
mProfileExecutor.awaitTermination(2, TimeUnit.SECONDS);
}
catch (InterruptedException e)
{
Thread.currentThread().interrupt();
}
mProfileExecutor.shutdown();
mDataSource.close();
}
@@ -461,6 +528,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
if (mNextProfile == null)
{
setState(State.DISABLED);
/* nothing was running (e.g. DISCONNECT while already
* disconnected, or the profile failed to load): drop the
* placeholder notification so the service is not kept
* in the foreground with a stale "Disabled" state */
removeNotification();
if (mTerminate)
{
break;
@@ -510,6 +582,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
writer.setValue("connection.local_id", mCurrentProfile.getLocalId());
writer.setValue("connection.remote_id", mCurrentProfile.getRemoteId());
writer.setValue("connection.certreq", (mCurrentProfile.getFlags() & VpnProfile.FLAGS_SUPPRESS_CERT_REQS) == 0);
writer.setValue("connection.fragmentation", org.strongswan.android.logic.profiles.ProfileExtras.get(CharonVpnService.this, mCurrentProfile.getUUID().toString()).getIkeFragmentation());
writer.setValue("connection.strict_revocation", (mCurrentProfile.getFlags() & VpnProfile.FLAGS_STRICT_REVOCATION) != 0);
writer.setValue("connection.ike_proposal", mCurrentProfile.getIkeProposal());
writer.setValue("connection.esp_proposal", mCurrentProfile.getEspProposal());
@@ -537,7 +610,25 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
* and let the regular retry machinery handle it. */
Log.e(TAG, "Exception in the connection thread", ex);
DiagLog.i(TAG, "exception in connection thread: " + ex);
stopCurrentConnection();
try
{
stopCurrentConnection();
}
catch (Exception again)
{ /* the exception may have come from stopCurrentConnection()
* itself (e.g. a broken next profile): drop that profile
* so this guard cannot throw again and kill the thread */
DiagLog.i(TAG, "cleanup after exception failed: " + again);
mNextProfile = null;
if (mCurrentProfile != null)
{
SimpleFetcher.disable();
deinitializeCharon();
sUserspacePushActive = false;
}
removeNotification();
mBuilderAdapter.closeBlocking();
}
setError(ErrorState.GENERIC_ERROR);
setState(State.DISABLED);
mCurrentProfile = null;
@@ -593,11 +684,28 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
public void run()
{
mShowNotification = true;
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
startForegroundSafe();
}
});
}
/**
* Promote the service to the foreground. Never throws: on Android 12+ the
* system may refuse the promotion while the app is in the background, which
* must not crash the process.
*/
private void startForegroundSafe()
{
try
{
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
}
catch (RuntimeException e)
{
DiagLog.i(TAG, "startForeground refused: " + e);
}
}
/**
* Remove the permanent notification.
*/
@@ -649,6 +757,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
/**
* Build a notification matching the current state
*/
/** Whether «RU sites direct» is on (any mode). */
private static boolean isRuSplitOn(Context context)
{
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(Constants.PREF_RUSPLIT_GEOIP_ENABLED, false);
}
private Notification buildNotification(boolean publicVersion)
{
/* the state service may not be bound yet when startForeground() is
@@ -669,6 +783,10 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
.setVisibility(publicVersion ? NotificationCompat.VISIBILITY_PUBLIC
: NotificationCompat.VISIBILITY_PRIVATE);
int s = R.string.state_disabled;
if (state != State.CONNECTED || error != ErrorState.NO_ERROR)
{
mConnectedSince = 0;
}
if (error != ErrorState.NO_ERROR)
{
s = mService.getErrorText();
@@ -715,6 +833,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
s = R.string.state_connected;
builder.setColor(ContextCompat.getColor(this, R.color.success_text));
builder.setUsesChronometer(true);
/* without an explicit start every re-post would reset the
* chronometer to 0:00 */
if (mConnectedSince == 0)
{
mConnectedSince = System.currentTimeMillis();
}
builder.setWhen(mConnectedSince);
add_action = true;
break;
case DISCONNECTING:
@@ -722,7 +847,18 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
break;
}
}
builder.setContentTitle(getString(s));
if (state == State.CONNECTED && error == ErrorState.NO_ERROR && !publicVersion && profile != null)
{ /* «Protected · Amsterdam» like the mockup shade */
builder.setContentTitle(getString(R.string.notif_protected, name));
}
else if (state == State.CONNECTING && error == ErrorState.NO_ERROR && !publicVersion && profile != null)
{ /* F_SysNotifs «Connecting to Helsinki…» */
builder.setContentTitle(getString(R.string.notif_connecting_to, name));
}
else
{
builder.setContentTitle(getString(s));
}
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
@@ -731,17 +867,33 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
}
if (!publicVersion)
{
if (state == State.CONNECTED && error == ErrorState.NO_ERROR && profile != null)
{ /* «Pause 15 min» and «Change» as on the mockup shade */
Intent pause = new Intent(getApplicationContext(), PauseReceiver.class).setAction(PauseManager.ACTION_PAUSE);
builder.addAction(R.drawable.ic_notification_connecting, getString(R.string.notif_pause),
PendingIntent.getBroadcast(getApplicationContext(), 1, pause, flags));
Intent change = new Intent(getApplicationContext(), MainActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra(MainActivity.EXTRA_OPEN_SERVERS, true);
builder.addAction(R.drawable.ic_notification, getString(R.string.notif_change),
PendingIntent.getActivity(getApplicationContext(), 2, change, flags));
}
if (add_action)
{
Intent intent = new Intent(getApplicationContext(), VpnProfileControlActivity.class);
intent.setAction(VpnProfileControlActivity.DISCONNECT);
PendingIntent pending = PendingIntent.getActivity(getApplicationContext(), 0, intent,
flags);
builder.addAction(R.drawable.ic_notification_disconnect, getString(R.string.disconnect), pending);
builder.addAction(R.drawable.ic_notification_disconnect,
getString(state == State.CONNECTING ? R.string.notif_stop : R.string.disconnect), pending);
}
if (error == ErrorState.NO_ERROR)
if (error == ErrorState.NO_ERROR && state == State.CONNECTING)
{
builder.setContentText(name);
builder.setContentText(getString(R.string.notif_connecting_text));
}
else if (error == ErrorState.NO_ERROR)
{
builder.setContentText(state == State.CONNECTED && isRuSplitOn(this) ? getString(R.string.notif_ru_direct) : name);
}
builder.setPublicVersion(buildNotification(true));
}
@@ -758,6 +910,17 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
if (mShowNotification)
{
/* the retry countdown notifies every 1-5 s; each re-post builds two
* notifications and several PendingIntents (IPC). Countdown-only
* updates are rate-limited, real state/error changes post at once */
String key = (mService != null ? mService.getState() + "/" + mService.getErrorState() : "");
long now = SystemClock.elapsedRealtime();
if (key.equals(mLastNotifiedKey) && now - mLastNotifiedAt < NOTIFICATION_MIN_INTERVAL_MS)
{
return;
}
mLastNotifiedKey = key;
mLastNotifiedAt = now;
NotificationManager manager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE);
manager.notify(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
}
@@ -788,6 +951,21 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
*/
private void setState(State state)
{
/* the daily traffic history samples the counters while the tunnel is up */
if (state == State.CONNECTED)
{
TrafficHistory.start(this);
AlwaysOnVpn.update(this);
}
else
{
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)
{
if (mService != null)
@@ -862,18 +1040,22 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
switch (status)
{
case STATE_CHILD_SA_DOWN:
logSaDown();
if (!mIsDisconnecting)
{
setState(State.CONNECTING);
}
break;
case STATE_CHILD_SA_UP:
mSaUpSince = SystemClock.elapsedRealtime();
mRetransmitTimeout = false;
mReauth = false;
setState(State.CONNECTED);
scheduleDnsDiagnostics();
scheduleRouteDiagnostics();
/* feed the userspace router for this connection (also picks
* up private DNS changes since the last push) */
mProfileExecutor.execute(() -> {
mDiagExecutor.execute(() -> {
getPrivateDnsServers();
pushUserspaceRoutingData(this);
});
@@ -896,12 +1078,49 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
case STATE_GENERIC_ERROR:
setErrorDisconnect(ErrorState.GENERIC_ERROR);
break;
case STATE_RETRANSMIT_TIMEOUT:
mRetransmitTimeout = true;
break;
case STATE_REAUTH:
mReauth = true;
break;
default:
Log.e(TAG, "Unknown status code received");
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. */
private static final long DNS_DIAG_DELAY_MS = 10_000;
private static final String DNS_DIAG_PROBE = "one.one.one.one";
@@ -915,7 +1134,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
return;
}
mProfileExecutor.execute(() -> {
sDnsDiagAt = SystemClock.elapsedRealtime();
mDiagExecutor.execute(() -> {
try
{
logDnsDiagnostics();
@@ -972,6 +1192,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
&& vpnCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
+ ", dns=" + (lp != null ? lp.getDnsServers() : "?")
+ ", 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)
{
NetworkCapabilities uCaps = cm.getNetworkCapabilities(underlying);
@@ -1024,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()
{
if (sDnsDiagAt != 0 && SystemClock.elapsedRealtime() - sDnsDiagAt < DNS_DIAG_MIN_GAP_MS)
{
return;
}
mHandler.removeCallbacks(mDnsDiagRunnable);
mHandler.postDelayed(mDnsDiagRunnable, DNS_DIAG_DELAY_MS);
}
@@ -1063,6 +1297,29 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
RuIpListManager list = RuIpListManager.getInstance(context);
List<IPRange> v4 = enabled ? list.getExcludedV4() : Collections.emptyList();
List<IPRange> v6 = enabled ? list.getExcludedV6() : Collections.emptyList();
if (enabled)
{ /* user rules: addresses and resolved sites that always go direct */
List<String> direct = org.strongswan.android.logic.routing.RoutingManager.getInstance(context).directCidrs();
if (!direct.isEmpty())
{
ArrayList<IPRange> d4 = new ArrayList<>(v4);
ArrayList<IPRange> d6 = new ArrayList<>(v6);
for (String cidr : direct)
{
try
{
IPRange range = new IPRange(cidr);
(range.getFrom() instanceof Inet4Address ? d4 : d6).add(range);
}
catch (Exception ignored)
{ /* stored prefixes are validated; skip anything stale */
}
}
/* the engine expects sorted, non-overlapping ranges */
v4 = RuIpListManager.Companion.mergeRanges(d4);
v6 = RuIpListManager.Companion.mergeRanges(d6);
}
}
List<IPRange> force = new ArrayList<>();
if (enabled)
@@ -1165,7 +1422,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
return;
}
mProfileExecutor.execute(() -> {
mDiagExecutor.execute(() -> {
try
{
logRouteDiagnostics();
@@ -1508,6 +1765,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
*/
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.
* Static so it can be polled from the UI.
@@ -1591,7 +1854,9 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
private ConnectivityManager mConnectivityManager;
private boolean mRegistered;
/* the set of networks last reported to the system, to skip redundant
* setUnderlyingNetworks() calls (the callbacks can be chatty) */
* setUnderlyingNetworks() calls (the callbacks can be chatty).
* Guarded by mNetworks: written by the ConnectivityThread and by
* charon's thread around establish() */
private Network[] mApplied;
private final ConnectivityManager.NetworkCallback mCallback = new ConnectivityManager.NetworkCallback()
@@ -1599,19 +1864,20 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
@Override
public void onAvailable(Network network)
{
update(network);
update(network, mConnectivityManager.getNetworkCapabilities(network));
}
@Override
public void onCapabilitiesChanged(Network network, NetworkCapabilities networkCapabilities)
{
update(network);
{ /* use the capabilities passed in: querying them again is an IPC
* on every signal-strength update */
update(network, networkCapabilities);
}
@Override
public void onLost(Network network)
{
update(network);
update(network, null);
}
};
@@ -1661,8 +1927,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
synchronized (mNetworks)
{
mNetworks.clear();
mApplied = null;
}
mApplied = null;
}
private boolean isUsableUnderlying(NetworkCapabilities capabilities)
@@ -1673,48 +1939,50 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN);
}
private void update(Network network)
private void update(Network network, NetworkCapabilities capabilities)
{
Network[] current;
synchronized (mNetworks)
{
mNetworks.remove(network);
if (isUsableUnderlying(mConnectivityManager.getNetworkCapabilities(network)))
if (isUsableUnderlying(capabilities))
{
mNetworks.add(network);
}
current = mNetworks.toArray(new Network[0]);
}
if (!sameNetworks(current, mApplied))
{
if (sameNetworks(current, mApplied))
{
return;
}
mApplied = current;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
mLastNetworkChange = SystemClock.elapsedRealtime();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
{
StringBuilder handles = new StringBuilder("underlying networks changed:");
for (Network underlying : current)
{
StringBuilder handles = new StringBuilder("underlying networks changed:");
for (Network underlying : current)
{
handles.append(" #").append(underlying.getNetworkHandle());
}
DiagLog.i(TAG, handles.toString());
handles.append(" #").append(underlying.getNetworkHandle());
}
if (mCurrentProfile != null &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
{ /* reflect roaming in the system's VPN routing right away
* instead of waiting for the next establish(). Passing
* null falls back to the system default, matching the
* establish-time behavior; the call is a no-op for the
* system if no tunnel is currently up. */
setUnderlyingNetworks(current.length > 0 ? current : null);
}
/* let the state service react to the change (e.g. retry
* immediately instead of waiting out a backoff that was sized
* for the previous network) */
synchronized (mServiceLock)
DiagLog.i(TAG, handles.toString());
}
if (mCurrentProfile != null &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
{ /* reflect roaming in the system's VPN routing right away
* instead of waiting for the next establish(). Passing
* null falls back to the system default, matching the
* establish-time behavior; the call is a no-op for the
* system if no tunnel is currently up. */
setUnderlyingNetworks(current.length > 0 ? current : null);
}
/* let the state service react to the change (e.g. retry
* immediately instead of waiting out a backoff that was sized
* for the previous network) */
synchronized (mServiceLock)
{
if (mService != null)
{
if (mService != null)
{
mService.onUnderlyingNetworksChanged();
}
mService.onUnderlyingNetworksChanged();
}
}
}
@@ -1764,24 +2032,61 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
return mNetworks.isEmpty() ? null : mNetworks.toArray(new Network[0]);
}
}
/**
* Snapshot for a builder that is about to establish(), remembered as
* applied so the tracker doesn't immediately repeat it.
*/
Network[] snapshotForEstablish()
{
synchronized (mNetworks)
{
Network[] current = mNetworks.isEmpty() ? null : mNetworks.toArray(new Network[0]);
mApplied = current;
return current;
}
}
/**
* After establish(): a network change that raced with it was applied
* via setUnderlyingNetworks() before the tunnel existed (a no-op), so
* the new tunnel may carry a stale set - re-apply the current one.
*/
void afterEstablish(Network[] used)
{
Network[] current;
synchronized (mNetworks)
{
current = mNetworks.toArray(new Network[0]);
if (sameNetworks(current, used))
{
return;
}
mApplied = current;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
{
DiagLog.i(TAG, "underlying networks changed during establish, re-applying");
setUnderlyingNetworks(current.length > 0 ? current : null);
}
}
}
/**
* Report the physical networks below the tunnel to the builder (API 22+).
*/
private void applyUnderlyingNetworks(VpnService.Builder builder)
private Network[] applyUnderlyingNetworks(VpnService.Builder builder)
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
{
Network[] networks = mUnderlyingTracker.getNetworks();
Network[] networks = mUnderlyingTracker.snapshotForEstablish();
if (networks != null)
{
builder.setUnderlyingNetworks(networks);
}
/* remember what was reported so the tracker doesn't immediately
* repeat the call via setUnderlyingNetworks() */
mUnderlyingTracker.mApplied = networks;
return networks;
}
return null;
}
public class BuilderAdapter
@@ -1920,10 +2225,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
try
{
mCache.applyData(mBuilder);
applyUnderlyingNetworks(mBuilder);
Network[] underlying = applyUnderlyingNetworks(mBuilder);
fd = mBuilder.establish();
if (fd != null)
{
mUnderlyingTracker.afterEstablish(underlying);
closeBlocking();
}
}
@@ -1980,8 +2286,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
Builder builder = createBuilder(mProfile.getName());
mEstablishedCache.applyData(builder, false);
applyUnderlyingNetworks(builder);
Network[] underlying = applyUnderlyingNetworks(builder);
fd = builder.establish();
if (fd != null)
{
mUnderlyingTracker.afterEstablish(underlying);
}
}
catch (Exception ex)
{
@@ -2085,6 +2395,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
*/
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> mRoutesIPv4 = new ArrayList<>();
private final List<IPRange> mRoutesIPv6 = new ArrayList<>();
@@ -2098,7 +2410,6 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
* the OS routes here */
private final boolean mUserspaceRouting;
private final int mSplitTunneling;
private final SelectedAppsHandling mAppHandling;
private final SortedSet<String> mSelectedApps;
private final List<InetAddress> mDnsServers = new ArrayList<>();
/* addresses of the system's private DNS server (empty if none is
@@ -2145,26 +2456,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
mExcludedSubnets = IPRangeSet.fromString(profile.getExcludedSubnets());
Integer splitTunneling = profile.getSplitTunneling();
mSplitTunneling = splitTunneling != null ? splitTunneling : 0;
SelectedAppsHandling appHandling = profile.getSelectedAppsHandling();
mSelectedApps = profile.getSelectedAppsSet();
/* exclude our own app, otherwise the fetcher is blocked */
switch (appHandling)
{
case SELECTED_APPS_DISABLE:
appHandling = SelectedAppsHandling.SELECTED_APPS_EXCLUDE;
mSelectedApps.clear();
/* fall-through */
case SELECTED_APPS_EXCLUDE:
mSelectedApps.add(getPackageName());
break;
case SELECTED_APPS_ONLY:
mSelectedApps.remove(getPackageName());
break;
}
/* global routing rules: packages that must bypass the tunnel */
Set<String> globalExcludes = RoutingManager.getInstance(CharonVpnService.this).computeExcludedPackages();
appHandling = RoutingManager.applyGlobalExcludes(appHandling, mSelectedApps, globalExcludes);
mAppHandling = appHandling;
/* the only per-app routing is the global one (Routing tab): its
* packages bypass the tunnel. The old per-profile handling is gone
* (migrated by RoutingManager); our own app is always excluded,
* otherwise the fetchers are blocked */
mSelectedApps = new TreeSet<>(RoutingManager.getInstance(CharonVpnService.this).computeExcludedPackages());
mSelectedApps.add(getPackageName());
/* RU geoip exclusions: a large presorted list. It is never passed
* to VpnService's excludeRoute() - on some devices (One UI was
@@ -2200,9 +2497,25 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
sAppliedGeoExclusionsV6 = mGeoExcludedV6;
if (profile.getDnsServers() != null)
/* 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)
{
for (String server : profile.getDnsServers().split("\\s+"))
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 */
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();
if (dnsServers != null)
{
for (String server : dnsServers.split("\\s+"))
{
try
{
@@ -2376,7 +2689,26 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
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())
{ /* neither the profile nor the server provided DNS servers:
@@ -2512,39 +2844,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
applyRoutesWithGeo(builder, rangesV6, mUserspaceRouting ? Collections.<IPRange>emptyList() : geoV6);
}
/* apply selected applications */
if (mSelectedApps.size() > 0)
/* apply the directly routed applications (never empty: our own package) */
for (String app : mSelectedApps)
{
switch (mAppHandling)
try
{
case SELECTED_APPS_EXCLUDE:
for (String app : mSelectedApps)
{
try
{
builder.addDisallowedApplication(app);
}
catch (PackageManager.NameNotFoundException e)
{
// possible if not configured via GUI or app was uninstalled
}
}
break;
case SELECTED_APPS_ONLY:
for (String app : mSelectedApps)
{
try
{
builder.addAllowedApplication(app);
}
catch (PackageManager.NameNotFoundException e)
{
// possible if not configured via GUI or app was uninstalled
}
}
break;
default:
break;
builder.addDisallowedApplication(app);
}
catch (PackageManager.NameNotFoundException e)
{
// app was uninstalled since the list was built
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && mProxyServer != null)
@@ -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"
}
}
@@ -62,9 +62,10 @@ object DiagLog
val base = dir ?: return
try
{
val line = "${format.format(Date())} $tag: $message\n"
synchronized(this)
{
/* SimpleDateFormat is not thread-safe: format under the lock */
val line = "${format.format(Date())} $tag: $message\n"
if (written > MAX_SIZE)
{
rotate(base)
@@ -79,7 +80,7 @@ object DiagLog
}
w.write(line)
w.flush()
written += line.length + 1
written += line.toByteArray(Charsets.UTF_8).size
}
}
catch (_: Exception)
@@ -25,6 +25,7 @@ import android.net.Network;
import android.net.NetworkRequest;
import android.os.Build;
import java.util.HashSet;
import java.util.LinkedList;
public class NetworkManager extends BroadcastReceiver implements Runnable
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
private volatile boolean mRegistered;
private ConnectivityManager.NetworkCallback mCallback;
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<>();
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
* callback registration */
mConnectedNetworks += 1;
mConnectedNetworks.add(network);
mEvents.addLast(true);
NetworkManager.this.notifyAll();
}
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
synchronized (NetworkManager.this)
{
/* in particular mobile connections are disconnected overlapping with WiFi */
mConnectedNetworks -= 1;
mEvents.addLast(mConnectedNetworks > 0);
mConnectedNetworks.remove(network);
mEvents.addLast(!mConnectedNetworks.isEmpty());
NetworkManager.this.notifyAll();
}
}
@@ -74,7 +77,11 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
public void Register()
{
mEvents.clear();
synchronized (this)
{
mEvents.clear();
mConnectedNetworks.clear();
}
mRegistered = true;
mEventNotifier = new Thread(this);
mEventNotifier.start();
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
break;
}
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 */
networkChanged(!connected);
@@ -0,0 +1,373 @@
/*
* 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.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.events.Events
/**
* "Pause VPN for N minutes": the tunnel is disconnected like a manual
* disconnect (the watchdog stands down) and an alarm brings the same profile
* back when the pause ends. Auto-connect does not start the tunnel while a
* pause is running, and any connect request cancels the pause.
*/
object PauseManager
{
private const val TAG = "PauseManager"
private const val PREF_UNTIL = "pref_pause_until"
private const val PREF_PROFILE = "pref_pause_profile"
private const val REQUEST_CODE = 45
/** «Pause 15 min» from the VPN notification */
const val ACTION_PAUSE = "org.strongswan.android.action.PAUSE_15"
/* safety net of "until the network changes" */
private const val NETWORK_PAUSE_MAX_MS = 12L * 60 * 60 * 1000
/* wall-clock end of the running pause, 0 if none; read by the Home screen */
private val _until = MutableStateFlow(0L)
val until: StateFlow<Long> = _until
@Volatile private var loaded = false
private fun load(context: Context)
{
if (!loaded)
{
val until = PreferenceManager.getDefaultSharedPreferences(context).getLong(PREF_UNTIL, 0L)
_until.value = if (until > System.currentTimeMillis()) until else 0L
loaded = true
}
}
/** The flow of the pause end, initialised from the stored state. */
@JvmStatic
fun until(context: Context): StateFlow<Long>
{
load(context.applicationContext)
return until
}
@JvmStatic
fun isPaused(context: Context): Boolean
{
load(context.applicationContext)
return _until.value > System.currentTimeMillis()
}
/** Disconnects [service] and schedules the reconnect of [profileUuid] in [minutes]. */
@JvmStatic
fun pause(context: Context, service: VpnStateService, profileUuid: String, minutes: Int)
{
pauseUntil(context, service, profileUuid, System.currentTimeMillis() + minutes * 60_000L)
}
/** Disconnects [service] until the wall-clock time [until]. */
@JvmStatic
fun pauseUntil(context: Context, service: VpnStateService, profileUuid: String, until: Long)
{
val ctx = context.applicationContext
unwatchNetwork(ctx)
service.disconnect()
store(ctx, until, profileUuid)
DiagLog.i(TAG, "VPN paused until $until")
}
/**
* Disconnects until the device moves to another network (e.g. leaves this
* Wi-Fi). A [NETWORK_PAUSE_MAX_MS] alarm is the safety net in case the
* process dies meanwhile and the change is never seen.
*/
@JvmStatic
fun pauseUntilNetworkChange(context: Context, service: VpnStateService, profileUuid: String)
{
val ctx = context.applicationContext
pauseUntil(ctx, service, profileUuid, System.currentTimeMillis() + NETWORK_PAUSE_MAX_MS)
watchNetwork(ctx)
}
/** Next 07:00 after [now], local time. */
@JvmStatic
fun nextMorning(now: Long): Long
{
val c = java.util.Calendar.getInstance()
c.timeInMillis = now
c.set(java.util.Calendar.HOUR_OF_DAY, 7)
c.set(java.util.Calendar.MINUTE, 0)
c.set(java.util.Calendar.SECOND, 0)
c.set(java.util.Calendar.MILLISECOND, 0)
if (c.timeInMillis <= now)
{
c.add(java.util.Calendar.DAY_OF_YEAR, 1)
}
return c.timeInMillis
}
private var networkCallback: android.net.ConnectivityManager.NetworkCallback? = null
private fun watchNetwork(ctx: Context)
{
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager ?: return
var first: android.net.Network? = null
val callback = object : android.net.ConnectivityManager.NetworkCallback()
{
override fun onAvailable(network: android.net.Network)
{
/* right after the disconnect the default network may still be the
* tunnel itself: only real uplinks count */
val caps = try
{
cm.getNetworkCapabilities(network)
}
catch (_: Exception)
{
null
}
if (caps == null || caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN))
{
return
}
/* the first uplink reported is the current one; any other one is a change */
if (first == null)
{
first = network
}
else if (network != first)
{
DiagLog.i(TAG, "network changed, ending the pause")
android.os.Handler(android.os.Looper.getMainLooper()).post { resumeNow(ctx) }
}
}
}
try
{
cm.registerDefaultNetworkCallback(callback)
networkCallback = callback
}
catch (_: RuntimeException)
{ /* too many callbacks: the safety alarm still ends the pause */
}
}
private fun unwatchNetwork(ctx: Context)
{
val callback = networkCallback ?: return
networkCallback = null
try
{
(ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager)?.unregisterNetworkCallback(callback)
}
catch (_: IllegalArgumentException)
{
}
}
/** Moves the end of the running pause by [minutes]. */
@JvmStatic
fun extend(context: Context, minutes: Int)
{
val ctx = context.applicationContext
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
val profile = prefs.getString(PREF_PROFILE, null) ?: return
if (!isPaused(ctx))
{
return
}
store(ctx, _until.value + minutes * 60_000L, profile)
}
/** Ends the pause right away and reconnects the paused profile. */
@JvmStatic
fun resumeNow(context: Context)
{
val ctx = context.applicationContext
val profile = PreferenceManager.getDefaultSharedPreferences(ctx).getString(PREF_PROFILE, null)
cancel(ctx)
if (profile != null)
{
reconnect(ctx, profile)
}
}
/**
* Drops the pause without connecting. Called for every connect request,
* so a manual connect (app, tile, widget, shortcut) wins over the pause.
*/
@JvmStatic
fun cancel(context: Context)
{
val ctx = context.applicationContext
load(ctx)
if (_until.value == 0L)
{
return
}
_until.value = 0L
unwatchNetwork(ctx)
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
.remove(PREF_UNTIL).remove(PREF_PROFILE).apply()
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)
{
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
.putLong(PREF_UNTIL, until).putString(PREF_PROFILE, profileUuid).apply()
_until.value = until
loaded = true
val am = alarmManager(ctx)
val pi = alarmIntent(ctx)
try
{
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
if (exact)
{
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
}
else
{
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
}
}
catch (_: SecurityException)
{ /* the exact-alarm grant was revoked in between; a late resume is fine */
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
}
}
private fun alarmManager(ctx: Context) = ctx.getSystemService(Context.ALARM_SERVICE) as AlarmManager
private fun alarmIntent(ctx: Context): PendingIntent =
PendingIntent.getBroadcast(
ctx, REQUEST_CODE, Intent(ctx, PauseReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Called by [PauseReceiver] when the alarm fires. */
internal fun onAlarm(context: Context)
{
val ctx = context.applicationContext
val profile = PreferenceManager.getDefaultSharedPreferences(ctx).getString(PREF_PROFILE, null)
cancel(ctx)
if (profile == null)
{
return
}
DiagLog.i(TAG, "pause over, reconnecting")
reconnect(ctx, profile)
Events.pauseOver(ctx)
}
private fun reconnect(ctx: Context, profileUuid: String)
{
val needsConsent = try
{
VpnService.prepare(ctx) != null
}
catch (_: Exception)
{
true
}
if (needsConsent)
{ /* the VPN permission went to another app meanwhile; only the UI can ask again */
DiagLog.i(TAG, "pause over, but the VPN permission is gone")
return
}
val service = VpnStateService.getLiveInstance()
if (service != null)
{ /* a live process: the regular connect path (keeps retries and state) */
if (service.state == VpnStateService.State.DISABLED)
{
val extras = Bundle()
extras.putString(VpnProfileDataSource.KEY_UUID, profileUuid)
/* a password typed for the paused session goes along with it */
service.profile?.takeIf { it.getUUID().toString() == profileUuid }?.password
?.let { extras.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
service.connect(extras, true)
}
return
}
/* the process is gone (the alarm started a fresh one): start the
* service directly, like the watchdog does */
val start = Intent(ctx, CharonVpnService::class.java)
start.putExtra(VpnProfileDataSource.KEY_UUID, profileUuid)
try
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
ctx.startForegroundService(start)
}
else
{
ctx.startService(start)
}
}
catch (_: IllegalStateException)
{
FgsStartRetry.schedule(ctx, start)
}
}
}
/** Fires when a pause ends and brings the tunnel back. */
class PauseReceiver : BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent?)
{
if (intent?.action == PauseManager.ACTION_PAUSE)
{
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(context)
val service = VpnStateService.getLiveInstance() ?: return
val profile = service.profile ?: return
PauseManager.pause(context, service, profile.getUUID().toString(), 15)
return
}
PauseManager.onAlarm(context)
}
}
@@ -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.IntentFilter;
import android.os.Build;
import android.os.SystemClock;
import android.util.Log;
import java.util.ArrayList;
import java.util.PriorityQueue;
import java.util.List;
import java.util.UUID;
import androidx.annotation.RequiresApi;
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
private final String TAG = Scheduler.class.getSimpleName();
private final Context mContext;
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")
public Scheduler(Context context)
{
mContext = context;
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
mJobs = new PriorityQueue<>();
mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
IntentFilter filter = new IntentFilter();
filter.addAction(EXECUTE_JOB);
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
* alarms instead of crashing with a SecurityException (connectivity then
* 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)
private void scheduleAlarm(long time)
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
{
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
{
if (exact)
{
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
}
else
{
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
}
}
catch (SecurityException e)
{ /* permission might have been revoked, or OEM restrictions apply */
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)
{
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms);
mJobs.add(job);
if (job == mJobs.peek())
{ /* update the alarm if the job has to be executed before all others */
scheduleAlarm(job.Time);
long time = SystemClock.elapsedRealtime() + ms;
if (mJobs.add(id, time))
{ /* the job is due before the armed alarm: move the alarm */
scheduleAlarm(time);
}
}
}
@@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver
@Override
public void onReceive(Context context, Intent intent)
{
ArrayList<ScheduledJob> jobs = new ArrayList<>();
long now = System.currentTimeMillis();
List<String> jobs;
synchronized (this)
{
ScheduledJob job = mJobs.peek();
while (job != null)
jobs = mJobs.popDue(SystemClock.elapsedRealtime());
long next = mJobs.nextAlarm();
if (next != ScheduledJobQueue.NONE)
{
if (job.Time > now)
{
break;
}
jobs.add(mJobs.remove());
job = mJobs.peek();
}
if (job != null)
{
scheduleAlarm(job.Time);
scheduleAlarm(next);
}
}
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
*/
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) -> {
DiagLog.i(TAG, "UNCAUGHT EXCEPTION on " + thread.getName() + ": " +
Log.getStackTraceString(throwable));
CrashWatch.markJavaCrash(StrongSwanApplication.this);
if (previousHandler != null)
{
previousHandler.uncaughtException(thread, throwable);
@@ -107,6 +108,9 @@ public class StrongSwanApplication extends Application
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);
mTrustedCertificateManager = new ManagedTrustedCertificateManager(mContext, mExecutorService, mMainHandler,
@@ -139,6 +143,11 @@ public class StrongSwanApplication extends Application
/* checks the update storage for a newer version of the app (if enabled and due) */
UpdateManager.getInstance(this).start();
/* daily re-read of the profiles that came from a link */
org.strongswan.android.logic.profiles.ProfileUpdater.schedule(this);
/* user scenarios of F_Automation */
org.strongswan.android.logic.scenarios.ScenarioEngine.start(this);
org.strongswan.android.logic.stats.WeeklySummary.schedule(this);
}
private void reloadManagedConfigurationAndNotifyListeners()
@@ -34,6 +34,8 @@ import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.ui.widget.ShxWidgetState;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnType;
import org.strongswan.android.logic.events.Events;
import org.strongswan.android.logic.profiles.Resilience;
import org.strongswan.android.logic.imc.ImcState;
import org.strongswan.android.logic.imc.RemediationInstruction;
import org.strongswan.android.logic.connectivity.WhiteListManager;
@@ -41,6 +43,7 @@ import org.strongswan.android.ui.VpnProfileControlActivity;
import java.lang.ref.WeakReference;
import java.util.Collections;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
@@ -87,6 +90,9 @@ public class VpnStateService extends Service
private long mRetryIn;
/* true while the countdown is parked because the ISP whitelist blocks the server */
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 */
private long mLastNetworkRetry;
/* double-start guard: profile and issue time of the last connect() that
@@ -199,6 +205,27 @@ public class VpnStateService extends Service
mListeners.remove(listener);
}
/**
* Notify all registered listeners about a state change. Iterates over a
* copy (a listener may unregister itself from its callback) and isolates
* the listeners from each other: one failing listener must neither skip
* the others nor crash the main thread.
*/
private void notifyStateListeners()
{
for (VpnStateListener listener : new ArrayList<>(mListeners))
{
try
{
listener.stateChanged();
}
catch (RuntimeException e)
{
DiagLog.i(TAG, "state listener failed: " + e);
}
}
}
/**
* Get the current VPN profile.
*
@@ -357,6 +384,7 @@ public class VpnStateService extends Service
else
{ /* the user explicitly disconnected, so the watchdog must not restore
* the connection after this */
Events.connectionStopped(this);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply();
@@ -373,7 +401,15 @@ public class VpnStateService extends Service
Context context = getApplicationContext();
Intent intent = new Intent(context, CharonVpnService.class);
intent.setAction(CharonVpnService.DISCONNECT_ACTION);
context.startService(intent);
try
{
context.startService(intent);
}
catch (IllegalStateException e)
{ /* background start of a service that is not running (Android 8+):
* without the service there is no tunnel to tear down */
DiagLog.i(TAG, "disconnect: VPN service not running (" + e.getMessage() + ")");
}
}
/**
@@ -401,6 +437,8 @@ public class VpnStateService extends Service
{
/* we assume we have the necessary permission */
Context context = getApplicationContext();
/* any connect request (app, tile, widget, shortcut, pause alarm) ends a running pause */
PauseManager.cancel(context);
Intent intent = new Intent(context, CharonVpnService.class);
if (profileInfo == null)
{
@@ -414,6 +452,10 @@ public class VpnStateService extends Service
{
uuid = mProfile.getUUID().toString();
}
if (fromScratch)
{ /* a user's connect starts a new resilience attempt */
Resilience.onConnect(uuid, profileInfo);
}
/* double-start guard: while a healthy connect attempt for the same
* profile is in flight (or was just issued and the service has not
* reached startConnection() yet) a second start intent only churns
@@ -422,6 +464,7 @@ public class VpnStateService extends Service
* here within 20 seconds. Deliberate retries during an error backoff
* (mError set) and requests for another profile always pass. */
if (uuid != null && uuid.equals(mLastConnectUuid) && mError == ErrorState.NO_ERROR
&& !profileInfo.getBoolean(Resilience.KEY_INTERNAL, false)
&& (mState == State.CONNECTING
|| ((mState == State.DISABLED || mState == State.DISCONNECTING)
&& SystemClock.uptimeMillis() - mLastConnectIssued < CONNECT_DEDUPE_MS)))
@@ -570,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
* this is done from the main UI thread and not the initial reporter thread.
@@ -589,10 +663,7 @@ public class VpnStateService extends Service
{
if (change.call())
{ /* otherwise there is no need to notify the listeners */
for (VpnStateListener listener : mListeners)
{
listener.stateChanged();
}
notifyStateListeners();
/* the home screen widget mirrors the state */
VpnProfile widgetProfile = mProfile;
ShxWidgetState.publish(VpnStateService.this, mState, getErrorState(),
@@ -602,7 +673,7 @@ public class VpnStateService extends Service
}
catch (Exception e)
{
e.printStackTrace();
DiagLog.i(TAG, "state change failed: " + e);
}
}
});
@@ -658,6 +729,11 @@ public class VpnStateService extends Service
if (VpnStateService.this.mState != state)
{
VpnStateService.this.mState = state;
if (state == State.CONNECTED)
{
Events.connectionRestored(VpnStateService.this);
Resilience.onConnected(VpnStateService.this);
}
if (state == State.CONNECTING)
{ /* reentered while the daemon reestablishes the tunnel; bound
* the total time so a lost alert can't strand us here */
@@ -690,6 +766,14 @@ public class VpnStateService extends Service
{
if (VpnStateService.this.mError != error)
{
/* the server does not answer: backup ports, then the
* reserve server, before the regular backoff */
if (VpnStateService.this.mError == ErrorState.NO_ERROR
&& (error == ErrorState.UNREACHABLE || error == ErrorState.LOOKUP_FAILED)
&& Resilience.onUnreachable(VpnStateService.this))
{
return false;
}
if (VpnStateService.this.mError == ErrorState.NO_ERROR)
{
setRetryTimer(error);
@@ -699,6 +783,17 @@ public class VpnStateService extends Service
resetRetryTimer();
}
VpnStateService.this.mError = error;
if (error != ErrorState.NO_ERROR)
{ /* repeats while retrying are merged into one event */
Events.connectionError(VpnStateService.this, error);
}
if (error == ErrorState.PASSWORD_MISSING)
{ /* only a person can type the password: the watchdog must not
* bring this attempt back after the process restarts */
PreferenceManager.getDefaultSharedPreferences(VpnStateService.this).edit()
.remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
.remove(Constants.PREF_WATCHDOG_AUTO_EXPECTED).apply();
}
return true;
}
return false;
@@ -765,7 +860,11 @@ public class VpnStateService extends Service
{
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
mRetryFrozen = false;
mRetryDue = SystemClock.elapsedRealtime() + mRetryTimeout;
DiagLog.i(TAG, "error " + error + ", retry in " + (mRetryTimeout / 1000) + "s");
/* never run two countdown chains in parallel (double-speed countdown,
* double notification updates and two reconnects at zero) */
mHandler.removeMessages(RETRY_MSG);
if (mRetryTimeout <= 0)
{
return;
@@ -778,6 +877,7 @@ public class VpnStateService extends Service
*/
private void resetRetryTimer()
{
mHandler.removeMessages(RETRY_MSG);
mRetryTimeout = 0;
mRetryIn = 0;
mRetryFrozen = false;
@@ -858,10 +958,7 @@ public class VpnStateService extends Service
/* calculate next interval before notifying listeners */
long next = SystemClock.uptimeMillis() + retryStep(service.mRetryIn);
for (VpnStateListener listener : service.mListeners)
{
listener.stateChanged();
}
service.notifyStateListeners();
sendMessageAtTime(obtainMessage(RETRY_MSG), next);
}
else
@@ -872,10 +969,7 @@ public class VpnStateService extends Service
* ticking every second */
service.mRetryFrozen = true;
service.mRetryIn = RETRY_SUPPRESS_MS;
for (VpnStateListener listener : service.mListeners)
{
listener.stateChanged();
}
service.notifyStateListeners();
sendMessageAtTime(obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_SUPPRESS_MS);
return;
}
@@ -30,6 +30,8 @@ import android.util.Log;
import androidx.preference.PreferenceManager;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnProfileSource;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.logic.autoconnect.AutoConnectManager;
import org.strongswan.android.logic.connectivity.WhiteListManager;
import org.strongswan.android.utils.Constants;
@@ -150,6 +152,20 @@ public class WatchdogReceiver extends BroadcastReceiver
return Action.RESTART;
}
/**
* Whether a check counts towards the steady-state backoff of a live
* process: connected, connecting, or held back by the ISP whitelist. In
* all of these the in-process machinery owns recovery and the alarm can
* only guard against the process dying - firing every minute (and waking
* Doze every 10 minutes) for hours of whitelist blocking only burns
* battery.
*/
static boolean isAliveSteady(Action action, boolean processAlive, boolean parked, boolean retryPending)
{ /* a pending retry needs the fast wakeup alarm: its countdown stands
* still while the device sleeps and only this alarm resumes it */
return action == Action.NONE && processAlive && !parked && !retryPending;
}
@Override
public void onReceive(Context context, Intent intent)
{
@@ -164,27 +180,30 @@ public class WatchdogReceiver extends BroadcastReceiver
return;
}
reschedule(context);
if (WhiteListManager.getInstance(context).shouldBlockReconnect())
{ /* the ISP whitelist blocks the server; the in-process retry
* machinery parks itself in that case, so should we */
DiagLog.i(TAG, "whitelist blocks the server, not restarting");
return;
}
/* the ISP whitelist blocks the server; the in-process retry
* machinery parks itself in that case, so should we */
boolean whitelistBlocks = WhiteListManager.getInstance(context).shouldBlockReconnect();
VpnStateService service = VpnStateService.getLiveInstance();
boolean processAlive = service != null;
boolean serviceTracking = processAlive && service.getState() != VpnStateService.State.DISABLED;
boolean wifiPresent = isWifiPresent(context);
boolean autoProfileConfigured = AutoConnectManager.getInstance(context).getCurrentConfig().getProfileUuid() != null;
Action action = decideAction(enabled, uuid != null, autoExpected, false, serviceTracking,
Action action = decideAction(enabled, uuid != null, autoExpected, whitelistBlocks, serviceTracking,
processAlive, wifiPresent, autoProfileConfigured);
boolean connected = serviceTracking && service.getState() == VpnStateService.State.CONNECTED;
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
/* 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)
{
sParkedChecks++;
sConnectedChecks = 0;
}
else if (connected && action == Action.NONE)
else if (alive)
{
sConnectedChecks++;
sParkedChecks = 0;
@@ -195,8 +214,8 @@ public class WatchdogReceiver extends BroadcastReceiver
sConnectedChecks = 0;
}
boolean parkedSteady = parked && sParkedChecks >= PARKED_BACKOFF_AFTER;
boolean connectedSteady = connected && sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
String cadence = parkedSteady ? "parked" : connectedSteady ? "connected" : null;
boolean connectedSteady = alive && sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
String cadence = parkedSteady ? "parked" : connectedSteady ? "alive" : null;
if (cadence != null)
{ /* steady state: log the transition once, then stay silent - a
* per-fire line only floods diag.log and burns flash I/O */
@@ -210,7 +229,7 @@ public class WatchdogReceiver extends BroadcastReceiver
{
DiagLog.i(TAG, "check: uuid=" + (uuid != null) + " autoExpected=" + autoExpected +
" processAlive=" + processAlive + " tracking=" + serviceTracking +
" wifi=" + wifiPresent + " -> " + action);
" whitelist=" + whitelistBlocks + " wifi=" + wifiPresent + " -> " + action);
}
sLastCadence = cadence;
switch (action)
@@ -220,6 +239,7 @@ public class WatchdogReceiver extends BroadcastReceiver
return;
case RESTART:
{
boolean autoRestart = uuid == null;
if (uuid == null)
{
uuid = resolveAutoProfile(context, prefs);
@@ -229,12 +249,29 @@ public class WatchdogReceiver extends BroadcastReceiver
return;
}
}
/* a profile that signs in with an unsaved password would only end
* in «password missing» after every restore: drop the expectation
* and say so instead */
VpnProfile expected = loadProfile(context, uuid);
if (expected != null && org.strongswan.android.logic.profiles.ProfileExtrasKt.needsPassword(expected))
{
DiagLog.i(TAG, "expected profile needs a password, not restoring");
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
.remove(Constants.PREF_WATCHDOG_AUTO_EXPECTED).apply();
org.strongswan.android.logic.events.Events.restoreNeedsPassword(context, uuid,
expected.getName() != null ? expected.getName() : expected.getGateway());
return;
}
/* either the process died (the receiver runs in a fresh one) or the
* service is idle while a connection is expected - restart it. In
* the rare second case this may duplicate a pending in-process retry
* once, which is harmless (retry flag set, same profile). */
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
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);
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
@@ -259,6 +296,24 @@ public class WatchdogReceiver extends BroadcastReceiver
}
}
private static VpnProfile loadProfile(Context context, String uuid)
{
VpnProfileSource source = new VpnProfileSource(context);
try
{
source.open();
return source.getVpnProfile(java.util.UUID.fromString(uuid));
}
catch (Exception e)
{
return null;
}
finally
{
source.close();
}
}
/**
* Profile the trusted-networks auto session should use: the configured
* auto-connect profile, falling back to the most recently used one.
@@ -36,6 +36,8 @@ import org.json.JSONObject
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.PauseManager
import org.strongswan.android.logic.events.Events
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.WatchdogReceiver
import org.strongswan.android.logic.connectivity.WhiteListManager
@@ -58,6 +60,10 @@ class AutoConnectManager private constructor(
val enabled: Boolean = false,
val profileUuid: String? = null,
val trustedSsids: Set<String> = emptySet(),
/** F_Automation «In a trusted Wi-Fi → turn the VPN off» */
val parkOnTrusted: Boolean = true,
/** F_Automation «Mobile network or a foreign Wi-Fi → turn the VPN on» */
val connectOnUntrusted: Boolean = true,
)
enum class NetworkKind
@@ -85,6 +91,8 @@ class AutoConnectManager private constructor(
private var currentNetwork: NetworkKind? = null
/* marks the session this manager started itself */
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) */
private var lastParkAt = 0L
@@ -117,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()
{
config = loadConfig()
if (!config.enabled)
if (!config.enabled && !scenariosNeedNetwork())
{
return
}
@@ -145,6 +171,9 @@ class AutoConnectManager private constructor(
return
}
handler.removeCallbacksAndMessages(null)
/* the runnable that clears this flag was just removed with the
* callbacks: clear it here or every later connect is swallowed */
pendingEvaluate.set(false)
service?.unregisterListener(this)
try
{
@@ -177,8 +206,8 @@ class AutoConnectManager private constructor(
}
else
{
stop()
autoSession.set(false)
scenariosChanged()
}
}
@@ -205,8 +234,8 @@ class AutoConnectManager private constructor(
}
else
{
stop()
autoSession.set(false)
scenariosChanged()
}
}
@@ -221,25 +250,51 @@ class AutoConnectManager private constructor(
}
currentNetwork = kind
evaluate()
org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context)
}
override fun stateChanged()
{
val state = service?.state ?: return
if (state == VpnStateService.State.DISABLED)
if (state != VpnStateService.State.DISABLED)
{
awaitingRestoredTunnel = false
}
else if (!awaitingRestoredTunnel)
{
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.
*/
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action =
evaluateDecision(
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action
{
val action = evaluateDecision(
config, network, vpnActive, autoSession,
whiteListBlocksReconnect = WhiteListManager.getInstance(context).shouldBlockReconnect(),
)
/* the user paused the VPN: the pause alarm brings it back, not a network change */
if (action == Action.CONNECT && PauseManager.isPaused(context))
{
return Action.IGNORE
}
/* a user scenario above the network rules is in force */
return if (action != Action.IGNORE && org.strongswan.android.logic.scenarios.ScenarioEngine.inControl(context)) Action.IGNORE else action
}
/** Re-runs the connect decision, e.g. when the white-list gate clears. */
fun reevaluate()
@@ -249,8 +304,8 @@ class AutoConnectManager private constructor(
private fun evaluate()
{
if (!running.get())
{
if (!running.get() || !config.enabled)
{ /* running only for the scenarios: they decide, not the network rules */
return
}
val svc = service ?: return
@@ -271,6 +326,7 @@ class AutoConnectManager private constructor(
PreferenceManager.getDefaultSharedPreferences(context).edit()
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, true).apply()
svc.disconnect(true)
Events.autoConnect(context, parked = true)
}
Action.IGNORE ->
{
@@ -329,6 +385,12 @@ class AutoConnectManager private constructor(
Log.e(TAG, "VpnService.prepare failed", e)
true
}
val livePassword = svc.profile?.takeIf { it.getUUID().toString() == uuid }?.password
if (!needsConsent && livePassword == null && org.strongswan.android.logic.profiles.blockedByPassword(context, uuid))
{ /* nobody is there to type the password */
autoSession.set(false)
return
}
if (!needsConsent)
{
DiagLog.i(TAG, "auto session starting, profile=$uuid")
@@ -338,9 +400,11 @@ class AutoConnectManager private constructor(
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply()
val extras = Bundle()
extras.putString(VpnProfileDataSource.KEY_UUID, uuid)
livePassword?.let { extras.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
/* automatic start: keep the previous session's charon log */
extras.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true)
svc.connect(extras, true)
Events.autoConnect(context, parked = false)
return
}
val intent = Intent(context, VpnProfileControlActivity::class.java)
@@ -372,6 +436,8 @@ class AutoConnectManager private constructor(
arr.optString(i)?.takeIf { it.isNotBlank() }
}.toSet()
} ?: emptySet(),
parkOnTrusted = obj.optBoolean("parkOnTrusted", true),
connectOnUntrusted = obj.optBoolean("connectOnUntrusted", true),
)
}
catch (_: JSONException)
@@ -386,6 +452,8 @@ class AutoConnectManager private constructor(
obj.put("enabled", config.enabled)
obj.put("profileUuid", config.profileUuid ?: JSONObject.NULL)
obj.put("trustedSsids", JSONArray(config.trustedSsids.sorted()))
obj.put("parkOnTrusted", config.parkOnTrusted)
obj.put("connectOnUntrusted", config.connectOnUntrusted)
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
}
@@ -440,13 +508,13 @@ class AutoConnectManager private constructor(
{
null -> Action.IGNORE
NetworkKind.TRUSTED_WIFI ->
if (vpnActive && autoSession) Action.DISCONNECT else Action.IGNORE
if (config.parkOnTrusted && vpnActive && autoSession) Action.DISCONNECT else Action.IGNORE
/* SSID is redacted in background until location is readable. Keep an
* existing tunnel (do not treat unknown as untrusted) and still
* auto-start if the VPN is down. */
NetworkKind.UNKNOWN_WIFI -> if (!vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
NetworkKind.UNKNOWN_WIFI -> if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
NetworkKind.UNTRUSTED_WIFI, NetworkKind.CELLULAR ->
if (!vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
}
}
@@ -94,8 +94,12 @@ object ConnectivityProbe
* throw (e.g. EPERM when the caller is disallowed from that
* network), so it must stay inside the try */
val socket = if (network != null) network.socketFactory.createSocket() else Socket()
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(host, port), timeoutMs)
/* close right away: a leaked socket holds an fd until GC and
* the peer's later FIN/RST wakes the radio a second time */
socket.use {
it.tcpNoDelay = true
it.connect(InetSocketAddress(host, port), timeoutMs)
}
ProbeResult(target = target, ok = true, rttMs = (System.nanoTime() - started) / 1_000_000)
}
catch (_: Exception)
@@ -25,9 +25,12 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.BatteryManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.os.PowerManager
import android.util.Log
import kotlinx.coroutines.CoroutineScope
@@ -41,7 +44,9 @@ import kotlinx.coroutines.sync.Mutex
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.events.Events
/** What to do when a check fails. */
enum class HealthAction
@@ -116,6 +121,9 @@ class HealthCheckManager private constructor(
private val cycleMutex = Mutex()
private var config = HealthConfig()
private var running = false
/* last state seen by onStateChanged(): VpnStateService also notifies on
* error/IMC/rebind changes, which must not restart a pending cycle */
private var lastState: VpnStateService.State? = null
private var service: VpnStateService? = null
private var cycleJob: Job? = null
private var confirmJob: Job? = null
@@ -124,6 +132,9 @@ class HealthCheckManager private constructor(
private var tunnelAttempts = 0
private var fastPollShifts = 0
private var powerState = PowerState()
/* tunnel inbound bytes at the previous cycle (-1: unknown) and when the last real probe ran */
private var lastCycleInbound = -1L
private var lastProbeAt = 0L
/* tracks screen/battery state so probing can back off while the device sleeps */
private val powerReceiver = object : BroadcastReceiver()
@@ -230,6 +241,7 @@ class HealthCheckManager private constructor(
{
}
service = null
lastState = null
resetCounters()
}
@@ -239,6 +251,8 @@ class HealthCheckManager private constructor(
if (config.enabled)
{
start()
/* re-arm with the new settings even if the state is unchanged */
lastState = null
onStateChanged()
}
else
@@ -265,6 +279,8 @@ class HealthCheckManager private constructor(
if (updated.enabled)
{
start()
/* re-arm with the new settings even if the state is unchanged */
lastState = null
onStateChanged()
}
else
@@ -284,12 +300,17 @@ class HealthCheckManager private constructor(
{
return
}
when (service?.state)
val state = service?.state
val previous = lastState
lastState = state
when (state)
{
VpnStateService.State.CONNECTED ->
{
/* let the fresh tunnel settle before the first probe */
scheduleCycle(INITIAL_DELAY_MS)
if (previous != VpnStateService.State.CONNECTED)
{ /* let the fresh tunnel settle before the first probe */
scheduleCycle(INITIAL_DELAY_MS)
}
}
VpnStateService.State.DISABLED ->
{
@@ -344,30 +365,52 @@ class HealthCheckManager private constructor(
return
}
val gateway = svc.profile?.gateway
val targets = config.targets
val checkServer = config.serverCheckMode != ServerCheckMode.OFF
val adapted = adaptToPowerState(config, powerState)
val nowMs = SystemClock.elapsedRealtime()
/* passive liveness: payload that came back through the tunnel since the
* previous cycle proves it works without waking the radio for probes */
val inbound = tunnelInboundBytes()
val inboundDelta = if (lastCycleInbound >= 0 && inbound >= 0) inbound - lastCycleInbound else -1L
lastCycleInbound = inbound
if (trafficProvesAlive(
adapted, serverAttempts + tunnelAttempts, fastPollShifts, pendingDecision != null,
inboundDelta, nowMs - lastProbeAt,
))
{
scheduleCycle(effectiveIntervalSec(adapted) * 1000L)
return
}
lastProbeAt = nowMs
/* screen off: one target is enough (the confirmation probe uses it too) */
val targets = if (adapted.adaptiveMode && !powerState.screenOn && config.targets.size > 1)
{
setOf(config.targets.first())
}
else
{
config.targets
}
val mode = config.serverCheckMode
val timeout = config.timeoutMs
val results = coroutineScope {
val serverDeferred = if (checkServer)
{
async { ConnectivityProbe.probe(ProbeTarget.SERVER, timeout, context, gateway) }
}
else
{
null
}
val external = targets.map { target ->
val externalResults = coroutineScope {
targets.map { target ->
async { ConnectivityProbe.probe(target, timeout, context, gateway) }
}
(listOfNotNull(serverDeferred) + external).map { it.await() }
}.map { it.await() }
}
val serverResult = results.firstOrNull { it.target == ProbeTarget.SERVER }
val externalResults = results.filter { it.target != ProbeTarget.SERVER }
val externalOk = externalResults.count { it.ok }
val externalTotal = externalResults.size
val serverDown = serverResult?.let { !it.ok }
/* the direct server ping (a fork of /system/bin/ping plus a DNS lookup
* outside the tunnel) only runs when its result can matter: always in
* ALWAYS mode, in WITH_TUNNEL mode only once the tunnel looks dead */
val serverDown = if (shouldProbeServer(mode, externalOk, externalTotal))
{
!ConnectivityProbe.probe(ProbeTarget.SERVER, timeout, context, gateway).ok
}
else
{
null
}
val decision = decide(config, serverDown, externalOk, externalTotal, serverAttempts, tunnelAttempts)
val healthy = serverDown != true && (externalTotal == 0 || externalOk == externalTotal)
@@ -463,6 +506,7 @@ class HealthCheckManager private constructor(
serverAttempts++
Log.i(TAG, "Reconnecting, server unreachable (attempt $serverAttempts/${config.serverMaxAttempts})")
service?.reconnectPreservingLog()
Events.healthReconnect(context, serverDown = true)
}
}
Decision.RECONNECT_TUNNEL ->
@@ -472,6 +516,7 @@ class HealthCheckManager private constructor(
tunnelAttempts++
Log.i(TAG, "Reconnecting, tunnel dead (attempt $tunnelAttempts/${config.tunnelDeadMaxAttempts})")
service?.reconnectPreservingLog()
Events.healthReconnect(context, serverDown = false)
}
}
Decision.IGNORE ->
@@ -523,9 +568,10 @@ class HealthCheckManager private constructor(
powerState = PowerState(
screenOn = pm?.isInteractive ?: true,
powerSaveMode = pm?.isPowerSaveMode ?: false,
batteryLow = false,
batteryLow = isBatteryLowNow(),
)
/* BATTERY_LOW/OKAY are sticky, so registration immediately reflects the current state */
/* BATTERY_LOW/OKAY are not sticky: the initial state comes from the
* sticky ACTION_BATTERY_CHANGED, later changes from the broadcasts */
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
@@ -543,6 +589,29 @@ class HealthCheckManager private constructor(
}
}
private fun isBatteryLowNow(): Boolean
{
return try
{
val sticky = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
?: return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
{
sticky.getBooleanExtra(BatteryManager.EXTRA_BATTERY_LOW, false)
}
else
{ /* the platform's default low-battery threshold */
val level = sticky.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = sticky.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
level >= 0 && scale > 0 && level * 100 / scale <= 15
}
}
catch (_: Exception)
{
false
}
}
private fun unregisterPowerReceiver()
{
try
@@ -569,6 +638,21 @@ class HealthCheckManager private constructor(
tunnelAttempts = 0
fastPollShifts = 0
pendingDecision = null
lastCycleInbound = -1L
lastProbeAt = 0L
}
/** Inbound byte counter of the tunnel's CHILD_SAs, -1 when unavailable. */
private fun tunnelInboundBytes(): Long
{
return try
{
CharonVpnService.queryTraffic()?.getOrNull(0) ?: -1L
}
catch (_: Throwable)
{
-1L
}
}
private fun registerVpnCallback()
@@ -665,6 +749,32 @@ class HealthCheckManager private constructor(
private const val FAST_POLL_MAX_SHIFTS = 3
private const val AGGRESSIVE_INTERVAL_SEC = 5
/** Inbound tunnel payload since the last cycle that counts as proof of life. */
internal const val PASSIVE_MIN_INBOUND_BYTES = 4096L
/** Even with traffic flowing a real probe runs at least this often. */
internal const val PASSIVE_MAX_PROBE_AGE_MS = 5 * 60 * 1000L
/**
* Pure function: whether the cycle may skip its probes because the
* tunnel carried inbound data since the previous cycle. Only in
* adaptive mode, never in aggressive mode, never while a failure is
* being observed, and never for longer than [PASSIVE_MAX_PROBE_AGE_MS].
* Outbound-only traffic proves nothing, so it does not count.
*/
fun trafficProvesAlive(
adapted: HealthConfig,
failedAttempts: Int,
fastPollShifts: Int,
decisionPending: Boolean,
inboundDelta: Long,
sinceLastProbeMs: Long,
): Boolean =
adapted.adaptiveMode && !adapted.aggressiveMode &&
failedAttempts == 0 && fastPollShifts == 0 && !decisionPending &&
inboundDelta >= PASSIVE_MIN_INBOUND_BYTES &&
sinceLastProbeMs in 0 until PASSIVE_MAX_PROBE_AGE_MS
/** Minimum probe interval in adaptive mode while the screen is off. */
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
@@ -710,6 +820,16 @@ class HealthCheckManager private constructor(
* @param serverAttempts reconnect decisions already taken for the server rule
* @param tunnelAttempts reconnect decisions already taken for the tunnel rules
*/
/** Whether the server probe can influence [decide] this cycle. */
@JvmStatic
fun shouldProbeServer(mode: ServerCheckMode, externalOk: Int, externalTotal: Int): Boolean =
when (mode)
{
ServerCheckMode.OFF -> false
ServerCheckMode.ALWAYS -> true
ServerCheckMode.WITH_TUNNEL -> externalTotal > 0 && externalOk == 0
}
fun decide(
config: HealthConfig,
serverDown: Boolean?,
@@ -44,6 +44,8 @@ import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.events.Events
import java.util.concurrent.ConcurrentHashMap
/** Whether the ISP currently blocks direct access to foreign hosts. */
enum class WhiteListStatus
@@ -177,6 +179,9 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
private val _state = MutableStateFlow(WhiteListState())
val state: StateFlow<WhiteListState> = _state
/* last classification-relevant capabilities per network (see capabilityKey) */
private val lastCapabilities = ConcurrentHashMap<Network, String>()
/* event trigger: the underlying network changed, the ISP may classify differently now */
private val networkCallback = object : ConnectivityManager.NetworkCallback()
{
@@ -186,12 +191,19 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
{
onNetworkEvent()
{ /* fired on every signal-strength change too: only a change that
* can alter the ISP's classification counts as an event (it also
* resets the recheck backoff) */
val key = capabilityKey(capabilities)
if (lastCapabilities.put(network, key) != key)
{
onNetworkEvent()
}
}
override fun onLost(network: Network)
{
lastCapabilities.remove(network)
onNetworkEvent()
}
}
@@ -238,6 +250,7 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
recheckJob?.cancel()
recheckJob = null
unregisterNetworkCallback()
lastCapabilities.clear()
service?.unregisterListener(this)
try
{
@@ -364,7 +377,10 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
return
}
backoffAttempt = 0
scope.launch { runCheck(force = true) }
/* a flapping tunnel (CONNECTING -> DISCONNECTING -> DISABLED -> ...)
* must not trigger a fresh direct probe round on every transition */
val force = SystemClock.elapsedRealtime() - lastCheckElapsed >= TRANSITION_MIN_GAP_MS
scope.launch { runCheck(force = force) }
}
private fun onNetworkEvent()
@@ -423,6 +439,16 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
val before = machine
machine = before.advance(candidate)
publish(round)
if (before.status != WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.ACTIVE)
{
Events.whitelist(context, true)
android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, true); org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context) }
}
else if (before.status == WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.INACTIVE)
{
Events.whitelist(context, false)
android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, false); org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context) }
}
if (machine.status == WhiteListStatus.ACTIVE)
{ /* keep watching for the whitelist to clear, at a low rate */
backoffAttempt++
@@ -453,7 +479,9 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
recheckJob?.cancel()
recheckJob = scope.launch {
delay(delayMs)
runCheck(force = false)
/* forced: the first backoff steps are shorter than FRESH_MS, an
* unforced recheck returned early and ended the chain */
runCheck(force = true)
}
}
@@ -598,6 +626,22 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
/** Cached results younger than this are not re-probed. */
private const val FRESH_MS = 60_000L
/** State transitions force a new round only if the last one is at least this old. */
private const val TRANSITION_MIN_GAP_MS = 15_000L
/** The parts of the capabilities that matter for the classification. */
@JvmStatic
internal fun capabilityKey(caps: NetworkCapabilities): String
{
val transports = intArrayOf(NetworkCapabilities.TRANSPORT_WIFI, NetworkCapabilities.TRANSPORT_CELLULAR,
NetworkCapabilities.TRANSPORT_ETHERNET, NetworkCapabilities.TRANSPORT_VPN)
.filter { caps.hasTransport(it) }
val capabilities = intArrayOf(NetworkCapabilities.NET_CAPABILITY_INTERNET,
NetworkCapabilities.NET_CAPABILITY_VALIDATED, NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
.filter { caps.hasCapability(it) }
return "$transports/$capabilities"
}
/**
* A cached ACTIVE status stops suppressing reconnects once no recheck
* has confirmed it for this long (fail open). Kept above the backoff
@@ -0,0 +1,442 @@
/*
* 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.events
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONArray
import org.json.JSONObject
import org.strongswan.android.R
import org.strongswan.android.ui.InboxActivity
import java.io.File
import java.util.concurrent.Executors
/** How an event looks and whether it waits for the user. */
enum class EventKind
{
/** something is wrong right now (pushed on the "problems" channel) */
WARN,
/** the app suggests something and waits for a decision */
ASK,
/** the app did something by itself */
INFO,
/** something got better */
OK,
}
enum class EventCategory
{
CONNECTION,
ROUTING,
APP,
}
/** What the event's button does; the Inbox maps these to screens. */
enum class EventAction
{
NONE,
OPEN_HOME,
OPEN_ROUTING,
OPEN_ROUTE_CHECK,
OPEN_LOG,
INSTALL_UPDATE,
/** let the app in [ShxEvent.target] bypass the VPN */
SET_APP_DIRECT,
/** show the link update of the profile [ShxEvent.target] (F_ProfileUpdate) */
PROFILE_UPDATE,
/** open the server page of the profile [ShxEvent.target] */
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(
val id: Long,
val at: Long,
val kind: EventKind,
val category: EventCategory,
val title: String,
val text: String,
val action: EventAction = EventAction.NONE,
/** groups repeats of the same event; null for one-offs */
val key: String? = null,
/** how many times it happened within the merge window */
val count: Int = 1,
val read: Boolean = false,
/** the problem went away by itself; shown dimmed, no longer "waiting" */
val resolved: Boolean = false,
/** what the action applies to, e.g. a package name */
val target: String? = null,
)
{
/** still needs the user's attention */
val waiting: Boolean
get() = !resolved && (kind == EventKind.WARN || kind == EventKind.ASK)
}
/**
* What the app did and noticed by itself, kept for [RETENTION_MS] on the
* device. Problems are additionally pushed while the app is in the
* background, so the user learns about them without opening it.
*/
class EventLog private constructor(private val context: Context)
{
private val file = File(context.filesDir, FILE_NAME)
private val io = Executors.newSingleThreadExecutor { r -> Thread(r, "EventLog").apply { isDaemon = true } }
private val _events = MutableStateFlow(load())
val events: StateFlow<List<ShxEvent>> = _events
/**
* Adds an event. A repeat of an unresolved event with the same [key]
* within [MERGE_WINDOW_MS] bumps the existing entry instead of adding a
* new one (retries would otherwise flood the list).
*/
fun record(
kind: EventKind,
category: EventCategory,
title: String,
text: String,
action: EventAction = EventAction.NONE,
key: String? = null,
target: String? = null,
)
{
val now = System.currentTimeMillis()
var posted: ShxEvent? = null
_events.update { list ->
val merged = key?.let { k ->
list.indexOfFirst { it.key == k && !it.resolved && now - it.at < MERGE_WINDOW_MS }
} ?: -1
val next = if (merged >= 0)
{
val old = list[merged]
val bumped = old.copy(at = now, title = title, text = text, action = action, target = target, count = old.count + 1, read = false)
posted = bumped
listOf(bumped) + list.filterIndexed { i, _ -> i != merged }
}
else
{
val event = ShxEvent(
id = now * 1000 + (list.size % 1000), at = now, kind = kind, category = category,
title = title, text = text, action = action, key = key, target = target,
)
posted = event
listOf(event) + list
}
prune(next, now)
}
persist()
posted?.let {
when
{
it.kind == EventKind.WARN && NotifPrefs.enabled(context, NotifPrefs.PROBLEMS) -> pushIfBackground(it, PROBLEMS_CHANNEL)
it.kind == EventKind.ASK && NotifPrefs.enabled(context, NotifPrefs.NEW_APPS) -> pushIfBackground(it, HINTS_CHANNEL)
}
}
}
/** Records once per [key] for the whole retention time (e.g. one update notice per version). */
fun recordOnce(kind: EventKind, category: EventCategory, title: String, text: String, action: EventAction, key: String, target: String? = null)
{
if (_events.value.none { it.key == key })
{
record(kind, category, title, text, action, key, target)
}
}
/** The user answered an event (did the action or said no): it no longer waits. */
fun answer(id: Long)
{
_events.update { list -> list.map { if (it.id == id) it.copy(resolved = true, read = true) else it } }
persist()
}
/** Marks the unresolved events whose key starts with [keyPrefix] as resolved. */
fun resolve(keyPrefix: String)
{
var changed = false
_events.update { list ->
list.map {
if (!it.resolved && it.key?.startsWith(keyPrefix) == true)
{
changed = true
NotificationManagerCompat.from(context).cancel(TAG, notificationId(it))
it.copy(resolved = true)
}
else it
}
}
if (changed)
{
persist()
}
}
fun markAllRead()
{
if (_events.value.none { !it.read })
{
return
}
val manager = NotificationManagerCompat.from(context)
_events.value.filter { !it.read }.forEach { manager.cancel(TAG, notificationId(it)) }
_events.update { list -> list.map { if (it.read) it else it.copy(read = true) } }
persist()
}
/** Hides an event the user acted on or dismissed. */
fun dismiss(id: Long)
{
_events.update { list -> list.filterNot { it.id == id } }
persist()
}
private fun pushIfBackground(event: ShxEvent, channel: String)
{
val foreground = try
{
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
catch (_: Exception)
{
false
}
if (foreground)
{ /* the Home screen shows the problem already */
return
}
val manager = NotificationManagerCompat.from(context)
if (!manager.areNotificationsEnabled())
{
return
}
ensureChannel(context)
val open = PendingIntent.getActivity(
context, 0, Intent(context, InboxActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = NotificationCompat.Builder(context, channel)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(event.title)
.setContentText(event.text)
.setStyle(NotificationCompat.BigTextStyle().bigText(event.text))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentIntent(open)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
/* F_SysNotifs: answers right in the notification */
if (event.action == EventAction.SET_APP_DIRECT)
{
builder.addAction(0, context.getString(R.string.notif_yes), answerIntent(event, true))
builder.addAction(0, context.getString(R.string.notif_no), answerIntent(event, false))
}
else if (event.kind == EventKind.WARN)
{
builder.addAction(0, context.getString(R.string.notif_details), open)
}
val notification = builder.build()
try
{
manager.notify(TAG, notificationId(event), notification)
}
catch (e: SecurityException)
{ /* POST_NOTIFICATIONS was revoked in between */
Log.w(TAG, "Problem notification not allowed", e)
}
}
private fun answerIntent(event: ShxEvent, yes: Boolean): PendingIntent = PendingIntent.getBroadcast(
context, (event.id % 100000).toInt() * 2 + if (yes) 1 else 0,
Intent(context, EventAnswerReceiver::class.java).putExtra(EXTRA_ID, event.id).putExtra(EXTRA_YES, yes),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** «Yes» / «No» of a suggestion pushed as a notification. */
internal fun answerFromNotification(id: Long, yes: Boolean)
{
val event = _events.value.firstOrNull { it.id == id } ?: return
if (yes && event.action == EventAction.SET_APP_DIRECT)
{
event.target?.let { org.strongswan.android.logic.routing.RoutingManager.getInstance(context).setAppDirect(it, true) }
}
answer(id)
NotificationManagerCompat.from(context).cancel(TAG, notificationId(event))
}
/* repeats of a keyed event replace their notification instead of stacking */
private fun notificationId(event: ShxEvent): Int = (event.key ?: event.id.toString()).hashCode()
private fun prune(list: List<ShxEvent>, now: Long): List<ShxEvent> =
list.filter { now - it.at < RETENTION_MS }.take(MAX_EVENTS)
private fun persist()
{
val snapshot = _events.value
io.execute {
try
{
val array = JSONArray()
snapshot.forEach { array.put(toJson(it)) }
val tmp = File(file.path + ".tmp")
tmp.writeText(array.toString())
if (!tmp.renameTo(file))
{
file.writeText(array.toString())
tmp.delete()
}
}
catch (e: Exception)
{
Log.w(TAG, "Saving events failed", e)
}
}
}
private fun load(): List<ShxEvent>
{
return try
{
if (!file.exists())
{
return emptyList()
}
val array = JSONArray(file.readText())
val list = (0 until array.length()).mapNotNull { fromJson(array.optJSONObject(it)) }
prune(list, System.currentTimeMillis())
}
catch (e: Exception)
{
Log.w(TAG, "Loading events failed", e)
emptyList()
}
}
companion object
{
private const val TAG = "EventLog"
private const val FILE_NAME = "events.json"
const val PROBLEMS_CHANNEL = "org.strongswan.android.problems"
const val HINTS_CHANNEL = "org.strongswan.android.hints"
internal const val EXTRA_ID = "org.strongswan.android.extra.EVENT_ID"
internal const val EXTRA_YES = "org.strongswan.android.extra.EVENT_YES"
const val RETENTION_MS = 30L * 24 * 60 * 60 * 1000
const val MERGE_WINDOW_MS = 30L * 60 * 1000
private const val MAX_EVENTS = 300
@Volatile private var instance: EventLog? = null
@JvmStatic
fun getInstance(context: Context): EventLog =
instance ?: synchronized(this) {
instance ?: EventLog(context.applicationContext).also { instance = it }
}
@JvmStatic
fun ensureChannel(context: Context)
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
val manager = context.getSystemService(NotificationManager::class.java) ?: return
val channel = NotificationChannel(
PROBLEMS_CHANNEL, context.getString(R.string.events_channel_problems),
NotificationManager.IMPORTANCE_DEFAULT,
)
channel.description = context.getString(R.string.events_channel_problems_desc)
manager.createNotificationChannel(channel)
/* suggestions arrive quietly (F_NotifSettings «Quiet») */
val hints = NotificationChannel(
HINTS_CHANNEL, context.getString(R.string.events_channel_hints),
NotificationManager.IMPORTANCE_LOW,
)
hints.description = context.getString(R.string.events_channel_hints_desc)
manager.createNotificationChannel(hints)
}
}
internal fun toJson(e: ShxEvent): JSONObject = JSONObject()
.put("id", e.id).put("at", e.at).put("kind", e.kind.name).put("category", e.category.name)
.put("title", e.title).put("text", e.text).put("action", e.action.name)
.put("key", e.key ?: JSONObject.NULL).put("count", e.count).put("read", e.read).put("resolved", e.resolved)
.put("target", e.target ?: JSONObject.NULL)
internal fun fromJson(o: JSONObject?): ShxEvent?
{
o ?: return null
return try
{
ShxEvent(
id = o.getLong("id"),
at = o.getLong("at"),
kind = EventKind.valueOf(o.getString("kind")),
category = EventCategory.valueOf(o.getString("category")),
title = o.getString("title"),
text = o.optString("text"),
action = runCatching { EventAction.valueOf(o.optString("action")) }.getOrDefault(EventAction.NONE),
key = if (o.isNull("key")) null else o.optString("key"),
count = o.optInt("count", 1),
read = o.optBoolean("read"),
resolved = o.optBoolean("resolved"),
target = if (o.isNull("target")) null else o.optString("target"),
)
}
catch (_: Exception)
{ /* an entry from an older/newer format: skip it rather than lose all */
null
}
}
}
}
/** Which events are also pushed (F_NotifSettings); all of them stay in «Events» anyway. */
object NotifPrefs
{
const val PROBLEMS = "pref_notif_problems"
const val NEW_APPS = "pref_notif_new_apps"
const val UPDATES = "pref_notif_updates"
/** «Weekly summary», the only one off by default */
const val WEEKLY = "pref_notif_weekly"
@JvmStatic
fun enabled(context: Context, key: String): Boolean =
androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, key != WEEKLY)
@JvmStatic
fun set(context: Context, key: String, value: Boolean) =
androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply()
}
/** Receives «Yes» / «No» from a suggestion notification. */
class EventAnswerReceiver : android.content.BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent)
{
val id = intent.getLongExtra(EventLog.EXTRA_ID, 0L)
EventLog.getInstance(context).answerFromNotification(id, intent.getBooleanExtra(EventLog.EXTRA_YES, false))
}
}
@@ -0,0 +1,202 @@
/*
* 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.events
import android.content.Context
import org.strongswan.android.R
import org.strongswan.android.logic.VpnStateService.ErrorState
/**
* The events the app records, one function per occasion, so the call sites
* in the service code stay one-liners.
*/
object Events
{
private const val KEY_ERROR = "error:"
private const val KEY_WHITELIST = "whitelist"
/** The connection failed; repeats while retrying are merged. */
@JvmStatic
fun connectionError(context: Context, error: ErrorState)
{
val (title, text, action) = when (error)
{
ErrorState.UNREACHABLE -> Triple(R.string.home_problem_unreachable_title, R.string.home_problem_unreachable_text, EventAction.OPEN_HOME)
ErrorState.LOOKUP_FAILED -> Triple(R.string.home_problem_lookup_title, R.string.home_problem_lookup_text, EventAction.OPEN_HOME)
ErrorState.AUTH_FAILED, ErrorState.PASSWORD_MISSING ->
Triple(R.string.home_problem_auth_title, R.string.home_problem_auth_text, EventAction.OPEN_HOME)
ErrorState.PEER_AUTH_FAILED, ErrorState.CERTIFICATE_UNAVAILABLE ->
Triple(R.string.home_problem_cert_title, R.string.home_problem_cert_text, EventAction.OPEN_LOG)
ErrorState.NO_ERROR -> return
else -> Triple(R.string.home_problem_generic_title, R.string.home_problem_generic_text, EventAction.OPEN_LOG)
}
EventLog.getInstance(context).record(
EventKind.WARN, EventCategory.CONNECTION, context.getString(title), context.getString(text),
action, KEY_ERROR + error.name,
)
}
/** The tunnel is up again after a recorded failure. */
@JvmStatic
fun connectionRestored(context: Context)
{
val log = EventLog.getInstance(context)
if (log.events.value.none { !it.resolved && it.key?.startsWith(KEY_ERROR) == true })
{
return
}
log.resolve(KEY_ERROR)
log.record(
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_restored_title),
context.getString(R.string.event_restored_text), key = "restored",
)
}
/** The user stopped the VPN: pending connection problems no longer matter. */
@JvmStatic
fun connectionStopped(context: Context)
{
EventLog.getInstance(context).resolve(KEY_ERROR)
}
@JvmStatic
fun whitelist(context: Context, active: Boolean)
{
val log = EventLog.getInstance(context)
if (active)
{
log.record(
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_whitelist_on_title),
context.getString(R.string.event_whitelist_on_text), EventAction.OPEN_HOME, KEY_WHITELIST,
)
}
else
{
log.resolve(KEY_WHITELIST)
log.record(
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_whitelist_off_title),
context.getString(R.string.event_whitelist_off_text), key = "whitelist-off",
)
}
}
/** The health check restarted the tunnel. */
@JvmStatic
fun healthReconnect(context: Context, serverDown: Boolean)
{
EventLog.getInstance(context).record(
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_health_reconnect_title),
context.getString(if (serverDown) R.string.event_health_reconnect_server else R.string.event_health_reconnect_tunnel),
key = "health-reconnect",
)
}
/** Auto-connect started the tunnel ([parked] = false) or parked it on a trusted Wi-Fi. */
@JvmStatic
fun autoConnect(context: Context, parked: Boolean)
{
EventLog.getInstance(context).record(
EventKind.INFO, EventCategory.CONNECTION,
context.getString(if (parked) R.string.event_auto_parked_title else R.string.event_auto_started_title),
context.getString(if (parked) R.string.event_auto_parked_text else R.string.event_auto_started_text),
key = if (parked) "auto-parked" else "auto-started",
)
}
/** A newly installed app looks like a Russian service that is not direct yet. */
@JvmStatic
fun newRuApp(context: Context, packageName: String, label: String)
{
EventLog.getInstance(context).recordOnce(
EventKind.ASK, EventCategory.ROUTING, context.getString(R.string.event_new_app_title, label),
context.getString(R.string.event_new_app_text), EventAction.SET_APP_DIRECT, "new-app:$packageName", packageName,
)
}
@JvmStatic
fun pauseOver(context: Context)
{
EventLog.getInstance(context).record(
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_pause_over_title),
context.getString(R.string.event_pause_over_text),
)
}
@JvmStatic
fun updateAvailable(context: Context, versionName: String, versionCode: Long)
{
EventLog.getInstance(context).recordOnce(
EventKind.INFO, EventCategory.APP, context.getString(R.string.event_update_title, versionName),
context.getString(R.string.event_update_text), EventAction.INSTALL_UPDATE, "update:$versionCode",
)
}
/** 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. */
@JvmStatic
fun restoreNeedsPassword(context: Context, uuid: String, name: String)
{
EventLog.getInstance(context).record(
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_restore_pw_title, name),
context.getString(R.string.event_restore_pw_text), EventAction.OPEN_SERVER, "restore-pw:$uuid", uuid,
)
}
/** The reserve or bridge [target] of [origin] has no saved password, so no switch. */
@JvmStatic
fun switchNeedsPassword(context: Context, targetUuid: String, target: String, origin: String, bridge: Boolean)
{
EventLog.getInstance(context).record(
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_switch_pw_title, target),
context.getString(if (bridge) R.string.event_switch_pw_bridge else R.string.event_switch_pw_reserve, origin),
EventAction.OPEN_SERVER, "switch-pw:$targetUuid", targetUuid,
)
}
/** A network rule or a scenario could not start [name]: no saved password. */
@JvmStatic
fun autoNeedsPassword(context: Context, uuid: String, name: String)
{
EventLog.getInstance(context).record(
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_auto_pw_title, name),
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",
)
}
}
@@ -0,0 +1,238 @@
/*
* 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.profiles
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/** Where a profile came from (F_Profile «Profile source»). */
enum class ProfileSourceType { LINK, FILE, QR, MANUAL, UNKNOWN }
/**
* What the app keeps about a profile besides the strongSwan profile itself:
* its source (and the update link), and the resilience settings of F_Profile.
*/
data class ProfileExtra(
val uuid: String,
val source: ProfileSourceType = ProfileSourceType.UNKNOWN,
/** https link for LINK, the file name for FILE */
val sourceValue: String? = null,
val addedAt: Long = 0L,
val autoUpdate: Boolean = true,
val checkedAt: Long = 0L,
/** number of changes found by the last check, -1 when it failed */
val lastChanges: Int = 0,
/** ports tried in turn when the main one does not answer */
val backupPorts: List<Int> = emptyList(),
/** profile used when this one does not connect in [RESERVE_TIMEOUT_S] */
val reserveUuid: String? = null,
/** profile switched to while the operator whitelists are on */
val bridgeUuid: String? = null,
val bridgeEnabled: Boolean = false,
/** IKE fragmentation (charon «fragmentation = yes»), on unless switched off */
val ikeFragmentation: Boolean = true,
)
const val RESERVE_TIMEOUT_S = 15
/** Store of [ProfileExtra] (profile_extras.json), observable. */
object ProfileExtras
{
private const val TAG = "ProfileExtras"
private const val FILE_NAME = "profile_extras.json"
private val _all = MutableStateFlow<Map<String, ProfileExtra>>(emptyMap())
val all: StateFlow<Map<String, ProfileExtra>> = _all
@Volatile private var loaded = false
@JvmStatic
fun get(context: Context, uuid: String): ProfileExtra
{
load(context)
return _all.value[uuid] ?: ProfileExtra(uuid)
}
fun flow(context: Context): StateFlow<Map<String, ProfileExtra>>
{
load(context)
return all
}
@JvmStatic
fun update(context: Context, uuid: String, transform: (ProfileExtra) -> ProfileExtra)
{
load(context)
_all.update { it + (uuid to transform(it[uuid] ?: ProfileExtra(uuid))) }
save(context)
}
/**
* A profile was imported: remember where from. A re-import from the
* same kind of source keeps the user's settings (auto update, resilience).
*/
@JvmStatic
fun recordImport(context: Context, uuid: String, scheme: String?, uri: String?, qr: Boolean)
{
val type = when
{
qr -> ProfileSourceType.QR
scheme == "http" || scheme == "https" -> ProfileSourceType.LINK
else -> ProfileSourceType.FILE
}
val value = when (type)
{
ProfileSourceType.LINK -> uri
ProfileSourceType.FILE -> uri?.substringAfterLast('/')?.substringAfterLast("%2F")?.takeIf { it.isNotBlank() }
else -> null
}
update(context, uuid) {
/* a link stays the update source even when the new copy came from a file */
if (it.source == ProfileSourceType.LINK && type != ProfileSourceType.LINK) it.copy(addedAt = System.currentTimeMillis())
else it.copy(source = type, sourceValue = value, addedAt = System.currentTimeMillis(), checkedAt = if (type == ProfileSourceType.LINK) System.currentTimeMillis() else it.checkedAt, lastChanges = 0)
}
}
/** A profile created by hand (when nothing is recorded for it yet). */
@JvmStatic
fun recordManual(context: Context, uuid: String)
{
load(context)
if (_all.value[uuid]?.source.let { it == null || it == ProfileSourceType.UNKNOWN })
{
update(context, uuid) { it.copy(source = ProfileSourceType.MANUAL, addedAt = System.currentTimeMillis()) }
}
}
@JvmStatic
fun remove(context: Context, uuid: String)
{
load(context)
_all.update { it - uuid }
save(context)
}
private fun load(context: Context)
{
if (loaded)
{
return
}
synchronized(this)
{
if (loaded)
{
return
}
try
{
val file = File(context.filesDir, FILE_NAME)
if (file.exists())
{
val array = JSONArray(file.readText())
_all.value = (0 until array.length()).map { fromJson(array.getJSONObject(it)) }.associateBy { it.uuid }
}
}
catch (e: Exception)
{
Log.w(TAG, "Loading failed", e)
}
loaded = true
}
}
private fun save(context: Context)
{
try
{
val array = JSONArray()
_all.value.values.forEach { array.put(toJson(it)) }
val file = File(context.filesDir, FILE_NAME)
val tmp = File(file.path + ".tmp")
tmp.writeText(array.toString())
if (!tmp.renameTo(file))
{
file.writeText(array.toString())
tmp.delete()
}
}
catch (e: Exception)
{
Log.w(TAG, "Saving failed", e)
}
}
internal fun toJson(e: ProfileExtra): JSONObject = JSONObject()
.put("uuid", e.uuid).put("source", e.source.name).put("value", e.sourceValue ?: JSONObject.NULL)
.put("added", e.addedAt).put("auto", e.autoUpdate).put("checked", e.checkedAt).put("changes", e.lastChanges)
.put("ports", JSONArray(e.backupPorts)).put("reserve", e.reserveUuid ?: JSONObject.NULL)
.put("bridge", e.bridgeUuid ?: JSONObject.NULL).put("bridgeOn", e.bridgeEnabled).put("frag", e.ikeFragmentation)
internal fun fromJson(o: JSONObject): ProfileExtra
{
val ports = o.optJSONArray("ports")
return ProfileExtra(
uuid = o.getString("uuid"),
source = runCatching { ProfileSourceType.valueOf(o.optString("source")) }.getOrDefault(ProfileSourceType.UNKNOWN),
sourceValue = o.optString("value").takeIf { !o.isNull("value") && it.isNotEmpty() },
addedAt = o.optLong("added"),
autoUpdate = o.optBoolean("auto", true),
checkedAt = o.optLong("checked"),
lastChanges = o.optInt("changes"),
backupPorts = if (ports == null) emptyList() else (0 until ports.length()).map { ports.getInt(it) },
reserveUuid = o.optString("reserve").takeIf { !o.isNull("reserve") && it.isNotEmpty() },
bridgeUuid = o.optString("bridge").takeIf { !o.isNull("bridge") && it.isNotEmpty() },
bridgeEnabled = o.optBoolean("bridgeOn"),
ikeFragmentation = o.optBoolean("frag", true),
)
}
}
/** The profile signs in with a password and none is saved: nobody can start it unattended. */
fun org.strongswan.android.data.VpnProfile.needsPassword(): Boolean =
vpnType.has(org.strongswan.android.data.VpnType.VpnTypeFeature.USER_PASS) && password.isNullOrEmpty()
/**
* For unattended starts (network rules, scenarios): true when [uuid] signs in
* with an unsaved password - the start would only end in «password missing».
* Records one event pointing at the server.
*/
fun blockedByPassword(context: android.content.Context, uuid: String): Boolean
{
val profile = runCatching {
val source = org.strongswan.android.data.VpnProfileSource(context)
source.open()
try
{
source.getVpnProfile(java.util.UUID.fromString(uuid))
}
finally
{
source.close()
}
}.getOrNull() ?: return false
if (!profile.needsPassword())
{
return false
}
org.strongswan.android.logic.DiagLog.i("ProfileExtras", "${profile.name} needs a password, no unattended start")
org.strongswan.android.logic.events.Events.autoNeedsPassword(context, uuid, profile.name ?: profile.gateway)
return true
}
@@ -0,0 +1,292 @@
/*
* 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.profiles
import android.content.Context
import android.content.Intent
import android.util.Base64
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.json.JSONObject
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.logic.VpnStateService
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
import org.strongswan.android.utils.Constants
import java.io.ByteArrayInputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.UUID
import java.util.concurrent.TimeUnit
/** One changed field (F_ProfileUpdate): label, old and new value as shown. */
data class ProfileChange(val label: String, val old: String, val new: String)
/** The server's copy of a profile and how it differs from the local one. */
class ProfileCheck(
val uuid: String,
val changes: List<ProfileChange>,
internal val gateway: String,
internal val port: Int?,
internal val remoteId: String?,
internal val certificate: X509Certificate?,
)
internal enum class ProfileField { ADDRESS, PORT, REMOTE_ID, CA }
/** A raw difference: CA values are the expiry dates. */
internal data class FieldChange(val field: ProfileField, val old: Any?, val new: Any?)
/** The server-side fields of a downloaded .sswan. */
internal data class RemoteProfile(val gateway: String, val port: Int?, val remoteId: String?, val certificate: X509Certificate?)
internal fun parseRemote(text: String, decode: (String) -> ByteArray): RemoteProfile
{
val remote = JSONObject(text).getJSONObject("remote")
return RemoteProfile(
gateway = remote.getString("addr"),
port = remote.optInt("port").takeIf { it in 1..65535 },
remoteId = remote.optString("id").ifEmpty { null },
certificate = remote.optString("cert").ifEmpty { null }?.let {
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(decode(it))) as X509Certificate
},
)
}
/** What changes when the local profile takes the [remote] fields (port 4500 when unset). */
internal fun diff(gateway: String?, port: Int?, remoteId: String?, currentCa: X509Certificate?, remote: RemoteProfile): List<FieldChange>
{
val changes = ArrayList<FieldChange>()
if (remote.gateway != gateway)
{
changes += FieldChange(ProfileField.ADDRESS, gateway, remote.gateway)
}
if ((remote.port ?: 4500) != (port ?: 4500))
{
changes += FieldChange(ProfileField.PORT, port ?: 4500, remote.port ?: 4500)
}
if (remote.remoteId != null && remote.remoteId != remoteId)
{
changes += FieldChange(ProfileField.REMOTE_ID, remoteId ?: gateway, remote.remoteId)
}
val cert = remote.certificate
if (cert != null && (currentCa == null || !currentCa.encoded.contentEquals(cert.encoded)))
{
changes += FieldChange(ProfileField.CA, currentCa?.notAfter, cert.notAfter)
}
return changes
}
/**
* Re-reads a profile from its link (F_Profile «Check now», the daily
* check) and applies the server-side fields: address, port, remote
* identity and CA certificate. The login, DNS, resilience settings,
* routing rules and apps stay as they are.
*/
object ProfileUpdater
{
private const val TAG = "ProfileUpdater"
private const val WORK_NAME = "profile-update-check"
/** Downloads and compares; throws on network or format errors. */
@JvmStatic
fun check(context: Context, uuid: String): ProfileCheck
{
val extra = ProfileExtras.get(context, uuid)
val link = extra.sourceValue?.takeIf { extra.source == ProfileSourceType.LINK } ?: throw IllegalStateException("no link")
val profile = loadProfile(context, uuid) ?: throw IllegalStateException("no profile")
val text = download(link)
val remote = parseRemote(text) { Base64.decode(it, Base64.DEFAULT) }
val currentCa = profile.certificateAlias?.let { TrustedCertificateManager.getInstance().load().getCACertificateFromAlias(it) }
val until = SimpleDateFormat("MM.yyyy", Locale.getDefault())
val changes = diff(profile.gateway, profile.port, profile.remoteId, currentCa, remote).map {
val label = context.getString(
when (it.field)
{
ProfileField.ADDRESS -> R.string.profile_update_addr
ProfileField.PORT -> R.string.profile_update_port
ProfileField.REMOTE_ID -> R.string.profile_update_id
ProfileField.CA -> R.string.profile_update_ca
}
)
fun show(value: Any?): String = when (value)
{
null -> "—"
is java.util.Date -> context.getString(R.string.profile_update_until, until.format(value))
else -> value.toString()
}
ProfileChange(label, show(it.old), show(it.new))
}
val gateway = remote.gateway
val port = remote.port
val remoteId = remote.remoteId
val certificate = remote.certificate
ProfileExtras.update(context, uuid) { it.copy(checkedAt = System.currentTimeMillis(), lastChanges = changes.size) }
return ProfileCheck(uuid, changes, gateway, port, remoteId, certificate)
}
/** Writes the server-side fields and reconnects when the profile is in use. */
@JvmStatic
fun apply(context: Context, check: ProfileCheck)
{
val source = VpnProfileSource(context)
source.open()
try
{
val profile = source.getVpnProfile(UUID.fromString(check.uuid)) ?: return
profile.gateway = check.gateway
profile.port = check.port
if (check.remoteId != null)
{
profile.remoteId = check.remoteId
}
check.certificate?.let { cert ->
val store = KeyStore.getInstance("LocalCertificateStore")
store.load(null, null)
store.setCertificateEntry(null, cert)
profile.certificateAlias = store.getCertificateAlias(cert)
TrustedCertificateManager.getInstance().reset()
}
source.updateVpnProfile(profile)
}
finally
{
source.close()
}
ProfileExtras.update(context, check.uuid) { it.copy(lastChanges = 0) }
val intent = Intent(Constants.VPN_PROFILES_CHANGED).putExtra(Constants.VPN_PROFILES_SINGLE, check.uuid)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
EventLog.getInstance(context).resolve("profile-update:${check.uuid}")
DiagLog.i(TAG, "profile ${check.uuid} updated from its link (${check.changes.size} changes)")
/* the running tunnel still uses the old settings */
val service = VpnStateService.getLiveInstance()
if (service != null && service.profile?.getUUID()?.toString() == check.uuid && service.state != VpnStateService.State.DISABLED)
{
service.reconnect()
}
}
private fun loadProfile(context: Context, uuid: String): VpnProfile?
{
val source = VpnProfileSource(context)
source.open()
try
{
return source.getVpnProfile(UUID.fromString(uuid))
}
finally
{
source.close()
}
}
private fun download(link: String): String
{
val connection = URL(link).openConnection() as HttpURLConnection
connection.connectTimeout = 15_000
connection.readTimeout = 15_000
try
{
if (connection.responseCode !in 200..299)
{
throw IllegalStateException("HTTP ${connection.responseCode}")
}
/* a profile is a few KiB; anything huge is not one */
return connection.inputStream.use { String(it.readNBytesCompat(512 * 1024), Charsets.UTF_8) }
}
finally
{
connection.disconnect()
}
}
private fun java.io.InputStream.readNBytesCompat(max: Int): ByteArray
{
val out = java.io.ByteArrayOutputStream()
val buffer = ByteArray(8192)
while (out.size() < max)
{
val n = read(buffer)
if (n < 0)
{
break
}
out.write(buffer, 0, n)
}
return out.toByteArray()
}
/** Daily check of the profiles with a link and «Update automatically» on. */
@JvmStatic
fun schedule(context: Context)
{
val request = PeriodicWorkRequestBuilder<ProfileUpdateWorker>(24, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
internal fun checkAll(context: Context)
{
val due = ProfileExtras.flow(context).value.values.filter { it.source == ProfileSourceType.LINK && it.autoUpdate }
for (extra in due)
{
try
{
val check = check(context, extra.uuid)
if (check.changes.isNotEmpty())
{
val name = loadProfile(context, extra.uuid)?.name ?: extra.uuid
EventLog.getInstance(context).recordOnce(
EventKind.ASK, EventCategory.CONNECTION, context.getString(R.string.event_profile_update_title, name),
context.getString(R.string.event_profile_update_text, check.changes.size), EventAction.PROFILE_UPDATE,
"profile-update:${extra.uuid}", extra.uuid,
)
}
}
catch (e: Exception)
{
ProfileExtras.update(context, extra.uuid) { it.copy(checkedAt = System.currentTimeMillis(), lastChanges = -1) }
DiagLog.i(TAG, "profile check failed: $e")
}
}
}
}
class ProfileUpdateWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
{
override suspend fun doWork(): Result
{
ProfileUpdater.checkAll(applicationContext)
return Result.success()
}
}
@@ -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.profiles
import android.content.Context
import android.os.Bundle
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.data.VpnType
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.events.EventCategory
import org.strongswan.android.logic.events.EventKind
import org.strongswan.android.logic.events.EventLog
import org.strongswan.android.logic.events.Events
import java.util.UUID
/**
* The resilience settings of F_Profile at work. When the server does not
* answer, the backup ports are tried in turn, then the reserve server; only
* then the regular retry backoff takes over. While the operator whitelists
* are on, a profile with «Bridge for whitelists» switches to its bridge and
* comes back when they clear.
*
* Called on the main thread by [VpnStateService] and the whitelist detector.
*/
object Resilience
{
private const val TAG = "Resilience"
/** marks the connects started here (they keep the attempt state) */
const val KEY_INTERNAL = "org.strongswan.android.resilience.INTERNAL"
/** port used instead of the profile's for this connect */
const val KEY_PORT_OVERRIDE = "org.strongswan.android.resilience.PORT"
/* the profile the user asked for and where the attempt stands */
private var origin: String? = null
private var portIndex = -1
private var onReserve = false
/* set while switched to the bridge: the profile to come back to */
private var bridgeOrigin: String? = null
/** Every connect request; a user's connect starts a new attempt. */
@JvmStatic
fun onConnect(uuid: String?, info: Bundle)
{
if (info.getBoolean(KEY_INTERNAL, false))
{
return
}
origin = uuid
portIndex = -1
onReserve = false
bridgeOrigin = null
}
/**
* The server of the current attempt does not answer. Returns true when
* another attempt was started (the regular retry stays off).
*/
@JvmStatic
fun onUnreachable(service: VpnStateService): Boolean
{
val current = service.profile ?: return false
val start = origin ?: current.getUUID().toString().also { origin = it }
val extra = ProfileExtras.get(service, start)
if (!onReserve && portIndex + 1 < extra.backupPorts.size)
{
portIndex++
val port = extra.backupPorts[portIndex]
DiagLog.i(TAG, "server does not answer, trying backup port $port")
connect(service, start, current.password.takeIf { current.getUUID().toString() == start }, port)
return true
}
val reserve = extra.reserveUuid
if (!onReserve && reserve != null && reserve != start)
{
val profile = load(service, reserve) ?: return false
if (profile.needsPassword())
{ /* the reserve needs a password nobody can type in now: say so once */
DiagLog.i(TAG, "reserve ${profile.name} needs a password, not switching")
onReserve = true
Events.switchNeedsPassword(service, reserve, profile.name ?: profile.gateway, load(service, start)?.name ?: "", bridge = false)
return false
}
onReserve = true
DiagLog.i(TAG, "server does not answer, switching to the reserve ${profile.name}")
EventLog.getInstance(service).record(
EventKind.INFO, EventCategory.CONNECTION,
service.getString(R.string.event_reserve_title, profile.name ?: profile.gateway),
service.getString(R.string.event_reserve_text, load(service, start)?.name ?: ""), key = "reserve:$start",
)
connect(service, reserve, null, null)
return true
}
return false
}
/** Connected: tell when it took a backup port. */
@JvmStatic
fun onConnected(service: VpnStateService)
{
val start = origin ?: return
if (!onReserve && portIndex >= 0)
{
val port = ProfileExtras.get(service, start).backupPorts.getOrNull(portIndex) ?: return
EventLog.getInstance(service).record(
EventKind.INFO, EventCategory.CONNECTION, service.getString(R.string.event_backup_port_title, port),
service.getString(R.string.event_backup_port_text), key = "port:$start",
)
}
}
/** The operator whitelists came or went (WhiteListManager). */
@JvmStatic
fun onWhitelist(context: Context, active: Boolean)
{
val service = VpnStateService.getLiveInstance() ?: return
val current = service.profile?.getUUID()?.toString() ?: return
if (active)
{
val extra = ProfileExtras.get(context, current)
val bridge = extra.bridgeUuid?.takeIf { extra.bridgeEnabled && it != current } ?: return
if (service.state == VpnStateService.State.DISABLED && service.errorState == VpnStateService.ErrorState.NO_ERROR)
{ /* the VPN is off on purpose */
return
}
val profile = load(context, bridge) ?: return
if (profile.needsPassword())
{
DiagLog.i(TAG, "bridge ${profile.name} needs a password, not switching")
Events.switchNeedsPassword(context, bridge, profile.name ?: profile.gateway, service.profile?.name ?: "", bridge = true)
return
}
DiagLog.i(TAG, "whitelists on, switching to the bridge ${profile.name}")
EventLog.getInstance(context).record(
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_bridge_on_title, profile.name ?: profile.gateway),
context.getString(R.string.event_bridge_on_text), key = "bridge",
)
connect(service, bridge, null, null)
bridgeOrigin = current
}
else
{
val back = bridgeOrigin ?: return
bridgeOrigin = null
if (service.state == VpnStateService.State.DISABLED && service.errorState == VpnStateService.ErrorState.NO_ERROR)
{
return
}
val profile = load(context, back) ?: return
DiagLog.i(TAG, "whitelists off, back to ${profile.name}")
EventLog.getInstance(context).resolve("bridge")
EventLog.getInstance(context).record(
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_bridge_off_title, profile.name ?: profile.gateway),
context.getString(R.string.event_bridge_off_text), key = "bridge-off",
)
connect(service, back, null, null)
}
}
private fun connect(service: VpnStateService, uuid: String, password: String?, port: Int?)
{
val info = Bundle()
info.putString(VpnProfileDataSource.KEY_UUID, uuid)
password?.let { info.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
port?.let { info.putInt(KEY_PORT_OVERRIDE, it) }
info.putBoolean(KEY_INTERNAL, true)
service.connect(info, true)
}
private fun load(context: Context, uuid: String) = runCatching {
val source = VpnProfileSource(context)
source.open()
try
{
source.getVpnProfile(UUID.fromString(uuid))
}
finally
{
source.close()
}
}.getOrNull()
}
@@ -0,0 +1,123 @@
/*
* 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.content.Context
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** The DNS server of the tunnel (F_Protection «DNS server»). */
enum class DnsChoice { PROFILE, CLOUDFLARE, ADGUARD, CUSTOM }
/**
* DNS and blocking (F_Protection). The blocking is done by the resolver
* itself, so it follows from the choice: AdGuard DNS blocks ads, trackers
* and phishing together, Cloudflare's 1.1.1.2 blocks malware and phishing
* only. The servers replace the profile's ones on the next connect.
*/
data class ProtectionState(
val choice: DnsChoice = DnsChoice.PROFILE,
/** with CLOUDFLARE: the malware / phishing filtering resolver */
val phishingFilter: Boolean = false,
val custom: String = "",
)
{
val blocksAds: Boolean get() = choice == DnsChoice.ADGUARD
val blocksTrackers: Boolean get() = choice == DnsChoice.ADGUARD
val blocksPhishing: Boolean get() = choice == DnsChoice.ADGUARD || (choice == DnsChoice.CLOUDFLARE && phishingFilter)
/** 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)
{
DnsChoice.PROFILE -> null
DnsChoice.ADGUARD -> listOf("94.140.14.14", "94.140.15.15", "2a10:50c0::ad1:ff", "2a10:50c0::ad2:ff")
DnsChoice.CLOUDFLARE -> if (phishingFilter) listOf("1.1.1.2", "1.0.0.2", "2606:4700:4700::1112", "2606:4700:4700::1002")
else listOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
DnsChoice.CUSTOM -> parseCustom(custom).takeIf { it.isNotEmpty() }
}
fun withAds(on: Boolean): ProtectionState = when
{
on -> copy(choice = DnsChoice.ADGUARD)
choice == DnsChoice.ADGUARD -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = true)
else -> this
}
fun withPhishing(on: Boolean): ProtectionState = when
{
on && choice == DnsChoice.ADGUARD -> this
on -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = true)
choice == DnsChoice.ADGUARD -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = false)
else -> copy(phishingFilter = false)
}
companion object
{
/** «1.2.3.4, 2001:db8::1» → the addresses that parse as IPs */
fun parseCustom(text: String): List<String> = text.split(',', ' ', ';').map { it.trim() }.filter { it.isNotEmpty() }.filter { isIp(it) }
private fun isIp(s: String): Boolean =
Regex("""^(\d{1,3})(\.\d{1,3}){3}$""").matches(s) && s.split('.').all { it.toInt() in 0..255 } ||
(s.contains(':') && Regex("""^[0-9a-fA-F:]+$""").matches(s))
}
}
object Protection
{
private const val PREF_CHOICE = "pref_dns_choice"
private const val PREF_PHISHING = "pref_dns_phishing"
private const val PREF_CUSTOM = "pref_dns_custom"
private val _state = MutableStateFlow<ProtectionState?>(null)
@JvmStatic
fun get(context: Context): ProtectionState
{
_state.value?.let { return it }
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val state = ProtectionState(
choice = runCatching { DnsChoice.valueOf(prefs.getString(PREF_CHOICE, DnsChoice.PROFILE.name)!!) }.getOrDefault(DnsChoice.PROFILE),
phishingFilter = prefs.getBoolean(PREF_PHISHING, false),
custom = prefs.getString(PREF_CUSTOM, "") ?: "",
)
_state.value = state
return state
}
fun flow(context: Context): StateFlow<ProtectionState?>
{
get(context)
return _state
}
fun set(context: Context, state: ProtectionState)
{
PreferenceManager.getDefaultSharedPreferences(context).edit()
.putString(PREF_CHOICE, state.choice.name).putBoolean(PREF_PHISHING, state.phishingFilter).putString(PREF_CUSTOM, state.custom).apply()
_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. */
@JvmStatic
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(' ', ':')
}
@@ -0,0 +1,114 @@
/*
* 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.routing
import android.app.AppOpsManager
import android.app.usage.NetworkStats
import android.app.usage.NetworkStatsManager
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import android.os.Process
import android.util.Log
/**
* Per-app network traffic from the system statistics ("Usage access"), for
* the «Recent apps» list. Android attributes tunneled traffic back to the
* app that produced it, so the totals cover both the VPN and direct paths.
* Only byte counts per app are read - never destinations or content.
*/
object AppTraffic
{
private const val TAG = "AppTraffic"
/* the first uid of regular apps; lower ones are system services */
private const val FIRST_APP_UID = 10000
fun hasAccess(context: Context): Boolean
{
val ops = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager ?: return false
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{
ops.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
}
else
{
@Suppress("DEPRECATION")
ops.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
}
return mode == AppOpsManager.MODE_ALLOWED
}
/**
* Bytes per package since [sinceMs] (wall clock), Wi-Fi and mobile
* together; empty without usage access. The system keeps the counters
* in buckets of up to two hours, so the window is approximate.
*/
fun since(context: Context, sinceMs: Long): Map<String, Long>
{
if (!hasAccess(context))
{
return emptyMap()
}
val manager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as? NetworkStatsManager ?: return emptyMap()
val now = System.currentTimeMillis()
val perUid = HashMap<Int, Long>()
@Suppress("DEPRECATION")
for (type in intArrayOf(ConnectivityManager.TYPE_WIFI, ConnectivityManager.TYPE_MOBILE))
{
try
{
manager.querySummary(type, null, sinceMs, now).use { stats -> sum(stats, perUid) }
}
catch (e: Exception)
{ /* no such network history or no access */
Log.d(TAG, "querySummary($type) failed: $e")
}
}
val pm = context.packageManager
val result = HashMap<String, Long>()
for ((uid, bytes) in perUid)
{
if (uid < FIRST_APP_UID || bytes <= 0)
{
continue
}
val pkg = pm.getPackagesForUid(uid)?.firstOrNull() ?: continue
if (pkg != context.packageName)
{
result[pkg] = (result[pkg] ?: 0L) + bytes
}
}
return result
}
private fun sum(stats: NetworkStats, into: HashMap<Int, Long>)
{
val bucket = NetworkStats.Bucket()
while (stats.hasNextBucket())
{
stats.getNextBucket(bucket)
into[bucket.uid] = (into[bucket.uid] ?: 0L) + bucket.rxBytes + bucket.txBytes
}
}
private inline fun <R> NetworkStats.use(block: (NetworkStats) -> R): R = try
{
block(this)
}
finally
{
close()
}
}
@@ -0,0 +1,79 @@
/*
* 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.routing
/**
* User-defined package name patterns ("package templates" in the UI):
* - "ru.*" - package names starting with "ru."
* - "*yandex*" - package names containing "yandex" anywhere
* - "*.beta" - package names ending with ".beta"
* - "ru.ozon.app" - without a star: prefix match, same as "ru.ozon.app*"
* Matching is case-insensitive.
*/
object PackagePatterns
{
private val ALLOWED = Regex("[a-z0-9._*-]+")
private val STARS = Regex("\\*+")
/** Canonical form of user input, or null if it is empty, too broad or has invalid characters. */
fun normalize(input: String): String?
{
val text = input.trim().lowercase()
if (text.isEmpty() || !ALLOWED.matches(text))
{
return null
}
val collapsed = text.replace(STARS, "*")
if (collapsed.replace("*", "").isEmpty())
{
return null
}
return if (collapsed.contains('*')) collapsed else "$collapsed*"
}
fun matches(pattern: String, pkg: String): Boolean
{
val core = pattern.trim('*').lowercase()
if (core.isEmpty())
{
return false
}
val name = pkg.lowercase()
val starts = pattern.startsWith("*")
val ends = pattern.endsWith("*")
return when
{
starts && ends -> name.contains(core)
ends -> name.startsWith(core)
starts -> name.endsWith(core)
else -> name == core
}
}
/** Packages (of [installed]) matched by any of the patterns. */
fun matchAll(patterns: Set<String>, installed: Set<String>): Set<String> =
if (patterns.isEmpty())
{
emptySet()
}
else
{
installed.filterTo(HashSet()) { pkg -> patterns.any { matches(it, pkg) } }
}
fun fromPrefix(prefix: String): String = "$prefix*"
fun fromKeyword(keyword: String): String = "*${keyword.lowercase()}*"
}
@@ -102,6 +102,63 @@ object RouteCheck
)
}
/** Why an address takes its route, in the order the engine decides. */
enum class Reason
{
/** a resolver of the tunnel or strict private DNS: always through the tunnel */
DNS,
/** a user rule (address or site) sends it through the VPN */
RULE_TUNNEL,
/** a user rule (address or site) sends it direct */
RULE_DIRECT,
/** it is in the RU address base */
RU_LIST,
/** nothing matched: the default route is the tunnel */
DEFAULT,
}
data class Verdict(val direct: Boolean, val reason: Reason)
/**
* Full verdict for an address: like [classify], plus the user rules,
* which the engine applies when [rulesActive] (userspace routing):
* "through the VPN" wins over "direct", both win over the RU base.
*/
@JvmStatic
fun verdict(
address: InetAddress,
exclusionsV4: List<IPRange>,
exclusionsV6: List<IPRange>,
dnsPunchBlocks: Set<String>,
forceRanges: List<IPRange>,
directRanges: List<IPRange>,
rulesActive: Boolean,
): Verdict
{
if (insidePunchBlock(dnsPunchBlocks, address))
{
return Verdict(direct = false, reason = Reason.DNS)
}
if (rulesActive && containsAddress(forceRanges, address))
{
return Verdict(direct = false, reason = Reason.RULE_TUNNEL)
}
if (rulesActive && containsAddress(directRanges, address))
{
return Verdict(direct = true, reason = Reason.RULE_DIRECT)
}
val exclusions = if (address is java.net.Inet4Address) exclusionsV4 else exclusionsV6
if (containsAddress(exclusions, address))
{
return Verdict(direct = true, reason = Reason.RU_LIST)
}
return Verdict(direct = false, reason = Reason.DEFAULT)
}
/** Parses CIDR strings, skipping invalid ones. */
@JvmStatic
fun ranges(cidrs: List<String>): List<IPRange> = cidrs.mapNotNull { runCatching { IPRange(it) }.getOrNull() }
/** Route verdict for one address under the applied exclusion set. */
@JvmStatic
fun classify(
@@ -0,0 +1,99 @@
/*
* 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.routing
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
/**
* Pure conversion rules from the pre-2.16 settings (named rule sets and
* per-profile split-tunneling apps) to the single global direct-apps list.
* No Android dependencies, so they are unit tested; [RoutingManager] applies
* them once at startup.
*/
object RoutingMigration
{
data class RuleSetResult(
val directPackages: Set<String>,
val patterns: Set<String>,
val categories: Set<String>,
)
/**
* Enabled rule sets become explicit packages plus package patterns. A
* built-in category whose every preset is fully present among the rule
* sets (what the old "all RF apps" action produced) turns into the
* category itself - same coverage, but shown as one switch. Disabled rule
* sets are dropped (the old JSON is kept in a backup key).
*/
fun migrateRuleSets(ruleSets: List<RuleSet>): RuleSetResult
{
val active = ruleSets.filter { it.enabled }
val direct = HashSet<String>()
val patterns = LinkedHashSet<String>()
for (ruleSet in active)
{
direct.addAll(ruleSet.packages)
ruleSet.prefixes.forEach { patterns.add(PackagePatterns.fromPrefix(it)) }
ruleSet.keywords.forEach { patterns.add(PackagePatterns.fromKeyword(it)) }
}
val categories = LinkedHashSet<String>()
for (category in RoutingPresets.ALL_CATEGORIES)
{
val presets = RoutingPresets.presetsOf(category)
if (presets.isNotEmpty() && presets.all { preset -> active.any { it.covers(preset) } })
{
categories.add(category)
for (preset in presets)
{
preset.prefixes.forEach { patterns.remove(PackagePatterns.fromPrefix(it)) }
preset.keywords.forEach { patterns.remove(PackagePatterns.fromKeyword(it)) }
}
}
}
return RuleSetResult(direct, patterns, categories)
}
private fun RuleSet.covers(preset: RoutingPresets.Preset): Boolean
{
if (preset.prefixes.isEmpty() && preset.keywords.isEmpty())
{
return false
}
val lowerKeywords = keywords.map { it.lowercase() }.toSet()
return prefixes.containsAll(preset.prefixes) && lowerKeywords.containsAll(preset.keywords.map { it.lowercase() })
}
/**
* Packages a profile's old per-app setting sends direct (outside the VPN):
* - EXCLUDE: the listed apps;
* - ONLY: "all installed internet apps except the listed ones" snapshot
* (an empty ONLY list never took effect in the old code, so it yields
* nothing);
* - DISABLE: nothing.
* The app's own package is never listed (it is always excluded anyway).
*/
fun profileAppsToDirect(
handling: SelectedAppsHandling,
selected: Set<String>,
installed: Set<String>,
ownPackage: String,
): Set<String> = when (handling)
{
SelectedAppsHandling.SELECTED_APPS_EXCLUDE -> selected - ownPackage
SelectedAppsHandling.SELECTED_APPS_ONLY ->
if (selected.isEmpty()) emptySet() else installed - selected - ownPackage
else -> emptySet()
}
}
@@ -14,10 +14,9 @@
package org.strongswan.android.logic.routing
import java.util.UUID
/**
* Built-in rule set templates.
* Built-in app templates, grouped into categories the user can switch on in
* one tap ("Banks", "Marketplaces", ...).
* - Prefixes match package names, so "ru." catches every package in the ru.*
* reverse-domain namespace.
* - Keywords match anywhere in the package name (case-insensitive), so
@@ -28,75 +27,112 @@ import java.util.UUID
*/
object RoutingPresets
{
data class Preset(val name: String, val prefixes: List<String> = emptyList(), val keywords: List<String> = emptyList())
/* category ids; the namespace-wide preset lives in the hidden NAMESPACE
* category that only "all RF apps" switches on */
const val BANKS = "banks"
const val GOV = "gov"
const val SHOP = "shop"
const val SOCIAL = "social"
const val TRAVEL = "travel"
const val SERVICES = "services"
const val NAMESPACE = "ru_namespace"
/** User-visible categories in display order (the hidden [NAMESPACE] one is excluded). */
val CATEGORIES: List<String> = listOf(BANKS, GOV, SHOP, SOCIAL, TRAVEL, SERVICES)
/** Everything "all RF apps" switches on. */
val ALL_CATEGORIES: Set<String> = (CATEGORIES + NAMESPACE).toSet()
data class Preset(
val name: String,
val category: String,
val prefixes: List<String> = emptyList(),
val keywords: List<String> = emptyList(),
)
/** Whether a package belongs to the preset (prefix, or keyword anywhere, case-insensitive). */
fun Preset.matches(pkg: String): Boolean
{
if (prefixes.any { pkg.startsWith(it) })
{
return true
}
val lower = pkg.lowercase()
return keywords.any { lower.contains(it.lowercase()) }
}
/** Presets of one category, in declaration order. */
fun presetsOf(category: String): List<Preset> = RF_BUNDLE.filter { it.category == category }
/** Whether the package matches any preset of [category] (the namespace one included). */
fun matchesCategory(category: String, pkg: String): Boolean =
presetsOf(category).any { it.matches(pkg) }
/** First user-visible category the package belongs to, or null. */
fun categoryOf(pkg: String): String? =
CATEGORIES.firstOrNull { matchesCategory(it, pkg) }
val RF_BUNDLE: List<Preset> = listOf(
/* namespace-wide */
Preset("Все RU.*", prefixes = listOf("ru.")),
Preset("Все RU.*", NAMESPACE, prefixes = listOf("ru.")),
/* ecosystems */
Preset("Яндекс", prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
Preset("VK", prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
Preset("Одноклассники", prefixes = listOf("ru.ok.")),
Preset("Дзен", keywords = listOf("dzen")),
Preset("Rutube", keywords = listOf("rutube")),
Preset("Звук", keywords = listOf("zvuk", "zvooq")),
Preset("Иви", prefixes = listOf("ru.ivi")),
Preset("Яндекс", SOCIAL, prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
Preset("VK", SOCIAL, prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
Preset("Одноклассники", SOCIAL, prefixes = listOf("ru.ok.")),
Preset("Дзен", SOCIAL, keywords = listOf("dzen")),
Preset("Rutube", SOCIAL, keywords = listOf("rutube")),
Preset("Звук", SOCIAL, keywords = listOf("zvuk", "zvooq")),
Preset("Иви", SOCIAL, prefixes = listOf("ru.ivi")),
/* banks & finance */
Preset("Сбер", keywords = listOf("sber")),
Preset("Т-Банк", keywords = listOf("tinkoff", "tbank")),
Preset("ВТБ", keywords = listOf("vtb")),
Preset("Альфа-Банк", keywords = listOf("alfabank", "alpha-bank")),
Preset("Газпромбанк", keywords = listOf("gazprombank")),
Preset("Совкомбанк", keywords = listOf("sovcombank")),
Preset("Почта Банк", keywords = listOf("pochtabank")),
Preset("РСХБ", keywords = listOf("rshb", "rosselkhozbank")),
Preset("ОТП-Банк", prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
Preset("МКБ", prefixes = listOf("ru.mkb")),
Preset("Уралсиб", keywords = listOf("uralsib")),
Preset("ЮMoney", keywords = listOf("yoomoney")),
Preset("Золотая Корона", keywords = listOf("koronapay")),
Preset("Мой налог", keywords = listOf("nalog")),
Preset("Сбер", BANKS, keywords = listOf("sber")),
Preset("Т-Банк", BANKS, keywords = listOf("tinkoff", "tbank")),
Preset("ВТБ", BANKS, keywords = listOf("vtb")),
Preset("Альфа-Банк", BANKS, keywords = listOf("alfabank", "alpha-bank")),
Preset("Газпромбанк", BANKS, keywords = listOf("gazprombank")),
Preset("Совкомбанк", BANKS, keywords = listOf("sovcombank")),
Preset("Почта Банк", BANKS, keywords = listOf("pochtabank")),
Preset("РСХБ", BANKS, keywords = listOf("rshb", "rosselkhozbank")),
Preset("ОТП-Банк", BANKS, prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
Preset("МКБ", BANKS, prefixes = listOf("ru.mkb")),
Preset("Уралсиб", BANKS, keywords = listOf("uralsib")),
Preset("ЮMoney", BANKS, keywords = listOf("yoomoney")),
Preset("Золотая Корона", BANKS, keywords = listOf("koronapay")),
/* government */
Preset("Госуслуги", GOV, prefixes = listOf("ru.rostel"), keywords = listOf("gosuslugi")),
Preset("Мой налог", GOV, keywords = listOf("nalog")),
/* retail & marketplaces */
Preset("Ozon", keywords = listOf("ozon")),
Preset("Wildberries", keywords = listOf("wildberries")),
Preset("Мегамаркет", keywords = listOf("megamarket")),
Preset("Пятёрочка", keywords = listOf("pyaterochka")),
Preset("Перекрёсток", keywords = listOf("perekrestok")),
Preset("Магнит", keywords = listOf("magnit")),
Preset("Лента", keywords = listOf("lenta")),
Preset("М.Видео", keywords = listOf("mvideo")),
Preset("Спортмастер", keywords = listOf("sportmaster")),
Preset("Лемана ПРО", keywords = listOf("leroymerlin", "lemana")),
Preset("Самокат", keywords = listOf("samokat")),
Preset("ВкусВилл", keywords = listOf("vkusvill")),
Preset("Ozon", SHOP, keywords = listOf("ozon")),
Preset("Wildberries", SHOP, keywords = listOf("wildberries")),
Preset("Мегамаркет", SHOP, keywords = listOf("megamarket")),
Preset("Пятёрочка", SHOP, keywords = listOf("pyaterochka")),
Preset("Перекрёсток", SHOP, keywords = listOf("perekrestok")),
Preset("Магнит", SHOP, keywords = listOf("magnit")),
Preset("Лента", SHOP, keywords = listOf("lenta")),
Preset("М.Видео", SHOP, keywords = listOf("mvideo")),
Preset("Спортмастер", SHOP, keywords = listOf("sportmaster")),
Preset("Лемана ПРО", SHOP, keywords = listOf("leroymerlin", "lemana")),
Preset("Самокат", SHOP, keywords = listOf("samokat")),
Preset("ВкусВилл", SHOP, keywords = listOf("vkusvill")),
/* telecom */
Preset("МТС", prefixes = listOf("ru.mts", "com.mts")),
Preset("Мегафон", keywords = listOf("megafon")),
Preset("Билайн", keywords = listOf("beeline")),
Preset("T2/Tele2", prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
Preset("Yota", prefixes = listOf("ru.yota")),
Preset("МТС", SOCIAL, prefixes = listOf("ru.mts", "com.mts")),
Preset("Мегафон", SOCIAL, keywords = listOf("megafon")),
Preset("Билайн", SOCIAL, keywords = listOf("beeline")),
Preset("T2/Tele2", SOCIAL, prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
Preset("Yota", SOCIAL, prefixes = listOf("ru.yota")),
/* transport & travel */
Preset("РЖД", keywords = listOf("rzd")),
Preset("Аэрофлот", keywords = listOf("aeroflot")),
Preset("Туту.ру", keywords = listOf("tutu.ru")),
Preset("Aviasales", keywords = listOf("aviasales")),
Preset("Ситимобил", keywords = listOf("sitimobil")),
Preset("Делимобиль", keywords = listOf("delimobil")),
Preset("РЖД", TRAVEL, keywords = listOf("rzd")),
Preset("Аэрофлот", TRAVEL, keywords = listOf("aeroflot")),
Preset("Туту.ру", TRAVEL, keywords = listOf("tutu.ru")),
Preset("Aviasales", TRAVEL, keywords = listOf("aviasales")),
Preset("Ситимобил", TRAVEL, keywords = listOf("sitimobil")),
Preset("Делимобиль", TRAVEL, keywords = listOf("delimobil")),
/* services */
Preset("2ГИС", keywords = listOf("2gis", "dublgis")),
Preset("ЦИАН", prefixes = listOf("ru.cian")),
Preset("Дром", keywords = listOf("drom")),
Preset("Юла", keywords = listOf("youla")),
Preset("HeadHunter", prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
Preset("Почта России", keywords = listOf("pochta", "russianpost")),
Preset("СДЭК", keywords = listOf("cdek")),
Preset("2ГИС", SERVICES, keywords = listOf("2gis", "dublgis")),
Preset("ЦИАН", SERVICES, prefixes = listOf("ru.cian")),
Preset("Дром", SERVICES, keywords = listOf("drom")),
Preset("Юла", SERVICES, keywords = listOf("youla")),
Preset("HeadHunter", SERVICES, prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
Preset("Почта России", SERVICES, keywords = listOf("pochta", "russianpost")),
Preset("СДЭК", SERVICES, keywords = listOf("cdek")),
)
}
fun RoutingPresets.Preset.asRuleSet(): RuleSet = RuleSet(
id = UUID.randomUUID().toString(),
name = name,
enabled = true,
prefixes = prefixes.toSet(),
keywords = keywords.map { it.lowercase() }.toSet(),
)
@@ -155,8 +155,10 @@ class RuIpListManager private constructor(private val context: Context)
* classification set is hot-swapped in native code instead */
CharonVpnService.pushUserspaceRoutingData(context)
}
else if (enabled)
{
else if (enabled || excludedV4.isNotEmpty() || excludedV6.isNotEmpty())
{ /* routes only change when the TUN is re-established - also when
* the split is switched OFF, or the RU ranges keep bypassing the
* tunnel until the next manual reconnect */
scheduleReconnect()
}
}
@@ -183,7 +185,17 @@ class RuIpListManager private constructor(private val context: Context)
return try
{
val (text, sha256) = withContext(Dispatchers.IO) { fetchList() }
val parsed = withContext(Dispatchers.IO) { parse(text) }
if (sha256 == prefs.getString(PREF_SHA256, null) &&
(excludedV4.isNotEmpty() || excludedV6.isNotEmpty()))
{ /* identical list: nothing to parse, save or re-apply - above
* all no reconnect that tears down a healthy tunnel daily */
val now = System.currentTimeMillis()
prefs.edit().putLong(PREF_LAST_UPDATE, now).remove(PREF_LAST_FAIL).apply()
publish(status = RuIpListStatus.UP_TO_DATE, lastUpdatedMs = now)
Log.i(TAG, "RU geoip list unchanged")
return true
}
val parsed = withContext(Dispatchers.Default) { parse(text) }
if (parsed.v4.isEmpty() && parsed.v6.isEmpty())
{
throw IOException("downloaded list contains no CIDRs")
@@ -195,6 +207,7 @@ class RuIpListManager private constructor(private val context: Context)
prefs.edit()
.putLong(PREF_LAST_UPDATE, now)
.putString(PREF_SHA256, sha256)
.remove(PREF_LAST_FAIL)
.apply()
publish(
status = RuIpListStatus.UP_TO_DATE,
@@ -213,6 +226,8 @@ class RuIpListManager private constructor(private val context: Context)
catch (e: Exception)
{
Log.w(TAG, "RU geoip list update failed", e)
/* remembered so a blocked source is not retried on every cold start */
prefs.edit().putLong(PREF_LAST_FAIL, System.currentTimeMillis()).apply()
publish(status = RuIpListStatus.ERROR)
false
}
@@ -224,14 +239,16 @@ class RuIpListManager private constructor(private val context: Context)
/* ------------------------------ internals ------------------------------ */
private fun loadFromDisk()
private suspend fun loadFromDisk()
{
val file = listFile()
if (file.isFile)
{
try
{
val parsed = parse(file.readText())
/* thousands of CIDRs: read and parse off the main thread (this
* runs at every process start) */
val parsed = withContext(Dispatchers.Default) { parse(file.readText()) }
excludedV4 = parsed.v4
excludedV6 = parsed.v6
publish(
@@ -257,8 +274,8 @@ class RuIpListManager private constructor(private val context: Context)
{
return
}
val last = prefs.getLong(PREF_LAST_UPDATE, 0L)
if (System.currentTimeMillis() - last >= Constants.RUSPLIT_CHECK_INTERVAL_MS)
if (isAutoCheckDue(System.currentTimeMillis(), prefs.getLong(PREF_LAST_UPDATE, 0L),
prefs.getLong(PREF_LAST_FAIL, 0L)))
{
scope.launch { updateNow() }
}
@@ -403,6 +420,21 @@ class RuIpListManager private constructor(private val context: Context)
private const val FILE_NAME = "ru.list"
private const val PREF_LAST_UPDATE = "rusplit_last_update"
private const val PREF_SHA256 = "rusplit_sha256"
private const val PREF_LAST_FAIL = "rusplit_last_fail"
/* after a failed download wait this long before the next automatic try */
internal const val FAIL_RETRY_MS = 3 * 60 * 60 * 1000L
/** Whether the automatic refresh is due (pure, unit-testable). */
@JvmStatic
internal fun isAutoCheckDue(now: Long, lastUpdate: Long, lastFail: Long): Boolean
{
if (now - lastUpdate < Constants.RUSPLIT_CHECK_INTERVAL_MS)
{
return false
}
/* clock moved backwards: don't let a "future" failure block forever */
return lastFail <= 0L || lastFail > now || now - lastFail >= FAIL_RETRY_MS
}
private const val RECONNECT_DEBOUNCE_MS = 2000L
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 30_000
@@ -0,0 +1,740 @@
/*
* 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.scenarios
import android.app.AlarmManager
import android.app.PendingIntent
import android.app.usage.UsageEvents
import android.app.usage.UsageStatsManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import android.telephony.TelephonyManager
import android.util.Log
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONArray
import org.json.JSONObject
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.PauseManager
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.autoconnect.SsidReader
import org.strongswan.android.logic.connectivity.WhiteListManager
import org.strongswan.android.logic.connectivity.WhiteListStatus
import org.strongswan.android.logic.events.EventCategory
import org.strongswan.android.logic.events.EventKind
import org.strongswan.android.logic.events.EventLog
import org.strongswan.android.logic.routing.AppTraffic
import org.strongswan.android.utils.Constants
import java.io.File
import java.util.Calendar
import java.util.UUID
/** «When» of a scenario (F_ScenarioAdd). */
enum class ScenarioTrigger { WIFI, MOBILE, APP, TIME, ROAMING, WHITELIST }
/** «What to do». */
enum class ScenarioAction { VPN_ON, VPN_OFF, PAUSE, SWITCH_SERVER }
/**
* A user scenario. While its trigger holds, its action is in force; when
* the trigger ends the VPN goes back to how it was («restore»).
*/
data class Scenario(
val id: String = UUID.randomUUID().toString(),
val enabled: Boolean = true,
val trigger: ScenarioTrigger,
/** WIFI: the networks; empty = any Wi-Fi */
val ssids: Set<String> = emptySet(),
/** APP: the packages */
val packages: Set<String> = emptySet(),
/** TIME: minutes of the day, the window may wrap past midnight */
val startMinute: Int = 60,
val endMinute: Int = 7 * 60,
val action: ScenarioAction,
/** VPN_ON / SWITCH_SERVER: the profile, null = the default one */
val profileUuid: String? = null,
)
/** Whether [minute] of the day is inside the (possibly wrapping) window. */
internal fun inWindow(minute: Int, start: Int, end: Int): Boolean =
if (start == end) false else if (start < end) minute in start until end else minute >= start || minute < end
/** What the engine sees right now; the decision is a pure function of it. */
internal data class ScenarioInputs(
val ssid: String?,
val wifi: Boolean,
val mobile: Boolean,
val roaming: Boolean,
val foreground: String?,
val minute: Int,
val whitelist: Boolean,
)
/** The first enabled scenario whose trigger holds: the top one wins. */
internal fun activeScenario(list: List<Scenario>, now: ScenarioInputs): Scenario? = list.firstOrNull { s ->
s.enabled && when (s.trigger)
{
ScenarioTrigger.WIFI -> now.wifi && (s.ssids.isEmpty() || s.ssids.any { it.equals(now.ssid, ignoreCase = true) })
ScenarioTrigger.MOBILE -> now.mobile && !now.wifi
ScenarioTrigger.APP -> now.foreground != null && now.foreground in s.packages
ScenarioTrigger.TIME -> inWindow(now.minute, s.startMinute, s.endMinute)
ScenarioTrigger.ROAMING -> now.roaming
ScenarioTrigger.WHITELIST -> now.whitelist
}
}
/** The two network rules of F_Automation, as entries of the common order. */
const val BUILTIN_TRUSTED = "builtin:trusted"
const val BUILTIN_UNTRUSTED = "builtin:untrusted"
/** What the built-in network rules say about the current network. */
internal data class BuiltinRules(
val enabled: Boolean,
val parkOnTrusted: Boolean,
val connectOnUntrusted: Boolean,
val trustedSsids: Set<String>,
)
/** The order of everything: [order] first (known ids), the rest after it (built-ins before new scenarios). */
internal fun orderedIds(order: List<String>, scenarios: List<Scenario>): List<String>
{
val all = listOf(BUILTIN_TRUSTED, BUILTIN_UNTRUSTED) + scenarios.map { it.id }
return order.filter { it in all } + all.filter { it !in order }
}
/**
* The top entry of the common order whose condition holds (built-in rule
* id or scenario id), null when nothing applies. «Upper wins».
*/
internal fun topEntry(order: List<String>, scenarios: List<Scenario>, rules: BuiltinRules, now: ScenarioInputs): String?
{
val byId = scenarios.associateBy { it.id }
val trustedHere = now.wifi && now.ssid != null && rules.trustedSsids.any { it.equals(now.ssid, ignoreCase = true) }
for (id in orderedIds(order, scenarios))
{
val holds = when (id)
{
BUILTIN_TRUSTED -> rules.enabled && rules.parkOnTrusted && trustedHere
/* like the auto-connect: a Wi-Fi with an unreadable name counts as foreign */
BUILTIN_UNTRUSTED -> rules.enabled && rules.connectOnUntrusted && ((now.wifi && !trustedHere) || (!now.wifi && now.mobile))
else -> byId[id]?.let { activeScenario(listOf(it), now) != null } ?: false
}
if (holds)
{
return id
}
}
return null
}
/**
* Runs the user scenarios of F_Automation. Re-evaluated on network
* changes, at the edges of time windows (alarm), while an app trigger
* exists (foreground app polled every few seconds, needs usage access)
* and when the whitelists come or go.
*/
object ScenarioEngine
{
private const val TAG = "Scenarios"
private const val FILE_NAME = "scenarios.json"
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 */
private const val SLOW_AFTER_POLLS = 15
private const val SLOW_POLL_MS = 8000L
private const val PREF_APPLIED = "pref_scenario_applied"
private const val PREF_WAS_ON = "pref_scenario_was_on"
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile"
/* the scenario the user overrode by hand: it stays out until it ends */
private const val PREF_SUSPENDED = "pref_scenario_suspended"
private const val PREF_ORDER = "pref_scenario_order"
private val _list = MutableStateFlow<List<Scenario>>(emptyList())
val list: StateFlow<List<Scenario>> = _list
@Volatile private var loaded = false
private val handler = Handler(Looper.getMainLooper())
private var appContext: Context? = null
private var polling = false
private var unchangedPolls = 0
private var lastForeground: String? = null
fun flow(context: Context): StateFlow<List<Scenario>>
{
load(context)
return list
}
fun save(context: Context, list: List<Scenario>)
{
load(context)
_list.value = list
persist(context)
start(context)
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
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. */
@JvmStatic
fun start(context: Context)
{
val app = context.applicationContext
appContext = app
load(app)
scheduleAlarm(app)
val needPoll = _list.value.any { it.enabled && it.trigger == ScenarioTrigger.APP }
if (needPoll && !polling)
{
polling = true
registerScreenReceiver(app)
if (isScreenOn(app))
{
handler.post(poll)
}
}
else if (!needPoll)
{
polling = false
handler.removeCallbacks(poll)
unregisterScreenReceiver(app)
}
}
private fun isScreenOn(context: Context): Boolean =
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
/* the foreground app only changes while the screen is on: no polling (and no
* wake-ups for it) while it is off, one immediate poll when it comes back */
private val screenReceiver = object : BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent)
{
handler.removeCallbacks(poll)
if (polling && intent.action == Intent.ACTION_SCREEN_ON)
{
unchangedPolls = 0
handler.post(poll)
}
}
}
private var screenReceiverRegistered = false
private fun registerScreenReceiver(app: Context)
{
if (screenReceiverRegistered)
{
return
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}
runCatching { app.registerReceiver(screenReceiver, filter, null, handler) }
.onSuccess { screenReceiverRegistered = true }
}
private fun unregisterScreenReceiver(app: Context)
{
if (!screenReceiverRegistered)
{
return
}
runCatching { app.unregisterReceiver(screenReceiver) }
screenReceiverRegistered = false
}
private val poll = object : Runnable
{
override fun run()
{
val app = appContext ?: return
if (!polling || !isScreenOn(app))
{ /* resumed by the screen-on broadcast */
return
}
val fg = foregroundApp(app)
if (fg != null && fg != lastForeground)
{
lastForeground = fg
unchangedPolls = 0
evaluate(app)
}
else
{
unchangedPolls++
}
handler.postDelayed(this, if (unchangedPolls >= SLOW_AFTER_POLLS) SLOW_POLL_MS else POLL_MS)
}
}
/**
* 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. */
@JvmStatic
fun evaluate(context: Context)
{
val app = context.applicationContext
load(app)
if (_list.value.none { it.enabled })
{
restoreIfApplied(app)
return
}
val now = inputs(app)
val top = topEntry(order(app), _list.value, builtinRules(app), now)
val prefs = PreferenceManager.getDefaultSharedPreferences(app)
/* a scenario overridden by hand stays out until it ends */
val suspended = prefs.getString(PREF_SUSPENDED, null)
if (suspended != null)
{
if (top == suspended)
{
return
}
prefs.edit().remove(PREF_SUSPENDED).apply()
}
val active = _list.value.firstOrNull { it.id == top }
val applied = prefs.getString(PREF_APPLIED, null)
if (active?.id == applied && active != null)
{
return
}
if (active == null)
{ /* nothing, or a built-in network rule above: the auto-connect acts */
restoreIfApplied(app)
return
}
val service = VpnStateService.getLiveInstance() ?: return
if (applied == null)
{ /* remember how it was, to put it back when the scenario ends */
val on = service.state != VpnStateService.State.DISABLED
prefs.edit().putBoolean(PREF_WAS_ON, on)
.putString(PREF_WAS_PROFILE, service.profile?.getUUID()?.toString()).apply()
}
prefs.edit().putString(PREF_APPLIED, active.id).apply()
DiagLog.i(TAG, "scenario ${active.trigger} → ${active.action}")
apply(app, service, active)
}
private fun apply(context: Context, service: VpnStateService, scenario: Scenario)
{
val profile = scenario.profileUuid ?: defaultProfile(context) ?: service.profile?.getUUID()?.toString()
when (scenario.action)
{
ScenarioAction.VPN_ON, ScenarioAction.SWITCH_SERVER ->
{
val running = service.state != VpnStateService.State.DISABLED
if (profile != null && (!running || service.profile?.getUUID()?.toString() != profile) && !connect(context, service, profile))
{ /* nothing happened: no «scenario did it» event */
return
}
}
ScenarioAction.VPN_OFF -> if (service.state != VpnStateService.State.DISABLED) service.disconnect()
ScenarioAction.PAUSE ->
{
val current = service.profile?.getUUID()?.toString() ?: profile ?: return
val until = if (scenario.trigger == ScenarioTrigger.TIME) windowEnd(scenario) else System.currentTimeMillis() + 12L * 60 * 60 * 1000
if (service.state != VpnStateService.State.DISABLED)
{
PauseManager.pauseUntil(context, service, current, until)
}
}
}
EventLog.getInstance(context).record(
EventKind.INFO, EventCategory.CONNECTION,
context.getString(R.string.event_scenario_title, context.getString(actionLabel(scenario.action))),
context.getString(R.string.event_scenario_text, describeWhen(context, scenario)), key = "scenario:${scenario.id}",
)
}
/** The common order of the built-in rules and the scenarios (F_Automation, top wins). */
fun order(context: Context): List<String>
{
val raw = PreferenceManager.getDefaultSharedPreferences(context).getString(PREF_ORDER, null)
return orderedIds(raw?.split(',')?.filter { it.isNotBlank() } ?: emptyList(), flow(context).value)
}
fun saveOrder(context: Context, order: List<String>)
{
PreferenceManager.getDefaultSharedPreferences(context).edit().putString(PREF_ORDER, order.joinToString(",")).apply()
_orderChanges.value = _orderChanges.value + 1
evaluate(context)
}
private val _orderChanges = MutableStateFlow(0)
/** Bumps when the order changed (the list screen redraws). */
val orderChanges: StateFlow<Int> = _orderChanges
/** Moves [id] one place up (-1) or down (+1) in the common order. */
fun move(context: Context, id: String, delta: Int)
{
val list = order(context).toMutableList()
val i = list.indexOf(id)
val j = i + delta
if (i < 0 || j < 0 || j >= list.size)
{
return
}
list.add(j, list.removeAt(i))
saveOrder(context, list)
}
/**
* The user turned the VPN on or off or paused it by hand: the running
* scenario steps back until it ends and puts nothing back afterwards.
*/
@JvmStatic
fun onManual(context: Context)
{
val prefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
val applied = prefs.getString(PREF_APPLIED, null) ?: return
DiagLog.i(TAG, "manual action, scenario steps back until it ends")
prefs.edit().putString(PREF_SUSPENDED, applied)
.remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
}
/** A scenario is in force: the built-in network rules (auto-connect) stand aside. */
@JvmStatic
fun inControl(context: Context): Boolean =
PreferenceManager.getDefaultSharedPreferences(context.applicationContext).getString(PREF_APPLIED, null) != null
private fun builtinRules(context: Context): BuiltinRules
{
val c = org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).currentConfig
return BuiltinRules(c.enabled, c.parkOnTrusted, c.connectOnUntrusted, c.trustedSsids)
}
private fun restoreIfApplied(context: Context)
{
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
if (prefs.getString(PREF_APPLIED, null) == null)
{
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 wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
if (PauseManager.isPaused(context))
{
if (wasOn) PauseManager.resumeNow(context) else PauseManager.cancel(context)
return
}
val running = service.state != VpnStateService.State.DISABLED
when
{
wasOn && wasProfile != null && (!running || service.profile?.getUUID()?.toString() != wasProfile) -> connect(context, service, wasProfile)
!wasOn && running -> service.disconnect()
}
}
/** Starts [uuid]; false when it could not (no VPN permission, no saved password). */
private fun connect(context: Context, service: VpnStateService, uuid: String): Boolean
{
val needsConsent = runCatching { VpnService.prepare(context) != null }.getOrDefault(true)
if (needsConsent)
{
DiagLog.i(TAG, "VPN permission missing, scenario cannot connect")
return false
}
/* a password typed for the running session goes along with it */
val livePassword = service.profile?.takeIf { it.getUUID().toString() == uuid }?.password
if (livePassword == null && org.strongswan.android.logic.profiles.blockedByPassword(context, uuid))
{
return false
}
val info = Bundle()
info.putString(VpnProfileDataSource.KEY_UUID, uuid)
livePassword?.let { info.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
service.connect(info, true)
return true
}
private fun defaultProfile(context: Context): String? =
PreferenceManager.getDefaultSharedPreferences(context).getString(Constants.PREF_MRU_VPN_PROFILE, null)
private fun inputs(context: Context): ScenarioInputs
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
var wifi = false
var mobile = false
var ssid: String? = null
@Suppress("DEPRECATION")
cm?.allNetworks?.forEach { network ->
val caps = runCatching { cm.getNetworkCapabilities(network) }.getOrNull() ?: return@forEach
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return@forEach
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
{
wifi = true
ssid = SsidReader.from(context, caps)
}
else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
{
mobile = true
}
}
val roaming = mobile && runCatching { (context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager)?.isNetworkRoaming == true }.getOrDefault(false)
val c = Calendar.getInstance()
val whitelist = runCatching { WhiteListManager.getInstance(context).state.value.status == WhiteListStatus.ACTIVE }.getOrDefault(false)
val fg = if (_list.value.any { it.enabled && it.trigger == ScenarioTrigger.APP }) lastForeground ?: foregroundApp(context) else null
return ScenarioInputs(ssid, wifi, mobile, roaming, fg, c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE), whitelist)
}
/** The app last moved to the foreground (usage events of the last minute); null without access. */
private fun foregroundApp(context: Context): String?
{
if (!AppTraffic.hasAccess(context))
{
return null
}
val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager ?: return null
val now = System.currentTimeMillis()
val events = runCatching { usm.queryEvents(now - 60_000, now) }.getOrNull() ?: return lastForeground
var last: String? = null
val event = UsageEvents.Event()
while (events.hasNextEvent())
{
events.getNextEvent(event)
if (event.eventType == UsageEvents.Event.ACTIVITY_RESUMED)
{
last = event.packageName
}
}
return last ?: lastForeground
}
private fun windowEnd(s: Scenario): Long
{
val c = Calendar.getInstance()
val now = c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE)
c.set(Calendar.HOUR_OF_DAY, s.endMinute / 60)
c.set(Calendar.MINUTE, s.endMinute % 60)
c.set(Calendar.SECOND, 0)
c.set(Calendar.MILLISECOND, 0)
if (s.endMinute <= now)
{
c.add(Calendar.DAY_OF_YEAR, 1)
}
return c.timeInMillis
}
/** An alarm at the next edge of any time window. */
private fun scheduleAlarm(context: Context)
{
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
val pi = PendingIntent.getBroadcast(context, 46, Intent(context, ScenarioReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val edges = _list.value.filter { it.enabled && it.trigger == ScenarioTrigger.TIME }.flatMap { listOf(it.startMinute, it.endMinute) }
if (edges.isEmpty())
{
am.cancel(pi)
return
}
val c = Calendar.getInstance()
val now = c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE)
val next = edges.minByOrNull { (it - now + 1440 - 1) % 1440 + 1 } ?: return
c.set(Calendar.HOUR_OF_DAY, next / 60)
c.set(Calendar.MINUTE, next % 60)
c.set(Calendar.SECOND, 5)
c.set(Calendar.MILLISECOND, 0)
if (c.timeInMillis <= System.currentTimeMillis())
{
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
{
if (exact)
{
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
}
else
{
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
}
}
catch (_: SecurityException)
{ /* revoked in between: an inexact alarm is still better than none */
runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) }
}
}
fun actionLabel(action: ScenarioAction): Int = when (action)
{
ScenarioAction.VPN_ON -> R.string.scenario_do_on
ScenarioAction.VPN_OFF -> R.string.scenario_do_off
ScenarioAction.PAUSE -> R.string.scenario_do_pause
ScenarioAction.SWITCH_SERVER -> R.string.scenario_do_switch
}
/** «Telegram or YouTube is open», «Every night 01:00–07:00» … */
fun describeWhen(context: Context, s: Scenario): String
{
fun hm(m: Int) = String.format("%02d:%02d", m / 60, m % 60)
return when (s.trigger)
{
ScenarioTrigger.WIFI -> if (s.ssids.isEmpty()) context.getString(R.string.scenario_when_any_wifi)
else context.getString(R.string.scenario_when_wifi, s.ssids.joinToString(", "))
ScenarioTrigger.MOBILE -> context.getString(R.string.scenario_when_mobile)
ScenarioTrigger.APP ->
{
val pm = context.packageManager
val names = s.packages.map { pkg -> runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrDefault(pkg) }
context.getString(R.string.scenario_when_app, names.joinToString(context.getString(R.string.scenario_or)))
}
ScenarioTrigger.TIME -> context.getString(R.string.scenario_when_time, hm(s.startMinute), hm(s.endMinute))
ScenarioTrigger.ROAMING -> context.getString(R.string.scenario_when_roaming)
ScenarioTrigger.WHITELIST -> context.getString(R.string.scenario_when_whitelist)
}
}
private fun load(context: Context)
{
if (loaded)
{
return
}
synchronized(this)
{
if (loaded)
{
return
}
try
{
val file = File(context.filesDir, FILE_NAME)
if (file.exists())
{
val array = JSONArray(file.readText())
_list.value = (0 until array.length()).mapNotNull { runCatching { fromJson(array.getJSONObject(it)) }.getOrNull() }
}
}
catch (e: Exception)
{
Log.w(TAG, "Loading failed", e)
}
loaded = true
}
}
private fun persist(context: Context)
{
try
{
val array = JSONArray()
_list.value.forEach { s ->
array.put(
JSONObject().put("id", s.id).put("on", s.enabled).put("when", s.trigger.name)
.put("ssids", JSONArray(s.ssids.toList())).put("apps", JSONArray(s.packages.toList()))
.put("start", s.startMinute).put("end", s.endMinute).put("do", s.action.name)
.put("profile", s.profileUuid ?: JSONObject.NULL)
)
}
File(context.filesDir, FILE_NAME).writeText(array.toString())
}
catch (e: Exception)
{
Log.w(TAG, "Saving failed", e)
}
}
private fun fromJson(o: JSONObject): Scenario
{
fun set(key: String): Set<String> = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getString(it) }.toSet() } ?: emptySet()
return Scenario(
id = o.getString("id"),
enabled = o.optBoolean("on", true),
trigger = ScenarioTrigger.valueOf(o.getString("when")),
ssids = set("ssids"),
packages = set("apps"),
startMinute = o.optInt("start", 60),
endMinute = o.optInt("end", 420),
action = ScenarioAction.valueOf(o.getString("do")),
profileUuid = o.optString("profile").takeIf { !o.isNull("profile") && it.isNotEmpty() },
)
}
}
/** Edge of a time window: decide again and arm the next edge. */
class ScenarioReceiver : BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent?)
{
ScenarioEngine.start(context)
val pending = goAsync()
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
}
}
@@ -0,0 +1,263 @@
/*
* 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.stats
import android.content.Context
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONArray
import org.json.JSONObject
import org.strongswan.android.logic.CharonVpnService
import java.io.File
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
/** Bytes of one day: through the tunnel and straight past it (userspace router). */
data class DayTraffic(val day: String, val vpn: Long, val direct: Long)
{
val total: Long get() = vpn + direct
}
/**
* Daily traffic history (F_Stats). While the tunnel is up the daemon
* 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
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
*/
object TrafficHistory
{
private const val TAG = "TrafficHistory"
private const val FILE_NAME = "traffic.json"
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 val executor = Executors.newSingleThreadScheduledExecutor()
private var task: ScheduledFuture<*>? = null
private var lastVpn = -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
private val days = LinkedHashMap<String, LongArray>()
private var firstDay: String? = null
private val _changes = MutableStateFlow(0L)
/** Bumps whenever a sample changed the history (for live screens). */
val changes: StateFlow<Long> = _changes
internal fun dayKey(ms: Long): String = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(ms)
/** Starts sampling (the tunnel came up); harmless when already running. */
@JvmStatic
@Synchronized
fun start(context: Context)
{
val app = context.applicationContext
if (task != null)
{
return
}
lastVpn = -1L
lastDirect = -1L
task = executor.scheduleWithFixedDelay({ sample(app) }, 0, SAMPLE_S, TimeUnit.SECONDS)
}
/** Stops sampling after a last sample (the tunnel goes down). */
@JvmStatic
@Synchronized
fun stop(context: Context)
{
val app = context.applicationContext
val running = task ?: return
running.cancel(false)
task = null
executor.execute {
sample(app, force = true)
flush(app)
}
}
/** 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 vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
/* direct bytes only exist with the userspace router (s[3]) */
val direct = runCatching { CharonVpnService.getRoutingStats() }.getOrNull()?.takeIf { it.size >= 4 }?.get(3) ?: 0L
val dVpn = if (lastVpn < 0 || vpn < lastVpn) 0L else vpn - lastVpn
val dDirect = if (lastDirect < 0 || direct < lastDirect) 0L else direct - lastDirect
/* a new session: count what it already did since its counters started */
val addVpn = if (lastVpn >= 0 && vpn < lastVpn) vpn else dVpn
val addDirect = if (lastDirect >= 0 && direct < lastDirect) direct else dDirect
lastVpn = vpn
lastDirect = direct
if (addVpn > 0 || addDirect > 0)
{
add(context, System.currentTimeMillis(), addVpn, addDirect)
}
}
@Synchronized
internal fun add(context: Context, at: Long, vpn: Long, direct: Long)
{
load(context)
val key = dayKey(at)
val row = days.getOrPut(key) { LongArray(2) }
row[0] += vpn
row[1] += direct
if (firstDay == null)
{
firstDay = key
}
prune()
dirty = true
if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key))
{
save(context)
}
_changes.value = System.currentTimeMillis()
if (screenOn(context))
{ /* nobody sees the widget with the screen off */
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
}
}
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
@Synchronized
fun range(context: Context, from: Long, to: Long): List<DayTraffic>
{
load(context)
val result = ArrayList<DayTraffic>()
val c = Calendar.getInstance()
c.timeInMillis = from
while (c.timeInMillis <= to)
{
val key = dayKey(c.timeInMillis)
val row = days[key]
result += DayTraffic(key, row?.get(0) ?: 0L, row?.get(1) ?: 0L)
c.add(Calendar.DAY_OF_YEAR, 1)
}
return result
}
@Synchronized
fun today(context: Context): DayTraffic = range(context, System.currentTimeMillis(), System.currentTimeMillis()).first()
/** First day with any record, null before the first connection. */
@Synchronized
fun firstDay(context: Context): String?
{
load(context)
return firstDay
}
private fun prune()
{
val cutoff = dayKey(System.currentTimeMillis() - KEEP_DAYS * 24L * 60 * 60 * 1000)
days.keys.filter { it < cutoff }.forEach { days.remove(it) }
}
private fun load(context: Context)
{
if (loaded)
{
return
}
loaded = true
try
{
val file = File(context.filesDir, FILE_NAME)
if (!file.exists())
{
return
}
val json = JSONObject(file.readText())
firstDay = json.optString("first").ifEmpty { null }
val array = json.optJSONArray("days") ?: JSONArray()
for (i in 0 until array.length())
{
val o = array.getJSONObject(i)
days[o.getString("d")] = longArrayOf(o.optLong("v"), o.optLong("r"))
}
}
catch (e: Exception)
{
Log.w(TAG, "Loading the traffic history failed", e)
}
}
private fun save(context: Context)
{
try
{
val array = JSONArray()
days.toSortedMap().forEach { (d, row) -> array.put(JSONObject().put("d", d).put("v", row[0]).put("r", row[1])) }
val json = JSONObject().put("first", firstDay ?: JSONObject.NULL).put("days", array)
val file = File(context.filesDir, FILE_NAME)
val tmp = File(file.path + ".tmp")
tmp.writeText(json.toString())
if (!tmp.renameTo(file))
{
file.writeText(json.toString())
tmp.delete()
}
dirty = false
lastSavedAt = SystemClock.elapsedRealtime()
lastSavedDay = dayKey(System.currentTimeMillis())
}
catch (e: Exception)
{
Log.w(TAG, "Saving the traffic history failed", e)
}
}
}
@@ -0,0 +1,123 @@
/*
* 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.stats
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.strongswan.android.R
import org.strongswan.android.logic.events.NotifPrefs
import org.strongswan.android.ui.StatsActivity
import org.strongswan.android.ui.compose.formatBytes
import java.util.Calendar
import java.util.concurrent.TimeUnit
/** «Weekly summary» (F_SysNotifs): last week's traffic on Monday morning, off by default. */
object WeeklySummary
{
private const val WORK_NAME = "weekly-summary"
private const val CHANNEL = "org.strongswan.android.weekly"
private const val NOTIFICATION_ID = 0x5717
/** Arms (or cancels) the Monday 10:00 job after the switch. */
@JvmStatic
fun schedule(context: Context)
{
val wm = WorkManager.getInstance(context)
if (!NotifPrefs.enabled(context, NotifPrefs.WEEKLY))
{
wm.cancelUniqueWork(WORK_NAME)
return
}
val c = Calendar.getInstance()
c.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
c.set(Calendar.HOUR_OF_DAY, 10)
c.set(Calendar.MINUTE, 0)
c.set(Calendar.SECOND, 0)
if (c.timeInMillis <= System.currentTimeMillis())
{
c.add(Calendar.WEEK_OF_YEAR, 1)
}
val request = PeriodicWorkRequestBuilder<WeeklySummaryWorker>(7, TimeUnit.DAYS)
.setInitialDelay(c.timeInMillis - System.currentTimeMillis(), TimeUnit.MILLISECONDS)
.build()
wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
}
internal fun post(context: Context)
{
if (!NotifPrefs.enabled(context, NotifPrefs.WEEKLY))
{
return
}
val day = 24L * 60 * 60 * 1000
val now = System.currentTimeMillis()
val week = TrafficHistory.range(context, now - 7 * day, now - day)
val total = week.sumOf { it.total }
if (total <= 0)
{
return
}
val vpnShare = (week.sumOf { it.vpn } * 100 / total).toInt()
val manager = NotificationManagerCompat.from(context)
if (!manager.areNotificationsEnabled())
{
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(
NotificationChannel(CHANNEL, context.getString(R.string.notif_weekly), NotificationManager.IMPORTANCE_LOW)
)
}
val open = PendingIntent.getActivity(
context, 0, Intent(context, StatsActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.weekly_title, formatBytes(total)))
.setContentText(context.getString(R.string.weekly_text, vpnShare, 100 - vpnShare))
.setContentIntent(open)
.setAutoCancel(true)
.build()
try
{
manager.notify(NOTIFICATION_ID, notification)
}
catch (_: SecurityException)
{
}
}
}
class WeeklySummaryWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
{
override suspend fun doWork(): Result
{
WeeklySummary.post(applicationContext)
return Result.success()
}
}
@@ -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")
}
@@ -29,7 +29,12 @@ class UpdateCheckWorker(context: Context, params: WorkerParameters) : CoroutineW
{
override suspend fun doWork(): Result
{
UpdateManager.getInstance(applicationContext).checkIfDue()
/* wait for the check (and a silent pre-download it starts): returning
* early lets the system reclaim the process mid-request, outside the
* worker's charging/network constraints */
val manager = UpdateManager.getInstance(applicationContext)
manager.checkIfDue()?.join()
manager.awaitDownload()
return Result.success()
}
@@ -26,6 +26,7 @@ import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.os.IBinder
import android.util.Log
@@ -41,7 +42,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -51,6 +51,8 @@ import org.strongswan.android.R
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
import org.strongswan.android.logic.events.Events
import org.strongswan.android.ui.MainActivity
import org.strongswan.android.utils.Constants
import java.io.File
@@ -60,6 +62,7 @@ import java.net.Proxy
import java.net.URL
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import kotlin.coroutines.cancellation.CancellationException
/** Categories of update failures, mapped to localized messages by the UI */
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
@@ -74,6 +77,10 @@ data class UpdateInfo(
val sha256: String,
val size: Long,
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
@@ -115,13 +122,30 @@ class UpdateManager private constructor(private val context: Context)
private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
private const val UPDATES_NOTIFICATION_ID = 1001
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
private var mInstance: UpdateManager? = null
/* called from the main thread and the WorkManager thread: a second
* instance would run its own check loop and state */
@JvmStatic
fun getInstance(context: Context): UpdateManager
{
return mInstance ?: UpdateManager(context.applicationContext).also { mInstance = it }
return mInstance ?: synchronized(this)
{
mInstance ?: UpdateManager(context.applicationContext).also { mInstance = it }
}
}
/**
@@ -154,6 +178,15 @@ class UpdateManager private constructor(private val context: Context)
private var mCheckJob: Job? = null
private var mDownloadJob: Job? = null
/* the user asked to install (e.g. tapped "update ready") while no update
* was known in this process: install as soon as the check finds it */
@Volatile
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 val mConnection = object : ServiceConnection
@@ -179,7 +212,8 @@ class UpdateManager private constructor(private val context: Context)
{
cleanup()
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()
.setRequiredNetworkType(NetworkType.CONNECTED)
/* background checks only run while charging - zero battery impact */
@@ -188,13 +222,16 @@ class UpdateManager private constructor(private val context: Context)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
UpdateCheckWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
mScope.launch {
while (true)
{
checkIfDue()
delay(Constants.UPDATE_CHECK_INTERVAL_MS)
}
}
/* no in-process timer: the VPN service keeps the process alive, so a
* loop here would check (and pre-download) on battery every 12 h and
* defeat the worker's charging constraint. The worker and the check
* on app resume cover it. */
}
/** Waits for a running silent download (used by the worker). */
suspend fun awaitDownload()
{
mDownloadJob?.join()
}
/**
@@ -202,17 +239,18 @@ class UpdateManager private constructor(private val context: Context)
* last-check timestamp). Called by the periodic worker, the in-process
* loop and on app resume.
*/
fun checkIfDue()
fun checkIfDue(): Job?
{
if (!isAutoCheckEnabled() || mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
{
return
return null
}
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()))
{
check()
return check()
}
return null
}
fun isAutoCheckEnabled(): Boolean
@@ -228,29 +266,101 @@ class UpdateManager private constructor(private val context: Context)
check()
}
private fun 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?)
{
if (mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
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
}
mCheckJob = mScope.launch {
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
private fun check(): Job?
{
if (mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
{
return mCheckJob?.takeIf { it.isActive }
}
val job = mScope.launch {
mState.value = UpdateState.Checking
val info = fetchManifest() ?: return@launch
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply()
val info = fetchManifest() ?: run {
mInstallPending = false
return@launch
}
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())
{
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
"installed ${currentVersionName()} (${currentVersionCode()})")
mState.value = UpdateState.Available(info)
maybeDownloadSilently(info)
Events.updateAvailable(context, info.versionName, info.versionCode)
if (mInstallPending)
{
mInstallPending = false
downloadAndInstall()
}
else
{
maybeDownloadSilently(info)
}
}
else
{
mInstallPending = false
DiagLog.i(TAG, "update: up to date (${currentVersionName()} ${currentVersionCode()})")
mState.value = UpdateState.UpToDate
}
}
mCheckJob = job
return job
}
/**
@@ -260,6 +370,7 @@ class UpdateManager private constructor(private val context: Context)
* right away; otherwise a notification tells the user it is ready - one
* tap installs it (a single system confirmation on Android < 12)
*/
@Synchronized
private fun maybeDownloadSilently(info: UpdateInfo)
{
if (!isSilentUpdatesEnabled())
@@ -267,7 +378,8 @@ class UpdateManager private constructor(private val context: Context)
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
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)")
return
@@ -276,12 +388,17 @@ class UpdateManager private constructor(private val context: Context)
{
return
}
mCancelRequested = false
mDownloadJob = mScope.launch {
try
{
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
val apk = downloadApk(info)
verifySignature(apk)
if (mCancelRequested)
{
throw CancellationException("update download cancelled")
}
if (isVpnDown() && canInstallSilently())
{
Log.i(TAG, "Applying the downloaded update silently")
@@ -292,18 +409,38 @@ class UpdateManager private constructor(private val context: Context)
else
{
DiagLog.i(TAG, "update: ${info.versionName} downloaded, waiting for the user")
notifyUpdateReady(info)
/* back to Available: Downloading would block downloadAndInstall()
* and leave the settings row stuck at 100% */
mState.value = UpdateState.Available(info)
if (mInstallPending)
{
mInstallPending = false
UpdateInstaller.install(context, apk)
mState.value = UpdateState.Idle
}
else
{
notifyUpdateReady(info)
}
}
}
catch (e: CancellationException)
{
onDownloadCancelled()
}
catch (e: Exception)
{
Log.e(TAG, "Silent update download failed", e)
DiagLog.i(TAG, "update: silent download failed: ${e.javaClass.simpleName}: ${e.message}")
/* the manual download from the settings screen still works */
mState.value = UpdateState.Available(info)
}
}
}
/** 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
{
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
@@ -311,8 +448,20 @@ class UpdateManager private constructor(private val context: Context)
private fun isNetworkMetered(): Boolean
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
return cm?.isActiveNetworkMetered ?: true
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return true
/* the VPN marks itself unmetered (setMetered(false)), so with the tunnel
* up the active network always looks unmetered - judge by the physical
* network below it instead */
val underlying = UnderlyingNetwork.resolve(context) ?: return cm.isActiveNetworkMetered
val caps = try
{
cm.getNetworkCapabilities(underlying)
}
catch (_: Exception)
{
null
} ?: return true
return !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
private fun isVpnDown(): Boolean
@@ -350,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 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)
.setContentTitle(title)
.setContentText("$progress%")
@@ -358,11 +507,16 @@ class UpdateManager private constructor(private val context: Context)
.setStyle(Notification.ProgressStyle()
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
.setProgress(progress))
.setRequestPromotedOngoing(true)
.setOngoing(true)
.setOnlyAlertOnce(true)
.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
{
@@ -395,6 +549,10 @@ class UpdateManager private constructor(private val context: Context)
/** Posts a notification that the downloaded update is ready to install */
private fun notifyUpdateReady(info: UpdateInfo)
{
if (!org.strongswan.android.logic.events.NotifPrefs.enabled(context, org.strongswan.android.logic.events.NotifPrefs.UPDATES))
{ /* switched off in the notification settings; the update stays in Settings and Events */
return
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
if (manager == null)
{
@@ -419,6 +577,13 @@ class UpdateManager private constructor(private val context: Context)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentIntent(pending)
.setAutoCancel(true)
/* F_SysNotifs: «Update» / «What is new» */
.addAction(0, context.getString(R.string.notif_update), pending)
.addAction(0, context.getString(R.string.notif_whats_new), PendingIntent.getActivity(
context, info.versionCode.toInt() + 1,
Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_UPDATES, true)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.build()
try
{
@@ -434,23 +599,41 @@ class UpdateManager private constructor(private val context: Context)
* Downloads the update APK (with progress) and installs it as an
* in-place update (the app data is preserved)
*/
@Synchronized
fun downloadAndInstall()
{
val info = (mState.value as? UpdateState.Available)?.info ?: return
if (mDownloadJob?.isActive == true)
{
{ /* a silent download is running: install once it is done */
mInstallPending = true
return
}
val info = (mState.value as? UpdateState.Available)?.info
if (info == null)
{ /* e.g. the "update ready" notification was tapped after the process
* restarted: look the update up again and install it when found */
mInstallPending = true
check()
return
}
mCancelRequested = false
mDownloadJob = mScope.launch {
try
{
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
val apk = downloadApk(info)
verifySignature(apk)
if (mCancelRequested)
{
throw CancellationException("update download cancelled")
}
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
UpdateInstaller.install(context, apk)
mState.value = UpdateState.Idle
}
catch (e: CancellationException)
{
onDownloadCancelled()
}
catch (e: UpdateException)
{
Log.e(TAG, "Update failed: ${e.kind}", e)
@@ -482,6 +665,26 @@ class UpdateManager private constructor(private val context: Context)
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
{
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
@@ -514,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>
{
if (notes == null)
@@ -528,8 +783,9 @@ class UpdateManager private constructor(private val context: Context)
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)
if (!dir.exists() && !dir.mkdirs())
{
@@ -542,36 +798,32 @@ class UpdateManager private constructor(private val context: Context)
}
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
try
{
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)
{
throw UpdateException(UpdateError.NETWORK, "HTTP $code")
}
val expected = if (info.size > 0) info.size else -1L
val digest = MessageDigest.getInstance("SHA-256")
val buffer = ByteArray(64 * 1024)
var downloaded = 0L
var lastProgress = -1
conn.inputStream.use { input ->
mDownloadConn = conn
val downloaded = conn.inputStream.use { input ->
FileOutputStream(apk).use { output ->
while (true)
{
val len = input.read(buffer)
if (len < 0)
{
break
}
output.write(buffer, 0, len)
digest.update(buffer, 0, len)
downloaded += len
copyDownload(input, output, digest, { mCancelRequested }) { done ->
if (expected > 0)
{
val progress = (downloaded * 100 / expected).toInt().coerceIn(0, 100)
val progress = (done * 100 / expected).toInt().coerceIn(0, 100)
if (progress != lastProgress)
{
lastProgress = progress
@@ -596,6 +848,7 @@ class UpdateManager private constructor(private val context: Context)
}
finally
{
mDownloadConn = null
conn.disconnect()
cancelDownloadProgress()
}
@@ -693,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
conn.connectTimeout = CONNECT_TIMEOUT_MS
conn.readTimeout = READ_TIMEOUT_MS
conn.setRequestProperty("Connection", "close")
conn.setRequestProperty("Cache-Control", "no-cache")
applyS3Auth(conn, url)
if (signS3)
{
applyS3Auth(conn, url)
}
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)
}
}
@@ -0,0 +1,235 @@
/*
* 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
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.strongswan.android.R
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxChip
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.ui.compose.theme.ShxType
/** «Without battery saving» (F_Battery): the system exemption, then the vendor switches. */
class BatteryActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
BatteryScreen(onBack = { finish() })
}
}
}
}
/* phone makers with their own background killers; the steps differ per brand */
private enum class Brand(val label: Int, val steps: List<Int>)
{
XIAOMI(R.string.battery_brand_xiaomi, listOf(R.string.battery_step_open, R.string.battery_xiaomi_2, R.string.battery_xiaomi_3)),
SAMSUNG(R.string.battery_brand_samsung, listOf(R.string.battery_step_open, R.string.battery_samsung_2, R.string.battery_samsung_3)),
HUAWEI(R.string.battery_brand_huawei, listOf(R.string.battery_step_open, R.string.battery_huawei_2, R.string.battery_huawei_3)),
OPPO(R.string.battery_brand_oppo, listOf(R.string.battery_step_open, R.string.battery_oppo_2, R.string.battery_oppo_3)),
OTHER(R.string.battery_brand_other, listOf(R.string.battery_step_open, R.string.battery_other_2)),
}
private fun detectBrand(): Brand
{
val m = (Build.MANUFACTURER + " " + Build.BRAND).lowercase()
return when
{
listOf("xiaomi", "redmi", "poco").any { it in m } -> Brand.XIAOMI
"samsung" in m -> Brand.SAMSUNG
"huawei" in m || "honor" in m -> Brand.HUAWEI
listOf("oppo", "realme", "oneplus").any { it in m } -> Brand.OPPO
else -> Brand.OTHER
}
}
private fun isExempt(context: Context): Boolean =
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isIgnoringBatteryOptimizations(context.packageName) ?: true
@SuppressLint("BatteryLife")
@Composable
private fun BatteryScreen(onBack: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
var checks by remember { mutableIntStateOf(0) }
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) checks++ }
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
val exempt = remember(checks) { isExempt(context) }
val detected = remember { detectBrand() }
var brand by rememberSaveable { mutableIntStateOf(detected.ordinal) }
fun open(intent: Intent) = runCatching { context.startActivity(intent) }.isSuccess
SecondaryScaffold(title = stringResource(R.string.battery_title), onBack = onBack) { padding ->
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
)
{
Text(
stringResource(R.string.battery_intro), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp, fontWeight = FontWeight.Normal),
color = c.textTonal, modifier = Modifier.padding(horizontal = 4.dp),
)
StepCard(1, stringResource(R.string.battery_step1_title), highlight = !exempt)
{
Text(stringResource(R.string.battery_step1_text), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
if (exempt)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp).clip(CircleShape).background(c.success))
{
Icon(ShxIcons.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp))
}
Text(stringResource(R.string.battery_step1_done), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.onSuccess)
}
}
else
{
ShxButton(
stringResource(R.string.perm_allow),
{ open(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:" + context.packageName))) },
height = 48.dp, modifier = Modifier.fillMaxWidth(),
)
}
}
StepCard(2, stringResource(R.string.battery_step2_title), highlight = false)
{
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp))
{
Brand.entries.forEach { b -> ShxChip(stringResource(b.label), brand == b.ordinal, { brand = b.ordinal }) }
}
if (detected != Brand.OTHER)
{
Text(
stringResource(R.string.battery_detected, Build.MANUFACTURER.replaceFirstChar { it.uppercase() }),
style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.accentText,
)
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp))
{
Brand.entries[brand].steps.forEachIndexed { i, step ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(22.dp).clip(CircleShape).background(c.tile))
{
Text("${i + 1}", style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
}
Text(stringResource(step), style = ShxType.body.copy(fontWeight = FontWeight.Normal, lineHeight = 20.sp), color = c.text)
}
}
}
ShxButton(
stringResource(R.string.battery_open_app_settings),
{ open(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null))) },
style = ShxButtonStyle.OUTLINE, height = 48.dp, modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp))
.background(if (exempt) c.successSoft else c.warningContainer).padding(horizontal = 14.dp, vertical = 12.dp),
)
{
Icon(if (exempt) ShxIcons.Check else ShxIcons.Alert, contentDescription = null, tint = if (exempt) c.onSuccess else c.onWarningContainer, modifier = Modifier.size(20.dp))
Text(
stringResource(if (exempt) R.string.battery_ok else R.string.battery_not_done),
style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = if (exempt) c.onSuccess else c.onWarningContainer,
)
}
ShxButton(stringResource(R.string.battery_check_again), { checks++ }, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
private fun StepCard(n: Int, title: String, highlight: Boolean, content: @Composable () -> Unit)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(24.dp)
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, if (highlight) c.accentBorder else c.cardBorder, shape).padding(16.dp),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(c.accent))
{
Text("$n", style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
}
Text(title, style = ShxType.rowTitle.copy(fontWeight = FontWeight.ExtraBold), color = c.text)
}
content()
}
}
@@ -0,0 +1,52 @@
/*
* 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.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import org.strongswan.android.ui.compose.DiagnosticsScreen
import org.strongswan.android.ui.compose.RouteCheckMode
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
/** «Diagnostics», opened from the pulse button on Home and from the alert states. */
class DiagnosticsActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background))
{
DiagnosticsScreen(
onBack = { finish() },
onLog = { startActivity(Intent(this@DiagnosticsActivity, LogActivity::class.java)) },
onCheckSite = { site -> startActivity(RouteCheckActivity.intent(this@DiagnosticsActivity, RouteCheckMode.SITE, site)) },
onCheckApp = { pkg -> startActivity(RouteCheckActivity.intent(this@DiagnosticsActivity, RouteCheckMode.APP, pkg)) },
)
}
}
}
}
}
@@ -0,0 +1,107 @@
/*
* 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.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import org.strongswan.android.logic.events.EventAction
import org.strongswan.android.logic.events.EventLog
import org.strongswan.android.logic.events.ShxEvent
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.ui.compose.InboxScreen
import org.strongswan.android.ui.compose.theme.ShxTheme
/** «Events»: what the app noticed and did by itself, opened from the bell on Home and from problem notifications. */
class InboxActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
InboxScreen(onBack = { finish() }, onAction = ::perform, onNotificationSettings = ::openNotificationSettings)
}
}
}
override fun onStop()
{
/* everything shown counts as seen once the user leaves the screen */
EventLog.getInstance(this).markAllRead()
super.onStop()
}
private fun perform(event: ShxEvent)
{
val action = event.action
if (action == EventAction.SET_APP_DIRECT)
{
event.target?.let { RoutingManager.getInstance(this).setAppDirect(it, true) }
EventLog.getInstance(this).answer(event.id)
return
}
val main = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
when (action)
{
EventAction.NONE, EventAction.SET_APP_DIRECT -> return
EventAction.OPEN_HOME -> startActivity(main)
EventAction.OPEN_ROUTING -> startActivity(main.putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
EventAction.INSTALL_UPDATE -> startActivity(main.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true))
EventAction.OPEN_ROUTE_CHECK -> startActivity(Intent(this, RouteCheckActivity::class.java))
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_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)
{
finish()
}
}
private fun openNotificationSettings()
{
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
EventLog.ensureChannel(this)
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
else
{
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.fromParts("package", packageName, null))
}
try
{
startActivity(intent)
}
catch (_: ActivityNotFoundException)
{
}
}
}
@@ -9,7 +9,11 @@
package org.strongswan.android.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
@@ -20,11 +24,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -35,13 +35,18 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -53,12 +58,31 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxChip
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.R
import org.strongswan.android.data.LogContentProvider
import org.strongswan.android.logic.CharonVpnService
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.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
import java.io.BufferedReader
import java.io.File
@@ -68,18 +92,61 @@ class LogActivity : AppCompatActivity()
{
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?)
{
super.onCreate(savedInstanceState)
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 {
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
* deaths and documents watchdog/reconnect decisions */
@@ -105,6 +172,10 @@ class LogActivity : AppCompatActivity()
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
intent.type = "text/plain"
if (comment.isNotBlank())
{
intent.putExtra(Intent.EXTRA_TEXT, comment)
}
if (uris.size > 1)
{
intent.putExtra(Intent.EXTRA_STREAM, uris)
@@ -122,8 +193,15 @@ class LogViewModel : ViewModel()
private val _lines = MutableStateFlow<List<String>>(emptyList())
val lines: StateFlow<List<String>> = _lines
/* tail only while the screen is shown (set by LogScreen) */
private val active = MutableStateFlow(false)
private var started = false
fun setActive(value: Boolean)
{
active.value = value
}
fun start(file: File)
{
if (started) return
@@ -131,34 +209,64 @@ class LogViewModel : ViewModel()
viewModelScope.launch(Dispatchers.IO) {
val buffer = ArrayList<String>()
var reader: BufferedReader? = null
var lastLength = 0L
suspend fun flush()
{
if (buffer.isEmpty())
{
return
}
val batch = buffer.toList()
buffer.clear()
withContext(Dispatchers.Main) {
val merged = _lines.value + batch
/* bounded: a long session must not grow the list forever */
_lines.value = if (merged.size > MAX_LINES) merged.takeLast(MAX_LINES) else merged
}
}
try
{
reader = if (file.exists()) BufferedReader(FileReader(file)) else null
while (isActive)
{
val line = reader?.readLine()
if (!active.value)
{
active.first { it }
}
if (reader == null)
{ /* the log may not exist yet (no connection so far) */
if (file.exists())
{
reader = BufferedReader(FileReader(file))
lastLength = 0L
}
else
{
delay(POLL_MS)
continue
}
}
val line = reader.readLine()
if (line == null)
{
if (buffer.isNotEmpty())
{
val batch = buffer.toList()
buffer.clear()
withContext(Dispatchers.Main) {
_lines.value = _lines.value + batch
}
flush()
val length = if (file.exists()) file.length() else -1L
if (length < lastLength)
{ /* deleted or truncated (a new connection starts a fresh log):
* the reader would stay on the old file forever */
try { reader.close() } catch (_: Exception) {}
reader = null
withContext(Dispatchers.Main) { _lines.value = emptyList() }
continue
}
delay(800)
lastLength = length
delay(POLL_MS)
}
else
{
buffer.add(line)
if (buffer.size >= 50)
{
val batch = buffer.toList()
buffer.clear()
withContext(Dispatchers.Main) {
_lines.value = _lines.value + batch
}
flush()
}
}
}
@@ -172,20 +280,44 @@ class LogViewModel : ViewModel()
}
}
}
private companion object
{
const val MAX_LINES = 5000
const val POLL_MS = 800L
}
}
/* F_Log: «Important» (default), «All», «Errors» */
private enum class LogLevelFilter(val labelRes: Int)
{
ALL(R.string.log_filter_all),
WARN(R.string.log_filter_warn),
IMPORTANT(R.string.log_filter_important),
ALL(R.string.log_filter_everything),
ERROR(R.string.log_filter_error),
}
private fun String.isErrorLine() = contains(" ERROR ")
private fun String.isErrorLine() = contains(" ERROR ") || contains("failed", ignoreCase = true) ||
contains("timeout", ignoreCase = true) || contains("giving up", ignoreCase = true)
private fun String.isWarnLine() = contains(" WARN ") || contains(" WARNING ")
private fun String.isWarnLine() = contains(" WARN ") || contains(" WARNING ") || contains("retransmit", ignoreCase = true)
private fun String.isGoodLine() = contains("established", ignoreCase = true) || contains("successful", ignoreCase = true) ||
contains("installed", ignoreCase = true)
/* the milestones of a connection: what «Important» keeps besides problems */
private val MILESTONES = listOf("initiating", "IKE_SA", "CHILD_SA", "authentication", "established", "deleting", "closing", "reauth", "network")
private fun String.isImportantLine() = isErrorLine() || isWarnLine() || MILESTONES.any { contains(it, ignoreCase = true) }
/* «Oct 12 10:21:49 06[IKE] message» → «10:21:49», «message» */
private val CHARON_LINE = Regex("""^\w{3}\s+\d+\s+(\d{2}:\d{2}:\d{2})\s+\d+\[\w+]\s?(.*)$""")
private fun splitLine(line: String): Pair<String, String>
{
val m = CHARON_LINE.find(line) ?: return "" to line
return m.groupValues[1] to m.groupValues[2]
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
private fun LogScreen(
onBack: () -> Unit,
@@ -195,91 +327,134 @@ private fun LogScreen(
)
{
val context = LocalContext.current
val c = LocalShxColors.current
val allLines by viewModel.lines.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
var filter by rememberSaveable { mutableStateOf(LogLevelFilter.ALL) }
var filter by rememberSaveable { mutableStateOf(LogLevelFilter.IMPORTANT) }
val lines = remember(allLines, filter) {
when (filter)
{
LogLevelFilter.IMPORTANT -> allLines.filter { it.isImportantLine() }
LogLevelFilter.ALL -> allLines
LogLevelFilter.WARN -> allLines.filter { it.isErrorLine() || it.isWarnLine() }
LogLevelFilter.ERROR -> allLines.filter { it.isErrorLine() }
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(Unit) {
viewModel.start(File(context.filesDir, CharonVpnService.LOG_FILE))
}
LaunchedEffect(lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.setActive(true)
try
{
awaitCancellation()
}
finally
{
viewModel.setActive(false)
}
}
}
LaunchedEffect(messages) {
messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
}
/* follow the tail only while the user is at the bottom (or switched the
* filter): scrolling up to read must not be undone by every new batch */
var followedSize by remember { mutableStateOf(0) }
var followedFilter by remember { mutableStateOf(filter) }
LaunchedEffect(lines.size, filter) {
if (lines.isNotEmpty())
{
listState.scrollToItem(lines.lastIndex)
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
if (filter != followedFilter || followedSize == 0 || lastVisible >= followedSize - 2 ||
lines.size < followedSize)
{
listState.scrollToItem(lines.lastIndex)
}
}
followedSize = lines.size
followedFilter = filter
}
val copied = stringResource(R.string.log_copied)
val scope = rememberCoroutineScope()
SecondaryScaffold(
title = stringResource(R.string.log_title),
onBack = onBack,
actions = {
IconButton(onClick = onSend) {
Icon(Icons.Default.Share, contentDescription = stringResource(R.string.send_log))
/* 44dp tonal circle of the mockup header */
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile)
.clickable(role = Role.Button) {
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("log", lines.joinToString("\n")))
scope.launch { snackbarHostState.showSnackbar(copied) }
},
)
{
Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.log_copy), tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
}
},
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
) { padding ->
Column(Modifier.fillMaxSize().padding(padding))
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize().padding(padding).padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
)
{
Row(
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
LogLevelFilter.entries.forEach { level ->
androidx.compose.material3.FilterChip(
selected = filter == level,
onClick = { filter = level },
label = { Text(stringResource(level.labelRes)) },
)
ShxChip(stringResource(level.labelRes), filter == level, { filter = level })
}
}
if (lines.isEmpty())
/* the dark console: grey time, coloured message */
Box(Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(Color(0xFF1C1B20)))
{
Text(
stringResource(R.string.empty_log),
modifier = Modifier.padding(ShxSpacing.xl),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else
{
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(horizontal = ShxSpacing.md),
) {
items(lines.size) { index ->
val line = lines[index]
val color = when
{
line.isErrorLine() -> MaterialTheme.colorScheme.error
line.isWarnLine() -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
if (lines.isEmpty())
{
Text(
stringResource(R.string.empty_log), style = LOG_STYLE, color = Color(0xFF79767F),
modifier = Modifier.padding(14.dp),
)
}
else
{
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(14.dp),
modifier = Modifier.fillMaxSize(),
) {
items(lines.size) { index ->
val line = lines[index]
val (time, message) = splitLine(line)
val color = when
{
line.isErrorLine() || line.isWarnLine() -> Color(0xFFFFB869)
line.isGoodLine() -> Color(0xFF7CE3B0)
else -> Color(0xFFE5E1E9)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
if (time.isNotEmpty())
{
Text(time, style = LOG_STYLE, color = Color(0xFF79767F))
}
Text(message, style = LOG_STYLE, color = color)
}
}
Text(
line,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
),
color = color,
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
ShxNote(stringResource(R.string.log_report_note))
ShxButton(stringResource(R.string.log_send_report), onSend, modifier = Modifier.fillMaxWidth())
}
}
}
private val LOG_STYLE = TextStyle(fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 17.sp)
@@ -21,6 +21,9 @@ import android.os.Bundle;
import org.strongswan.android.logic.updates.UpdateManager;
import org.strongswan.android.ui.compose.ShxAppKt;
import org.strongswan.android.ui.compose.ShxNavigation;
import org.strongswan.android.ui.compose.ShxTab;
import android.service.quicksettings.TileService;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.splashscreen.SplashScreen;
@@ -35,6 +38,31 @@ public class MainActivity extends AppCompatActivity
*/
public static final String EXTRA_INSTALL_UPDATE = "org.strongswan.android.extra.INSTALL_UPDATE";
/**
* Set as an intent extra to open the Routing tab (e.g. from the profile form)
*/
public static final String EXTRA_OPEN_ROUTING = "org.strongswan.android.extra.OPEN_ROUTING";
/**
* Set as an intent extra to open the «Add server» sheet (end of the first-run screens)
*/
public static final String EXTRA_ADD_SERVER = "org.strongswan.android.extra.ADD_SERVER";
/**
* Set as an intent extra to open the Servers tab (the «Change» action of the notification)
*/
public static final String EXTRA_OPEN_SERVERS = "org.strongswan.android.extra.OPEN_SERVERS";
/**
* Set as an intent extra to connect the fastest server (the «Fastest» button of the widget)
*/
public static final String EXTRA_CONNECT_FASTEST = "org.strongswan.android.extra.CONNECT_FASTEST";
/**
* Set as an intent extra to show the Updates page (the update notification)
*/
public static final String EXTRA_OPEN_UPDATES = "org.strongswan.android.extra.OPEN_UPDATES";
/**
* Use "bring your own device" (BYOD) features
*/
@@ -47,7 +75,13 @@ public class MainActivity extends AppCompatActivity
SplashScreen.Companion.installSplashScreen(this);
super.onCreate(savedInstanceState);
handleInstallUpdate(getIntent());
handleOpenRouting(getIntent());
handleAddServer(getIntent());
ShxAppKt.installShxUi(this);
if (savedInstanceState == null && OnboardingActivity.isDue(this))
{ /* a fresh install: what the app does and the permissions first */
startActivity(new Intent(this, OnboardingActivity.class));
}
}
@Override
@@ -62,8 +96,19 @@ public class MainActivity extends AppCompatActivity
@Override
protected void onNewIntent(Intent intent)
{
handleAddServer(intent);
super.onNewIntent(intent);
handleInstallUpdate(intent);
handleOpenRouting(intent);
}
private void handleOpenRouting(Intent intent)
{
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_ROUTING, false))
{
intent.removeExtra(EXTRA_OPEN_ROUTING);
ShxNavigation.INSTANCE.request(ShxTab.ROUTING);
}
}
private void handleInstallUpdate(Intent intent)
@@ -74,4 +119,33 @@ public class MainActivity extends AppCompatActivity
UpdateManager.getInstance(this).downloadAndInstall();
}
}
private void handleAddServer(Intent intent)
{
if (intent != null && intent.getBooleanExtra(EXTRA_ADD_SERVER, false))
{
intent.removeExtra(EXTRA_ADD_SERVER);
ShxNavigation.INSTANCE.requestAddServer();
}
if (intent != null && TileService.ACTION_QS_TILE_PREFERENCES.equals(intent.getAction()))
{
intent.setAction(null);
ShxNavigation.INSTANCE.request(ShxTab.PROFILES);
}
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_SERVERS, false))
{
intent.removeExtra(EXTRA_OPEN_SERVERS);
ShxNavigation.INSTANCE.request(ShxTab.PROFILES);
}
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_UPDATES, false))
{
intent.removeExtra(EXTRA_OPEN_UPDATES);
ShxNavigation.INSTANCE.requestUpdatesPage();
}
if (intent != null && intent.getBooleanExtra(EXTRA_CONNECT_FASTEST, false))
{
intent.removeExtra(EXTRA_CONNECT_FASTEST);
ShxNavigation.INSTANCE.requestFastest();
}
}
}
@@ -0,0 +1,232 @@
/*
* 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.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
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.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.preference.PreferenceManager
import org.strongswan.android.R
import org.strongswan.android.ui.compose.PermissionsList
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.ui.compose.theme.ShxType
/**
* First run (F_Onboarding → F_Permissions → add a server): what the app
* does, the permissions it needs, then Home with the «Add server» sheet.
*/
class OnboardingActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
var step by rememberSaveable { mutableIntStateOf(0) }
BackHandler(enabled = step > 0) { step-- }
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background).windowInsetsPadding(WindowInsets.safeDrawing))
{
when (step)
{
0 -> IntroStep(onStart = { step = 1 }, onSkip = ::finishToHome, onScan = { finishToHome(addServer = true) })
else -> PermissionsStep(onContinue = { finishToHome(addServer = true) })
}
}
}
}
}
private fun finishToHome(addServer: Boolean = false)
{
markDone(this)
startActivity(
Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
.putExtra(MainActivity.EXTRA_ADD_SERVER, addServer)
)
finish()
}
companion object
{
private const val PREF_DONE = "pref_onboarding_done"
/** Whether the first-run screens are still due: a fresh install that has not seen them. */
@JvmStatic
fun isDue(context: Context): Boolean
{
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
if (prefs.getBoolean(PREF_DONE, false))
{
return false
}
/* an update of an existing installation never shows them */
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
if (info != null && info.firstInstallTime != info.lastUpdateTime)
{
markDone(context)
return false
}
return true
}
@JvmStatic
fun markDone(context: Context) = PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(PREF_DONE, true).apply()
}
}
/** Step 1: the two blobs with the route bubbles, the promise, Start / Scan QR. */
@Composable
private fun IntroStep(onStart: () -> Unit, onSkip: () -> Unit, onScan: () -> Unit)
{
val c = LocalShxColors.current
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 28.dp))
{
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End)
{
Text(
stringResource(R.string.onb_skip), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), color = c.textSub,
modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onSkip).padding(horizontal = 8.dp, vertical = 12.dp),
)
}
Box(Modifier.fillMaxWidth().height(290.dp).padding(top = 8.dp), contentAlignment = Alignment.Center)
{
Box(
Modifier.size(260.dp).clip(RoundedCornerShape(topStartPercent = 42, topEndPercent = 58, bottomEndPercent = 50, bottomStartPercent = 50))
.background(c.accentContainer),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(180.dp).clip(RoundedCornerShape(topStartPercent = 55, topEndPercent = 45, bottomEndPercent = 48, bottomStartPercent = 52))
.background(c.accent),
)
{
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(84.dp))
}
Bubble(stringResource(R.string.onb_bubble_direct), Modifier.align(Alignment.TopStart).offset(x = 10.dp, y = 46.dp))
Bubble(stringResource(R.string.onb_bubble_vpn), Modifier.align(Alignment.BottomEnd).offset(x = (-2).dp, y = (-50).dp))
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 20.dp))
{
Text(stringResource(R.string.onb_step, 1, 3).uppercase(), style = ShxType.section.copy(letterSpacing = androidx.compose.ui.unit.TextUnit(0.06f, androidx.compose.ui.unit.TextUnitType.Em)), color = c.accentText)
Text(stringResource(R.string.onb_title), style = ShxType.screenTitle.copy(fontSize = 30.sp, lineHeight = 34.sp), color = c.text)
Text(stringResource(R.string.onb_text), style = ShxType.note.copy(fontSize = 16.sp, lineHeight = 24.sp), color = c.textSoft)
}
Spacer(Modifier.height(32.dp))
Dots(0)
Spacer(Modifier.height(20.dp))
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.onb_start), onStart, modifier = Modifier.fillMaxWidth())
ShxButton(stringResource(R.string.add_qr), onScan, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth(), icon = ShxIcons.Scan)
}
}
}
/** Step 2: the permissions list with Continue. */
@Composable
private fun PermissionsStep(onContinue: () -> Unit)
{
val c = LocalShxColors.current
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 28.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(start = 6.dp, end = 6.dp, top = 8.dp, bottom = 18.dp))
{
Text(stringResource(R.string.onb_step, 2, 3).uppercase(), style = ShxType.section, color = c.accentText)
Text(stringResource(R.string.perm_title), style = ShxType.screenTitle.copy(fontSize = 28.sp, lineHeight = 32.sp), color = c.text)
Text(stringResource(R.string.perm_intro), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp), color = c.textSoft)
}
PermissionsList()
Spacer(Modifier.height(24.dp))
ShxButton(stringResource(R.string.onb_continue), onContinue, modifier = Modifier.fillMaxWidth())
Text(
stringResource(R.string.perm_later), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
)
}
}
@Composable
private fun Bubble(text: String, modifier: Modifier)
{
val c = LocalShxColors.current
Text(
text,
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold),
color = c.onAccentContainer,
modifier = modifier.shadow(6.dp, RoundedCornerShape(14.dp), ambientColor = c.onAccentContainer.copy(alpha = 0.2f), spotColor = c.onAccentContainer.copy(alpha = 0.2f))
.clip(RoundedCornerShape(14.dp)).background(c.card).padding(horizontal = 12.dp, vertical = 8.dp),
)
}
@Composable
private fun Dots(current: Int)
{
val c = LocalShxColors.current
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally))
{
repeat(3) { i ->
Box(Modifier.width(if (i == current) 28.dp else 8.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(if (i == current) c.accent else c.outline))
}
}
}
@@ -40,14 +40,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -61,6 +57,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.components.PredictiveBackScale
import org.strongswan.android.logic.imc.RemediationInstruction
import org.strongswan.android.ui.compose.components.ShxCard
@@ -85,7 +85,7 @@ class RemediationInstructionsActivity : AppCompatActivity()
setContent {
ShxTheme {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Surface(Modifier.fillMaxSize(), color = LocalShxColors.current.background) {
RemediationUi(
instructions = loadInstructions(),
selected = selected,
@@ -132,25 +132,9 @@ private fun RemediationUi(
{
Column(Modifier.fillMaxSize())
{
TopAppBar(
title = {
Text(
selected?.getTitle() ?: stringResource(R.string.remediation_instructions_title),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
},
navigationIcon = {
IconButton(onClick = { if (selected != null) onSelect(null) else onFinish() }) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
ShxPageHeader(
selected?.getTitle() ?: stringResource(R.string.remediation_instructions_title),
onBack = { if (selected != null) onSelect(null) else onFinish() },
)
AnimatedContent(
targetState = selected,
@@ -167,7 +151,7 @@ private fun RemediationUi(
if (instructions.isEmpty())
{
ShxEmptyState(
icon = Icons.Rounded.CheckCircle,
icon = ShxIcons.Check,
message = stringResource(R.string.remediation_instructions_title),
modifier = Modifier.padding(ShxSpacing.lg),
)
@@ -190,14 +174,14 @@ private fun RemediationUi(
.clickable { onSelect(item) }
.padding(ShxSpacing.lg),
) {
Text(item.getTitle(), style = MaterialTheme.typography.titleMedium)
Text(item.getTitle(), style = ShxType.cardTitle)
if (!item.getDescription().isNullOrEmpty())
{
Spacer(Modifier.height(ShxSpacing.xs))
Text(
item.getDescription(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.note,
color = LocalShxColors.current.textSub,
)
}
}
@@ -216,15 +200,15 @@ private fun RemediationUi(
) {
Text(
instruction.getDescription(),
style = MaterialTheme.typography.bodyMedium,
style = ShxType.note,
modifier = Modifier.padding(top = ShxSpacing.sm),
)
if (!instruction.getHeader().isNullOrEmpty())
{
Text(
instruction.getHeader(),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.section,
color = LocalShxColors.current.textSub,
modifier = Modifier.padding(top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
@@ -232,7 +216,7 @@ private fun RemediationUi(
instruction.getItems().forEachIndexed { index, item ->
Text(
item,
style = MaterialTheme.typography.bodyLarge,
style = ShxType.body,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
if (index < instruction.getItems().lastIndex)
@@ -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),
)
}
}
@@ -14,34 +14,46 @@
package org.strongswan.android.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import org.strongswan.android.R
import org.strongswan.android.ui.compose.RouteCheckMode
import org.strongswan.android.ui.compose.RouteCheckScreen
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
/** Standalone route diagnostics screen, opened from the Routing tab. */
/** «How traffic goes»: route of a site, an address or an app. */
class RouteCheckActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val mode = RouteCheckMode.entries.getOrElse(intent.getIntExtra(EXTRA_MODE, 0)) { RouteCheckMode.SITE }
val query = intent.getStringExtra(EXTRA_QUERY).orEmpty()
setContent {
ShxTheme {
SecondaryScaffold(title = stringResource(R.string.route_check_title), onBack = { finish() }) { padding ->
Box(Modifier.padding(padding)) {
RouteCheckScreen()
}
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background))
{
RouteCheckScreen(onBack = { finish() }, initialMode = mode, initialQuery = query)
}
}
}
}
companion object
{
private const val EXTRA_MODE = "mode"
private const val EXTRA_QUERY = "query"
fun intent(context: Context, mode: RouteCheckMode, query: String): Intent =
Intent(context, RouteCheckActivity::class.java).putExtra(EXTRA_MODE, mode.ordinal).putExtra(EXTRA_QUERY, query)
}
}
@@ -0,0 +1,737 @@
/*
* 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
import android.Manifest
import android.app.TimePickerDialog
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.os.Bundle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.Image
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.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
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.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.autoconnect.AutoConnectManager
import org.strongswan.android.logic.autoconnect.SsidReader
import org.strongswan.android.logic.routing.AppTraffic
import org.strongswan.android.logic.scenarios.Scenario
import org.strongswan.android.logic.scenarios.ScenarioAction
import org.strongswan.android.logic.scenarios.ScenarioEngine
import org.strongswan.android.logic.scenarios.ScenarioTrigger
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.ShxPageHeader
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.ShxSwitch
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.utils.Constants
/** «Scenarios» (F_Automation): the built-in network rules, the user scenarios, trusted networks. */
class ScenariosActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
ScenariosScreen(onBack = { finish() })
}
}
}
}
private fun loadProfiles(context: Context): List<VpnProfile>
{
val source = VpnProfileSource(context)
source.open()
try
{
return source.allVpnProfiles
}
finally
{
source.close()
}
}
@Composable
private fun ScenariosScreen(onBack: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
val auto = remember { AutoConnectManager.getInstance(context) }
var config by remember { mutableStateOf(auto.currentConfig) }
val scenarios by remember { ScenarioEngine.flow(context) }.collectAsStateWithLifecycle()
var editing by remember { mutableStateOf<Scenario?>(null) }
var pickRulesServer by remember { mutableStateOf(false) }
val orderChanges by ScenarioEngine.orderChanges.collectAsStateWithLifecycle()
var creating by remember { mutableStateOf(false) }
var resumes by remember { mutableIntStateOf(0) }
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { resumes++; config = auto.currentConfig } }
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
val profiles by produceState(emptyList<VpnProfile>(), resumes) { value = withContext(Dispatchers.IO) { loadProfiles(context) } }
fun updateAuto(transform: (AutoConnectManager.Config) -> AutoConnectManager.Config)
{
auto.updateConfig { current ->
var next = transform(current)
next = next.copy(enabled = next.parkOnTrusted || next.connectOnUntrusted)
if (next.enabled && next.profileUuid == null)
{ /* the rules need a server: the last used, else the first */
val mru = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getString(Constants.PREF_MRU_VPN_PROFILE, null)
next = next.copy(profileUuid = mru ?: profiles.firstOrNull()?.getUUID()?.toString())
}
next
}
config = auto.currentConfig
}
/* «+ Add current network» needs the location permission to read the SSID */
var pendingAdd by remember { mutableStateOf(false) }
fun addCurrentNetwork()
{
val ssid = SsidReader.from(context)
if (ssid != null)
{
updateAuto { it.copy(trustedSsids = it.trustedSsids + ssid) }
}
else
{
android.widget.Toast.makeText(context, R.string.scenarios_no_wifi, android.widget.Toast.LENGTH_SHORT).show()
}
}
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
if (pendingAdd)
{
pendingAdd = false
addCurrentNetwork()
}
}
Box(Modifier.fillMaxSize().background(c.background))
{
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 104.dp))
{
ShxPageHeader(stringResource(R.string.scenarios_title), onBack = onBack)
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp))
{
Text(
stringResource(R.string.scenarios_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal),
color = c.textSoft, modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
)
/* one order for the network rules and the scenarios: the top one that applies wins */
val order = remember(scenarios, orderChanges) { ScenarioEngine.order(context) }
order.forEach { id ->
when (id)
{
org.strongswan.android.logic.scenarios.BUILTIN_TRUSTED ->
RuleCard(ShxIcons.Wifi, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_trusted), stringResource(R.string.scenario_do_off), config.enabled && config.parkOnTrusted, null)
{ on -> updateAuto { it.copy(parkOnTrusted = on, connectOnUntrusted = if (on && !it.enabled) true else it.connectOnUntrusted) } }
org.strongswan.android.logic.scenarios.BUILTIN_UNTRUSTED ->
RuleCard(ShxIcons.Phone, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_untrusted), stringResource(R.string.scenario_do_on), config.enabled && config.connectOnUntrusted, null)
{ on -> updateAuto { it.copy(connectOnUntrusted = on, parkOnTrusted = if (on && !it.enabled) true else it.parkOnTrusted) } }
else -> scenarios.firstOrNull { it.id == id }?.let { s ->
val (icon, bg, fg) = triggerLook(s.trigger)
RuleCard(icon, bg, fg, ScenarioEngine.describeWhen(context, s), stringResource(ScenarioEngine.actionLabel(s.action)), s.enabled, onClick = { editing = s })
{ on -> ScenarioEngine.save(context, scenarios.map { if (it.id == s.id) it.copy(enabled = on) else it }) }
}
}
}
/* the server the two network rules connect (was the old «Trusted networks» screen) */
if (config.enabled)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(18.dp))
.clickable(role = Role.Button) { pickRulesServer = true }.padding(horizontal = 14.dp, vertical = 12.dp),
)
{
Text(stringResource(R.string.scenarios_rules_server), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
Text(
profiles.firstOrNull { it.getUUID().toString() == config.profileUuid }?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto),
style = ShxType.body, color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false),
)
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
}
ShxSectionHeader(stringResource(R.string.scenarios_trusted).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 10.dp))
ShxPanel()
{
config.trustedSsids.sorted().forEach { ssid ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().height(56.dp).padding(start = 16.dp, end = 8.dp))
{
Icon(ShxIcons.Wifi, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
Text(ssid, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(40.dp).clip(CircleShape).clickable(role = Role.Button) { updateAuto { it.copy(trustedSsids = it.trustedSsids - ssid) } },
)
{
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.scenarios_remove_net), tint = c.iconMuted, modifier = Modifier.size(18.dp))
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().height(56.dp).clickable(role = Role.Button) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)
{
pendingAdd = true
permission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
else addCurrentNetwork()
},
)
{
Text(stringResource(R.string.scenarios_add_current), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), color = c.accentText)
}
}
}
}
/* «+ Scenario» */
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 24.dp).height(60.dp)
.shadow(10.dp, RoundedCornerShape(20.dp), ambientColor = c.accent, spotColor = c.accent)
.clip(RoundedCornerShape(20.dp)).background(c.accent).clickable(role = Role.Button) { creating = true }.padding(horizontal = 22.dp),
)
{
Text(stringResource(R.string.scenarios_add), style = ShxType.button.copy(fontSize = 16.sp), color = Color.White)
}
}
if (pickRulesServer)
{
ShxSheet(onDismiss = { pickRulesServer = false })
{
Text(stringResource(R.string.scenarios_rules_server_pick), style = ShxType.note.copy(fontWeight = FontWeight.Normal, lineHeight = 19.sp), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp))
ShxPanel()
{
profiles.forEachIndexed { i, p ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable {
updateAuto { it.copy(profileUuid = p.getUUID().toString()) }
pickRulesServer = false
}.padding(horizontal = 16.dp),
)
{
Text(p.name ?: p.gateway, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
if (config.profileUuid == p.getUUID().toString())
{
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
}
}
if (i < profiles.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
}
}
}
if (creating || editing != null)
{
ScenarioSheet(
initial = editing,
profiles = profiles,
onDismiss = { creating = false; editing = null },
onSave = { saved ->
val list = if (scenarios.any { it.id == saved.id }) scenarios.map { if (it.id == saved.id) saved else it } else scenarios + saved
ScenarioEngine.save(context, list)
creating = false
editing = null
},
onDelete = editing?.let { e -> { ScenarioEngine.save(context, scenarios.filter { it.id != e.id }); editing = null } },
onMove = editing?.let { e ->
{ delta: Int ->
ScenarioEngine.move(context, e.id, delta)
editing = null
}
},
)
}
}
@Composable
private fun triggerLook(trigger: ScenarioTrigger): Triple<ImageVector, Color, Color>
{
val c = LocalShxColors.current
return when (trigger)
{
ScenarioTrigger.WIFI -> Triple(ShxIcons.Wifi, c.accentContainer, c.onAccentContainer)
ScenarioTrigger.MOBILE -> Triple(ShxIcons.Phone, c.accentContainer, c.onAccentContainer)
ScenarioTrigger.APP -> Triple(ShxIcons.Apps, c.warningStrong, c.onWarning)
ScenarioTrigger.TIME -> Triple(ShxIcons.Clock, c.tile, c.textSoft)
ScenarioTrigger.ROAMING -> Triple(ShxIcons.Globe, c.accentContainer, c.onAccentContainer)
ScenarioTrigger.WHITELIST -> Triple(ShxIcons.Alert, c.warningStrong, c.onWarning)
}
}
/** One rule of F_Automation: icon well, «when», «→ then», switch. */
@Composable
private fun RuleCard(icon: ImageVector, bg: Color, fg: Color, whenText: String, thenText: String, on: Boolean, onClick: (() -> Unit)?, onToggle: (Boolean) -> Unit)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(24.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape)
.then(if (onClick != null) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier).padding(14.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(bg))
{
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
{
Text(whenText, style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.textSub, maxLines = 2, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
{
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.accent, modifier = Modifier.size(16.dp))
Text(thenText, style = ShxType.cardTitle, color = c.text)
}
}
ShxSwitch(on, onToggle)
}
}
/** «New scenario» (F_ScenarioAdd): when, what, server, the summary. */
@Composable
private fun ScenarioSheet(
initial: Scenario?,
profiles: List<VpnProfile>,
onDismiss: () -> Unit,
onSave: (Scenario) -> Unit,
onDelete: (() -> Unit)?,
onMove: ((Int) -> Unit)?,
)
{
val c = LocalShxColors.current
val context = LocalContext.current
var trigger by remember { mutableStateOf(initial?.trigger ?: ScenarioTrigger.APP) }
var action by remember { mutableStateOf(initial?.action ?: ScenarioAction.VPN_ON) }
var packages by remember { mutableStateOf(initial?.packages ?: emptySet()) }
var ssids by remember { mutableStateOf(initial?.ssids?.joinToString(", ") ?: SsidReader.from(context).orEmpty()) }
var start by remember { mutableIntStateOf(initial?.startMinute ?: 60) }
var end by remember { mutableIntStateOf(initial?.endMinute ?: 7 * 60) }
var profile by remember { mutableStateOf(initial?.profileUuid) }
var picking by remember { mutableStateOf(false) }
var pickProfile by remember { mutableStateOf(false) }
val draft = Scenario(
id = initial?.id ?: java.util.UUID.randomUUID().toString(),
enabled = initial?.enabled ?: true,
trigger = trigger,
ssids = ssids.split(',').map { it.trim() }.filter { it.isNotEmpty() }.toSet(),
packages = packages,
startMinute = start,
endMinute = end,
action = action,
profileUuid = profile?.takeIf { action == ScenarioAction.VPN_ON || action == ScenarioAction.SWITCH_SERVER },
)
val valid = when (trigger)
{
ScenarioTrigger.APP -> packages.isNotEmpty()
ScenarioTrigger.TIME -> start != end
ScenarioTrigger.WIFI -> true
else -> true
} && !(action == ScenarioAction.SWITCH_SERVER && profile == null)
ShxSheet(onDismiss = onDismiss)
{
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
{
Text(stringResource(if (initial == null) R.string.scenario_new else R.string.scenario_edit), style = ShxType.sheetTitle, color = c.text, modifier = Modifier.padding(start = 4.dp, top = 4.dp))
Text(stringResource(R.string.scenario_step_when), style = ShxType.section, color = c.accentText, modifier = Modifier.padding(horizontal = 4.dp))
val whens = listOf(
ScenarioTrigger.WIFI to R.string.scenario_t_wifi, ScenarioTrigger.MOBILE to R.string.scenario_t_mobile, ScenarioTrigger.APP to R.string.scenario_t_app,
ScenarioTrigger.TIME to R.string.scenario_t_time, ScenarioTrigger.ROAMING to R.string.scenario_t_roaming, ScenarioTrigger.WHITELIST to R.string.scenario_t_whitelist,
)
whens.chunked(3).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
row.forEach { (t, label) ->
val on = trigger == t
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
modifier = Modifier.weight(1f).height(76.dp).clip(RoundedCornerShape(18.dp)).background(if (on) c.accentSoft else c.card)
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, RoundedCornerShape(18.dp))
.clickable(role = Role.RadioButton) { trigger = t }.padding(horizontal = 4.dp),
)
{
Icon(triggerLook(t).first, contentDescription = null, tint = if (on) c.onAccentContainer else c.text, modifier = Modifier.size(20.dp))
Text(stringResource(label), style = ShxType.meta.copy(fontWeight = FontWeight.Bold, lineHeight = 14.sp), color = if (on) c.onAccentContainer else c.text, textAlign = TextAlign.Center, maxLines = 2)
}
}
}
}
when (trigger)
{
ScenarioTrigger.APP -> AppChips(packages, onPick = { picking = true }, onRemove = { packages = packages - it })
ScenarioTrigger.WIFI -> ShxFormField(label = stringResource(R.string.scenario_ssids), value = ssids, onValueChange = { ssids = it }, placeholder = stringResource(R.string.scenario_ssids_hint))
ScenarioTrigger.TIME -> Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
TimeBox(stringResource(R.string.scenario_from), start, Modifier.weight(1f)) { start = it }
TimeBox(stringResource(R.string.scenario_to), end, Modifier.weight(1f)) { end = it }
}
else -> Unit
}
Text(stringResource(R.string.scenario_step_what), style = ShxType.section, color = c.accentText, modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 6.dp))
listOf(ScenarioAction.VPN_ON, ScenarioAction.VPN_OFF, ScenarioAction.PAUSE, ScenarioAction.SWITCH_SERVER).chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
row.forEach { a ->
val on = action == a
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f).height(52.dp).clip(RoundedCornerShape(16.dp)).background(if (on) c.accentSoft else c.card)
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, RoundedCornerShape(16.dp))
.clickable(role = Role.RadioButton) { action = a },
)
{
Text(stringResource(ScenarioEngine.actionLabel(a)), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (on) c.onAccentContainer else c.text)
}
}
}
}
if (action == ScenarioAction.VPN_ON || action == ScenarioAction.SWITCH_SERVER)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(18.dp))
.clickable(role = Role.Button) { pickProfile = true }.padding(horizontal = 14.dp, vertical = 12.dp),
)
{
Text(stringResource(R.string.scenario_server), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
Text(
profiles.firstOrNull { it.getUUID().toString() == profile }?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto),
style = ShxType.body, color = c.textSub,
)
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
}
Text(
stringResource(R.string.scenario_summary, ScenarioEngine.describeWhen(context, draft),
midSentence(stringResource(ScenarioEngine.actionLabel(action)))),
style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal), color = c.onAccentContainer,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.accentSoft).padding(horizontal = 14.dp, vertical = 12.dp),
)
if (trigger == ScenarioTrigger.APP && !AppTraffic.hasAccess(context))
{
Text(stringResource(R.string.scenario_app_access), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.onWarningContainer,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(c.warningContainer).padding(12.dp))
}
if (onMove != null)
{
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.scenario_raise), { onMove(-1) }, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.ArrowUp)
ShxButton(stringResource(R.string.scenario_lower), { onMove(1) }, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.ArrowDown)
}
}
if (onDelete != null)
{
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
onDelete?.let { ShxButton(stringResource(R.string.server_delete_confirm), it, style = ShxButtonStyle.DANGER, height = 48.dp, modifier = Modifier.weight(1f)) }
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.server_save), { if (valid) onSave(draft) }, modifier = Modifier.weight(2f), enabled = valid)
}
}
}
if (picking)
{
AppPicker(packages, onDone = { packages = it; picking = false }, onDismiss = { picking = false })
}
if (pickProfile)
{
ShxSheet(onDismiss = { pickProfile = false })
{
ShxPanel()
{
(listOf<VpnProfile?>(null) + profiles).forEachIndexed { i, p ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable { profile = p?.getUUID()?.toString(); pickProfile = false }.padding(horizontal = 16.dp),
)
{
Text(p?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
if (profile == p?.getUUID()?.toString())
{
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
}
}
if (i < profiles.size)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
}
}
}
}
@Composable
private fun TimeBox(label: String, minute: Int, modifier: Modifier, onPick: (Int) -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = modifier.clip(RoundedCornerShape(16.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(16.dp))
.clickable(role = Role.Button) {
TimePickerDialog(context, { _, h, m -> onPick(h * 60 + m) }, minute / 60, minute % 60, true).show()
}.padding(horizontal = 14.dp, vertical = 10.dp),
)
{
Text(label, style = ShxType.meta, color = c.textSub)
Text(String.format("%02d:%02d", minute / 60, minute % 60), style = ShxType.cardHeading, color = c.text)
}
}
/** Chosen apps as chips with icons, plus «+ Choose». */
@Composable
private fun AppChips(packages: Set<String>, onPick: () -> Unit, onRemove: (String) -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
val pm = context.packageManager
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 2.dp))
{
packages.forEach { pkg ->
val info = remember(pkg) { runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull() }
val label = remember(pkg) { info?.let { pm.getApplicationLabel(it).toString() } ?: pkg }
val icon = remember(pkg) { info?.let { runCatching { pm.getApplicationIcon(it).toBitmap(72, 72).asImageBitmap() }.getOrNull() } }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(18.dp)).background(c.accentContainer).clickable { onRemove(pkg) }.padding(start = 6.dp, end = 12.dp),
)
{
if (icon != null) Image(icon, contentDescription = null, modifier = Modifier.size(24.dp).clip(RoundedCornerShape(8.dp)))
Text(label, style = ShxType.chip, color = c.onAccentContainer, maxLines = 1)
}
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(18.dp)).border(1.dp, c.iconMuted, RoundedCornerShape(18.dp)).clickable(role = Role.Button, onClick = onPick).padding(horizontal = 12.dp),
)
{
Text(stringResource(R.string.scenario_pick_apps), style = ShxType.chip, color = c.accentText)
}
}
}
private class LaunchableApp(val pkg: String, val label: String, val icon: Drawable?)
/** Launchable apps, the most used of the last week first (with usage access). */
private fun loadApps(context: Context): Pair<List<LaunchableApp>, Set<String>>
{
val pm = context.packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
val apps = pm.queryIntentActivities(intent, 0).map { it.activityInfo.packageName }.distinct()
.filter { it != context.packageName }
.map { pkg ->
val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull()
LaunchableApp(pkg, info?.let { pm.getApplicationLabel(it).toString() } ?: pkg, info?.let { runCatching { pm.getApplicationIcon(it) }.getOrNull() })
}
val frequent = if (AppTraffic.hasAccess(context))
{
val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
val now = System.currentTimeMillis()
usm?.queryUsageStats(UsageStatsManager.INTERVAL_BEST, now - 7L * 24 * 60 * 60 * 1000, now)
?.filter { it.totalTimeInForeground > 0 }?.sortedByDescending { it.totalTimeInForeground }
?.map { it.packageName }?.filter { p -> apps.any { it.pkg == p } }?.distinct()?.take(7)?.toSet() ?: emptySet()
}
else emptySet()
return apps.sortedBy { it.label.lowercase() } to frequent
}
/** «Apps» (F_AppPicker): search, often opened first, checkboxes, Done. */
@Composable
private fun AppPicker(selected: Set<String>, onDone: (Set<String>) -> Unit, onDismiss: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
var chosen by remember { mutableStateOf(selected) }
var query by remember { mutableStateOf("") }
val data by produceState<Pair<List<LaunchableApp>, Set<String>>?>(null) { value = withContext(Dispatchers.IO) { loadApps(context) } }
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false))
{
Column(Modifier.fillMaxSize().background(c.background).statusBarsPadding().navigationBarsPadding())
{
ShxPageHeader(stringResource(R.string.stats_apps), onBack = onDismiss)
{
Text(stringResource(R.string.scenario_chosen, chosen.size), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.accentText, modifier = Modifier.padding(end = 8.dp))
}
Column(Modifier.weight(1f).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(26.dp)).background(c.tile).padding(horizontal = 16.dp),
)
{
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
Box(Modifier.weight(1f))
{
if (query.isEmpty())
{
Text(stringResource(R.string.scenario_search_apps), style = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium), color = c.textSub)
}
BasicTextField(query, { query = it }, singleLine = true, textStyle = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium, color = c.text), cursorBrush = SolidColor(c.accent), modifier = Modifier.fillMaxWidth())
}
}
val (apps, frequent) = data ?: (emptyList<LaunchableApp>() to emptySet())
val shown = apps.filter { query.isBlank() || it.label.contains(query.trim(), ignoreCase = true) }
val top = if (query.isBlank()) shown.filter { it.pkg in frequent }.sortedBy { frequent.indexOf(it.pkg) } else emptyList()
val rest = shown - top.toSet()
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp))
{
if (top.isNotEmpty())
{
item { ShxSectionHeader(stringResource(R.string.scenario_often).uppercase(), Modifier.padding(horizontal = 6.dp)) }
item { AppList(top, chosen) { pkg -> chosen = if (pkg in chosen) chosen - pkg else chosen + pkg } }
item { ShxSectionHeader(stringResource(R.string.scenario_all_apps).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp)) }
}
item { AppList(rest, chosen) { pkg -> chosen = if (pkg in chosen) chosen - pkg else chosen + pkg } }
}
ShxButton(stringResource(R.string.scenario_done), { onDone(chosen) }, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp))
}
}
}
}
@Composable
private fun AppList(apps: List<LaunchableApp>, chosen: Set<String>, onToggle: (String) -> Unit)
{
val c = LocalShxColors.current
if (apps.isEmpty()) return
ShxPanel()
{
apps.forEachIndexed { i, app ->
val icon = remember(app.pkg) { app.icon?.let { runCatching { it.toBitmap(96, 96).asImageBitmap() }.getOrNull() } }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(60.dp).clickable { onToggle(app.pkg) }.padding(start = 16.dp, end = 8.dp),
)
{
Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(c.tile), contentAlignment = Alignment.Center)
{
if (icon != null) Image(icon, contentDescription = null, modifier = Modifier.size(36.dp))
else Text(app.label.take(1).uppercase(), style = ShxType.chip, color = c.textSoft)
}
Text(app.label, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Checkbox(app.pkg in chosen, { onToggle(app.pkg) }, colors = CheckboxDefaults.colors(checkedColor = c.accent))
}
if (i < apps.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
}
}
/** An action label inside the summary sentence: lower case, unless German (nouns) or it starts with an acronym like «VPN». */
private fun midSentence(label: String): String =
if (java.util.Locale.getDefault().language == "de" || label.getOrNull(1)?.isUpperCase() == true) label
else label.replaceFirstChar { it.lowercase() }
@@ -1,213 +0,0 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedTextField
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.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.ui.adapter.SelectedApplicationEntry
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
import java.util.TreeSet
class SelectedApplicationsActivity : AppCompatActivity()
{
private val selection = TreeSet<String>()
private var readOnly = false
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
readOnly = intent.getBooleanExtra(VpnProfileDataSource.KEY_READ_ONLY, false)
val initial = savedInstanceState?.getStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
?: intent.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
?: arrayListOf()
selection.addAll(initial)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true)
{
override fun handleOnBackPressed()
{
prepareResult()
finish()
}
})
setContent {
ShxTheme {
SelectedAppsScreen(
initial = selection,
readOnly = readOnly,
onBack = {
prepareResult()
finish()
},
onChange = {
selection.clear()
selection.addAll(it)
},
)
}
}
}
override fun onSaveInstanceState(outState: Bundle)
{
super.onSaveInstanceState(outState)
outState.putStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
}
private fun prepareResult()
{
val data = Intent()
data.putExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
setResult(RESULT_OK, data)
}
}
@Composable
private fun SelectedAppsScreen(
initial: Set<String>,
readOnly: Boolean,
onBack: () -> Unit,
onChange: (Set<String>) -> Unit,
)
{
val context = LocalContext.current
var query by remember { mutableStateOf("") }
var apps by remember { mutableStateOf<List<SelectedApplicationEntry>>(emptyList()) }
var selected by remember { mutableStateOf(initial.toSet()) }
var loading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
val loaded = withContext(Dispatchers.IO) {
val pm = context.packageManager
val list = ArrayList<SelectedApplicationEntry>()
for (info in pm.getInstalledApplications(PackageManager.GET_META_DATA))
{
if (pm.checkPermission(Manifest.permission.INTERNET, info.packageName) == PackageManager.PERMISSION_GRANTED)
{
val entry = SelectedApplicationEntry(pm, info)
entry.isSelected = selected.contains(info.packageName)
list.add(entry)
}
}
list.sorted()
}
apps = loaded
loading = false
}
fun toggle(pkg: String)
{
if (readOnly) return
selected = if (selected.contains(pkg)) selected - pkg else selected + pkg
onChange(selected)
}
/* pure system apps are hidden by default (they are rarely worth excluding); a selected one always shows */
var showSystem by remember { mutableStateOf(false) }
val filtered = remember(apps, query, showSystem, selected) {
apps.filter { entry ->
val flags = entry.info.flags
val system = (flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 &&
(flags and android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) == 0
(showSystem || !system || selected.contains(entry.info.packageName)) &&
(query.isBlank() || entry.toString().contains(query, ignoreCase = true) ||
entry.info.packageName.contains(query, ignoreCase = true))
}
}
SecondaryScaffold(title = stringResource(R.string.profile_select_apps), onBack = onBack) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text(stringResource(R.string.search)) },
singleLine = true,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm).fillMaxWidth(),
)
androidx.compose.foundation.layout.Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { showSystem = !showSystem }
.padding(horizontal = ShxSpacing.lg),
)
{
Text(stringResource(R.string.apps_show_system), modifier = Modifier.weight(1f))
androidx.compose.material3.Switch(checked = showSystem, onCheckedChange = null)
}
if (loading)
{
LoadingIndicator(Modifier.padding(ShxSpacing.xl).size(32.dp))
}
else
{
LazyColumn(Modifier.fillMaxSize()) {
items(filtered, key = { it.info.packageName }) { entry ->
val pkg = entry.info.packageName
val checked = selected.contains(pkg)
ListItem(
headlineContent = { Text(entry.toString()) },
supportingContent = { Text(pkg) },
leadingContent = {
val bmp = remember(pkg) { entry.icon.toBitmap() }
Image(bitmap = bmp.asImageBitmap(), contentDescription = null, modifier = Modifier.size(40.dp))
},
trailingContent = {
Checkbox(checked = checked, onCheckedChange = { toggle(pkg) }, enabled = !readOnly)
},
modifier = Modifier.clickable(enabled = !readOnly) { toggle(pkg) },
)
}
}
}
}
}
}
@@ -0,0 +1,818 @@
/*
* 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
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
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.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.data.VpnType
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.connectivity.ConnectivityProbe
import org.strongswan.android.logic.profiles.ProfileCheck
import org.strongswan.android.logic.profiles.ProfileExtra
import org.strongswan.android.logic.profiles.ProfileExtras
import org.strongswan.android.logic.profiles.ProfileSourceType
import org.strongswan.android.logic.profiles.ProfileUpdater
import org.strongswan.android.logic.profiles.needsPassword
import org.strongswan.android.logic.profiles.RESERVE_TIMEOUT_S
import org.strongswan.android.ui.compose.ProfileQr
import org.strongswan.android.ui.compose.ShareProfileScreen
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxDialog
import org.strongswan.android.ui.compose.design.ShxFormField
import org.strongswan.android.ui.compose.design.ShxIcons
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.ShxSwitch
import org.strongswan.android.ui.compose.home.serverCode
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.utils.Constants
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
/** One server (F_Profile): source and updates, server, resilience and network settings. */
class ServerActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val uuid = intent.getStringExtra(VpnProfileDataSource.KEY_UUID)
if (uuid == null)
{
finish()
return
}
val checkNow = intent.getBooleanExtra(EXTRA_CHECK_UPDATE, false)
setContent {
ShxTheme {
ServerScreen(uuid, checkNow, onBack = { finish() })
}
}
}
companion object
{
private const val EXTRA_CHECK_UPDATE = "org.strongswan.android.extra.CHECK_UPDATE"
@JvmStatic
@JvmOverloads
fun intent(context: Context, uuid: String, checkUpdate: Boolean = false): Intent =
Intent(context, ServerActivity::class.java)
.putExtra(VpnProfileDataSource.KEY_UUID, uuid)
.putExtra(EXTRA_CHECK_UPDATE, checkUpdate)
}
}
private fun loadProfiles(context: Context): List<VpnProfile>
{
val source = VpnProfileSource(context)
source.open()
try
{
return source.allVpnProfiles
}
finally
{
source.close()
}
}
/* which picker sheet is open */
private enum class Picker { RESERVE, BRIDGE }
@Composable
private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
var reloads by remember { mutableIntStateOf(0) }
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) reloads++ }
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
val profiles by produceState<List<VpnProfile>?>(null, reloads) { value = withContext(Dispatchers.IO) { loadProfiles(context) } }
val all = profiles ?: return
val profile = all.firstOrNull { it.getUUID().toString() == uuid }
if (profile == null)
{
LaunchedEffect(Unit) { onBack() }
return
}
val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
val extra = extras[uuid] ?: ProfileExtra(uuid)
/* the live state of the tunnel and a direct ping of the server */
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null, owner) {
/* only while the page is on screen */
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true)
{
val service = VpnStateService.getLiveInstance()
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
delay(1500)
}
}
}
val rtt by produceState<Long?>(null, profile.gateway, owner) {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true)
{
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
delay(30_000)
}
}
}
val active = vpn?.second == uuid
val connected = active && vpn?.first == VpnStateService.State.CONNECTED
var checking by remember { mutableStateOf(false) }
var check by remember { mutableStateOf<ProfileCheck?>(null) }
var showShare by remember { mutableStateOf(false) }
var showDelete by remember { mutableStateOf(false) }
var showLink by remember { mutableStateOf(false) }
var showPorts by remember { mutableStateOf(false) }
var picker by remember { mutableStateOf<Picker?>(null) }
val noChanges = stringResource(R.string.profile_check_none)
val failed = stringResource(R.string.profile_check_failed)
val updated = stringResource(R.string.profile_updated)
fun runCheck()
{
if (checking) return
checking = true
scope.launch {
val result = withContext(Dispatchers.IO) { runCatching { ProfileUpdater.check(context, uuid) } }
checking = false
result.onSuccess { if (it.changes.isEmpty()) snackbar.showSnackbar(noChanges) else check = it }
result.onFailure {
ProfileExtras.update(context, uuid) { e -> e.copy(checkedAt = System.currentTimeMillis(), lastChanges = -1) }
snackbar.showSnackbar(failed)
}
}
}
LaunchedEffect(checkOnOpen) { if (checkOnOpen && extra.source == ProfileSourceType.LINK) runCheck() }
fun edit() = context.startActivity(Intent(context, VpnProfileDetailActivity::class.java).putExtra(VpnProfileDataSource.KEY_UUID, uuid))
fun export() = scope.launch {
val file = withContext(Dispatchers.IO) { org.strongswan.android.logic.VpnProfileExporter.exportProfile(context, profile, false) }
if (file == null)
{
snackbar.showSnackbar(context.getString(R.string.share_profile_failed))
return@launch
}
val send = Intent(Intent.ACTION_SEND).setType(org.strongswan.android.data.ProfileExportProvider.MIME_TYPE).putExtra(Intent.EXTRA_STREAM, file)
try
{
context.startActivity(Intent.createChooser(send, context.getString(R.string.share_profile)))
}
catch (_: ActivityNotFoundException)
{
snackbar.showSnackbar(context.getString(R.string.share_profile_failed))
}
}
val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->
result.contents?.let { text -> ProfileQr.importIntent(context, text)?.let { context.startActivity(it) } }
}
Box(Modifier.fillMaxSize().background(c.background))
{
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()).navigationBarsPadding()
.padding(bottom = 24.dp),
)
{
/* header: back, «Share» pill */
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 8.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onBack))
{
Icon(ShxIcons.Back, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
}
Spacer(Modifier.weight(1f))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(c.accentContainer)
.clickable(role = Role.Button) { showShare = true }.padding(horizontal = 14.dp),
)
{
Icon(ShxIcons.Scan, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
Text(stringResource(R.string.server_share), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
}
}
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
/* who: avatar, name, state */
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 6.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(CircleShape).background(c.accent))
{
Text(serverCode(profile, null), style = ShxType.cardHeading.copy(fontSize = 18.sp), color = Color.White)
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
{
Text(profile.name ?: profile.gateway, style = ShxType.screenTitle, color = c.text)
val (dot, textColor, label) = when
{
connected -> Triple(c.success, c.onSuccess, rtt?.let { stringResource(R.string.server_state_connected_ms, it.toInt()) } ?: stringResource(R.string.server_state_connected))
active && vpn?.first == VpnStateService.State.CONNECTING -> Triple(c.accent, c.accentText, stringResource(R.string.server_state_connecting))
rtt != null -> Triple(c.textSub, c.textSub, stringResource(R.string.server_state_answers, rtt!!.toInt()))
else -> Triple(c.warning, c.onWarningContainer, stringResource(R.string.server_state_no_answer))
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
{
Box(Modifier.size(8.dp).clip(CircleShape).background(dot))
Text(label, style = ShxType.chip, color = textColor)
}
}
}
SourceSection(
extra = extra,
checking = checking,
onEditLink = { showLink = true },
onAuto = { on -> ProfileExtras.update(context, uuid) { it.copy(autoUpdate = on) } },
onCheck = ::runCheck,
onFile = { context.startActivity(Intent(context, VpnProfileImportActivity::class.java)) },
onQr = { scanner.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false).setOrientationLocked(true).setPrompt("")) },
)
val others = all.filter { it.getUUID().toString() != uuid }
fun nameOf(other: String?) = others.firstOrNull { it.getUUID().toString() == other }?.let { it.name ?: it.gateway }
/* a reserve or bridge that signs in with an unsaved password cannot be switched to */
fun lacksPassword(other: String?) = others.firstOrNull { it.getUUID().toString() == other }?.needsPassword() ?: false
Group(stringResource(R.string.server_group_server))
{
ValueRow(stringResource(R.string.server_addr), profile.gateway + (profile.port?.let { ":$it" } ?: ""), onClick = ::edit, divider = true)
ValueRow(stringResource(R.string.server_login), stringResource(loginLabel(profile.vpnType)), onClick = ::edit)
}
Group(stringResource(R.string.server_group_resilience))
{
ValueRow(
stringResource(R.string.server_ports), extra.backupPorts.joinToString(", ").ifEmpty { stringResource(R.string.server_none) },
sub = stringResource(R.string.server_ports_sub), onClick = { showPorts = true }, divider = true,
)
ValueRow(
stringResource(R.string.server_reserve), nameOf(extra.reserveUuid) ?: stringResource(R.string.server_none),
sub = stringResource(R.string.server_reserve_sub, RESERVE_TIMEOUT_S), onClick = { picker = Picker.RESERVE },
divider = !lacksPassword(extra.reserveUuid),
)
if (lacksPassword(extra.reserveUuid))
{
PasswordWarning(divider = true)
}
val bridgeName = nameOf(extra.bridgeUuid)
ToggleRow(
stringResource(R.string.server_bridge),
if (bridgeName != null) stringResource(R.string.server_bridge_sub, bridgeName) else stringResource(R.string.server_bridge_pick),
extra.bridgeEnabled && bridgeName != null,
) { on ->
if (on && bridgeName == null) picker = Picker.BRIDGE
else ProfileExtras.update(context, uuid) { it.copy(bridgeEnabled = on) }
}
if (extra.bridgeEnabled && lacksPassword(extra.bridgeUuid))
{
PasswordWarning(divider = false)
}
}
Group(stringResource(R.string.server_group_network))
{
ValueRow("DNS", profile.dnsServers?.takeIf { it.isNotBlank() }?.replace(" ", ", ") ?: stringResource(R.string.server_dns_default), onClick = ::edit, divider = true)
ValueRow("MTU", profile.mtu?.toString() ?: stringResource(R.string.server_auto_value), onClick = ::edit, divider = true)
ToggleRow(stringResource(R.string.server_fragmentation), null, extra.ikeFragmentation) { on ->
ProfileExtras.update(context, uuid) { it.copy(ikeFragmentation = on) }
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 4.dp))
{
ShxButton(stringResource(R.string.server_export), { export() }, style = ShxButtonStyle.OUTLINE, height = 52.dp, modifier = Modifier.weight(1f))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f).height(52.dp).clip(RoundedCornerShape(18.dp)).border(1.dp, c.errorBorder, RoundedCornerShape(18.dp))
.clickable(role = Role.Button) { showDelete = true },
)
{
Text(stringResource(R.string.server_delete_confirm), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.error)
}
}
}
}
ShxSnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
}
if (showShare)
{
ShareProfileScreen(profile, onDismiss = { showShare = false }, onSendFile = { export() })
}
check?.let { found ->
ProfileUpdateSheet(
name = profile.name ?: profile.gateway,
check = found,
onLater = { check = null },
onApply = {
check = null
scope.launch {
withContext(Dispatchers.IO) { ProfileUpdater.apply(context, found) }
reloads++
snackbar.showSnackbar(updated)
}
},
)
}
if (showDelete)
{
ShxDialog(
title = stringResource(R.string.server_delete_title, profile.name ?: profile.gateway),
paragraphs = listOfNotNull(
if (active && vpn?.first != VpnStateService.State.DISABLED) stringResource(R.string.server_delete_current) else null,
stringResource(R.string.server_delete_restore),
),
confirm = stringResource(R.string.server_delete_confirm),
dismiss = stringResource(android.R.string.cancel),
onConfirm = {
showDelete = false
scope.launch {
withContext(Dispatchers.IO) {
if (active) VpnStateService.getLiveInstance()?.disconnect()
val source = VpnProfileSource(context)
source.open()
try { source.deleteVpnProfile(profile) } finally { source.close() }
}
val intent = Intent(Constants.VPN_PROFILES_CHANGED).putExtra(Constants.VPN_PROFILES_MULTIPLE, arrayOf(uuid))
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
onBack()
}
},
onDismiss = { showDelete = false },
icon = ShxIcons.Trash,
danger = true,
)
}
if (showLink)
{
TextDialog(
title = stringResource(R.string.server_link_title),
label = stringResource(R.string.server_link_label),
initial = extra.sourceValue?.takeIf { extra.source == ProfileSourceType.LINK } ?: "https://",
keyboard = KeyboardType.Uri,
validate = { it.startsWith("https://") && it.length > 10 },
onDone = { link ->
showLink = false
ProfileExtras.update(context, uuid) { it.copy(source = ProfileSourceType.LINK, sourceValue = link.trim(), autoUpdate = true) }
runCheck()
},
onDismiss = { showLink = false },
)
}
if (showPorts)
{
TextDialog(
title = stringResource(R.string.server_ports),
label = stringResource(R.string.server_ports_label),
initial = extra.backupPorts.joinToString(", "),
keyboard = KeyboardType.Number,
validate = { text -> parsePorts(text) != null },
onDone = { text ->
showPorts = false
ProfileExtras.update(context, uuid) { it.copy(backupPorts = parsePorts(text).orEmpty()) }
},
onDismiss = { showPorts = false },
)
}
picker?.let { which ->
val others = all.filter { it.getUUID().toString() != uuid }
ShxSheet(onDismiss = { picker = null })
{
ShxSheetTitle(stringResource(if (which == Picker.RESERVE) R.string.server_reserve else R.string.server_bridge_title))
Text(
stringResource(if (which == Picker.RESERVE) R.string.server_reserve_pick_note else R.string.server_bridge_pick_note),
style = ShxType.note.copy(fontWeight = FontWeight.Normal, lineHeight = 19.sp), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp),
)
ShxPanel()
{
val current = if (which == Picker.RESERVE) extra.reserveUuid else extra.bridgeUuid
val rows = listOf<VpnProfile?>(null) + others
rows.forEachIndexed { i, other ->
val id = other?.getUUID()?.toString()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button) {
picker = null
ProfileExtras.update(context, uuid) {
if (which == Picker.RESERVE) it.copy(reserveUuid = id) else it.copy(bridgeUuid = id, bridgeEnabled = id != null)
}
}.padding(horizontal = 16.dp),
)
{
Text(other?.let { it.name ?: it.gateway } ?: stringResource(R.string.server_none), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
if (current == id)
{
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
}
}
if (i < rows.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
}
}
}
}
/** «No saved password» note under a reserve / bridge row. */
@Composable
private fun PasswordWarning(divider: Boolean)
{
val c = LocalShxColors.current
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 14.dp, bottom = 12.dp),
)
{
Icon(ShxIcons.Alert, contentDescription = null, tint = c.onWarningContainer, modifier = Modifier.size(16.dp))
Text(stringResource(R.string.server_needs_password), style = ShxType.meta.copy(fontWeight = FontWeight.SemiBold, lineHeight = 16.sp), color = c.onWarningContainer)
}
if (divider)
{
Divider()
}
}
/** «443, 53» → [443, 53]; null when something is not a port. */
internal fun parsePorts(text: String): List<Int>?
{
val parts = text.split(',', ' ', ';').map { it.trim() }.filter { it.isNotEmpty() }
val ports = parts.map { it.toIntOrNull()?.takeIf { p -> p in 1..65535 } ?: return null }
return ports.distinct()
}
private fun loginLabel(type: VpnType): Int = when (type)
{
VpnType.IKEV2_CERT, VpnType.IKEV2_EAP_TLS -> R.string.server_login_cert
VpnType.IKEV2_CERT_EAP -> R.string.server_login_both
else -> R.string.server_login_password
}
@Composable
private fun SourceSection(
extra: ProfileExtra,
checking: Boolean,
onEditLink: () -> Unit,
onAuto: (Boolean) -> Unit,
onCheck: () -> Unit,
onFile: () -> Unit,
onQr: () -> Unit,
)
{
val c = LocalShxColors.current
val date = if (extra.addedAt > 0) SimpleDateFormat("d MMM", Locale.getDefault()).format(Date(extra.addedAt)) else null
val type = if (extra.source == ProfileSourceType.UNKNOWN) ProfileSourceType.FILE else extra.source
ShxSectionHeader(stringResource(R.string.server_source).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
ShxPanel()
{
val (icon, label, value) = when (type)
{
ProfileSourceType.LINK -> Triple(ShxIcons.Globe, stringResource(R.string.server_source_link), extra.sourceValue.orEmpty().removePrefix("https://"))
ProfileSourceType.QR -> Triple(ShxIcons.Scan, stringResource(R.string.server_source_qr), date?.let { stringResource(R.string.server_source_scanned, it) } ?: "")
ProfileSourceType.MANUAL -> Triple(ShxIcons.Sliders, stringResource(R.string.server_source_manual), date?.let { stringResource(R.string.server_source_created, it) } ?: "")
else -> Triple(
ShxIcons.Doc, stringResource(R.string.server_source_file),
listOfNotNull(extra.sourceValue, date?.let { stringResource(R.string.server_source_added, it) }).joinToString(" · ")
.ifEmpty { stringResource(R.string.server_source_earlier) },
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
.then(if (type == ProfileSourceType.LINK) Modifier.clickable(role = Role.Button, onClick = onEditLink) else Modifier)
.padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(c.tile))
{
Icon(icon, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(label, style = ShxType.chip, color = c.textSub)
Text(
value, maxLines = 1, overflow = TextOverflow.Ellipsis,
style = ShxType.body.copy(fontWeight = FontWeight.Bold, fontFamily = if (type == ProfileSourceType.LINK) FontFamily.Monospace else ShxType.body.fontFamily),
color = c.text,
)
}
if (type == ProfileSourceType.LINK)
{
Icon(ShxIcons.Sliders, contentDescription = stringResource(R.string.server_link_title), tint = c.textSub, modifier = Modifier.size(18.dp))
}
}
Divider()
if (type == ProfileSourceType.LINK)
{
val now = System.currentTimeMillis()
val checked = when
{
extra.checkedAt <= 0 -> null
now - extra.checkedAt < DateUtils.MINUTE_IN_MILLIS -> stringResource(R.string.server_just_now)
else -> DateUtils.getRelativeTimeSpanString(extra.checkedAt, now, DateUtils.MINUTE_IN_MILLIS).toString()
}
val result = when
{
extra.lastChanges < 0 -> stringResource(R.string.server_auto_failed)
extra.lastChanges > 0 -> stringResource(R.string.server_auto_changes, extra.lastChanges)
else -> stringResource(R.string.server_auto_same)
}
ToggleRow(
stringResource(R.string.server_auto),
listOfNotNull(stringResource(R.string.server_auto_daily), checked?.let { stringResource(R.string.server_auto_checked, it) }, if (checked != null) result else null).joinToString(" · "),
extra.autoUpdate, onAuto,
)
Divider()
}
else
{
Text(
stringResource(
when (type)
{
ProfileSourceType.QR -> R.string.server_note_qr
ProfileSourceType.MANUAL -> R.string.server_note_manual
else -> R.string.server_note_file
}
),
style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
if (type != ProfileSourceType.MANUAL)
{
Divider()
}
}
if (type != ProfileSourceType.MANUAL)
{
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min).padding(horizontal = 14.dp, vertical = 12.dp))
{
when (type)
{
ProfileSourceType.LINK -> SoftButton(stringResource(if (checking) R.string.server_checking else R.string.server_check_now), onCheck, Modifier.weight(1f))
ProfileSourceType.QR -> SoftButton(stringResource(R.string.server_replace_qr), onQr, Modifier.weight(1f))
else ->
{
SoftButton(stringResource(R.string.server_replace_file), onFile, Modifier.weight(1f))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(1f).heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).border(1.dp, c.outline, RoundedCornerShape(14.dp))
.clickable(role = Role.Button, onClick = onEditLink).padding(horizontal = 10.dp, vertical = 6.dp),
)
{
Text(stringResource(R.string.server_set_link), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
}
}
}
}
}
}
}
@Composable
private fun SoftButton(text: String, onClick: () -> Unit, modifier: Modifier)
{
val c = LocalShxColors.current
Box(
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).background(c.accentMuted).clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
)
{
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = c.textTonal, maxLines = 2, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
}
}
@Composable
private fun Divider()
{
Box(Modifier.fillMaxWidth().height(1.dp).background(LocalShxColors.current.tile))
}
@Composable
private fun Group(title: String, content: @Composable ColumnScope.() -> Unit)
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
ShxSectionHeader(title.uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
ShxPanel(content = content)
}
}
@Composable
private fun ValueRow(title: String, value: String, sub: String? = null, onClick: () -> Unit, divider: Boolean = false)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
sub?.let { Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub) }
}
Text(value, style = ShxType.body, color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(0.8f, fill = false))
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
if (divider)
{
Divider()
}
}
@Composable
private fun ToggleRow(title: String, sub: String?, checked: Boolean, onChange: (Boolean) -> Unit)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
sub?.let { Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub) }
}
ShxSwitch(checked, onChange)
}
}
@Composable
private fun TextDialog(
title: String,
label: String,
initial: String,
keyboard: KeyboardType,
validate: (String) -> Boolean,
onDone: (String) -> Unit,
onDismiss: () -> Unit,
)
{
var text by remember { mutableStateOf(initial) }
ShxAlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
ShxFormField(label = label, value = text, onValueChange = { text = it }, keyboardOptions = KeyboardOptions(keyboardType = keyboard))
},
confirmButton = { ShxDialogButton(primary = true, onClick = { onDone(text) }, enabled = validate(text.trim())) { Text(stringResource(R.string.server_save)) } },
dismissButton = { ShxDialogButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
)
}
/** «The profile has changes» (F_ProfileUpdate): old → new per field, «Later» / «Apply and reconnect». */
@Composable
private fun ProfileUpdateSheet(name: String, check: ProfileCheck, onLater: () -> Unit, onApply: () -> Unit)
{
val c = LocalShxColors.current
ShxSheet(onDismiss = onLater)
{
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(horizontal = 4.dp))
{
Text(stringResource(R.string.profile_update_title), style = ShxType.sheetTitle, color = c.text)
Text(stringResource(R.string.profile_update_sub, name), style = ShxType.body.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
ShxPanel()
{
check.changes.forEachIndexed { i, change ->
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp))
{
Text(change.label, style = ShxType.chip, color = c.textSub)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
{
val mono = ShxType.body.copy(fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace)
Text(change.old, style = mono.copy(textDecoration = TextDecoration.LineThrough), color = c.iconMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(16.dp))
Text(change.new, style = mono, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
}
}
if (i < check.changes.lastIndex)
{
Divider()
}
}
}
Row(
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.profile_update_keep), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onSuccess)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.cert_later), onLater, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.profile_update_apply), onApply, modifier = Modifier.weight(2f))
}
}
}
}
@@ -55,6 +55,7 @@ class SettingsActivity : AppCompatActivity()
Box(Modifier.padding(padding)) {
SettingsScreen(profiles = profiles, showTitle = false)
}
org.strongswan.android.ui.compose.UpdateSheetHost()
}
}
}
@@ -0,0 +1,370 @@
/*
* 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
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.Image
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.logic.routing.AppTraffic
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.stats.DayTraffic
import org.strongswan.android.logic.stats.TrafficHistory
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxTabs
import org.strongswan.android.ui.compose.formatBytes
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.ui.compose.theme.ShxType
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
/** «Statistics» (F_Stats / F_StatsEmpty): traffic per day, VPN vs direct, top apps. */
class StatsActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
StatsScreen(onBack = { finish() })
}
}
}
}
private enum class Period(val label: Int) { DAY(R.string.stats_day), WEEK(R.string.stats_week), MONTH(R.string.stats_month) }
/** One app row: label, icon, bytes and whether it goes direct. */
private class AppRow(val label: String, val icon: android.graphics.drawable.Drawable?, val bytes: Long, val direct: Boolean)
private class StatsData(val days: List<DayTraffic>, val title: String, val labels: List<String>, val highlight: Int, val first: String?, val apps: List<AppRow>, val usage: Boolean)
private fun startOfDay(ms: Long): Long = Calendar.getInstance().apply {
timeInMillis = ms; set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
}.timeInMillis
private fun load(context: Context, period: Period): StatsData
{
val now = System.currentTimeMillis()
val day = 24L * 60 * 60 * 1000
val cal = Calendar.getInstance()
val (from, to) = when (period)
{
/* the day view still shows the last week, today highlighted */
Period.DAY -> startOfDay(now) - 6 * day to now
Period.WEEK ->
{
cal.timeInMillis = startOfDay(now)
cal.firstDayOfWeek = Calendar.MONDAY
cal.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
if (cal.timeInMillis > now) cal.add(Calendar.WEEK_OF_YEAR, -1)
cal.timeInMillis to cal.timeInMillis + 6 * day
}
Period.MONTH ->
{
cal.timeInMillis = startOfDay(now)
cal.set(Calendar.DAY_OF_MONTH, 1)
val start = cal.timeInMillis
start to start + (cal.getActualMaximum(Calendar.DAY_OF_MONTH) - 1) * day
}
}
val days = TrafficHistory.range(context, from, to)
val dow = SimpleDateFormat("EEEEEE", Locale.getDefault())
val parse = SimpleDateFormat("yyyy-MM-dd", Locale.US)
val labels = days.mapIndexed { i, d ->
val date = parse.parse(d.day)!!
when (period)
{
Period.MONTH -> if (i % 5 == 0) (i + 1).toString() else ""
else -> dow.format(date).replaceFirstChar { it.uppercase() }
}
}
val today = TrafficHistory.dayKey(now)
val highlight = days.indexOfFirst { it.day == today }
val title = when (period)
{
Period.DAY -> context.getString(R.string.stats_today)
Period.WEEK ->
{
val f = SimpleDateFormat("d MMMM", Locale.getDefault())
val a = parse.parse(days.first().day)!!
val b = parse.parse(days.last().day)!!
SimpleDateFormat("d", Locale.getDefault()).format(a) + " – " + f.format(b)
}
Period.MONTH -> SimpleDateFormat("LLLL yyyy", Locale.getDefault()).format(parse.parse(days.first().day)!!).replaceFirstChar { it.uppercase() }
}
/* apps: system counters over the same period (needs usage access) */
val usage = AppTraffic.hasAccess(context)
val since = if (period == Period.DAY) startOfDay(now) else from
val direct = runCatching { RoutingManager.getInstance(context).computeExcludedPackages() }.getOrDefault(emptySet())
val pm = context.packageManager
val apps = if (!usage) emptyList() else AppTraffic.since(context, since).entries.sortedByDescending { it.value }.take(5).map { (pkg, bytes) ->
val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull()
AppRow(info?.let { pm.getApplicationLabel(it).toString() } ?: pkg, info?.let { runCatching { pm.getApplicationIcon(it) }.getOrNull() }, bytes, pkg in direct)
}
return StatsData(days, title, labels, highlight, TrafficHistory.firstDay(context), apps, usage)
}
@Composable
private fun StatsScreen(onBack: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
var period by rememberSaveable { mutableIntStateOf(Period.WEEK.ordinal) }
val changes by TrafficHistory.changes.collectAsStateWithLifecycle()
val data by produceState<StatsData?>(null, period, changes) {
value = withContext(Dispatchers.IO) { load(context, Period.entries[period]) }
}
SecondaryScaffold(title = stringResource(R.string.stats_title), onBack = onBack) { padding ->
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp),
)
{
ShxTabs(Period.entries.map { stringResource(it.label) }, period, { period = it })
val d = data ?: return@Column
val today = TrafficHistory.dayKey(System.currentTimeMillis())
/* F_StatsEmpty: nothing older than today yet */
val fresh = d.first == null || d.first >= today
val shown = if (Period.entries[period] == Period.DAY) d.days.filter { it.day == today } else d.days
val vpn = shown.sumOf { it.vpn }
val direct = shown.sumOf { it.direct }
val shape = RoundedCornerShape(24.dp)
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(18.dp),
)
{
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(d.title, style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.textSub)
Text(formatBytes(vpn + direct), style = ShxType.number.copy(fontSize = 32.sp, lineHeight = 36.sp), color = c.text)
}
Bars(d, fresh, today)
if (!fresh)
{
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Tile(stringResource(R.string.stats_vpn), formatBytes(vpn), c.accentContainer, c.onAccentContainer, Modifier.weight(1f))
Tile(stringResource(R.string.stats_direct), formatBytes(direct), c.tile, c.textSoft, Modifier.weight(1f))
}
}
}
if (fresh)
{
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(c.accentSoft).padding(16.dp),
)
{
Icon(ShxIcons.Stats, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(22.dp))
Column(verticalArrangement = Arrangement.spacedBy(4.dp))
{
Text(stringResource(R.string.stats_fresh_title), style = ShxType.cardTitle, color = c.text)
Text(stringResource(R.string.stats_fresh_text), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onAccentContainer)
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp))
{
Text(stringResource(R.string.stats_apps), style = ShxType.cardTitle, color = c.text, modifier = Modifier.padding(horizontal = 4.dp))
val panel = RoundedCornerShape(22.dp)
Column(Modifier.fillMaxWidth().clip(panel).background(c.card).border(1.dp, c.cardBorder, panel).padding(horizontal = 14.dp, vertical = 4.dp))
{
when
{
!d.usage -> Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) {
if (runCatching { context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS, Uri.fromParts("package", context.packageName, null))) }.isFailure)
{
runCatching { context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) }
}
}.padding(vertical = 14.dp),
)
{
Text(stringResource(R.string.stats_apps_access), style = ShxType.note.copy(fontWeight = FontWeight.Normal), color = c.textSoft, modifier = Modifier.weight(1f))
Text(stringResource(R.string.perm_allow), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.accentText)
}
d.apps.isEmpty() -> Text(stringResource(R.string.stats_apps_none), style = ShxType.note, color = c.textSub, modifier = Modifier.padding(vertical = 14.dp))
else ->
{
val max = d.apps.maxOf { it.bytes }.coerceAtLeast(1)
d.apps.forEach { AppLine(it, it.bytes.toFloat() / max) }
}
}
}
}
}
}
}
@Composable
private fun Bars(d: StatsData, fresh: Boolean, today: String)
{
val c = LocalShxColors.current
val max = d.days.maxOf { it.total }.coerceAtLeast(1)
val month = d.days.size > 7
val outline = c.outline
Row(
horizontalArrangement = Arrangement.spacedBy(if (month) 3.dp else 10.dp),
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth().height(if (fresh) 140.dp else 170.dp),
)
{
d.days.forEachIndexed { i, day ->
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f))
{
Column(Modifier.weight(1f).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.Bottom))
{
val before = d.first == null || day.day < d.first
if (day.day > today)
{ /* days still ahead: nothing to draw */
}
else if (before)
{ /* no history yet: a dashed placeholder as on F_StatsEmpty */
Box(
Modifier.fillMaxWidth().height(34.dp).drawBehind {
drawRoundRect(
outline, cornerRadius = CornerRadius(6.dp.toPx()),
style = Stroke(1.5.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 6f))),
)
},
)
}
else
{
val full = 150f
val directH = (day.direct.toFloat() / max * full).coerceAtLeast(if (day.direct > 0) 3f else 0f)
val vpnH = (day.vpn.toFloat() / max * full).coerceAtLeast(if (day.vpn > 0 || day.direct == 0L) 4f else 0f)
if (directH > 0f)
{
Box(Modifier.fillMaxWidth().height(directH.dp).clip(RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 2.dp, bottomEnd = 2.dp)).background(outline))
}
Box(
Modifier.fillMaxWidth().height(vpnH.dp)
.clip(if (directH > 0f) RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp, bottomStart = 6.dp, bottomEnd = 6.dp) else RoundedCornerShape(6.dp))
.background(if (fresh && day.day == today) c.accent else if (fresh) c.tile else c.accent),
)
}
}
Text(
d.labels[i], style = ShxType.meta.copy(fontWeight = if (i == d.highlight) FontWeight.ExtraBold else FontWeight.SemiBold),
color = if (i == d.highlight) c.text else c.textSub, maxLines = 1, softWrap = false,
/* two-digit dates of the month view are wider than their thin bar */
modifier = Modifier.wrapContentWidth(unbounded = true),
)
}
}
}
}
@Composable
private fun Tile(label: String, value: String, bg: Color, fg: Color, modifier: Modifier)
{
val c = LocalShxColors.current
Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = modifier.clip(RoundedCornerShape(16.dp)).background(bg).padding(12.dp))
{
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = fg)
Text(value, style = ShxType.cardHeading.copy(fontSize = 20.sp), color = c.text)
}
}
@Composable
private fun AppLine(app: AppRow, share: Float)
{
val c = LocalShxColors.current
val icon = remember(app) { app.icon?.toBitmap(96, 96)?.asImageBitmap() }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(c.tile))
{
if (icon != null)
{
Image(icon, contentDescription = null, modifier = Modifier.size(36.dp))
}
else
{
Text(app.label.take(1).uppercase(), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.textSoft)
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp))
{
Row()
{
Text(app.label, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Spacer(Modifier.size(8.dp))
Text(formatBytes(app.bytes), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
}
Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.tile))
{
/* purple: through the VPN, grey: goes direct */
Box(Modifier.fillMaxWidth(share.coerceIn(0.02f, 1f)).height(6.dp).clip(RoundedCornerShape(3.dp)).background(if (app.direct) c.textSub else c.accent))
}
}
}
}
@@ -25,9 +25,8 @@ import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.AlertDialog
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -38,6 +37,7 @@ import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.ui.compose.theme.ShxTheme
@@ -175,12 +175,12 @@ class TrustedCertificateImportActivity : AppCompatActivity()
@androidx.compose.runtime.Composable
private fun ConfirmImportUi(certificate: X509Certificate)
{
AlertDialog(
ShxAlertDialog(
onDismissRequest = { finish() },
title = { Text(stringResource(R.string.import_certificate)) },
text = { Text(certificate.getSubjectDN().toString()) },
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
if (storeCertificate(certificate))
{
Toast.makeText(this, R.string.cert_imported_successfully, Toast.LENGTH_LONG).show()
@@ -194,7 +194,7 @@ class TrustedCertificateImportActivity : AppCompatActivity()
}) { Text(stringResource(R.string.import_certificate)) }
},
dismissButton = {
TextButton(onClick = { finish() }) {
ShxDialogButton(onClick = { finish() }) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -9,6 +9,7 @@
package org.strongswan.android.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -16,24 +17,23 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -42,22 +42,40 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource
import org.strongswan.android.security.TrustedCertificateEntry
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.design.ShxDialog
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxTabs
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.ui.compose.theme.ShxType
import java.beans.PropertyChangeListener
import java.security.KeyStore
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
class TrustedCertificatesActivity : AppCompatActivity()
{
@@ -86,7 +104,43 @@ class TrustedCertificatesActivity : AppCompatActivity()
}
}
@OptIn(ExperimentalMaterial3Api::class)
/** A CA certificate as a row of F_Certs: who uses it and how long it is valid. */
private data class CertRow(val entry: TrustedCertificateEntry, val usedBy: List<String>)
/* the three tabs of F_Certs */
private enum class CertTab { PROFILES, ADDED, SYSTEM }
/** Days a certificate counts as «expires soon» (amber). */
internal const val CERT_SOON_DAYS = 30L
/**
* The CA certificates the profiles point to, with the names of those
* profiles. Profiles that pick the CA automatically are not listed.
*/
internal fun profileCertificates(context: Context): Map<String, List<String>>
{
val source = VpnProfileSource(context)
source.open()
try
{
return source.allVpnProfiles.mapNotNull { p -> p.certificateAlias?.let { it to (p.name ?: p.gateway) } }
.groupBy({ it.first }, { it.second })
}
finally
{
source.close()
}
}
/** For the Settings list: how many CAs come from profiles and how many of them expire soon. */
internal fun certificateSummary(context: Context): Pair<Int, Int>
{
val manager = TrustedCertificateManager.getInstance().load()
val certs = profileCertificates(context).keys.mapNotNull { manager.getCACertificateFromAlias(it) }
val soon = certs.count { TimeUnit.MILLISECONDS.toDays(it.notAfter.time - System.currentTimeMillis()) <= CERT_SOON_DAYS }
return certs.size to soon
}
@Composable
private fun CertificatesScreen(
selectMode: Boolean,
@@ -95,28 +149,28 @@ private fun CertificatesScreen(
)
{
val context = LocalContext.current
val sources = remember {
listOf(
TrustedCertificateSource.SYSTEM to context.getString(R.string.system_tab),
TrustedCertificateSource.USER to context.getString(R.string.user_tab),
TrustedCertificateSource.LOCAL to context.getString(R.string.local_tab),
)
}
val c = LocalShxColors.current
var tab by remember { mutableIntStateOf(0) }
var entries by remember { mutableStateOf(listOf<TrustedCertificateEntry>()) }
var rows by remember { mutableStateOf(listOf<CertRow>()) }
var profileCount by remember { mutableIntStateOf(0) }
var pendingDelete by remember { mutableStateOf<TrustedCertificateEntry?>(null) }
val scope = rememberCoroutineScope()
val allowImport = StrongSwanApplication.getInstance().managedConfigurationService.managedConfiguration.isAllowCertificateImport
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == android.app.Activity.RESULT_OK)
{
scope.launch { reload(sources[tab].first) { entries = it } }
}
}
fun load()
{
scope.launch { reload(sources[tab].first) { entries = it } }
scope.launch {
val (list, count) = withContext(Dispatchers.IO) { loadRows(context, CertTab.entries[tab]) }
rows = list
profileCount = count
}
}
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == android.app.Activity.RESULT_OK)
{
load()
}
}
DisposableEffect(tab) {
@@ -126,83 +180,155 @@ private fun CertificatesScreen(
onDispose { TrustedCertificateManager.getInstance().deleteObserver(observer) }
}
SecondaryScaffold(
title = stringResource(R.string.trusted_certs_title),
onBack = onBack,
actions = {
SecondaryScaffold(title = stringResource(R.string.trusted_certs_title), onBack = onBack) { padding ->
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
)
{
Text(
stringResource(R.string.certs_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal),
color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp),
)
ShxTabs(
listOf(
stringResource(R.string.certs_tab_profiles, profileCount),
stringResource(R.string.certs_tab_added),
stringResource(R.string.certs_tab_system),
),
tab, { tab = it },
)
if (rows.isEmpty())
{
Text(
stringResource(if (tab == 0) R.string.certs_none_profiles else R.string.certs_none),
style = ShxType.sub, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
)
}
else
{
ShxPanel()
{
rows.forEachIndexed { i, row ->
CertItem(row, onClick = {
when
{
selectMode -> onPicked(row.entry.alias)
CertTab.entries[tab] == CertTab.ADDED -> pendingDelete = row.entry
}
})
if (i < rows.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
}
}
if (allowImport)
{
IconButton(onClick = {
importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java))
}) { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.import_certificate)) }
}
IconButton(onClick = {
scope.launch {
withContext(Dispatchers.IO) {
TrustedCertificateManager.getInstance().reset().load()
}
reload(sources[tab].first) { entries = it }
}
}) { Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.reload_trusted_certs)) }
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
SecondaryTabRow(selectedTabIndex = tab) {
sources.forEachIndexed { index, pair ->
Tab(selected = tab == index, onClick = { tab = index }, text = { Text(pair.second) })
}
}
LazyColumn(Modifier.fillMaxSize()) {
items(entries, key = { it.alias }) { entry ->
ListItem(
headlineContent = { Text(entry.subjectPrimary) },
supportingContent = { Text(entry.subjectSecondary.ifEmpty { entry.alias }) },
modifier = Modifier.clickable {
if (selectMode) onPicked(entry.alias)
else if (sources[tab].first == TrustedCertificateSource.LOCAL) pendingDelete = entry
},
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(18.dp))
.border(1.dp, c.outline, RoundedCornerShape(18.dp))
.clickable(role = Role.Button) { importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java)) },
)
{
Icon(ShxIcons.Plus, contentDescription = null, tint = c.text, modifier = Modifier.size(18.dp))
Text(stringResource(R.string.certs_add), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
}
}
Text(
stringResource(R.string.certs_system_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp),
color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp),
)
}
}
pendingDelete?.let { entry ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.delete_certificate_question)) },
text = { Text(entry.subjectPrimary) },
confirmButton = {
TextButton(onClick = {
try
{
val store = KeyStore.getInstance("LocalCertificateStore")
store.load(null, null)
store.deleteEntry(entry.alias)
TrustedCertificateManager.getInstance().reset()
}
catch (_: Exception)
{
}
pendingDelete = null
load()
}) { Text(stringResource(R.string.delete_profile)) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text(stringResource(android.R.string.cancel))
ShxDialog(
title = stringResource(R.string.delete_certificate_question),
paragraphs = listOf(entry.subjectPrimary),
confirm = stringResource(R.string.server_delete_confirm),
dismiss = stringResource(android.R.string.cancel),
onConfirm = {
try
{
val store = KeyStore.getInstance("LocalCertificateStore")
store.load(null, null)
store.deleteEntry(entry.alias)
TrustedCertificateManager.getInstance().reset()
}
catch (_: Exception)
{
}
pendingDelete = null
load()
},
onDismiss = { pendingDelete = null },
icon = ShxIcons.Trash,
danger = true,
)
}
}
private suspend fun reload(source: TrustedCertificateSource, onResult: (List<TrustedCertificateEntry>) -> Unit)
@Composable
private fun CertItem(row: CertRow, onClick: () -> Unit)
{
val list = withContext(Dispatchers.IO) {
val manager = TrustedCertificateManager.getInstance().load()
manager.getCACertificates(source).map { (alias, cert) -> TrustedCertificateEntry(alias, cert) }
.sorted()
val c = LocalShxColors.current
val notAfter = row.entry.certificate.notAfter
val days = TimeUnit.MILLISECONDS.toDays(notAfter.time - System.currentTimeMillis())
val date = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()).format(notAfter)
/* green while valid, amber when it expires soon, red when expired */
val (bg, fg, expColor, expText) = when
{
days < 0 -> Look(c.errorContainer, c.error, c.error, stringResource(R.string.certs_expired, date))
days <= CERT_SOON_DAYS -> Look(c.warningStrong, c.onWarning, c.warning, stringResource(R.string.certs_expires_in, days.toInt(), date))
else -> Look(c.successContainer, c.onSuccess, c.onSuccess, stringResource(R.string.certs_valid_until, SimpleDateFormat("MM.yyyy", Locale.getDefault()).format(notAfter)))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(bg))
{
Icon(ShxIcons.Cert, contentDescription = null, tint = fg, modifier = Modifier.size(20.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(row.entry.subjectPrimary, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
val used = if (row.usedBy.isNotEmpty()) stringResource(R.string.certs_used_by, row.usedBy.joinToString(", "))
else row.entry.subjectSecondary
if (used.isNotEmpty())
{
Text(used, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
Text(expText, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = expColor)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
onResult(list)
}
private data class Look(val bg: Color, val fg: Color, val exp: Color, val text: String)
/** Rows of [tab] and the number of CAs used by profiles (shown on the first tab). */
private fun loadRows(context: Context, tab: CertTab): Pair<List<CertRow>, Int>
{
val manager = TrustedCertificateManager.getInstance().load()
val usage = profileCertificates(context)
val fromProfiles = usage.mapNotNull { (alias, names) ->
manager.getCACertificateFromAlias(alias)?.let { CertRow(TrustedCertificateEntry(alias, it), names) }
}.sortedBy { it.entry }
val rows = when (tab)
{
CertTab.PROFILES -> fromProfiles
CertTab.ADDED -> manager.getCACertificates(TrustedCertificateSource.LOCAL)
.map { (alias, cert) -> CertRow(TrustedCertificateEntry(alias, cert), usage[alias].orEmpty()) }.sortedBy { it.entry }
CertTab.SYSTEM -> (manager.getCACertificates(TrustedCertificateSource.SYSTEM) + manager.getCACertificates(TrustedCertificateSource.USER))
.map { (alias, cert) -> CertRow(TrustedCertificateEntry(alias, cert), usage[alias].orEmpty()) }.sortedBy { it.entry }
}
return rows to fromProfiles.size
}
@@ -1,70 +0,0 @@
/*
* 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.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.autoconnect.AutoConnectManager
import org.strongswan.android.logic.autoconnect.SsidReader
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.TrustedNetworksScreen
import org.strongswan.android.ui.compose.theme.ShxTheme
class TrustedNetworksActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
AutoConnectManager.getInstance(this).reloadConfig()
val currentSsid = SsidReader.from(this)
setContent {
ShxTheme {
SecondaryScaffold(title = stringResource(R.string.auto_connect_title), onBack = { finish() }) { padding ->
Box(Modifier.padding(padding)) {
/* profile list comes from SQLite, load off the main thread */
val profiles by produceState<List<VpnProfile>>(emptyList()) {
value = withContext(Dispatchers.IO) {
val source = VpnProfileSource(applicationContext).open()
try
{
source.allVpnProfiles
}
finally
{
source.close()
}
}
}
TrustedNetworksScreen(profiles = profiles, currentSsid = currentSsid)
}
}
}
}
}
}
@@ -79,6 +79,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
private Bundle mProfileInfo;
private boolean mWaitingForResult;
private VpnStateService mService;
private boolean mBound;
/* profile lookups are SQLite queries and must not block the main thread */
private final ExecutorService mProfileExecutor = Executors.newSingleThreadExecutor();
private final ServiceConnection mServiceConnection = new ServiceConnection()
@@ -101,6 +102,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
new ActivityResultContracts.StartActivityForResult(),
result -> {
mWaitingForResult = false;
VpnDialogBus.INSTANCE.setConsentHint(false);
if (result.getResultCode() == RESULT_OK && mProfileInfo != null)
{
onVpnServicePrepared();
@@ -140,8 +142,8 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
{
mWaitingForResult = savedInstanceState.getBoolean(WAITING_FOR_RESULT, false);
}
this.bindService(new Intent(this, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
mBound = this.bindService(new Intent(this, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
VpnDialogBus.INSTANCE.setListener(this);
VpnDialogsKt.installVpnDialogHost(this);
}
@@ -159,10 +161,12 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
super.onDestroy();
VpnDialogBus.INSTANCE.setListener(null);
mProfileExecutor.shutdown();
if (mService != null)
if (mBound)
{
this.unbindService(mServiceConnection);
mBound = false;
}
mService = null;
}
/**
@@ -221,6 +225,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
try
{
mWaitingForResult = true;
VpnDialogBus.INSTANCE.setConsentHint(true);
mPrepareVpnService.launch(intent);
}
catch (ActivityNotFoundException ex)
@@ -229,6 +234,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
* don't have the VPN components built into the system image.
* com.android.vpndialogs/com.android.vpndialogs.ConfirmDialog
* will not be found then */
VpnDialogBus.INSTANCE.setConsentHint(false);
showNotSupported(R.string.vpn_not_supported);
mWaitingForResult = false;
}
@@ -429,6 +435,10 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
mProfileExecutor.execute(() -> {
VpnProfile profile = loadProfile(uuid);
runOnUiThread(() -> {
if (isFinishing() || isDestroyed())
{ /* rotated or finished while the query ran */
return;
}
if (profile != null)
{
startVpnProfile(profile);
@@ -479,7 +489,12 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
final Intent startIntent = intent;
mProfileExecutor.execute(() -> {
VpnProfile profile = loadProfile(uuid);
runOnUiThread(() -> handleDisconnect(startIntent, profile));
runOnUiThread(() -> {
if (!isFinishing() && !isDestroyed())
{
handleDisconnect(startIntent, profile);
}
});
});
}
@@ -521,11 +536,13 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
String action = normalizeAction(intent);
if (START_PROFILE.equals(action))
{
{ /* app, tile, widget, shortcut: a hand action wins over a scenario */
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(this);
startVpnProfile(intent);
}
else if (DISCONNECT.equals(action))
{
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(this);
disconnect(intent);
}
else if (TOGGLE_VPN.equals(action))
@@ -95,6 +95,7 @@ class VpnProfileDetailActivity : AppCompatActivity()
val profile = checked.toProfile(existing)
if (existing != null) dataSource.updateVpnProfile(profile)
else dataSource.insertProfile(profile)
if (existing == null) org.strongswan.android.logic.profiles.ProfileExtras.recordManual(this@VpnProfileDetailActivity, profile.getUUID().toString())
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
intent.putExtra(Constants.VPN_PROFILES_SINGLE, profile.getUUID().toString())
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
@@ -34,6 +34,7 @@ import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.strongswan.android.R;
import org.strongswan.android.logic.profiles.ProfileExtras;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
import org.strongswan.android.data.VpnProfileDataSource;
@@ -41,6 +42,7 @@ import org.strongswan.android.data.VpnProfileSource;
import org.strongswan.android.data.VpnType;
import org.strongswan.android.data.VpnType.VpnTypeFeature;
import org.strongswan.android.logic.TrustedCertificateManager;
import org.strongswan.android.logic.routing.RoutingManager;
import org.strongswan.android.security.TrustedCertificateEntry;
import org.strongswan.android.ui.compose.ImportUiModel;
import org.strongswan.android.ui.compose.ImportScreenKt;
@@ -178,7 +180,7 @@ public class VpnProfileImportActivity extends AppCompatActivity
ImportScreenKt.installImportScreen(this, mUi,
this::saveProfile, this::finish,
this::importPkcs12, this::chooseUserCertificate);
this::importPkcs12, this::chooseUserCertificate, this::retryLoad);
Intent intent = getIntent();
String action = intent.getAction();
@@ -245,13 +247,29 @@ public class VpnProfileImportActivity extends AppCompatActivity
mImportPKCS12.launch(intent);
}
private Uri mLastUri;
private void loadProfile(Uri uri)
{
mUi.setLoading(true);
/* shown in the preview: "link" for downloads, "file" otherwise */
mUi.setSource(uri.getScheme());
mUi.setSourceUri(uri.toString());
mUi.setLoadError(null);
mLastUri = uri;
Bundle args = new Bundle();
args.putParcelable(PROFILE_URI, uri);
LoaderManager.getInstance(this).initLoader(PROFILE_LOADER, args, mProfileLoaderCallbacks);
LoaderManager.getInstance(this).restartLoader(PROFILE_LOADER, args, mProfileLoaderCallbacks);
}
/** «Try again» after a failed download. */
private void retryLoad()
{
if (mLastUri != null)
{
loadProfile(mLastUri);
}
}
public void handleProfile(ProfileLoadResult data)
@@ -299,15 +317,8 @@ public class VpnProfileImportActivity extends AppCompatActivity
e.printStackTrace();
}
}
if (error != null)
{
Toast.makeText(this, getString(R.string.profile_import_failed_detail, error), Toast.LENGTH_LONG).show();
}
else
{
Toast.makeText(this, R.string.profile_import_failed, Toast.LENGTH_LONG).show();
}
finish();
/* the sheet shows the failure with the likely reasons and a retry */
mUi.setLoadError(error != null ? error : getString(R.string.profile_import_failed));
return;
}
mExisting = mDataSource.getVpnProfile(mProfile.getUUID());
@@ -629,6 +640,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
if (verifyInput())
{
updateProfileData();
if (mProfile.getSelectedAppsHandling() != SelectedAppsHandling.SELECTED_APPS_DISABLE)
{ /* per-profile apps are gone: they join the global direct-apps list */
RoutingManager.getInstance(this).importProfileApps(mProfile.getSelectedAppsHandling(), mProfile.getSelectedAppsSet());
mProfile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE);
}
if (mExisting != null)
{
mProfile.setUUID(mExisting.getUUID());
@@ -653,6 +669,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
e.printStackTrace();
}
}
/* remember where it came from (F_Profile «Profile source») */
ProfileExtras.recordImport(this, mProfile.getUUID().toString(),
mLastUri != null ? mLastUri.getScheme() : null,
mLastUri != null ? mLastUri.toString() : null,
mLastUri != null && mLastUri.getPath() != null && mLastUri.getPath().contains("/qr/"));
Intent intent = new Intent(Constants.VPN_PROFILES_CHANGED);
intent.putExtra(Constants.VPN_PROFILES_SINGLE, mProfile.getUUID().toString());
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
@@ -660,6 +681,13 @@ public class VpnProfileImportActivity extends AppCompatActivity
intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
if (mUi.getConnectNow())
{ /* «Connect right away» in the preview */
Intent start = new Intent(this, VpnProfileControlActivity.class);
start.setAction(VpnProfileControlActivity.START_PROFILE);
start.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, mProfile.getUUID().toString());
startActivity(start);
}
setResult(RESULT_OK, new Intent().putExtra(VpnProfileDataSource.KEY_UUID, mProfile.getUUID().toString()));
finish();
@@ -695,6 +723,12 @@ public class VpnProfileImportActivity extends AppCompatActivity
*/
private void updateProfileData()
{
/* the name can be changed in the preview */
String name = mUi.getName().trim();
if (!name.isEmpty())
{
mProfile.setName(name);
}
if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS))
{
mProfile.setUsername(mUi.getUsername().trim());
@@ -17,9 +17,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -33,6 +30,28 @@ import androidx.core.graphics.drawable.IconCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.home.serverCode
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxIcons
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.draw.clip
import androidx.compose.ui.Alignment
import androidx.compose.material3.Icon
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.ui.compose.SecondaryScaffold
@@ -90,20 +109,40 @@ private fun ProfileSelectScreen(
onSelect: (VpnProfile) -> Unit,
)
{
val c = LocalShxColors.current
SecondaryScaffold(title = stringResource(R.string.strongswan_shortcut), onBack = onBack) { padding ->
if (profiles.isEmpty())
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 24.dp))
{
Text(stringResource(R.string.no_profiles), modifier = Modifier.padding(padding).padding(24.dp))
}
else
{
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(profiles, key = { it.getUUID().toString() }) { profile ->
ListItem(
headlineContent = { Text(profile.name ?: "") },
supportingContent = { Text(profile.gateway ?: "") },
modifier = Modifier.clickable { onSelect(profile) },
if (profiles.isEmpty())
{
Text(stringResource(R.string.no_profiles), style = ShxType.body, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp))
return@Column
}
/* the servers as on F_Servers: code circle, name, address */
ShxPanel()
{
profiles.forEachIndexed { i, profile ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(68.dp).clickable(role = Role.Button) { onSelect(profile) }.padding(horizontal = 14.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(c.tile))
{
Text(serverCode(profile, null), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp))
{
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(profile.gateway ?: "", style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
if (i < profiles.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
}
}
@@ -45,6 +45,9 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
private boolean mListening;
private VpnProfileDataSource mDataSource;
private VpnStateService mService;
private boolean mBound;
private VpnProfile mDefaultProfile;
private boolean mDefaultProfileLoaded;
private final ServiceConnection mServiceConnection = new ServiceConnection()
{
@Override
@@ -71,8 +74,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
super.onCreate();
Context context = getApplicationContext();
context.bindService(new Intent(context, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
mBound = context.bindService(new Intent(context, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
mDataSource = new VpnProfileSource(this);
mDataSource.open();
@@ -83,10 +86,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
{
super.onDestroy();
if (mService != null)
/* the binding lives on the application context: unbind even if the
* service never connected, or it leaks for the life of the process */
if (mBound)
{
getApplicationContext().unbindService(mServiceConnection);
mBound = false;
}
mService = null;
mDataSource.close();
mDataSource = null;
}
@@ -96,6 +103,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
{
super.onStartListening();
mListening = true;
/* the configured profile may have changed while the panel was closed */
mDefaultProfileLoaded = false;
if (mService != null)
{
@@ -118,7 +127,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
private VpnProfile getProfile()
{
return mDataSource != null ? mDataSource.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(this)) : null;
/* updateTile() runs on every state notification (including retry
* countdown ticks): query the database once per listening session */
if (!mDefaultProfileLoaded)
{
mDefaultProfile = mDataSource != null ? mDataSource.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(this)) : null;
mDefaultProfileLoaded = true;
}
return mDefaultProfile;
}
@Override
@@ -136,7 +152,12 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
{ /* always get the plain profile without cached password */
profile = mDataSource.getVpnProfile(profile.getUUID());
}
/* reconnect the profile in case of an error */
/* a problem: the tap opens the app, which explains it (F_QsTile) */
if (mService.getErrorState() != VpnStateService.ErrorState.NO_ERROR)
{
openApp(new Intent(this, MainActivity.class));
return;
}
if (mService.getErrorState() == VpnStateService.ErrorState.NO_ERROR)
{
switch (mService.getState())
@@ -196,11 +217,16 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
return;
}
}
Intent intent = new Intent(this, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
/* no servers yet: straight to «Add server» */
openApp(new Intent(this, MainActivity.class).putExtra(MainActivity.EXTRA_ADD_SERVER, getProfile() == null));
}
private void openApp(Intent intent)
{
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
{
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE));
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
}
else
{
@@ -239,11 +265,28 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
return;
}
/* F_QsTile: always «SHX VPN», the state in the subtitle */
tile.setLabel(getString(R.string.tile_label));
String subtitle;
if (error != VpnStateService.ErrorState.NO_ERROR)
{
tile.setState(Tile.STATE_INACTIVE);
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_warning));
tile.setLabel(getString(R.string.tile_connect));
switch (error)
{
case UNREACHABLE:
case LOOKUP_FAILED:
subtitle = getString(R.string.tile_no_answer);
break;
case AUTH_FAILED:
case PEER_AUTH_FAILED:
case PASSWORD_MISSING:
subtitle = getString(R.string.tile_auth_error);
break;
default:
subtitle = getString(R.string.tile_problem);
break;
}
}
else
{
@@ -253,30 +296,25 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
case DISABLED:
tile.setState(Tile.STATE_INACTIVE);
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_disconnected));
tile.setLabel(getString(R.string.tile_connect));
subtitle = getString(R.string.tile_off);
break;
case CONNECTING:
tile.setState(Tile.STATE_ACTIVE);
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_connecting));
tile.setLabel(getString(R.string.tile_disconnect));
subtitle = getString(R.string.tile_connecting);
break;
case CONNECTED:
default:
tile.setState(Tile.STATE_ACTIVE);
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification));
tile.setLabel(getString(R.string.tile_disconnect));
/* the server name, unless the screen is locked */
subtitle = profile != null && !isSecure() ? profile.getName() : getString(R.string.status_protected);
break;
}
}
if (profile != null && !isSecure())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{ /* keep the action label, show the profile as the tile subtitle */
tile.setSubtitle(profile.getName());
}
else
{
tile.setLabel(profile.getName());
}
tile.setSubtitle(subtitle);
}
tile.updateTile();
}
@@ -1,73 +0,0 @@
/*
* Copyright (C) 2017 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
* 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.adapter;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.graphics.drawable.Drawable;
import java.text.Collator;
import androidx.annotation.NonNull;
public class SelectedApplicationEntry implements Comparable<SelectedApplicationEntry>
{
private final ApplicationInfo mInfo;
private final Drawable mIcon;
private final String mName;
private boolean mSelected;
public SelectedApplicationEntry(PackageManager packageManager, ApplicationInfo info)
{
mInfo = info;
CharSequence name = info.loadLabel(packageManager);
mName = name == null ? info.packageName : name.toString();
mIcon = info.loadIcon(packageManager);
}
public void setSelected(boolean selected)
{
mSelected = selected;
}
public boolean isSelected()
{
return mSelected;
}
public ApplicationInfo getInfo()
{
return mInfo;
}
public Drawable getIcon()
{
return mIcon;
}
@Override
public String toString()
{
return mName;
}
@Override
public int compareTo(@NonNull SelectedApplicationEntry another)
{
return Collator.getInstance().compare(toString(), another.toString());
}
}
@@ -0,0 +1,263 @@
/*
* 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.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
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.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.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.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import org.strongswan.android.R
import org.strongswan.android.ui.VpnProfileDetailActivity
import org.strongswan.android.ui.VpnProfileImportActivity
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import java.io.File
/** QR codes of profiles: what a scan turns into, and a profile turned into a QR image. */
object ProfileQr
{
/**
* The import intent for a scanned text: a link opens the import screen on
* that URL, a .sswan document is handed over as a temporary file.
*/
fun importIntent(context: Context, text: String): Intent?
{
val trimmed = text.trim()
val intent = Intent(context, VpnProfileImportActivity::class.java).setAction(Intent.ACTION_VIEW)
if (trimmed.startsWith("https://", true) || trimmed.startsWith("http://", true))
{
return intent.setData(Uri.parse(trimmed))
}
if (!trimmed.startsWith("{"))
{
return null
}
val dir = File(context.cacheDir, "qr").apply { mkdirs() }
val file = File(dir, "scanned.sswan")
file.writeText(trimmed)
val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file)
return intent.setDataAndType(uri, "application/vnd.strongswan.profile").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
/** A QR bitmap of [text], or null when it does not fit into a QR code. */
fun bitmap(text: String, size: Int = 720, highCorrection: Boolean = false): Bitmap? = try
{
val matrix = QRCodeWriter().encode(
text, BarcodeFormat.QR_CODE, size, size,
mapOf(
EncodeHintType.ERROR_CORRECTION to if (highCorrection) ErrorCorrectionLevel.H else ErrorCorrectionLevel.L,
EncodeHintType.MARGIN to 1,
EncodeHintType.CHARACTER_SET to "UTF-8",
),
)
val pixels = IntArray(matrix.width * matrix.height) { i ->
if (matrix.get(i % matrix.width, i / matrix.width)) android.graphics.Color.BLACK else android.graphics.Color.WHITE
}
Bitmap.createBitmap(pixels, matrix.width, matrix.height, Bitmap.Config.ARGB_8888)
}
catch (_: Exception)
{
null
}
}
/** «Add server» sheet (F_Import): QR first, then a link, then a file or manual entry. */
@Composable
fun AddServerSheet(onDismiss: () -> Unit)
{
val context = LocalContext.current
val c = LocalShxColors.current
var link by remember { mutableStateOf("") }
val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->
val text = result.contents ?: return@rememberLauncherForActivityResult
val intent = ProfileQr.importIntent(context, text)
if (intent != null)
{
onDismiss()
context.startActivity(intent)
}
else
{
Toast.makeText(context, R.string.add_qr_not_profile, Toast.LENGTH_LONG).show()
}
}
fun openLink()
{
val url = link.trim()
if (!url.startsWith("http://", true) && !url.startsWith("https://", true))
{
Toast.makeText(context, R.string.add_link_invalid, Toast.LENGTH_SHORT).show()
return
}
onDismiss()
context.startActivity(Intent(context, VpnProfileImportActivity::class.java).setAction(Intent.ACTION_VIEW).setData(Uri.parse(url)))
}
ShxSheet(onDismiss = onDismiss)
{
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp))
{
Text(stringResource(R.string.add_server_title), style = ShxType.sheetTitle, color = c.text, modifier = Modifier.weight(1f))
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onDismiss))
{
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.add_close), tint = c.textSoft, modifier = Modifier.size(22.dp))
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(c.accent)
.clickable(role = Role.Button) {
scanner.launch(
ScanOptions()
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
.setPrompt(context.getString(R.string.add_qr_prompt))
.setBeepEnabled(false)
.setOrientationLocked(false)
)
}
.padding(18.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(48.dp).clip(RoundedCornerShape(16.dp)).background(Color.White.copy(alpha = 0.18f)))
{
Icon(ShxIcons.Scan, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.add_qr), style = ShxType.cardHeading, color = Color.White)
Text(stringResource(R.string.add_qr_sub), style = ShxType.sub, color = Color(0xFFE7DFFF))
}
}
val shape = RoundedCornerShape(24.dp)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(14.dp),
)
{
Text(stringResource(R.string.add_link_title), style = ShxType.cardTitle, color = c.text)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
{
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(14.dp)).background(c.tile).padding(horizontal = 12.dp),
)
{
if (link.isEmpty())
{
Text("https://…/profile.sswan", style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium), color = c.textSub, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
}
BasicTextField(
value = link,
onValueChange = { link = it },
singleLine = true,
textStyle = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium, color = c.text),
cursorBrush = SolidColor(c.accent),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { openLink() }),
modifier = Modifier.fillMaxWidth(),
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(48.dp).clip(RoundedCornerShape(14.dp)).background(c.accentMuted)
.clickable(role = Role.Button) {
if (link.isBlank())
{
val clip = (context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip
link = clip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString()?.trim().orEmpty()
}
else
{
openLink()
}
}
.padding(horizontal = 16.dp),
)
{
Text(
stringResource(if (link.isBlank()) R.string.add_link_paste else R.string.add_link_open),
style = ShxType.body.copy(fontWeight = FontWeight.Bold),
color = c.textTonal,
)
}
}
Text(stringResource(R.string.add_link_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(
stringResource(R.string.add_file),
{
onDismiss()
context.startActivity(Intent(context, VpnProfileImportActivity::class.java))
},
style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f),
)
ShxButton(
stringResource(R.string.add_manual),
{
onDismiss()
context.startActivity(Intent(context, VpnProfileDetailActivity::class.java))
},
style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f),
)
}
}
}
@@ -22,25 +22,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.BatterySaver
import androidx.compose.material.icons.rounded.HealthAndSafety
import androidx.compose.material.icons.rounded.NetworkCheck
import androidx.compose.material.icons.rounded.Remove
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Speed
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material3.AlertDialog
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -52,6 +41,10 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.ui.compose.design.ShxDialogButton
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.components.ShxSettingRow
@@ -92,7 +85,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.settings_section_general))
{
ShxSettingToggleRow(
icon = Icons.Rounded.NetworkCheck,
icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_title),
subtitle = stringResource(R.string.health_check_summary),
checked = config.enabled,
@@ -100,7 +93,7 @@ fun ConnectivityCheckScreen()
)
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.Timer,
icon = ShxIcons.Clock,
title = stringResource(R.string.health_check_interval),
subtitle = intervalLabel(config.intervalSec, config.aggressiveMode),
enabled = config.enabled && !config.aggressiveMode,
@@ -108,7 +101,7 @@ fun ConnectivityCheckScreen()
)
ShxSectionDivider()
ShxSettingToggleRow(
icon = Icons.Rounded.Speed,
icon = ShxIcons.Bolt,
title = stringResource(R.string.health_check_aggressive_title),
subtitle = stringResource(R.string.health_check_aggressive_summary),
checked = config.aggressiveMode,
@@ -116,7 +109,7 @@ fun ConnectivityCheckScreen()
)
ShxSectionDivider()
ShxSettingToggleRow(
icon = Icons.Rounded.BatterySaver,
icon = ShxIcons.Battery,
title = stringResource(R.string.health_check_adaptive_title),
subtitle = stringResource(R.string.health_check_adaptive_summary),
checked = config.adaptiveMode,
@@ -127,7 +120,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.health_check_targets_title))
{
ShxSettingToggleRow(
icon = Icons.Rounded.HealthAndSafety,
icon = ShxIcons.Shield,
title = stringResource(R.string.health_check_server_title),
subtitle = stringResource(R.string.health_check_server_summary),
checked = config.serverCheckMode != ServerCheckMode.OFF,
@@ -140,7 +133,7 @@ fun ConnectivityCheckScreen()
listOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE).forEach { target ->
ShxSectionDivider()
ShxSettingToggleRow(
icon = Icons.Rounded.Public,
icon = ShxIcons.Globe,
title = targetLabel(target),
subtitle = null,
checked = config.targets.contains(target),
@@ -157,8 +150,8 @@ fun ConnectivityCheckScreen()
{
Text(
stringResource(R.string.health_check_mode),
style = MaterialTheme.typography.bodyLarge,
color = if (config.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.body,
color = if (config.enabled) LocalShxColors.current.text else LocalShxColors.current.textSub,
)
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(top = ShxSpacing.sm))
{
@@ -175,7 +168,7 @@ fun ConnectivityCheckScreen()
}
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.NetworkCheck,
icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.serverAction),
enabled = config.enabled,
@@ -203,7 +196,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.health_check_tunnel_dead_title))
{
ShxSettingRow(
icon = Icons.Rounded.NetworkCheck,
icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.tunnelDeadAction),
enabled = config.enabled,
@@ -231,7 +224,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.health_check_partial_title))
{
ShxSettingRow(
icon = Icons.Rounded.NetworkCheck,
icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.partialDownAction),
enabled = config.enabled,
@@ -252,7 +245,7 @@ fun ConnectivityCheckScreen()
if (showIntervalPicker)
{
val options = listOf(15, 30, 60, 300)
AlertDialog(
ShxAlertDialog(
onDismissRequest = { showIntervalPicker = false },
title = { Text(stringResource(R.string.health_check_interval)) },
text = {
@@ -276,7 +269,7 @@ fun ConnectivityCheckScreen()
}
},
confirmButton = {
TextButton(onClick = { showIntervalPicker = false }) {
ShxDialogButton(onClick = { showIntervalPicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -336,20 +329,20 @@ private fun AttemptsRow(
{
Text(
title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.body,
color = if (enabled) LocalShxColors.current.text else LocalShxColors.current.textSub,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDecrement, enabled = enabled && attempts > 1) {
Icon(Icons.Rounded.Remove, contentDescription = stringResource(R.string.health_check_attempt_minus))
Icon(ShxIcons.Minus, contentDescription = stringResource(R.string.health_check_attempt_minus))
}
Text(
text = attempts.toString(),
style = MaterialTheme.typography.titleMedium,
style = ShxType.cardTitle,
modifier = Modifier.padding(horizontal = ShxSpacing.sm),
)
IconButton(onClick = onIncrement, enabled = enabled && attempts < 5) {
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.health_check_attempt_plus))
Icon(ShxIcons.Plus, contentDescription = stringResource(R.string.health_check_attempt_plus))
}
}
}
@@ -0,0 +1,407 @@
/*
* 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.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
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.fillMaxSize
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.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
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.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.connectivity.ConnectivityProbe
import org.strongswan.android.logic.connectivity.ProbeTarget
import org.strongswan.android.logic.connectivity.WhiteListManager
import org.strongswan.android.logic.connectivity.WhiteListStatus
import org.strongswan.android.logic.routing.AppTraffic
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.routing.AppIcon
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/** One line of the check list. */
data class DiagStep(val title: String, val value: String, val ok: Boolean?)
/** Result of a diagnostics round. */
data class DiagReport(val steps: List<DiagStep>, val at: Long, val vpnUp: Boolean)
{
/** null = still checking, true = everything answered */
val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true }
}
internal suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
val vpn = VpnStateService.getLiveInstance()
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
val gateway = (vpn?.profile)?.gateway
val whiteList = WhiteListManager.getInstance(context)
whiteList.checkNow()
val server = async { if (gateway != null) ConnectivityProbe.probe(ProbeTarget.SERVER, 3000, context, gateway) else null }
val foreign = async { ConnectivityProbe.probe(ProbeTarget.GOOGLE, 3000, context, null) }
val network = withContext(Dispatchers.Default) { networkLabel(context) }
/* the whitelist check runs its own direct round; give it a moment */
delay(2500)
val wl = whiteList.state.value
val serverResult = server.await()
val foreignResult = foreign.await()
val steps = listOf(
DiagStep(context.getString(R.string.diag_step_internet), network ?: context.getString(R.string.diag_none), network != null),
DiagStep(
context.getString(R.string.diag_step_ru),
when (wl.domesticDirectOk)
{
true -> wl.domesticDirectRttMs?.let { context.getString(R.string.diag_ms, it) } ?: context.getString(R.string.diag_ok)
false -> context.getString(R.string.diag_fail)
null -> context.getString(R.string.diag_unknown)
},
wl.domesticDirectOk ?: (network != null),
),
DiagStep(
context.getString(R.string.diag_step_foreign),
when
{
!foreignResult.ok -> if (wl.status == WhiteListStatus.ACTIVE) context.getString(R.string.diag_whitelist) else context.getString(R.string.diag_fail)
vpnUp -> context.getString(R.string.diag_via_vpn_ms, foreignResult.rttMs ?: 0)
else -> context.getString(R.string.diag_direct_ms, foreignResult.rttMs ?: 0)
},
foreignResult.ok,
),
DiagStep(
context.getString(R.string.diag_step_server),
when
{
gateway == null -> context.getString(R.string.diag_no_server)
serverResult?.ok == true -> context.getString(R.string.diag_ms, serverResult.rttMs ?: 0)
else -> context.getString(R.string.diag_no_answer)
},
if (gateway == null) network != null else serverResult?.ok == true,
),
DiagStep(
context.getString(R.string.diag_step_dns),
context.getString(if (vpnUp) R.string.diag_dns_tunnel else R.string.diag_dns_provider),
if (vpnUp) foreignResult.ok else network != null,
),
)
DiagReport(steps, System.currentTimeMillis(), vpnUp)
}
private fun networkLabel(context: Context): String?
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null
var wifi = false
var cell = false
for (n in cm.allNetworks)
{
val caps = runCatching { cm.getNetworkCapabilities(n) }.getOrNull() ?: continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
continue
}
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) wifi = true
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) cell = true
}
return when
{
wifi -> context.getString(R.string.network_wifi)
cell -> context.getString(R.string.network_cellular)
else -> null
}
}
/** «Diagnostics» (F_Diag): one look at the whole path, what does not open, unreachable hosts. */
@Composable
fun DiagnosticsScreen(
onBack: () -> Unit,
onLog: () -> Unit,
onCheckSite: (String) -> Unit,
onCheckApp: (String) -> Unit,
)
{
val context = LocalContext.current
val c = LocalShxColors.current
var round by remember { mutableIntStateOf(0) }
val report by produceState<DiagReport?>(null, round) {
value = null
value = runDiagnostics(context)
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
val dead by produceState(emptyList<DeadTargetUi>(), lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true)
{
val raw = runCatching { withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() } }.getOrNull()
value = HomeViewModel.parseDeadTargets(raw).filter { !it.viaTunnel }
delay(5_000)
}
}
}
val topApps by produceState(emptyList<String>()) {
value = withContext(Dispatchers.IO) {
AppTraffic.since(context, System.currentTimeMillis() - 60L * 60 * 1000).entries
.sortedByDescending { it.value }.map { it.key }.take(4)
}
}
var site by remember { mutableStateOf("") }
val manager = remember { RoutingManager.getInstance(context) }
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
{
ShxPageHeader(stringResource(R.string.diag_title), onBack)
{
ShxButton(stringResource(R.string.diag_log), onLog, style = ShxButtonStyle.TONAL, height = 40.dp)
}
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(14.dp))
{
Verdict(report, onRepeat = { round++ })
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 4.dp))
{
val steps = report?.steps ?: listOf(
R.string.diag_step_internet, R.string.diag_step_ru, R.string.diag_step_foreign, R.string.diag_step_server, R.string.diag_step_dns,
).map { DiagStep(context.getString(it), context.getString(R.string.diag_checking), null) }
steps.forEachIndexed { i, step ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().height(50.dp))
{
val (bg, fg, mark) = when (step.ok)
{
true -> Triple(c.successContainer, c.onSuccess, "✓")
false -> Triple(c.errorContainer, c.onErrorContainer, "×")
null -> Triple(c.tile, c.iconMuted, "…")
}
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(22.dp).clip(CircleShape).background(bg))
{
Text(mark, style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = fg)
}
Text(step.title, style = ShxType.body.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
Text(step.value, style = ShxType.sub, color = c.textSub)
}
if (i < steps.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
ShxSectionHeader(stringResource(R.string.diag_not_opening))
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(14.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
{
if (topApps.isNotEmpty())
{
Row(horizontalArrangement = Arrangement.spacedBy(6.dp))
{
topApps.forEach { pkg ->
val label = remember(pkg) {
runCatching { context.packageManager.let { pm -> pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString() } }.getOrDefault(pkg)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.weight(1f).clip(RoundedCornerShape(15.dp)).clickable(role = Role.Button) { onCheckApp(pkg) },
)
{
Box(Modifier.size(48.dp).clip(RoundedCornerShape(15.dp))) { AppIcon(pkg, 48.dp) }
Text(label, style = ShxType.tag.copy(fontWeight = FontWeight.SemiBold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(16.dp)).background(c.tile).padding(horizontal = 12.dp),
)
{
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
Box(Modifier.weight(1f))
{
if (site.isEmpty())
{
Text(stringResource(R.string.diag_site_hint), style = ShxType.body.copy(fontSize = 15.sp), color = c.textSub)
}
BasicTextField(
value = site,
onValueChange = { site = it },
singleLine = true,
textStyle = ShxType.body.copy(fontSize = 15.sp, color = c.text),
cursorBrush = SolidColor(c.accent),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { if (site.isNotBlank()) onCheckSite(site.trim()) }),
modifier = Modifier.fillMaxWidth(),
)
}
}
ShxButton(stringResource(R.string.route_check_button), { onCheckSite(site.trim()) }, height = 48.dp, enabled = site.isNotBlank())
}
}
}
if (dead.isNotEmpty())
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
{
ShxSectionHeader(stringResource(R.string.dead_targets_title))
Text(
dead.size.toString(),
style = ShxType.tag,
color = c.onWarning,
modifier = Modifier.padding(top = 6.dp).clip(RoundedCornerShape(6.dp)).background(c.warningStrong).padding(horizontal = 7.dp, vertical = 1.dp),
)
}
ShxPanel()
{
dead.forEachIndexed { i, target ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().height(60.dp).padding(start = 16.dp, end = 12.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(target.dst, style = ShxType.mono.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
stringResource(R.string.diag_dead_meta, target.fails, formatDuration(target.leftMs / 1000)),
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
color = c.textSub,
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(c.warningStrong)
.clickable(role = Role.Button) {
val ip = target.dst.substringBeforeLast(':').removeSurrounding("[", "]")
manager.addForcePrefix(ip + if (ip.contains(':')) "/128" else "/32")
}
.padding(horizontal = 12.dp),
)
{
Text(stringResource(R.string.dead_targets_force), style = ShxType.chip, color = c.onWarningStrong)
}
}
if (i < dead.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
ShxButton(stringResource(R.string.diag_send_report), onLog, style = ShxButtonStyle.OUTLINE, height = 52.dp, modifier = Modifier.fillMaxWidth())
}
}
}
/** Green / amber / grey verdict card with a big round mark. */
@Composable
private fun Verdict(report: DiagReport?, onRepeat: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
val ok = report?.allOk
val (bg, mark, markFg, titleColor, subColor) = when (ok)
{
true -> Five(c.successContainer, c.success, androidx.compose.ui.graphics.Color.White, c.onSuccessStrong, c.onSuccess)
false -> Five(c.warningStrong, c.warning, androidx.compose.ui.graphics.Color.White, c.onWarningStrong, c.onWarningContainer)
null -> Five(c.tile, c.iconMuted, androidx.compose.ui.graphics.Color.White, c.text, c.textSub)
}
val title = when (ok)
{
true -> stringResource(if (report?.vpnUp == true) R.string.diag_verdict_ok else R.string.diag_verdict_ok_no_vpn)
false -> stringResource(R.string.diag_verdict_problem, report?.steps?.firstOrNull { it.ok == false }?.title ?: "")
null -> stringResource(R.string.diag_checking_title)
}
val sub = when (ok)
{
null -> stringResource(R.string.diag_checking_sub)
else -> stringResource(R.string.diag_checked_at, formatTimeAgo(context, report!!.at))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(bg)
.clickable(enabled = ok != null, role = Role.Button, onClick = onRepeat).padding(16.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(mark))
{
Icon(if (ok == false) ShxIcons.Alert else if (ok == null) ShxIcons.Refresh else ShxIcons.Check, contentDescription = null, tint = markFg, modifier = Modifier.size(22.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.cardHeading, color = titleColor)
Text(sub, style = ShxType.sub, color = subColor)
}
}
}
private data class Five<A, B, C, D, E>(val a: A, val b: B, val c: C, val d: D, val e: E)
private fun formatTimeAgo(context: Context, at: Long): String
{
val sec = ((System.currentTimeMillis() - at) / 1000).coerceAtLeast(0)
return if (sec < 60) context.getString(R.string.diag_ago_sec, sec) else context.getString(R.string.diag_ago_min, sec / 60)
}
@@ -12,13 +12,16 @@ package org.strongswan.android.ui.compose
import java.util.Locale
import kotlin.math.abs
/* «1,2 ГБ» in Russian, «1.2 GB» otherwise */
private fun russian() = Locale.getDefault().language == "ru"
fun formatBytes(bytes: Long): String
{
val units = if (russian()) arrayOf("Б", "КБ", "МБ", "ГБ", "ТБ") else arrayOf("B", "KB", "MB", "GB", "TB")
if (bytes <= 0L)
{
return "0 B"
return "0 " + units[0]
}
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var value = bytes.toDouble()
var unit = 0
while (value >= 1024 && unit < units.lastIndex)
@@ -26,17 +29,18 @@ fun formatBytes(bytes: Long): String
value /= 1024
unit++
}
return if (unit == 0) String.format(Locale.US, "%d %s", bytes, units[unit])
else String.format(Locale.US, "%.1f %s", value, units[unit])
return if (unit == 0) String.format(Locale.getDefault(), "%d %s", bytes, units[unit])
else String.format(Locale.getDefault(), "%.1f %s", value, units[unit])
}
fun formatRate(bytesPerSecond: Long): String
{
val perSecond = if (russian()) "/с" else "/s"
if (abs(bytesPerSecond) < 32)
{
return "0 B/s"
return formatBytes(0) + perSecond
}
return formatBytes(bytesPerSecond) + "/s"
return formatBytes(bytesPerSecond) + perSecond
}
fun formatDuration(seconds: Long): String
@@ -52,26 +52,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.SignalCellularAlt
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -89,6 +70,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@@ -104,6 +86,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -116,7 +99,29 @@ import kotlinx.coroutines.flow.StateFlow
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.runtime.produceState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.ui.compose.home.CertExpiry
import org.strongswan.android.ui.compose.home.certExpiry
import org.strongswan.android.ui.compose.home.snoozeCertExpiry
import org.strongswan.android.ui.compose.home.connectProgress
import org.strongswan.android.R
import androidx.compose.ui.draw.clip
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSheetTitle
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.design.ShxIcons
import androidx.compose.foundation.layout.widthIn
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.ui.DiagnosticsActivity
import org.strongswan.android.ui.compose.home.HomeActions
import org.strongswan.android.ui.compose.home.HomeModel
import org.strongswan.android.ui.compose.home.HomeContent
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.ErrorState
import org.strongswan.android.logic.VpnStateService.State
@@ -128,19 +133,27 @@ import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ScreenPadding
import androidx.compose.material.icons.rounded.Check
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntSize
import org.strongswan.android.ui.compose.home.AvailabilityCard
import org.strongswan.android.ui.compose.home.HeroConnect
import org.strongswan.android.ui.compose.home.HomeBannerSlot
import org.strongswan.android.ui.compose.home.ProfileChip
import org.strongswan.android.ui.compose.home.DEFAULT_PAUSE_MINUTES
import org.strongswan.android.ui.compose.home.HomeProblem
import org.strongswan.android.ui.compose.home.PauseSheet
import org.strongswan.android.ui.compose.home.formatClock
import org.strongswan.android.ui.compose.home.formatCountdown
import org.strongswan.android.ui.compose.home.homeProblem
import org.strongswan.android.ui.compose.home.rememberNow
import org.strongswan.android.ui.compose.home.titleRes
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.InboxActivity
import org.strongswan.android.logic.events.EventLog
import android.content.Intent
import android.os.Build
import android.provider.Settings
import org.strongswan.android.ui.compose.home.RTT_HISTORY
import org.strongswan.android.ui.compose.home.TrafficTiles
import org.strongswan.android.ui.compose.home.labelRes
import org.strongswan.android.ui.compose.home.rttText
import org.strongswan.android.ui.compose.home.sortedForDisplay
@@ -167,17 +180,35 @@ fun HomeScreen(
val whiteList by viewModel.whiteList.collectAsStateWithLifecycle()
val exit by viewModel.exit.collectAsStateWithLifecycle()
val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle()
val pauseUntil by viewModel.pauseUntil.collectAsStateWithLifecycle()
val networkAvailable by viewModel.networkAvailable.collectAsStateWithLifecycle()
val events by remember { EventLog.getInstance(context).events }.collectAsStateWithLifecycle()
val unreadEvents = events.count { !it.read }
/* rolling RTT history per target, rendered as sparklines in the availability card */
val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() }
/* the map is updated one target at a time as the parallel probes finish:
* only append results that are new, not every entry on every emission */
val lastSeen = remember { HashMap<ProbeTarget, ProbeResult>() }
DisposableEffect(viewModel)
{ /* the view model only polls traffic and probes while Home is shown */
viewModel.setHomeVisible(true)
onDispose { viewModel.setHomeVisible(false) }
}
LaunchedEffect(probes)
{
if (probes.isEmpty())
{
probeHistory.clear()
lastSeen.clear()
}
else
{
probes.forEach { (target, result) ->
if (lastSeen[target] === result)
{
return@forEach
}
lastSeen[target] = result
val list = probeHistory.getOrPut(target) { mutableStateListOf() }
list.add(result)
while (list.size > RTT_HISTORY)
@@ -187,177 +218,138 @@ fun HomeScreen(
}
}
}
var pendingDelete by remember { mutableStateOf<VpnProfile?>(null) }
var sheetProfile by remember { mutableStateOf<VpnProfile?>(null) }
var pickerOpen by remember { mutableStateOf(false) }
var pauseSheetOpen by remember { mutableStateOf(false) }
val networkKinds by viewModel.networkKinds.collectAsStateWithLifecycle()
val findingFastest by viewModel.findingFastest.collectAsStateWithLifecycle()
val ruState by remember { RuIpListManager.getInstance(context).state }.collectAsStateWithLifecycle()
val connected = state.vpnState == State.CONNECTED
val connecting = state.vpnState == State.CONNECTING || state.vpnState == State.DISCONNECTING
val hasError = state.error != ErrorState.NO_ERROR
val selected = state.selected
val durationSec = if (connected) traffic.durationSec else 0L
val status = LocalShxStatusColors.current
val statusText = when
{
hasError -> stringResource(state.errorText)
connected -> stringResource(R.string.status_protected)
connecting && state.vpnState == State.DISCONNECTING -> stringResource(R.string.state_disconnecting)
connecting -> stringResource(R.string.status_connecting)
else -> stringResource(R.string.status_unprotected)
}
val statusColor = when
{
hasError -> MaterialTheme.colorScheme.error
connected -> status.connected
connecting -> status.connecting
else -> status.disconnected
}
val subtitle = when
{
connected -> "${selected?.name ?: ""} · ${formatDuration(durationSec)}"
connecting -> selected?.name ?: ""
else -> stringResource(R.string.status_choose_profile)
}
/* a pause only matters while the tunnel is actually down */
val pauseActive = pauseUntil > 0L && state.vpnState == State.DISABLED
val now = if (pauseActive) rememberNow() else System.currentTimeMillis()
val problem = if (pauseActive && pauseUntil > now) null else homeProblem(state, networkAvailable)
val whiteListActive = whiteList.config.enabled && whiteList.status == WhiteListStatus.ACTIVE
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<IntSize>()
Box(Modifier.fillMaxSize())
{
StatusGlowBackground(color = statusColor, active = connected || connecting)
val wide = LocalConfiguration.current.screenWidthDp >= 840
val leftItems: LazyListScope.() -> Unit = {
item(key = "hero") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = ShxSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HeroConnect(
state = state.vpnState,
enabled = selected != null,
onConnect = { selected?.let { viewModel.connect(context, it) } },
onDisconnect = { viewModel.disconnect() },
onCancel = { viewModel.disconnect() },
)
Spacer(Modifier.height(ShxSpacing.lg))
/* the status is announced to TalkBack whenever it changes */
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
) {
AnimatedContent(
targetState = statusText,
transitionSpec = { fadeIn(effects) togetherWith fadeOut(effects) },
label = "statusText",
) { text ->
Text(
text = text,
style = MaterialTheme.typography.headlineMedium,
color = statusColor,
)
}
Spacer(Modifier.height(ShxSpacing.xs))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (state.profiles.isEmpty())
{
item(key = "empty") {
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) {
ShxEmptyState(
icon = Icons.Rounded.Language,
message = stringResource(R.string.no_profiles),
actionLabel = stringResource(R.string.add_profile_cta),
onAction = onAddProfile,
)
}
}
}
val lockdown = remember { context.isVpnLockdownEnabled() }
/* F_CertExpiry: the CA of the shown profile runs out soon */
val certAlias = (state.activeProfile ?: selected)?.certificateAlias
var certSnoozes by remember { mutableStateOf(0) }
val certExpiry by produceState<CertExpiry?>(null, certAlias, certSnoozes) {
value = withContext(Dispatchers.IO) { certExpiry(context, certAlias) }
}
val connectingNow = state.vpnState == State.CONNECTING
val connectStep by produceState(1, connectingNow) {
while (connectingNow)
{
value = withContext(Dispatchers.IO) { connectProgress(context) }
kotlinx.coroutines.delay(600)
}
}
val historyChanges by org.strongswan.android.logic.stats.TrafficHistory.changes.collectAsStateWithLifecycle()
val today by produceState<org.strongswan.android.logic.stats.DayTraffic?>(null, historyChanges) {
value = withContext(Dispatchers.IO) { org.strongswan.android.logic.stats.TrafficHistory.today(context) }
}
val ssid by viewModel.ssid.collectAsStateWithLifecycle()
var hintTick by remember { mutableStateOf(0) }
val hint by produceState<org.strongswan.android.ui.compose.home.ScenarioHint?>(null, ssid, state.vpnState, hintTick) {
value = withContext(Dispatchers.IO) { org.strongswan.android.ui.compose.home.ScenarioHints.hint(context, ssid) }
}
val extras by remember { org.strongswan.android.logic.profiles.ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
val shownUuid = (state.activeProfile ?: selected)?.getUUID()?.toString()
val hasLink = shownUuid != null && extras[shownUuid]?.source == org.strongswan.android.logic.profiles.ProfileSourceType.LINK
val model = HomeModel(
state = state,
traffic = traffic,
exit = exit,
serverRttMs = probes[ProbeTarget.SERVER]?.takeIf { it.ok }?.rttMs,
problem = problem,
whiteListActive = whiteListActive,
pausedUntil = if (pauseActive) pauseUntil else 0L,
now = now,
ruDirect = ruState.enabled,
lockdown = lockdown,
networkKinds = networkKinds,
unread = unreadEvents,
findingFastest = findingFastest,
certExpiry = certExpiry,
connectStep = connectStep,
today = today,
hint = hint,
hasLink = hasLink,
)
val actions = HomeActions(
connect = { selected?.let { viewModel.connect(context, it) } },
disconnect = { viewModel.disconnect() },
retry = { viewModel.reconnect() },
resume = { viewModel.resumeNow() },
extendPause = { viewModel.extendPause(DEFAULT_PAUSE_MINUTES) },
openPause = { pauseSheetOpen = true },
fastest = { viewModel.connectFastest(context) },
pickServer = { pickerOpen = true },
addServer = onAddProfile,
editProfile = { selected?.let(onEditProfile) },
showLog = onShowLog,
inbox = { context.startActivity(Intent(context, InboxActivity::class.java)) },
diagnostics = { context.startActivity(Intent(context, DiagnosticsActivity::class.java)) },
routeCheck = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) },
networkSettings = {
val panel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
Intent(Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
else
Intent(Settings.ACTION_WIRELESS_SETTINGS)
try
{
item(key = "chip") {
ProfileChip(
profile = selected,
exit = exit,
connected = connected,
onClick = { pickerOpen = true },
modifier = Modifier.padding(horizontal = ShxSpacing.lg).animateItem(),
)
}
context.startActivity(panel)
}
item(key = "traffic") {
AnimatedVisibility(
visible = connected,
enter = fadeIn(effects) + expandVertically(spatial),
exit = fadeOut(effects) + shrinkVertically(spatial),
) {
TrafficTiles(traffic = traffic, onClick = { viewModel.openDetails(true) })
}
}
}
val rightItems: LazyListScope.() -> Unit = {
item(key = "banner") {
HomeBannerSlot(
error = hasError,
whiteListActive = whiteListActive,
onRetry = { viewModel.reconnect() },
onShowLog = onShowLog,
)
}
if (connected)
catch (_: android.content.ActivityNotFoundException)
{
item(key = "availability") {
AvailabilityCard(
probes = probes,
probing = probing,
whiteList = whiteList,
history = probeHistory,
deadTargets = deadTargets,
onOpenRouting = { viewModel.setTab(ShxTab.ROUTING) },
onForceTunnel = viewModel::forceTunnelDeadTarget,
onRefresh = { viewModel.refreshProbes() },
)
}
context.startActivity(Intent(Settings.ACTION_SETTINGS))
}
item(key = "space") { Spacer(Modifier.height(ShxSpacing.xl)) }
}
if (wide)
{
/* expanded width: hero and profile on the left, status blocks on the right */
Row(Modifier.fillMaxSize().statusBarsPadding())
},
vpnSettings = {
try
{
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ShxSpacing.lg),
) { leftItems() }
LazyColumn(
modifier = Modifier.weight(1f).fillMaxHeight().padding(top = ShxSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ShxSpacing.lg),
) { rightItems() }
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
}
}
else
{
LazyColumn(
state = listState,
/* the adaptive navigation scaffold no longer pads the content area,
* so the hero offsets itself below the status bar */
modifier = Modifier.fillMaxSize().statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ShxSpacing.lg),
)
catch (_: android.content.ActivityNotFoundException)
{
leftItems()
rightItems()
}
},
details = { viewModel.openDetails(true) },
certUpdate = {
if (hasLink && shownUuid != null) context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, shownUuid, checkUpdate = true))
else ShxNavigation.requestAddServer()
},
updateProfile = { shownUuid?.let { context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, it, checkUpdate = true)) } },
stats = { context.startActivity(Intent(context, org.strongswan.android.ui.StatsActivity::class.java)) },
scenarios = { context.startActivity(Intent(context, org.strongswan.android.ui.ScenariosActivity::class.java)) },
suggestYes = {
ssid?.let { org.strongswan.android.ui.compose.home.ScenarioHints.accept(context, it, selected?.getUUID()?.toString()) }
hintTick++
},
suggestNo = {
ssid?.let { org.strongswan.android.ui.compose.home.ScenarioHints.decline(context, it) }
hintTick++
},
certLater = {
certAlias?.let { snoozeCertExpiry(context, it) }
certSnoozes++
},
)
LazyColumn(
state = listState,
/* the adaptive navigation scaffold no longer pads the content area,
* so the header offsets itself below the status bar */
modifier = Modifier.fillMaxSize().statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
)
{
item(key = "home") {
Box(Modifier.widthIn(max = 560.dp))
{
HomeContent(model, actions)
}
}
}
@@ -380,7 +372,7 @@ fun HomeScreen(
},
onLongPick = { profile ->
pickerOpen = false
sheetProfile = profile
context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString()))
},
onManage = {
pickerOpen = false
@@ -393,69 +385,29 @@ fun HomeScreen(
)
}
if (state.detailsOpen)
if (pauseSheetOpen)
{
ConnectionDetailsSheet(
profile = state.activeProfile ?: state.selected,
traffic = traffic,
durationSec = durationSec,
probes = probes,
exit = exit,
onDismiss = { viewModel.openDetails(false) },
onDisconnect = {
viewModel.openDetails(false)
viewModel.disconnect()
PauseSheet(
onDismiss = { pauseSheetOpen = false },
onPick = { minutes ->
pauseSheetOpen = false
viewModel.pause(minutes)
},
onMorning = {
pauseSheetOpen = false
viewModel.pauseUntilMorning()
},
onNetworkChange = {
pauseSheetOpen = false
viewModel.pauseUntilNetworkChange()
},
)
}
sheetProfile?.let { profile ->
ProfileActionsSheet(
profile = profile,
active = connected && profile.getUUID() == state.activeProfile?.getUUID(),
onDismiss = { sheetProfile = null },
onConnect = {
sheetProfile = null
viewModel.select(profile)
viewModel.connect(context, profile)
},
onEdit = {
sheetProfile = null
onEditProfile(profile)
},
onShare = {
sheetProfile = null
onShareProfile(profile)
},
onDelete = {
sheetProfile = null
pendingDelete = profile
},
)
}
pendingDelete?.let { profile ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.delete_profile)) },
text = { Text(profile.name ?: "") },
confirmButton = {
TextButton(onClick = {
viewModel.delete(profile)
pendingDelete = null
}) { Text(stringResource(R.string.delete_profile)) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
/** Bottom sheet to switch the profile without leaving the Connect tab. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
/** «Server» sheet of Home: the servers as on F_Servers, manage / add below. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ProfilePickerSheet(
profiles: List<VpnProfile>,
@@ -468,279 +420,58 @@ private fun ProfilePickerSheet(
onAdd: () -> Unit,
)
{
val c = LocalShxColors.current
/* the current profile first, the rest in list order */
val ordered = remember(profiles, selected) {
profiles.sortedByDescending { it.getUUID() == selected?.getUUID() }
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
ShxSheet(onDismiss = onDismiss)
{
Column(Modifier.fillMaxWidth())
ShxSheetTitle(stringResource(R.string.picker_title))
ShxPanel()
{
Text(
text = stringResource(R.string.home_profile_picker_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.sm).semantics { heading() },
)
ordered.forEachIndexed { index, profile ->
ShxProfileRow(
profile = profile,
active = profile.getUUID() == activeUuid,
onClick = { onPick(profile) },
onLongClick = { onLongPick(profile) },
onLongClickLabel = stringResource(R.string.profile_actions),
trailing = if (profile.getUUID() == selected?.getUUID() && profile.getUUID() != activeUuid)
{
{ Icon(Icons.Rounded.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }
}
else null,
val active = profile.getUUID() == activeUuid
val chosen = profile.getUUID() == selected?.getUUID()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(68.dp)
.combinedClickable(
onClick = { onPick(profile) },
onLongClick = { onLongPick(profile) },
onLongClickLabel = stringResource(R.string.profile_actions),
)
.padding(horizontal = 14.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(if (active) c.accent else c.tile))
{
Text(
org.strongswan.android.ui.compose.home.serverCode(profile, null),
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = if (active) Color.White else c.onAccentContainer,
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp))
{
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle, color = c.text, maxLines = 1)
Text(profile.gateway ?: "", style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1)
}
if (chosen)
{
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
}
}
if (index < ordered.lastIndex)
{
ShxDivider()
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
horizontalArrangement = Arrangement.SpaceBetween,
)
{
TextButton(onClick = onManage) { Text(stringResource(R.string.home_manage_profiles)) }
TextButton(onClick = onAdd) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(ShxSpacing.xs))
Text(stringResource(R.string.add_profile_cta))
}
}
Spacer(Modifier.height(ShxSpacing.lg))
}
}
}
/** Soft state-colored glow behind the hero; the pulse is skipped when system animations are off. */
@Composable
private fun StatusGlowBackground(color: Color, active: Boolean)
{
val context = LocalContext.current
val animationsOn = remember {
android.provider.Settings.Global.getFloat(
context.contentResolver,
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
) != 0f
}
val animated by animateColorAsState(color, label = "glow")
val transition = rememberInfiniteTransition(label = "pulse")
/* read inside the draw scope below so the pulse only invalidates drawing,
* instead of recomposing this composable on every frame */
val pulse by transition.animateFloat(
initialValue = 0.30f,
targetValue = 0.55f,
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Reverse),
label = "a",
)
Canvas(Modifier.fillMaxSize())
{
val alpha = if (active) (if (animationsOn) pulse else 0.42f) else 0f
val cx = size.width / 2f
val cy = size.height * 0.16f
drawCircle(
brush = Brush.radialGradient(
colors = listOf(animated.copy(alpha = alpha * 0.30f), Color.Transparent),
center = Offset(cx, cy),
radius = size.minDimension * 0.62f,
),
radius = size.minDimension * 0.62f,
center = Offset(cx, cy),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConnectionDetailsSheet(
profile: VpnProfile?,
traffic: TrafficUi,
durationSec: Long,
probes: Map<ProbeTarget, ProbeResult>,
exit: ExitUi?,
onDismiss: () -> Unit,
onDisconnect: () -> Unit,
)
{
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
Column(Modifier.padding(horizontal = 24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
{
ProfileAvatar(icon = Icons.Rounded.Language, connected = true)
Column {
Text(
profile?.name ?: "",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
profile?.gateway ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(16.dp))
DetailRow(icon = Icons.Rounded.Language, label = stringResource(R.string.profile_gateway_label), value = profile?.gateway ?: "—")
DetailRow(icon = Icons.Rounded.Schedule, label = stringResource(R.string.duration_label), value = formatDuration(durationSec))
DetailRow(
icon = Icons.Rounded.ArrowDownward,
label = stringResource(R.string.traffic_down),
value = "${formatBytes(traffic.bytesIn)} · ${formatRate(traffic.rateIn)}",
)
DetailRow(
icon = Icons.Rounded.ArrowUpward,
label = stringResource(R.string.traffic_up),
value = "${formatBytes(traffic.bytesOut)} · ${formatRate(traffic.rateOut)}",
)
/* RU-split session breakdown; null in the system-routes mode where
* the engine never sees the direct traffic */
traffic.split?.let { split ->
DetailRow(
icon = Icons.Rounded.AltRoute,
label = stringResource(R.string.traffic_split_tunnel),
value = "${formatBytes(split.tunnelBytes)} · ${formatRate(split.tunnelRate)}",
)
DetailRow(
icon = Icons.Rounded.AltRoute,
label = stringResource(R.string.traffic_split_direct),
value = "${formatBytes(split.directBytes)} · ${formatRate(split.directRate)}",
)
}
exit?.let { e ->
e.ip?.let { ip ->
DetailRow(
icon = Icons.Rounded.Public,
label = stringResource(R.string.exit_ip_label),
value = e.country?.let { "$ip · $it" } ?: ip,
)
}
e.network?.let { network ->
DetailRow(
icon = if (e.ssid != null) Icons.Rounded.Wifi else Icons.Rounded.SignalCellularAlt,
label = stringResource(R.string.network_label),
value = e.ssid?.let { "$network · $it" } ?: network,
)
}
}
if (probes.isNotEmpty())
{
probes.sortedForDisplay().forEach { result ->
DetailRow(
icon = Icons.Rounded.Dns,
label = stringResource(result.target.labelRes()),
value = result.rttText(),
)
}
}
Spacer(Modifier.height(24.dp))
FilledTonalButton(
onClick = onDisconnect,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.disconnect))
}
Spacer(Modifier.height(32.dp))
ShxButton(stringResource(R.string.picker_all), onManage, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.picker_add), onAdd, style = ShxButtonStyle.OUTLINE, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.Plus)
}
}
}
@Composable
private fun DetailRow(icon: ImageVector, label: String, value: String)
{
ListItem(
leadingContent = {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier
.padding(8.dp)
.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
headlineContent = { Text(label) },
supportingContent = { Text(value) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileActionsSheet(
profile: VpnProfile,
active: Boolean,
onDismiss: () -> Unit,
onConnect: () -> Unit,
onEdit: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
)
{
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
Column(Modifier.fillMaxWidth())
{
ShxProfileRow(profile = profile, active = active, onClick = {}, onLongClick = {})
ShxDivider(indent = false)
SheetAction(icon = Icons.Rounded.PlayArrow, label = stringResource(R.string.connect), onClick = onConnect)
ShxDivider(indent = false)
SheetAction(icon = Icons.Rounded.Edit, label = stringResource(R.string.edit_profile), onClick = onEdit)
ShxDivider(indent = false)
SheetAction(icon = Icons.Rounded.Share, label = stringResource(R.string.share_profile), onClick = onShare)
ShxDivider(indent = false)
SheetAction(
icon = Icons.Rounded.Delete,
label = stringResource(R.string.delete_profile),
tint = MaterialTheme.colorScheme.error,
onClick = onDelete,
)
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun SheetAction(icon: ImageVector, label: String, onClick: () -> Unit, tint: Color = Color.Unspecified)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 16.dp),
)
{
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
Spacer(Modifier.size(16.dp))
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}
@@ -24,7 +24,9 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.IBinder
import android.os.SystemClock
import android.widget.Toast
@@ -35,12 +37,18 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -50,6 +58,7 @@ import org.strongswan.android.data.ProfileExportProvider
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.PauseManager
import org.strongswan.android.logic.VpnProfileExporter
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.VpnStateService.ErrorState
@@ -118,6 +127,8 @@ data class HomeUiState(
val vpnState: State = State.DISABLED,
val error: ErrorState = ErrorState.NO_ERROR,
val errorText: Int = 0,
/** seconds until the next automatic retry while an error is shown, 0 if none */
val retryIn: Int = 0,
val activeProfile: VpnProfile? = null,
val profiles: List<VpnProfile> = emptyList(),
val selected: VpnProfile? = null,
@@ -131,6 +142,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
private val _messages = MutableSharedFlow<Int>(extraBufferCapacity = 4)
val messages: SharedFlow<Int> = _messages
private val homeVisible = MutableStateFlow(false)
private val _ui = MutableStateFlow(HomeUiState())
val ui: StateFlow<HomeUiState> = _ui
@@ -158,6 +170,75 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
private val _deadTargets = MutableStateFlow<List<DeadTargetUi>>(emptyList())
val deadTargets: StateFlow<List<DeadTargetUi>> = _deadTargets
/* wall-clock end of a running "pause VPN", 0 if none */
val pauseUntil: StateFlow<Long> = PauseManager.until(app)
/* whether any non-VPN network is up; tells "no internet" apart from a server problem */
private val _networkAvailable = MutableStateFlow(true)
val networkAvailable: StateFlow<Boolean> = _networkAvailable
/* (Wi-Fi up, mobile data up) for the "no internet" checklist */
private val _networkKinds = MutableStateFlow(false to false)
val networkKinds: StateFlow<Pair<Boolean, Boolean>> = _networkKinds
/* the current Wi-Fi network (null off Wi-Fi or without location access) */
private val _ssid = MutableStateFlow<String?>(null)
val ssid: StateFlow<String?> = _ssid
private val underlyingNetworks = HashMap<Network, NetworkCapabilities?>()
private val networkCallback = object : ConnectivityManager.NetworkCallback()
{
override fun onAvailable(network: Network)
{
synchronized(underlyingNetworks)
{
underlyingNetworks[network] = null
publishNetworks()
}
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
{
synchronized(underlyingNetworks)
{
val previous = underlyingNetworks.put(network, capabilities)
/* fired on every signal-strength tick: the published state (network
* kinds, SSID) only depends on the transports, and reading the SSID
* is a system call, so unchanged transports end here */
if (previous != null && sameTransports(previous, capabilities))
{
return
}
publishNetworks()
}
}
override fun onLost(network: Network)
{
synchronized(underlyingNetworks)
{
underlyingNetworks.remove(network)
publishNetworks()
}
}
}
private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
private fun publishNetworks()
{
_networkAvailable.value = underlyingNetworks.isNotEmpty()
val caps = underlyingNetworks.values.filterNotNull()
_networkKinds.value = caps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } to
caps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) }
val wifi = caps.firstOrNull { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) }
_ssid.value = wifi?.let { org.strongswan.android.logic.autoconnect.SsidReader.from(getApplication(), it) }
}
/* true while «Fastest» pings the servers */
private val _findingFastest = MutableStateFlow(false)
val findingFastest: StateFlow<Boolean> = _findingFastest
private var service: VpnStateService? = null
/* written from the main thread (pullState) and the poll coroutine (Default) */
@@ -204,46 +285,78 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
)
reloadProfiles()
WhiteListManager.getInstance(ctx).refreshIfStale()
/* poll traffic only while the app is visible, and off the main thread */
/* the default request excludes VPN networks: this tracks the real uplinks */
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
try
{
cm?.registerNetworkCallback(
NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build(),
networkCallback,
)
}
catch (_: RuntimeException)
{ /* too many callbacks registered app-wide; keep assuming a network */
}
/* poll traffic only while the Home screen is shown (not on the other
* tabs or behind another activity), the VPN is up, and off the main thread */
viewModelScope.launch {
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (isActive)
{
whileHomeConnected {
tickTraffic()
delay(TRAFFIC_POLL_MS)
}
}
}
/* probe availability while the app is visible and the VPN is connected */
/* probe availability under the same conditions */
viewModelScope.launch {
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
var lastProbeAt = 0L
while (isActive)
{
val connected = _ui.value.vpnState == State.CONNECTED
if (connected && SystemClock.elapsedRealtime() - lastProbeAt >= PROBE_INTERVAL_MS)
whileHomeConnected {
val wait = PROBE_INTERVAL_MS - (SystemClock.elapsedRealtime() - lastProbeAt)
if (wait > 0)
{
lastProbeAt = SystemClock.elapsedRealtime()
probeNow()
delay(wait)
}
delay(PROBE_TICK_MS)
lastProbeAt = SystemClock.elapsedRealtime()
probeNow()
}
}
}
}
/**
* Runs [block] repeatedly while the Home screen is visible and the VPN is
* connected; suspends without any polling otherwise. [block] is
* cancelled as soon as either condition goes away.
*/
private suspend fun whileHomeConnected(block: suspend () -> Unit)
{
combine(homeVisible, _ui.map { it.vpnState == State.CONNECTED }) { visible, connected ->
visible && connected
}.distinctUntilChanged().collectLatest { active ->
while (active && currentCoroutineContext().isActive)
{
block()
}
}
}
/** Called by the Home screen while it is (not) shown. */
fun setHomeVisible(visible: Boolean)
{
homeVisible.value = visible
}
/** Immediately reruns the availability probe round (refresh button). */
fun refreshProbes()
{
WhiteListManager.getInstance(getApplication()).checkNow()
viewModelScope.launch {
probeNow()
refreshExit(force = true)
refreshDeadTargets()
probeNow(forceExit = true)
}
}
private suspend fun probeNow()
private suspend fun probeNow(forceExit: Boolean = false)
{
/* direct (outside the tunnel) probes: at most once per TTL, event-driven */
WhiteListManager.getInstance(getApplication()).refreshIfStale()
@@ -263,7 +376,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
addAll(healthConfig.targets)
}
refreshDeadTargets()
viewModelScope.launch { refreshExit(force = false) }
viewModelScope.launch { refreshExit(force = forceExit) }
if (targets.isEmpty())
{
return
@@ -308,6 +421,13 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
{
}
LocalBroadcastManager.getInstance(ctx).unregisterReceiver(profilesChanged)
try
{
(ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)?.unregisterNetworkCallback(networkCallback)
}
catch (_: IllegalArgumentException)
{
}
super.onCleared()
}
@@ -333,6 +453,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
fun connect(context: Context, profile: VpnProfile)
{
org.strongswan.android.ui.compose.home.ScenarioHints.manualOn(context)
val intent = Intent(context, VpnProfileControlActivity::class.java)
intent.action = VpnProfileControlActivity.START_PROFILE
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profile.getUUID().toString())
@@ -341,6 +462,9 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
fun disconnect()
{
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
/* counted per Wi-Fi for the «turn it off here automatically?» suggestion */
org.strongswan.android.ui.compose.home.ScenarioHints.manualOff(getApplication(), if (_networkKinds.value.first) _ssid.value else null)
service?.disconnect()
}
@@ -349,17 +473,108 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
service?.reconnect()
}
/** Disconnects for [minutes]; the tunnel comes back by itself when the pause ends. */
fun pause(minutes: Int)
{
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
val svc = service ?: return
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
PauseManager.pause(getApplication(), svc, profile.getUUID().toString(), minutes)
}
fun extendPause(minutes: Int)
{
PauseManager.extend(getApplication(), minutes)
}
/** Pause until 07:00 (today or tomorrow). */
fun pauseUntilMorning()
{
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
val svc = service ?: return
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
PauseManager.pauseUntil(getApplication(), svc, profile.getUUID().toString(), PauseManager.nextMorning(System.currentTimeMillis()))
}
/** Pause until the device moves to another network. */
fun pauseUntilNetworkChange()
{
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
val svc = service ?: return
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
PauseManager.pauseUntilNetworkChange(getApplication(), svc, profile.getUUID().toString())
}
/**
* «Fastest»: pings every profile's server directly (outside the tunnel)
* and connects the one that answers first; a message if none answers.
*/
fun connectFastest(context: Context)
{
if (_findingFastest.value)
{
return
}
val profiles = _ui.value.profiles
if (profiles.isEmpty())
{
return
}
_findingFastest.value = true
viewModelScope.launch {
try
{
val rtts = coroutineScope {
profiles.map { profile ->
async {
profile to ConnectivityProbe.pingServerDirect(getApplication(), profile.gateway, FASTEST_TIMEOUT_MS)?.first
}
}.map { it.await() }
}
val best = rtts.filter { it.second != null }.minByOrNull { it.second!! }?.first
if (best == null)
{
_messages.tryEmit(R.string.home_fastest_none)
return@launch
}
val current = _ui.value.activeProfile
if (_ui.value.vpnState == State.CONNECTED && current?.getUUID() == best.getUUID())
{
_messages.tryEmit(R.string.home_fastest_already)
return@launch
}
select(best)
connect(context, best)
}
finally
{
_findingFastest.value = false
}
}
}
fun resumeNow()
{
PauseManager.resumeNow(getApplication())
}
fun delete(profile: VpnProfile)
{
viewModelScope.launch(Dispatchers.IO) {
val source = VpnProfileSource(getApplication())
source.open()
source.deleteVpnProfile(profile)
source.close()
try
{
source.deleteVpnProfile(profile)
}
finally
{
source.close()
}
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
intent.putExtra(Constants.VPN_PROFILES_MULTIPLE, arrayOf(profile.getUUID().toString()))
/* the broadcast reaches profilesChanged, which reloads the list */
LocalBroadcastManager.getInstance(getApplication()).sendBroadcast(intent)
reloadProfiles()
}
}
@@ -369,11 +584,16 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
viewModelScope.launch(Dispatchers.IO) {
val source = VpnProfileSource(getApplication())
source.open()
source.insertProfile(profile)
source.close()
try
{
source.insertProfile(profile)
}
finally
{
source.close()
}
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
LocalBroadcastManager.getInstance(getApplication()).sendBroadcast(intent)
reloadProfiles()
}
}
@@ -408,8 +628,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
viewModelScope.launch(Dispatchers.IO) {
val source = VpnProfileSource(getApplication())
source.open()
val all = source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
source.close()
val all = try
{
source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
}
finally
{
source.close()
}
_ui.update { state ->
val selected = all.firstOrNull { it.getUUID() == state.selected?.getUUID() }
?: all.firstOrNull { it.getUUID() == state.activeProfile?.getUUID() }
@@ -450,6 +676,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
vpnState = state,
error = svc.errorState,
errorText = svc.errorText,
retryIn = if (svc.errorState != ErrorState.NO_ERROR) svc.retryIn else 0,
activeProfile = svc.profile,
selected = svc.profile ?: it.selected,
)
@@ -630,10 +857,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
companion object
{
private const val TRAFFIC_POLL_MS = 1000L
private const val PROBE_TICK_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_TIMEOUT_MS = 5000
private const val FASTEST_TIMEOUT_MS = 1500
/** Parses "ip:port|fails|window_ms|left_ms|via_tunnel" entries of the native snapshot. */
fun parseDeadTargets(raw: Array<String>?): List<DeadTargetUi> =
@@ -11,53 +11,59 @@
package org.strongswan.android.ui.compose
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.FileDownload
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.VpnKey
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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.graphics.vector.ImageVector
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
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.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.ui.unit.sp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.LogActivity
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.design.ShxSwitch
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.ui.compose.theme.ShxType
/**
* Observable UI state of the profile import screen. Owned by the (Java)
@@ -89,21 +95,28 @@ class ImportUiModel
var remoteCertPrimary by mutableStateOf<String?>(null)
var remoteCertSecondary by mutableStateOf<String?>(null)
var hideImport by mutableStateOf(false)
/** uri scheme of the profile source (https, content, file) */
var source by mutableStateOf<String?>(null)
var sourceUri by mutableStateOf<String?>(null)
/** set when the profile could not be read; the sheet shows it with a retry */
var loadError by mutableStateOf<String?>(null)
/** «Connect right away» after adding */
var connectNow by mutableStateOf(true)
}
/** Renders the import UI as a bottom sheet in the transparent import activity. */
@OptIn(ExperimentalMaterial3Api::class)
fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnable, onClose: Runnable,
onImportPkcs12: Runnable, onSelectUserCert: Runnable)
/** Renders the import UI as the mockup sheet (F_ImportPreview / F_ErrImport) in the transparent import activity. */
fun ComponentActivity.installImportScreen(
model: ImportUiModel,
onImport: Runnable,
onClose: Runnable,
onImportPkcs12: Runnable,
onSelectUserCert: Runnable,
onRetry: Runnable,
)
{
setContent {
ShxTheme {
/* the activity is a transparent host: the import UI is an Expressive bottom sheet */
ModalBottomSheet(
onDismissRequest = onClose::run,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.background,
)
ShxSheet(onDismiss = onClose::run)
{
ImportScreen(
model = model,
@@ -111,13 +124,13 @@ fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnab
onClose = onClose::run,
onImportPkcs12 = onImportPkcs12::run,
onSelectUserCert = onSelectUserCert::run,
onRetry = onRetry::run,
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImportScreen(
model: ImportUiModel,
@@ -125,221 +138,241 @@ fun ImportScreen(
onClose: () -> Unit,
onImportPkcs12: () -> Unit,
onSelectUserCert: () -> Unit,
onRetry: () -> Unit,
)
{
Column(Modifier.fillMaxSize())
val c = LocalShxColors.current
val context = LocalContext.current
when
{
TopAppBar(
title = { Text(stringResource(R.string.profile_import), fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Rounded.Close, contentDescription = stringResource(android.R.string.cancel))
}
},
actions = {
if (model.showForm && !model.hideImport)
model.loadError != null || (model.hideImport && model.existsWarning != null) ->
{
/* F_ErrImport */
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(c.errorContainer))
{
TextButton(onClick = onImport) { Text(stringResource(R.string.profile_edit_import)) }
Icon(ShxIcons.Alert, contentDescription = null, tint = c.error, modifier = Modifier.size(26.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(
stringResource(if (model.source?.startsWith("http") == true) R.string.import_failed_download else R.string.import_failed_read),
style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text,
)
model.sourceUri?.let {
Text(it.substringAfter("://"), style = ShxType.sub.copy(fontFamily = FontFamily.Monospace), color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
},
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
if (model.loading)
{
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
LoadingIndicator()
}
return
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = ShxSpacing.lg)
.padding(bottom = ShxSpacing.xl),
)
{
if (model.existsWarning != null)
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),
)
{
ImportBanner(
text = model.existsWarning ?: "",
container = MaterialTheme.colorScheme.errorContainer,
content = MaterialTheme.colorScheme.onErrorContainer,
)
}
ShxCard(modifier = Modifier.padding(top = ShxSpacing.md))
{
ImportInfoRow(Icons.Rounded.Language, stringResource(R.string.profile_name_label_simple), model.name)
ImportInfoRow(Icons.Rounded.Dns, stringResource(R.string.profile_gateway_label), model.gateway)
ImportInfoRow(Icons.Rounded.VpnKey, stringResource(R.string.profile_vpn_type_label), model.vpnType, divider = false)
}
if (model.requireUserPass)
{
Text(
stringResource(R.string.profile_username_label).uppercase().replace(":", ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
Column(Modifier.padding(ShxSpacing.lg)) {
OutlinedTextField(
value = model.username,
onValueChange = { model.username = it },
label = { Text(stringResource(R.string.profile_username_label)) },
singleLine = true,
enabled = model.usernameEditable,
isError = model.usernameError,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(ShxSpacing.md))
OutlinedTextField(
value = model.password,
onValueChange = { model.password = it },
label = { Text(stringResource(R.string.profile_password_label)) },
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
singleLine = true,
enabled = model.passwordEditable,
modifier = Modifier.fillMaxWidth(),
Text(stringResource(R.string.import_reasons).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
val reasons = if (model.source?.startsWith("http") == true) listOf(
R.string.import_reason_blocked to R.string.import_reason_blocked_d,
R.string.import_reason_offline to R.string.import_reason_offline_d,
R.string.import_reason_tls to R.string.import_reason_tls_d,
) else listOf(R.string.import_reason_format to R.string.import_reason_format_d)
reasons.forEach { (t, d) ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.error))
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(t)) }
append(" — ")
withStyle(SpanStyle(color = c.textSub)) { append(stringResource(d)) }
},
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
)
}
}
if (model.sharedSecretWarning)
(model.loadError ?: model.existsWarning)?.let {
Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
}
ShxNote(stringResource(R.string.import_ask_admin))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
if (model.source?.startsWith("http") == true)
{
ImportBanner(
text = stringResource(R.string.profile_import_shared_secret),
container = MaterialTheme.colorScheme.surfaceContainerHigh,
content = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = ShxSpacing.sm),
ShxButton(stringResource(R.string.retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
}
ShxButton(stringResource(R.string.add_close), onClose, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
{
Text(
stringResource(R.string.import_support_details),
style = ShxType.chip, color = c.accentText,
modifier = Modifier.clip(RoundedCornerShape(12.dp))
.clickable(role = Role.Button) { context.startActivity(Intent(context, LogActivity::class.java)) }.padding(8.dp),
)
}
}
model.loading ->
{
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp))
{
LoadingIndicator(Modifier.size(48.dp))
Text(stringResource(R.string.import_reading), style = ShxType.body, color = c.textSub)
}
}
else ->
{
/* F_ImportPreview */
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(56.dp).clip(CircleShape).background(c.accentContainer))
{
Text(model.name.filter { it.isLetterOrDigit() }.take(2).uppercase().ifEmpty { "VPN" }, style = ShxType.cardHeading.copy(fontSize = 18.sp), color = c.onAccentContainer)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.import_add_title), style = ShxType.sheetTitle, color = c.text)
Text(stringResource(R.string.import_read_ok), style = ShxType.sub, color = c.textSub)
}
}
val shape = RoundedCornerShape(24.dp)
Column(Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape))
{
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp))
{
Text(stringResource(R.string.import_name), style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.textSub)
BasicTextField(
value = model.name,
onValueChange = { model.name = it },
singleLine = true,
textStyle = ShxType.rowTitle.copy(color = c.text),
cursorBrush = SolidColor(c.accent),
modifier = Modifier.fillMaxWidth(),
)
}
}
if (model.requireUserCert)
{
Text(
stringResource(R.string.profile_user_certificate_label).uppercase().replace(":", ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onSelectUserCert)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
) {
Column(Modifier.weight(1f)) {
Text(
model.userCertAlias ?: stringResource(R.string.profile_user_select_certificate_label),
style = MaterialTheme.typography.bodyLarge,
color = if (model.userCertError) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurface,
)
Text(
model.userCertSubject
?: stringResource(R.string.profile_user_select_certificate),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (model.userCertLoading)
{
LoadingIndicator(Modifier.size(18.dp))
}
}
if (model.showImportUserCert)
{
TextButton(onClick = onImportPkcs12, modifier = Modifier.padding(start = ShxSpacing.sm, bottom = ShxSpacing.sm)) {
Icon(Icons.Rounded.FileDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.profile_cert_import))
}
}
val rows = buildList {
add(stringResource(R.string.import_server) to (model.gateway to c.text))
add(stringResource(R.string.import_type) to (model.vpnType to c.text))
add(stringResource(R.string.import_login) to ((when
{
model.requireUserCert && model.requireUserPass -> stringResource(R.string.import_login_both)
model.requireUserCert -> stringResource(R.string.import_login_cert)
else -> stringResource(R.string.import_login_password)
}) to c.text))
add(stringResource(R.string.import_source) to (stringResource(if (model.source?.startsWith("http") == true) R.string.import_source_link else R.string.import_source_file) to c.text))
if (model.showRemoteCert)
{
add(stringResource(R.string.import_ca) to (stringResource(R.string.import_ca_included) to c.onSuccess))
}
}
rows.forEach { (k, v) ->
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 16.dp))
{
Text(k, style = ShxType.body, color = c.textSoft, maxLines = 1, softWrap = false)
Text(
v.first, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = v.second, maxLines = 1, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
)
}
}
}
if (model.showRemoteCert)
if (model.requireUserPass)
{
ImportField(stringResource(R.string.profile_username_label).trimEnd(':'), model.username, model.usernameEditable, model.usernameError) { model.username = it }
ImportField(stringResource(R.string.profile_password_label).trimEnd(':'), model.password, model.passwordEditable, false, secret = true) { model.password = it }
if (model.sharedSecretWarning)
{
ShxNote(stringResource(R.string.profile_import_shared_secret), tone = ShxTone.NEUTRAL)
}
}
if (model.requireUserCert)
{
val certShape = RoundedCornerShape(24.dp)
Column(Modifier.fillMaxWidth().clip(certShape).background(c.card).border(1.dp, if (model.userCertError) c.error else c.cardBorder, certShape))
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clickable(onClick = onSelectUserCert).padding(horizontal = 16.dp, vertical = 12.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(model.userCertAlias ?: stringResource(R.string.profile_user_select_certificate_label), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = if (model.userCertError) c.error else c.text)
Text(model.userCertSubject ?: stringResource(R.string.profile_user_select_certificate), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
if (model.userCertLoading) LoadingIndicator(Modifier.size(20.dp))
else Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
}
if (model.showImportUserCert)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
Text(
stringResource(R.string.profile_cert_import), style = ShxType.chip, color = c.accentText,
modifier = Modifier.fillMaxWidth().clickable(onClick = onImportPkcs12).padding(horizontal = 16.dp, vertical = 14.dp),
)
}
}
}
val toggleShape = RoundedCornerShape(24.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(toggleShape).background(c.card).border(1.dp, c.cardBorder, toggleShape).padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
)
{
Text(stringResource(R.string.import_connect_now), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
ShxSwitch(checked = model.connectNow, onCheckedChange = { model.connectNow = it })
}
if (model.existsWarning != null)
{
Text(
stringResource(R.string.profile_ca_label).uppercase().replace(":", ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.import_exists_title)) }
append(" ")
append(stringResource(R.string.import_exists_text))
},
style = ShxType.note, color = c.textTonal,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.accentSoft).padding(horizontal = 14.dp, vertical = 12.dp),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(android.R.string.cancel), onClose, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(
stringResource(if (model.existsWarning != null) R.string.import_update_server else R.string.import_add_server),
onImport, modifier = Modifier.weight(2f), enabled = !model.hideImport,
)
ShxCard {
ImportInfoRow(Icons.Rounded.Dns, model.remoteCertPrimary ?: "", model.remoteCertSecondary ?: "", divider = false)
}
}
}
}
}
/** Field of the manual/import forms: white box, radius 16, label 12/700, value 16/600. */
@Composable
private fun ImportBanner(
text: String,
container: androidx.compose.ui.graphics.Color,
content: androidx.compose.ui.graphics.Color,
modifier: Modifier = Modifier,
)
private fun ImportField(label: String, value: String, editable: Boolean, error: Boolean, secret: Boolean = false, onChange: (String) -> Unit)
{
Surface(
shape = MaterialTheme.shapes.medium,
color = container,
contentColor = content,
modifier = modifier
.fillMaxWidth()
.padding(top = ShxSpacing.sm),
) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
val c = LocalShxColors.current
val shape = RoundedCornerShape(16.dp)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(if (error) 2.dp else 1.dp, if (error) c.error else c.cardBorder, shape).padding(horizontal = 14.dp, vertical = 10.dp),
)
{
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = if (error) c.error else c.textSub)
BasicTextField(
value = value,
onValueChange = onChange,
enabled = editable,
singleLine = true,
textStyle = ShxType.body.copy(fontSize = 16.sp, color = if (editable) c.text else c.textSub),
cursorBrush = SolidColor(c.accent),
visualTransformation = if (secret) androidx.compose.ui.text.input.PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun ImportInfoRow(icon: ImageVector, label: String, value: String, divider: Boolean = true)
{
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Column(Modifier.padding(start = ShxSpacing.lg)) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(value, style = MaterialTheme.typography.bodyLarge)
}
}
if (divider)
{
androidx.compose.material3.HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
modifier = Modifier.padding(start = 52.dp),
)
}
}
}
@@ -0,0 +1,315 @@
/*
* 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.text.format.DateUtils
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.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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
import org.strongswan.android.logic.events.ShxEvent
import org.strongswan.android.ui.compose.design.ShxChip
import org.strongswan.android.ui.compose.design.ShxChipTone
import org.strongswan.android.ui.compose.design.ShxHeaderAction
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.home.formatClock
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import java.util.Calendar
/** Inbox filters, in chip order. */
internal enum class InboxFilter
{
ALL,
WAITING,
CONNECTION,
ROUTING,
APP;
fun matches(event: ShxEvent): Boolean = when (this)
{
ALL -> true
WAITING -> event.waiting
CONNECTION -> event.category == EventCategory.CONNECTION
ROUTING -> event.category == EventCategory.ROUTING
APP -> event.category == EventCategory.APP
}
}
/** Groups events by calendar day, newest first: (day start, events). */
internal fun groupByDay(events: List<ShxEvent>): List<Pair<Long, List<ShxEvent>>> =
events.sortedByDescending { it.at }.groupBy { dayStart(it.at) }.toList()
internal fun dayStart(at: Long): Long
{
val c = Calendar.getInstance()
c.timeInMillis = at
c.set(Calendar.HOUR_OF_DAY, 0)
c.set(Calendar.MINUTE, 0)
c.set(Calendar.SECOND, 0)
c.set(Calendar.MILLISECOND, 0)
return c.timeInMillis
}
/** «Events» (F_Inbox). */
@Composable
fun InboxScreen(onBack: () -> Unit, onAction: (ShxEvent) -> Unit, onNotificationSettings: () -> Unit)
{
val context = LocalContext.current
val c = LocalShxColors.current
val log = remember { EventLog.getInstance(context) }
val events by log.events.collectAsStateWithLifecycle()
var filter by rememberSaveable { mutableStateOf(InboxFilter.ALL) }
val waiting = events.count { it.waiting }
val shown = remember(events, filter) { groupByDay(events.filter(filter::matches)) }
LazyColumn(
modifier = Modifier.fillMaxSize().background(c.background).statusBarsPadding(),
contentPadding = PaddingValues(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
)
{
item(key = "header") {
ShxPageHeader(stringResource(R.string.events_title), onBack)
{
if (events.any { !it.read })
{
ShxHeaderAction(stringResource(R.string.events_read_all)) { log.markAllRead() }
}
}
}
item(key = "filters") {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 4.dp),
)
{
ShxChip(stringResource(R.string.events_filter_all), filter == InboxFilter.ALL, { filter = InboxFilter.ALL })
ShxChip(
if (waiting > 0) stringResource(R.string.events_filter_waiting_n, waiting) else stringResource(R.string.events_filter_waiting),
filter == InboxFilter.WAITING, { filter = InboxFilter.WAITING },
tone = if (waiting > 0) ShxChipTone.WARNING else ShxChipTone.NORMAL,
)
ShxChip(stringResource(R.string.events_filter_connection), filter == InboxFilter.CONNECTION, { filter = InboxFilter.CONNECTION })
ShxChip(stringResource(R.string.events_filter_routing), filter == InboxFilter.ROUTING, { filter = InboxFilter.ROUTING })
ShxChip(stringResource(R.string.events_filter_app), filter == InboxFilter.APP, { filter = InboxFilter.APP })
}
}
if (shown.isEmpty())
{
item(key = "empty") {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 48.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(18.dp)).background(c.accentContainer))
{
Icon(ShxIcons.Bell, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(26.dp))
}
Text(
stringResource(if (events.isEmpty()) R.string.events_empty else R.string.events_empty_filter),
style = ShxType.note.copy(fontSize = 14.sp),
color = c.textSoft,
textAlign = TextAlign.Center,
)
}
}
}
shown.forEach { (day, dayEvents) ->
item(key = "day-$day") {
ShxSectionHeader(dayLabel(day), Modifier.padding(horizontal = 16.dp))
}
items(dayEvents, key = { it.id }) { event ->
EventCard(event = event, onAction = { onAction(event) }, onDecline = { log.answer(event.id) })
}
}
item(key = "footer") {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 12.dp),
)
{
Text(stringResource(R.string.events_footer), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, textAlign = TextAlign.Center)
ShxHeaderAction(stringResource(R.string.events_notification_settings), onNotificationSettings)
}
}
}
}
@Composable
private fun dayLabel(day: Long): String
{
val context = LocalContext.current
val today = dayStart(System.currentTimeMillis())
return when (day)
{
today -> stringResource(R.string.events_today)
dayStart(today - 12 * 60 * 60 * 1000L) -> stringResource(R.string.events_yesterday)
else -> DateUtils.formatDateTime(context, day, DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR)
}
}
/** Event card of the mockup: radius 22, 36dp icon well, title 15/800 + time, text 13, small buttons. */
@Composable
private fun EventCard(event: ShxEvent, onAction: () -> Unit, onDecline: () -> Unit)
{
val context = LocalContext.current
val c = LocalShxColors.current
val warn = event.kind == EventKind.WARN && !event.resolved
val ask = event.kind == EventKind.ASK && !event.resolved
val (bg, border) = when
{
warn -> c.warningContainer to c.warningBorder
ask -> c.card to c.accentBorder
else -> c.card to c.cardBorder
}
val (icon: ImageVector, iconBg: Color, iconFg: Color) = when (event.kind)
{
EventKind.WARN -> Triple(ShxIcons.Alert, c.warning, Color.White)
EventKind.ASK -> Triple(if (event.category == EventCategory.ROUTING) ShxIcons.Apps else ShxIcons.Help, c.accent, Color.White)
EventKind.INFO -> Triple(
when (event.category)
{
EventCategory.APP -> ShxIcons.Download
EventCategory.ROUTING -> ShxIcons.Route
EventCategory.CONNECTION -> ShxIcons.Swap
},
c.tile, c.onAccentContainer,
)
EventKind.OK -> Triple(ShxIcons.Shield, c.successContainer, c.onSuccess)
}
val actionBg = if (warn) c.warning else c.accent
val shape = RoundedCornerShape(22.dp)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
.alpha(if (event.resolved) 0.7f else 1f)
.clip(shape)
.background(bg)
.border(1.dp, border, shape)
.padding(14.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(iconBg))
{
Icon(icon, contentDescription = null, tint = iconFg, modifier = Modifier.size(18.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
{
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(8.dp))
{
Text(
if (event.count > 1) stringResource(R.string.events_title_count, event.title, event.count) else event.title,
style = ShxType.cardTitle.copy(fontWeight = if (event.read) FontWeight.Bold else FontWeight.ExtraBold),
color = c.text,
modifier = Modifier.weight(1f),
)
Text(formatClock(context, event.at), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
if (event.text.isNotEmpty())
{
Text(event.text, style = ShxType.note, color = c.textSoft)
}
if (event.resolved)
{
Text(stringResource(R.string.events_resolved), style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.success)
}
else if (event.action != EventAction.NONE)
{
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp))
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(actionBg)
.clickable(role = Role.Button, onClick = onAction).padding(horizontal = 14.dp),
)
{
Text(stringResource(event.action.labelRes()), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
}
if (event.kind == EventKind.ASK)
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).border(1.dp, c.outline, RoundedCornerShape(12.dp))
.clickable(role = Role.Button, onClick = onDecline).padding(horizontal = 12.dp),
)
{
Text(stringResource(R.string.events_action_no), style = ShxType.chip, color = c.text)
}
}
}
}
}
}
}
private fun EventAction.labelRes(): Int = when (this)
{
EventAction.OPEN_HOME -> R.string.events_action_open
EventAction.OPEN_ROUTING -> R.string.events_action_routing
EventAction.OPEN_ROUTE_CHECK -> R.string.events_action_check
EventAction.OPEN_LOG -> R.string.show_log
EventAction.INSTALL_UPDATE -> R.string.events_action_update
EventAction.SET_APP_DIRECT -> R.string.events_action_direct
EventAction.PROFILE_UPDATE -> R.string.events_action_profile_update
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
}
@@ -0,0 +1,209 @@
/*
* 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.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
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.FlowRow
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.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.strongswan.android.R
import org.strongswan.android.logic.routing.AppTraffic
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/** How much a permission matters, as the tag on its row. */
enum class PermissionLevel { REQUIRED, IMPORTANT, OPTIONAL }
/** Current state of the five permissions the VPN works best with. */
data class PermissionStatus(
val vpn: Boolean,
val notifications: Boolean,
val battery: Boolean,
val usage: Boolean,
val location: Boolean,
)
@SuppressLint("BatteryLife")
fun permissionStatus(context: Context): PermissionStatus
{
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
return PermissionStatus(
vpn = runCatching { VpnService.prepare(context) == null }.getOrDefault(false),
notifications = NotificationManagerCompat.from(context).areNotificationsEnabled(),
battery = pm?.isIgnoringBatteryOptimizations(context.packageName) ?: true,
usage = AppTraffic.hasAccess(context),
location = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED,
)
}
/**
* «For the VPN to work reliably» (F_Permissions): one card per permission
* with its tag (required / important / optional), why it is needed, and
* a green check or an «Allow» button. Re-checked whenever the screen resumes.
*/
@SuppressLint("BatteryLife")
@Composable
fun PermissionsList(modifier: Modifier = Modifier)
{
val context = LocalContext.current
var resumes by remember { mutableIntStateOf(0) }
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
val status = remember(resumes) { permissionStatus(context) }
val vpnLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { resumes++ }
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { resumes++ }
fun open(intent: Intent) = runCatching { context.startActivity(intent) }
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp))
{
PermissionCard(
ShxIcons.Shield, stringResource(R.string.perm_vpn), PermissionLevel.REQUIRED, stringResource(R.string.perm_vpn_why), status.vpn,
) { runCatching { VpnService.prepare(context) }.getOrNull()?.let { vpnLauncher.launch(it) } }
PermissionCard(
ShxIcons.Bell, stringResource(R.string.perm_notifications), PermissionLevel.REQUIRED, stringResource(R.string.perm_notifications_why), status.notifications,
)
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
{
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
open(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName))
}
}
PermissionCard(
ShxIcons.Phone, stringResource(R.string.perm_battery), PermissionLevel.IMPORTANT, stringResource(R.string.perm_battery_why), status.battery,
/* the vendor switches (step 2 of F_Battery) matter even when Android allows it */
openWhenDone = true,
) { open(Intent(context, org.strongswan.android.ui.BatteryActivity::class.java)) }
PermissionCard(
ShxIcons.Stats, stringResource(R.string.perm_usage), PermissionLevel.OPTIONAL, stringResource(R.string.perm_usage_why), status.usage,
)
{
if (open(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS, Uri.fromParts("package", context.packageName, null))).isFailure)
{
open(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
}
}
PermissionCard(
ShxIcons.Pin, stringResource(R.string.perm_location), PermissionLevel.OPTIONAL, stringResource(R.string.perm_location_why), status.location,
) { permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
}
}
@Composable
private fun PermissionCard(icon: ImageVector, title: String, level: PermissionLevel, why: String, done: Boolean, openWhenDone: Boolean = false, onAllow: () -> Unit)
{
val c = LocalShxColors.current
val (tagBg, tagFg, tag) = when (level)
{
PermissionLevel.REQUIRED -> Triple(c.accent, Color.White, stringResource(R.string.perm_tag_required))
PermissionLevel.IMPORTANT -> Triple(c.warningStrong, c.onWarning, stringResource(R.string.perm_tag_important))
PermissionLevel.OPTIONAL -> Triple(c.tile, c.textSoft, stringResource(R.string.perm_tag_optional))
}
val border = if (done || level == PermissionLevel.OPTIONAL) c.cardBorder else c.accentBorder
val shape = RoundedCornerShape(22.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, border, shape)
.then(if (done && openWhenDone) Modifier.clickable(role = Role.Button, onClick = onAllow) else Modifier).padding(14.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(if (done) c.successContainer else c.tile))
{
Icon(icon, contentDescription = null, tint = if (done) c.onSuccess else c.onAccentContainer, modifier = Modifier.size(20.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(2.dp), itemVerticalAlignment = Alignment.CenterVertically)
{
Text(title, style = ShxType.cardTitle, color = c.text)
Text(
tag.uppercase(), style = ShxType.tag.copy(fontSize = 10.sp), color = tagFg, maxLines = 1, softWrap = false,
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(tagBg).padding(horizontal = 6.dp, vertical = 1.dp),
)
}
Text(why, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub)
}
if (done)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(32.dp).clip(CircleShape).background(c.success))
{
Icon(ShxIcons.Check, contentDescription = stringResource(R.string.perm_done), tint = Color.White, modifier = Modifier.size(18.dp))
}
}
else
{
val optional = level == PermissionLevel.OPTIONAL
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(if (optional) c.tile else c.accent)
.clickable(role = Role.Button, onClick = onAllow).padding(horizontal = 12.dp),
)
{
Text(stringResource(R.string.perm_allow), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = if (optional) c.onAccentContainer else Color.White)
}
}
}
}
@@ -1,179 +0,0 @@
/*
* 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 androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Searchable list of the built-in presets shown inside the rule set editor
* sheet. Selection toggles immediately; "Done"/back returns to the editor.
*/
@Composable
fun PresetPickerContent(
ruleSet: RuleSet,
onToggle: (RoutingPresets.Preset) -> Unit,
onBack: () -> Unit,
)
{
var query by remember { mutableStateOf("") }
val filtered = remember(query)
{
val q = query.trim()
if (q.isEmpty())
{
RoutingPresets.RF_BUNDLE
}
else
{
RoutingPresets.RF_BUNDLE.filter { it.name.contains(q, ignoreCase = true) }
}
}
Column(Modifier.fillMaxWidth())
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = ShxSpacing.md, end = ShxSpacing.md, bottom = ShxSpacing.xs),
)
{
IconButton(onClick = onBack)
{
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
Text(
text = stringResource(R.string.routing_presets_label),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack)
{
Text(stringResource(R.string.action_done))
}
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text(stringResource(R.string.routing_presets_search)) },
singleLine = true,
leadingIcon = {
Icon(Icons.Rounded.Search, contentDescription = null)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl),
shape = MaterialTheme.shapes.medium,
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 460.dp)
.padding(top = ShxSpacing.xs),
)
{
items(filtered, key = { it.name })
{ preset ->
val selected = ruleSet.prefixes.containsAll(preset.prefixes) &&
ruleSet.keywords.containsAll(preset.keywords)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onToggle(preset) }
.padding(horizontal = ShxSpacing.xl, vertical = 6.dp),
)
{
Column(
Modifier
.weight(1f)
.padding(vertical = 6.dp),
)
{
Text(preset.name, style = MaterialTheme.typography.bodyLarge)
Text(
preset.patternSummary(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Checkbox(checked = selected, onCheckedChange = { onToggle(preset) })
}
}
if (filtered.isEmpty())
{
item {
Text(
text = stringResource(R.string.routing_presets_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.lg),
)
}
}
}
}
}
/** "3 prefixes · 2 keywords" summary, skipping empty groups. */
@Composable
private fun RoutingPresets.Preset.patternSummary(): String
{
val parts = listOf(
if (prefixes.isEmpty()) null else stringResource(R.string.routing_preset_prefixes, prefixes.size),
if (keywords.isEmpty()) null else stringResource(R.string.routing_preset_keywords, keywords.size),
).filterNotNull()
return if (parts.isEmpty())
{
stringResource(R.string.routing_preset_no_patterns)
}
else
{
parts.joinToString(separator = " · ")
}
}
@@ -70,6 +70,7 @@ fun ProfileEditorPane(
val saved = checked.toProfile(profile)
if (profile != null) dataSource.updateVpnProfile(saved)
else dataSource.insertProfile(saved)
if (profile == null) org.strongswan.android.logic.profiles.ProfileExtras.recordManual(context, saved.getUUID().toString())
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
intent.putExtra(Constants.VPN_PROFILES_SINGLE, saved.getUUID().toString())
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
@@ -10,6 +10,7 @@
package org.strongswan.android.ui.compose
import org.strongswan.android.ui.compose.components.ShxSectionCard
import org.strongswan.android.ui.compose.components.ShxSettingRow
import android.app.Activity
import android.content.Intent
import android.security.KeyChain
@@ -38,11 +39,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
@@ -84,18 +80,37 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxFormField
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxRow
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxTabs
import org.strongswan.android.ui.compose.design.ShxToggleRow
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnType
import org.strongswan.android.data.VpnType.VpnTypeFeature
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.MainActivity
import org.strongswan.android.ui.TrustedCertificatesActivity
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.utils.Constants
import org.strongswan.android.utils.IPRangeSet
import org.strongswan.android.utils.Utils
import java.util.TreeSet
data class ProfileFormState(
val name: String = "",
@@ -123,8 +138,6 @@ data class ProfileFormState(
val excludedSubnets: String = "",
val blockIpv4: Boolean = false,
val blockIpv6: Boolean = false,
val appsHandling: SelectedAppsHandling = SelectedAppsHandling.SELECTED_APPS_DISABLE,
val selectedApps: Set<String> = emptySet(),
val ikeProposal: String = "",
val espProposal: String = "",
val proxyHost: String = "",
@@ -177,8 +190,6 @@ fun ProfileFormState.toProfile(existing: VpnProfile?): VpnProfile
if (blockIpv4) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4
if (blockIpv6) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6
profile.splitTunneling = if (st == 0) null else st
profile.selectedAppsHandling = appsHandling
profile.setSelectedApps(TreeSet(selectedApps))
profile.ikeProposal = ikeProposal.trim().ifEmpty { null }
profile.espProposal = espProposal.trim().ifEmpty { null }
profile.proxyHost = proxyHost.trim().ifEmpty { null }
@@ -217,8 +228,6 @@ fun VpnProfile.toFormState(): ProfileFormState
excludedSubnets = excludedSubnets ?: "",
blockIpv4 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4 != 0,
blockIpv6 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6 != 0,
appsHandling = selectedAppsHandling,
selectedApps = selectedAppsSet ?: emptySet(),
ikeProposal = ikeProposal ?: "",
espProposal = espProposal ?: "",
proxyHost = proxyHost ?: "",
@@ -228,7 +237,6 @@ fun VpnProfile.toFormState(): ProfileFormState
advanced = remoteId != null || localId != null || dnsServers != null || getMTU() != null
|| port != null || getNATKeepAlive() != null || flags != 0 || st != 0
|| includedSubnets != null || excludedSubnets != null
|| selectedAppsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE
|| ikeProposal != null || espProposal != null || proxyHost != null,
)
}
@@ -289,6 +297,9 @@ fun ProfileFormState.isValid(): Boolean
return true
}
/** The three sign-in variants of the mockup tabs; the rarer types stay in «More». */
private val LOGIN_TYPES = listOf(VpnType.IKEV2_EAP, VpnType.IKEV2_CERT, VpnType.IKEV2_CERT_EAP)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileFormScreen(
@@ -302,15 +313,14 @@ fun ProfileFormScreen(
)
{
val context = LocalContext.current
val c = LocalShxColors.current
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
androidx.compose.runtime.LaunchedEffect(messages)
{
messages?.collect { snackbarHostState.showSnackbar(context.getString(it)) }
}
val types = stringArrayResource(R.array.vpn_types)
val appsHandling = stringArrayResource(R.array.apps_handling)
var typeExpanded by remember { mutableStateOf(false) }
var appsExpanded by remember { mutableStateOf(false) }
var showPassword by remember { mutableStateOf(false) }
val enabled = !state.readOnly
@@ -322,336 +332,244 @@ fun ProfileFormScreen(
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
}
}
val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK)
{
val list = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST) ?: arrayListOf()
onChange(latest.value.copy(selectedApps = list.toSet()))
}
}
Scaffold(
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
),
contentWindowInsets = WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Top,
),
topBar = {
TopAppBar(
title = { Text(title, maxLines = 1) },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
},
bottomBar = {
if (enabled)
{
Surface(
color = MaterialTheme.colorScheme.background,
tonalElevation = 0.dp,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
),
)
{
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = ShxSpacing.md),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
) {
OutlinedButton(
onClick = onCancel,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.weight(1f)
.height(48.dp),
) { Text(stringResource(R.string.profile_edit_cancel)) }
Button(
onClick = onSave,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.weight(2f)
.height(48.dp),
) { Text(stringResource(R.string.profile_edit_save)) }
}
}
}
},
) { padding ->
Box(Modifier.fillMaxSize().background(c.background))
{
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState()),
)
{
if (state.readOnly)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 8.dp))
{
Text(stringResource(R.string.alert_text_vpn_profile_read_only), color = MaterialTheme.colorScheme.error)
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onCancel))
{
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
}
Text(title, style = ShxType.pageTitle, color = c.text, maxLines = 1, modifier = Modifier.weight(1f))
}
ShxSectionCard(title = stringResource(R.string.profile_section_basic))
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
OutlinedTextField(
if (state.readOnly)
{
ShxNote(stringResource(R.string.alert_text_vpn_profile_read_only), tone = ShxTone.ERROR)
}
ShxFormField(
label = stringResource(R.string.profile_name_label).trimEnd(':'),
value = state.name,
onValueChange = { onChange(state.copy(name = it)) },
placeholder = stringResource(R.string.form_name_placeholder),
enabled = enabled,
)
ShxFormField(
label = stringResource(R.string.profile_gateway_label).trimEnd(':'),
value = state.gateway,
onValueChange = { onChange(state.copy(gateway = it, gatewayError = null)) },
label = { Text(stringResource(R.string.profile_gateway_label)) },
supportingText = { Text(state.gatewayError ?: stringResource(R.string.profile_gateway_hint)) },
isError = state.gatewayError != null,
placeholder = stringResource(R.string.form_gateway_placeholder),
error = state.gatewayError,
enabled = enabled,
singleLine = true,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it }) {
OutlinedTextField(
value = types.getOrElse(state.vpnType.ordinal) { "" },
onValueChange = {},
readOnly = true,
enabled = enabled,
label = { Text(stringResource(R.string.profile_vpn_type_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
ShxSectionHeader(stringResource(R.string.form_login))
if (state.vpnType in LOGIN_TYPES)
{
ShxTabs(
options = listOf(stringResource(R.string.form_login_password), stringResource(R.string.form_login_cert), stringResource(R.string.form_login_both)),
selected = LOGIN_TYPES.indexOf(state.vpnType),
onSelect = { if (enabled) onChange(state.copy(vpnType = LOGIN_TYPES[it])) },
)
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false }) {
VpnType.values().forEachIndexed { index, type ->
DropdownMenuItem(
text = { Text(types.getOrElse(index) { type.identifier }) },
onClick = {
onChange(state.copy(vpnType = type))
typeExpanded = false
},
)
}
}
}
else
{
ShxNote(types.getOrElse(state.vpnType.ordinal) { state.vpnType.identifier }, tone = ShxTone.NEUTRAL)
}
if (state.vpnType.has(VpnTypeFeature.USER_PASS))
{
OutlinedTextField(
ShxFormField(
label = stringResource(R.string.profile_username_label).trimEnd(':'),
value = state.username,
onValueChange = { onChange(state.copy(username = it, usernameError = null)) },
label = { Text(stringResource(R.string.profile_username_label)) },
isError = state.usernameError != null,
supportingText = state.usernameError?.let { { Text(it) } },
error = state.usernameError,
enabled = enabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
ShxFormField(
label = stringResource(R.string.profile_password_label).trimEnd(':'),
value = state.password,
onValueChange = { onChange(state.copy(password = it)) },
label = { Text(stringResource(R.string.profile_password_label)) },
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
supporting = stringResource(R.string.profile_password_hint),
enabled = enabled,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null)
trailing = {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable { showPassword = !showPassword })
{
Icon(if (showPassword) ShxIcons.EyeOff else ShxIcons.Eye, null, tint = c.textSub, modifier = Modifier.size(20.dp))
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
}
if (state.vpnType.has(VpnTypeFeature.CERTIFICATE))
{
TextButton(onClick = {
FormPickRow(
title = state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate),
subtitle = stringResource(R.string.profile_user_certificate_label).trimEnd(':'),
enabled = enabled,
)
{
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
onChange(latest.value.copy(userCertAlias = alias))
}, arrayOf("RSA", "EC"), null, null, -1, state.userCertAlias)
}, enabled = enabled) {
Text(state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate))
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = state.caAuto,
onCheckedChange = { onChange(state.copy(caAuto = it)) },
enabled = enabled,
)
Text(stringResource(R.string.profile_ca_auto_label), modifier = Modifier.clickable(enabled = enabled) {
onChange(state.copy(caAuto = !state.caAuto))
})
}
if (!state.caAuto)
ShxPanel()
{
TextButton(onClick = {
val intent = Intent(context, TrustedCertificatesActivity::class.java)
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
pickCa.launch(intent)
}, enabled = enabled) {
Text(state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) })
ShxToggleRow(stringResource(R.string.profile_ca_auto_label), state.caAuto, { onChange(state.copy(caAuto = it)) }, enabled = enabled, divider = !state.caAuto)
if (!state.caAuto)
{
FormPickRow(
title = state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) },
subtitle = stringResource(R.string.profile_ca_label).trimEnd(':'),
enabled = enabled,
bordered = false,
)
{
val intent = Intent(context, TrustedCertificatesActivity::class.java)
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
pickCa.launch(intent)
}
}
}
OutlinedTextField(
value = state.name,
onValueChange = { onChange(state.copy(name = it)) },
label = { Text(stringResource(R.string.profile_name_label)) },
supportingText = { Text(stringResource(R.string.profile_name_hint)) },
enabled = enabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider()
val chevron by animateFloatAsState(if (state.advanced) 180f else 0f, label = "adv")
ListItem(
headlineContent = { Text(stringResource(R.string.profile_advanced_label)) },
supportingContent = { Text(stringResource(R.string.profile_advanced_hint)) },
trailingContent = {
Icon(
Icons.Default.ExpandMore,
contentDescription = stringResource(R.string.profile_show_advanced_label),
modifier = Modifier.rotate(chevron),
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier
.fillMaxWidth()
.clickable { onChange(state.copy(advanced = !state.advanced)) },
/* «More: port, MTU, DNS, identities» */
val chevron by animateFloatAsState(if (state.advanced) 90f else 0f, label = "adv")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(16.dp)).background(c.card)
.border(1.dp, c.cardBorder, RoundedCornerShape(16.dp))
.clickable(role = Role.Button) { onChange(state.copy(advanced = !state.advanced)) }.padding(horizontal = 16.dp),
)
{
Text(stringResource(R.string.form_more), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
Icon(ShxIcons.Chevron, contentDescription = stringResource(R.string.profile_show_advanced_label), tint = c.iconMuted, modifier = Modifier.size(18.dp).rotate(chevron))
}
AnimatedVisibility(
visible = state.advanced,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) {
OutlinedTextField(state.remoteId, { onChange(state.copy(remoteId = it)) },
label = { Text(stringResource(R.string.profile_remote_id_label)) },
supportingText = { Text(stringResource(R.string.profile_remote_id_hint)) },
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.localId, { onChange(state.copy(localId = it)) },
label = { Text(stringResource(R.string.profile_local_id_label)) },
supportingText = { Text(stringResource(R.string.profile_local_id_hint_user)) },
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.dns, { onChange(state.copy(dns = it)) },
label = { Text(stringResource(R.string.profile_dns_servers_label)) },
supportingText = { Text(stringResource(R.string.profile_dns_servers_hint)) },
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.mtu, { onChange(state.copy(mtu = it)) },
label = { Text(stringResource(R.string.profile_mtu_label)) },
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.port, { onChange(state.copy(port = it)) },
label = { Text(stringResource(R.string.profile_port_label)) },
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
label = { Text(stringResource(R.string.profile_nat_keepalive_label)) },
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
SwitchRow(stringResource(R.string.profile_cert_req_label), state.certReq, enabled) { onChange(state.copy(certReq = it)) }
SwitchRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, enabled) { onChange(state.copy(useOcsp = it)) }
SwitchRow(stringResource(R.string.profile_use_crl_label), state.useCrl, enabled) { onChange(state.copy(useCrl = it)) }
SwitchRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, enabled) { onChange(state.copy(strictRevocation = it)) }
SwitchRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, enabled) { onChange(state.copy(rsaPss = it)) }
SwitchRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, enabled) { onChange(state.copy(ipv6Transport = it)) }
OutlinedTextField(state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
label = { Text(stringResource(R.string.profile_included_subnets_label)) },
isError = state.includedSubnetsError != null,
supportingText = { state.includedSubnetsError?.let { Text(it) } },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
label = { Text(stringResource(R.string.profile_excluded_subnets_label)) },
isError = state.excludedSubnetsError != null,
supportingText = { state.excludedSubnetsError?.let { Text(it) } },
enabled = enabled, modifier = Modifier.fillMaxWidth())
SwitchRow(
label = stringResource(R.string.profile_split_tunnelingv4_title),
checked = state.blockIpv4,
enabled = enabled,
) { onChange(state.copy(blockIpv4 = it)) }
SwitchRow(
label = stringResource(R.string.profile_split_tunnelingv6_title),
supporting = stringResource(R.string.profile_split_tunnelingv6_hint),
checked = state.blockIpv6,
enabled = enabled,
) { onChange(state.copy(blockIpv6 = it)) }
ExposedDropdownMenuBox(expanded = appsExpanded, onExpandedChange = { if (enabled) appsExpanded = it }) {
OutlinedTextField(
value = appsHandling.getOrElse(state.appsHandling.ordinal) { "" },
onValueChange = {},
readOnly = true,
enabled = enabled,
label = { Text(stringResource(R.string.profile_select_apps)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(appsExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = appsExpanded, onDismissRequest = { appsExpanded = false }) {
SelectedAppsHandling.values().forEachIndexed { index, handling ->
DropdownMenuItem(
text = { Text(appsHandling.getOrElse(index) { handling.name }) },
onClick = {
onChange(state.copy(appsHandling = handling))
appsExpanded = false
},
)
Column(verticalArrangement = Arrangement.spacedBy(12.dp))
{
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it })
{
FormPickRow(
title = types.getOrElse(state.vpnType.ordinal) { "" },
subtitle = stringResource(R.string.profile_vpn_type_label).trimEnd(':'),
enabled = enabled,
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
) { typeExpanded = true }
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false })
{
VpnType.values().forEachIndexed { index, type ->
DropdownMenuItem(
text = { Text(types.getOrElse(index) { type.identifier }, style = ShxType.body) },
onClick = {
onChange(state.copy(vpnType = type))
typeExpanded = false
},
)
}
}
}
}
if (state.appsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE)
{
TextButton(onClick = {
val intent = Intent(context, SelectedApplicationsActivity::class.java)
intent.putStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(state.selectedApps))
pickApps.launch(intent)
}, enabled = enabled) {
Text(stringResource(R.string.profile_select_apps) + " (${state.selectedApps.size})")
ShxFormField(stringResource(R.string.profile_remote_id_label).trimEnd(':'), state.remoteId, { onChange(state.copy(remoteId = it)) },
supporting = stringResource(R.string.profile_remote_id_hint), enabled = enabled)
ShxFormField(stringResource(R.string.profile_local_id_label).trimEnd(':'), state.localId, { onChange(state.copy(localId = it)) },
supporting = stringResource(R.string.profile_local_id_hint_user), enabled = enabled)
ShxFormField(stringResource(R.string.profile_dns_servers_label).trimEnd(':'), state.dns, { onChange(state.copy(dns = it)) },
supporting = stringResource(R.string.profile_dns_servers_hint), enabled = enabled, mono = true)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
ShxFormField(stringResource(R.string.profile_port_label).trimEnd(':'), state.port, { onChange(state.copy(port = it)) },
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
ShxFormField(stringResource(R.string.profile_mtu_label).trimEnd(':'), state.mtu, { onChange(state.copy(mtu = it)) },
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
}
ShxFormField(stringResource(R.string.profile_nat_keepalive_label).trimEnd(':'), state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
ShxPanel()
{
ShxToggleRow(stringResource(R.string.profile_cert_req_label), state.certReq, { onChange(state.copy(certReq = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, { onChange(state.copy(useOcsp = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_use_crl_label), state.useCrl, { onChange(state.copy(useCrl = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, { onChange(state.copy(strictRevocation = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, { onChange(state.copy(rsaPss = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, { onChange(state.copy(ipv6Transport = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv4_title), state.blockIpv4, { onChange(state.copy(blockIpv4 = it)) }, enabled = enabled, divider = true)
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv6_title), state.blockIpv6, { onChange(state.copy(blockIpv6 = it)) },
subtitle = stringResource(R.string.profile_split_tunnelingv6_hint), enabled = enabled)
}
ShxFormField(stringResource(R.string.profile_included_subnets_label).trimEnd(':'), state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
error = state.includedSubnetsError, enabled = enabled, mono = true, singleLine = false)
ShxFormField(stringResource(R.string.profile_excluded_subnets_label).trimEnd(':'), state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
error = state.excludedSubnetsError, enabled = enabled, mono = true, singleLine = false)
ShxPanel()
{
ShxRow(
title = stringResource(R.string.profile_routing_link_title),
subtitle = stringResource(R.string.profile_routing_link_summary),
icon = ShxIcons.Route,
chevron = true,
onClick = { context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true)) },
)
}
ShxFormField(stringResource(R.string.profile_proposals_ike_label).trimEnd(':'), state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
error = state.ikeProposalError, enabled = enabled, mono = true)
ShxFormField(stringResource(R.string.profile_proposals_esp_label).trimEnd(':'), state.espProposal, { onChange(state.copy(espProposal = it)) },
error = state.espProposalError, enabled = enabled, mono = true)
ShxFormField(stringResource(R.string.profile_proxy_host_label).trimEnd(':'), state.proxyHost, { onChange(state.copy(proxyHost = it)) }, enabled = enabled)
ShxFormField(stringResource(R.string.profile_proxy_port_label).trimEnd(':'), state.proxyPort, { onChange(state.copy(proxyPort = it)) },
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
ShxFormField(stringResource(R.string.profile_proxy_exclusions_label).trimEnd(':'), state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) }, enabled = enabled)
}
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
isError = state.ikeProposalError != null,
supportingText = { state.ikeProposalError?.let { Text(it) } },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.espProposal, { onChange(state.copy(espProposal = it)) },
label = { Text(stringResource(R.string.profile_proposals_esp_label)) },
isError = state.espProposalError != null,
supportingText = { state.espProposalError?.let { Text(it) } },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.proxyHost, { onChange(state.copy(proxyHost = it)) },
label = { Text(stringResource(R.string.profile_proxy_host_label)) },
enabled = enabled, modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.proxyPort, { onChange(state.copy(proxyPort = it)) },
label = { Text(stringResource(R.string.profile_proxy_port_label)) },
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) },
label = { Text(stringResource(R.string.profile_proxy_exclusions_label)) },
enabled = enabled, modifier = Modifier.fillMaxWidth())
}
}
if (enabled)
{
ShxButton(stringResource(R.string.profile_edit_save), onSave, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
}
}
}
org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))
}
}
/** White row that opens a picker: 12 label on top, 15/700 value, chevron. */
@Composable
private fun SwitchRow(
label: String,
checked: Boolean,
private fun FormPickRow(
title: String,
subtitle: String,
enabled: Boolean,
supporting: String? = null,
onChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
bordered: Boolean = true,
onClick: () -> Unit,
)
{
ListItem(
headlineContent = { Text(label) },
supportingContent = supporting?.let { { Text(it) } },
trailingContent = {
Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.fillMaxWidth(),
val c = LocalShxColors.current
val shape = RoundedCornerShape(16.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier
.fillMaxWidth()
.then(if (bordered) Modifier.clip(shape).background(c.card).border(1.dp, c.cardBorder, shape) else Modifier)
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 10.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
{
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.textSub)
Text(title, style = ShxType.body.copy(fontSize = 16.sp), color = if (enabled) c.text else c.textSub, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
}
}
@@ -1,135 +0,0 @@
/*
* 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 org.strongswan.android.ui.compose.components.ShxConnectedPill
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
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.material.icons.Icons
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
@Composable
fun ProfileAvatar(icon: ImageVector, connected: Boolean, modifier: Modifier = Modifier)
{
val status = LocalShxStatusColors.current
val container = if (connected) status.connectedContainer else MaterialTheme.colorScheme.primaryContainer
val tint = if (connected) status.connected else MaterialTheme.colorScheme.primary
Box(
modifier = modifier
.size(40.dp)
.background(container, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp),
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ShxProfileRow(
profile: VpnProfile,
active: Boolean,
leadingIcon: ImageVector = Icons.Rounded.Language,
onClick: () -> Unit,
onLongClick: () -> Unit = {},
onLongClickLabel: String? = null,
trailing: (@Composable () -> Unit)? = null,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = onLongClickLabel,
)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
) {
ProfileAvatar(icon = leadingIcon, connected = active)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.md),
) {
Text(
text = profile.name ?: "",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = profile.gateway ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (trailing != null)
{
trailing()
}
else if (active)
{
ActiveBadge()
}
}
}
@Composable
fun ActiveBadge(modifier: Modifier = Modifier)
{
ShxConnectedPill(text = stringResource(R.string.badge_connected), modifier = modifier)
}
@Composable
fun ShxDivider(modifier: Modifier = Modifier, indent: Boolean = true)
{
Surface(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
modifier = modifier
.fillMaxWidth()
.padding(start = if (indent) 68.dp else 0.dp, end = if (indent) ShxSpacing.lg else 0.dp)
.height(1.dp),
) {}
}
@@ -17,18 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.SearchOff
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -71,6 +59,42 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.saveable.rememberSaveable
import org.strongswan.android.R
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.size
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.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.produceState
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import org.strongswan.android.logic.connectivity.ConnectivityProbe
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxDialog
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxRow
import org.strongswan.android.ui.compose.design.ShxScreenTitle
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.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.compose.components.ShxCard
@@ -92,11 +116,12 @@ fun ProfilesScreen(
onRestore: (VpnProfile) -> Unit,
onShare: (VpnProfile) -> Unit,
onAdd: () -> Unit,
onFastest: () -> Unit = {},
)
{
if (LocalConfiguration.current.screenWidthDp < 840)
{
ProfilesList(state, onConnect, onDisconnect, onEdit, onDelete, onRestore, onShare, onAdd)
ProfilesList(state, onConnect, onDisconnect, onEdit, onDelete, onRestore, onShare, onAdd, onFastest)
return
}
/* null = nothing open, "" = new profile, otherwise the edited profile's uuid */
@@ -135,7 +160,7 @@ fun ProfilesScreen(
{
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center)
{
ShxEmptyState(icon = Icons.Rounded.Language, message = stringResource(R.string.profile_detail_placeholder))
ShxEmptyState(icon = ShxIcons.Globe, message = stringResource(R.string.profile_detail_placeholder))
}
}
}
@@ -143,7 +168,9 @@ fun ProfilesScreen(
)
}
@OptIn(ExperimentalMaterial3Api::class)
/** Server status line: green "connected · 38 ms", green "available · 29 ms" or amber "no answer". */
private data class ServerPing(val rttMs: Long?)
@Composable
private fun ProfilesList(
state: HomeUiState,
@@ -154,21 +181,33 @@ private fun ProfilesList(
onRestore: (VpnProfile) -> Unit,
onShare: (VpnProfile) -> Unit,
onAdd: () -> Unit,
onFastest: () -> Unit = {},
)
{
val context = LocalContext.current
val c = LocalShxColors.current
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
var query by remember { mutableStateOf("") }
var menuFor by remember { mutableStateOf<VpnProfile?>(null) }
var deleteFor by remember { mutableStateOf<VpnProfile?>(null) }
val activeUuid = state.activeProfile?.getUUID()
val connected = state.vpnState == State.CONNECTED
val profiles = state.profiles
val filtered = remember(query, profiles)
{
if (query.isBlank()) profiles
else profiles.filter {
(it.name ?: "").contains(query, ignoreCase = true) || (it.gateway ?: "").contains(query, ignoreCase = true)
/* direct pings of every server (outside the tunnel), refreshed while the tab is shown */
val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle
val pings by produceState(emptyMap<String, ServerPing>(), profiles, lifecycle) {
lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
while (true)
{
value = coroutineScope {
profiles.map { p ->
async {
p.getUUID().toString() to ServerPing(ConnectivityProbe.pingServerDirect(context, p.gateway, 1500)?.first)
}
}.map { it.await() }.toMap()
}
delay(30_000)
}
}
}
@@ -188,183 +227,207 @@ private fun ProfilesList(
}
}
Scaffold(
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.background,
) { _ ->
Column(Modifier.fillMaxSize())
deleteFor?.let { profile ->
val current = profile.getUUID() == activeUuid && state.vpnState != State.DISABLED
ShxDialog(
title = stringResource(R.string.server_delete_title, profile.name ?: profile.gateway),
paragraphs = listOfNotNull(
if (current) stringResource(R.string.server_delete_current) else null,
stringResource(R.string.server_delete_restore),
),
confirm = stringResource(R.string.server_delete_confirm),
dismiss = stringResource(android.R.string.cancel),
onConfirm = {
deleteFor = null
if (current)
{
onDisconnect()
}
deleteWithUndo(profile)
},
onDismiss = { deleteFor = null },
icon = ShxIcons.Trash,
danger = true,
)
}
Box(Modifier.fillMaxSize().background(c.background))
{
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
{
TopAppBar(
title = { Text(stringResource(R.string.nav_profiles), fontWeight = FontWeight.SemiBold) },
actions = {
IconButton(onClick = onAdd) {
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.add_profile_cta))
}
},
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
SearchBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.xs),
inputField = {
SearchBarDefaults.InputField(
query = query,
onQueryChange = { query = it },
onSearch = {},
expanded = false,
onExpandedChange = {},
placeholder = { Text(stringResource(R.string.search_profiles)) },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty())
{
IconButton(onClick = { query = "" }) {
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.action_clear_search))
}
}
},
)
},
expanded = false,
onExpandedChange = {},
) {}
if (filtered.isEmpty())
ShxScreenTitle(stringResource(R.string.nav_profiles))
{
EmptyProfilesState(query = query, onAdd = onAdd)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.accent)
.clickable(role = Role.Button, onClickLabel = stringResource(R.string.add_profile_cta), onClick = onAdd),
)
{
Icon(ShxIcons.Plus, contentDescription = stringResource(R.string.add_profile_cta), tint = Color.White, modifier = Modifier.size(22.dp))
}
}
else
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
) {
LazyColumn {
itemsIndexed(filtered, key = { _, p -> p.getUUID().toString() }) { index, profile ->
val isActive = connected && profile.getUUID() == activeUuid
Column(Modifier.animateItem()) {
Box {
ShxProfileRow(
profile = profile,
active = isActive,
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
onLongClick = { menuFor = profile },
trailing = {
IconButton(onClick = { menuFor = profile }) {
Icon(
Icons.Rounded.MoreVert,
contentDescription = stringResource(R.string.action_more_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isActive) ActiveBadge()
},
)
DropdownMenu(expanded = menuFor == profile, onDismissRequest = { menuFor = null })
{
if (isActive)
{
DropdownMenuItem(
text = { Text(stringResource(R.string.disconnect)) },
leadingIcon = { Icon(Icons.Rounded.Stop, contentDescription = null) },
onClick = {
menuFor = null
onDisconnect()
},
)
}
else
{
DropdownMenuItem(
text = { Text(stringResource(R.string.connect)) },
leadingIcon = { Icon(Icons.Rounded.PlayArrow, contentDescription = null) },
onClick = {
menuFor = null
onConnect(profile)
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.edit_profile)) },
leadingIcon = { Icon(Icons.Rounded.Edit, contentDescription = null) },
onClick = {
menuFor = null
onEdit(profile)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.share_profile)) },
leadingIcon = { Icon(Icons.Rounded.Share, contentDescription = null) },
onClick = {
menuFor = null
onShare(profile)
},
)
DropdownMenuItem(
text = {
Text(
stringResource(R.string.delete_profile),
color = MaterialTheme.colorScheme.error,
)
},
leadingIcon = {
Icon(
Icons.Rounded.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuFor = null
deleteWithUndo(profile)
},
)
}
}
if (index < filtered.lastIndex)
{
ShxDivider()
}
}
if (profiles.isEmpty())
{
EmptyProfilesState(onAdd)
return@Column
}
if (profiles.size > 1)
{
AutoServerCard(onFastest)
}
ShxSectionHeader(stringResource(R.string.servers_mine, profiles.size), Modifier.padding(top = 8.dp))
ShxPanel()
{
profiles.forEachIndexed { index, profile ->
val isActive = connected && profile.getUUID() == activeUuid
ServerRow(
profile = profile,
active = isActive,
ping = pings[profile.getUUID().toString()],
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
onMore = { menuFor = profile },
)
if (index < profiles.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
Text(stringResource(R.string.servers_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
}
}
ShxSnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))
}
menuFor?.let { profile ->
val isActive = connected && profile.getUUID() == activeUuid
ShxSheet(onDismiss = { menuFor = null })
{
ShxSheetTitle(profile.name ?: profile.gateway ?: "")
Text(profile.gateway ?: "", style = ShxType.note.copy(fontSize = 14.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 4.dp))
ShxPanel()
{
ShxRow(
title = stringResource(if (isActive) R.string.disconnect else R.string.connect),
icon = if (isActive) ShxIcons.Close else ShxIcons.Power,
divider = true, minHeight = 64.dp,
onClick = {
menuFor = null
if (isActive) onDisconnect() else onConnect(profile)
},
)
ShxRow(
title = stringResource(R.string.edit_profile),
icon = ShxIcons.Sliders, iconBackground = c.tile,
divider = true, minHeight = 64.dp,
onClick = {
menuFor = null
onEdit(profile)
},
)
ShxRow(
title = stringResource(R.string.share_profile),
icon = ShxIcons.Download, iconBackground = c.tile,
minHeight = 64.dp,
onClick = {
menuFor = null
onShare(profile)
},
)
}
ShxButton(
stringResource(R.string.delete_profile),
{
menuFor = null
deleteFor = profile
},
style = ShxButtonStyle.DANGER,
modifier = Modifier.fillMaxWidth(),
icon = ShxIcons.Trash,
)
}
}
}
private const val SEARCH_THRESHOLD = 5
/** «Automatically»: the fastest server that connects (2px purple border, bolt circle). */
@Composable
private fun EmptyProfilesState(query: String, onAdd: () -> Unit)
private fun AutoServerCard(onClick: () -> Unit)
{
ShxCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
val c = LocalShxColors.current
val shape = RoundedCornerShape(24.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.accentSoft).border(2.dp, c.accent, shape)
.clickable(role = Role.Button, onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
)
{
if (query.isBlank())
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(c.accent))
{
ShxEmptyState(
icon = Icons.Rounded.Language,
message = stringResource(R.string.no_profiles),
actionLabel = stringResource(R.string.add_profile_cta),
onAction = onAdd,
)
Icon(ShxIcons.Bolt, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
else
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
ShxEmptyState(
icon = Icons.Rounded.SearchOff,
message = stringResource(R.string.nothing_found),
)
Text(stringResource(R.string.servers_auto), style = ShxType.rowTitle.copy(fontWeight = FontWeight.ExtraBold), color = c.text)
Text(stringResource(R.string.servers_auto_sub), style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.onAccentContainer)
}
}
}
/** Server row of the mockup: 68dp, code circle, name 16/700, status dot line, "more" button. */
@Composable
private fun ServerRow(profile: VpnProfile, active: Boolean, ping: ServerPing?, onClick: () -> Unit, onMore: () -> Unit)
{
val c = LocalShxColors.current
val code = org.strongswan.android.ui.compose.home.serverCode(profile, null)
val (dot, meta) = when
{
ping == null -> c.iconMuted to stringResource(R.string.servers_checking)
ping.rttMs == null -> c.warning to stringResource(R.string.servers_no_answer)
active -> c.success to stringResource(R.string.servers_connected_ms, ping.rttMs)
else -> c.success to stringResource(R.string.servers_available_ms, ping.rttMs)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(68.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 14.dp, end = 8.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(if (active) c.accent else c.tile))
{
Text(code, style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = if (active) Color.White else c.onAccentContainer)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp))
{
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
{
Box(Modifier.size(7.dp).clip(CircleShape).background(dot))
Text(meta, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1)
}
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onMore),
)
{
Icon(ShxIcons.More, contentDescription = stringResource(R.string.action_more_options), tint = c.textSub, modifier = Modifier.size(20.dp))
}
}
}
@Composable
private fun EmptyProfilesState(onAdd: () -> Unit)
{
ShxPanel()
{
ShxEmptyState(
icon = ShxIcons.Globe,
message = stringResource(R.string.no_profiles),
actionLabel = stringResource(R.string.add_profile_cta),
onAction = onAdd,
)
}
}
@@ -9,175 +9,748 @@
package org.strongswan.android.ui.compose
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
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.Spacer
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
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.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
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.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
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.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import org.strongswan.android.ui.compose.components.ShxStatusPill
import org.strongswan.android.ui.compose.components.ShxSectionCard
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.routing.RouteCheck
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxTabs
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.routing.AppIcon
import org.strongswan.android.ui.compose.routing.DirectApps
import org.strongswan.android.ui.compose.routing.InstalledApp
import org.strongswan.android.ui.compose.routing.templateLabel
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
/** Diagnostic: how a domain resolves and which route its addresses take. */
/** What the route check looks at. */
enum class RouteCheckMode { SITE, IP, APP }
/** TCP 443 reachability over one network: rtt in ms, or null if it did not open. */
data class Reach(val viaDefault: Long?, val direct: Long?, val vpn: Long?)
private sealed interface CheckResult
{
data class Site(val report: RouteCheck.Report, val verdicts: Map<String, RouteCheck.Verdict>, val reach: Reach) : CheckResult
data class Ip(val ip: String, val verdict: RouteCheck.Verdict, val reach: Reach) : CheckResult
data class App(val app: InstalledApp) : CheckResult
data object BadInput : CheckResult
}
private const val PREF_RECENT = "route_check_recent"
/** «How traffic goes» (F_Check): route of a site, an address or an app, why, and a one-tap change. */
@Composable
fun RouteCheckScreen()
fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteCheckMode.SITE, initialQuery: String = "")
{
val context = LocalContext.current
val c = LocalShxColors.current
val scope = rememberCoroutineScope()
var domain by remember { mutableStateOf("") }
val manager = remember { RoutingManager.getInstance(context) }
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
var mode by rememberSaveable { mutableStateOf(initialMode) }
var query by rememberSaveable { mutableStateOf(initialQuery) }
var checking by remember { mutableStateOf(false) }
var report by remember { mutableStateOf<RouteCheck.Report?>(null) }
var result by remember { mutableStateOf<CheckResult?>(null) }
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
/** 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>
{
Text(
text = stringResource(R.string.route_check_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = domain,
onValueChange = { domain = it },
label = { Text(stringResource(R.string.route_check_field)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
shape = MaterialTheme.shapes.medium,
)
Button(
onClick = {
checking = true
scope.launch {
report = RouteCheck.check(
context,
domain.trim(),
CharonVpnService.sAppliedGeoExclusionsV4,
CharonVpnService.sAppliedGeoExclusionsV6,
CharonVpnService.sDnsPunchIps,
)
checking = false
}
},
enabled = domain.isNotBlank() && !checking,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.padding(top = 10.dp),
)
if (apps.isEmpty())
{
Text(stringResource(if (checking) R.string.route_check_checking else R.string.route_check_button))
apps = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
}
report?.let { rep -> RouteCheckResult(rep) }
return apps
}
}
@Composable
private fun RouteCheckResult(report: RouteCheck.Report)
{
val status = LocalShxStatusColors.current
Column(
modifier = Modifier.padding(top = ShxSpacing.lg),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.md),
)
LaunchedEffect(mode)
{
if (!report.vpnNetworkFound)
if (mode == RouteCheckMode.APP)
{
ShxStatusPill(
text = stringResource(R.string.route_check_no_vpn),
containerColor = status.disconnectedContainer,
contentColor = status.onDisconnectedContainer,
shape = MaterialTheme.shapes.small,
style = MaterialTheme.typography.labelMedium,
)
loadApps()
}
ShxSectionCard(title = stringResource(R.string.route_check_dns_section))
}
fun rememberQuery(q: String)
{
recent = (listOf(q) + recent.filter { it != q }).take(4)
prefs.edit().putString(PREF_RECENT, recent.joinToString("\n")).apply()
}
fun run()
{
val input = query.trim()
if (input.isEmpty())
{
RouteCheckRow(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
RouteCheckRow(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
return
}
ShxSectionCard(title = stringResource(R.string.route_check_routes))
{
for (route in report.routes)
checking = true
scope.launch {
result = when (mode)
{
val label = stringResource(
when (route.kind)
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
RouteCheckMode.APP -> loadApps().firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
}
if (result !is CheckResult.BadInput)
{
rememberQuery(if (result is CheckResult.App) (result as CheckResult.App).app.label else input)
}
checking = false
}
}
fun rerun(wait: Long)
{
scope.launch {
delay(wait)
run()
}
}
LaunchedEffect(Unit)
{
if (initialQuery.isNotBlank())
{
run()
}
}
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
{
ShxPageHeader(stringResource(R.string.route_check_title), onBack)
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
ShxTabs(
options = listOf(
stringResource(R.string.route_check_mode_site),
stringResource(R.string.route_check_mode_ip),
stringResource(R.string.route_check_mode_app),
),
selected = mode.ordinal,
onSelect = {
val m = RouteCheckMode.entries[it]
if (m != mode)
{
RouteCheck.RouteKind.TUNNEL -> R.string.route_verdict_tunnel
RouteCheck.RouteKind.DIRECT_RU -> R.string.route_verdict_direct
RouteCheck.RouteKind.TUNNEL_DNS -> R.string.route_verdict_dns
},
)
val direct = route.kind == RouteCheck.RouteKind.DIRECT_RU
mode = m
query = ""
result = null
}
},
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
{
val shape = RoundedCornerShape(16.dp)
Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f).height(52.dp).clip(shape).background(c.card).border(2.dp, c.accent, shape).padding(horizontal = 14.dp),
)
{
Text(
text = route.ip,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
Icon(
when (mode)
{
RouteCheckMode.SITE -> ShxIcons.Globe
RouteCheckMode.IP -> ShxIcons.Route
RouteCheckMode.APP -> ShxIcons.Apps
},
contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp),
)
ShxStatusPill(
text = label,
containerColor = if (direct) status.connectingContainer else status.connectedContainer,
contentColor = if (direct) status.onConnectingContainer else status.onConnectedContainer,
Box(Modifier.weight(1f))
{
if (query.isEmpty())
{
Text(
stringResource(
when (mode)
{
RouteCheckMode.SITE -> R.string.route_check_field
RouteCheckMode.IP -> R.string.route_check_field_ip
RouteCheckMode.APP -> R.string.route_check_field_app
}
),
style = ShxType.body.copy(fontSize = 16.sp),
color = c.textSub,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
BasicTextField(
value = query,
onValueChange = {
query = it
if (mode == RouteCheckMode.APP)
{
result = null
}
},
singleLine = true,
textStyle = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold, color = c.text),
cursorBrush = SolidColor(c.accent),
keyboardOptions = KeyboardOptions(
keyboardType = when (mode)
{
RouteCheckMode.SITE -> KeyboardType.Uri
RouteCheckMode.IP -> KeyboardType.Ascii
RouteCheckMode.APP -> KeyboardType.Text
},
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(onSearch = { run() }),
modifier = Modifier.fillMaxWidth(),
)
}
}
if (mode != RouteCheckMode.APP)
{
ShxButton(
stringResource(if (checking) R.string.route_check_checking else R.string.route_check_button),
::run, height = 52.dp, enabled = query.isNotBlank() && !checking,
)
}
}
if (report.routes.isEmpty())
if (recent.isNotEmpty() && result == null && query.isEmpty())
{
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp))
{
recent.forEach { item ->
val shape = RoundedCornerShape(10.dp)
Text(
item,
style = ShxType.meta,
color = c.textSoft,
modifier = Modifier
.clip(shape)
.border(1.dp, c.outline, shape)
.clickable(role = Role.Button) {
query = item
run()
}
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
if (mode == RouteCheckMode.APP && result == null && query.isNotBlank())
{
AppSuggestions(apps, query) { app ->
query = app.label
result = CheckResult.App(app)
rememberQuery(app.label)
}
}
when (val r = result)
{
is CheckResult.Site -> SiteResult(r, rulesActive, onAlways = { direct ->
manager.addSiteRule(r.report.domain, direct)
rerun(1500)
})
is CheckResult.Ip -> IpResult(r, rulesActive, onAlways = { direct ->
if (direct) manager.addDirectPrefix(r.ip) else manager.addForcePrefix(r.ip)
rerun(300)
})
is CheckResult.App -> AppResult(r.app, manager)
CheckResult.BadInput -> ShxNote(
stringResource(
when (mode)
{
RouteCheckMode.IP -> R.string.route_check_bad_ip
RouteCheckMode.APP -> R.string.route_check_bad_app
RouteCheckMode.SITE -> R.string.route_check_resolve_failed
}
),
tone = ShxTone.ERROR,
)
null -> if (query.isEmpty()) Text(stringResource(R.string.route_check_hint), style = ShxType.note, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
}
}
}
}
/* ------------------------------ checks ------------------------------ */
private fun networks(context: Context): Pair<Network?, Network?>
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null to null
var vpn: Network? = null
var direct: Network? = null
for (n in cm.allNetworks)
{
val caps = runCatching { cm.getNetworkCapabilities(n) }.getOrNull() ?: continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
vpn = n
}
else if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && direct == null)
{
direct = n
}
}
return vpn to direct
}
/** TCP connect to ip:443; through [network] when given, else the app's default route. */
private fun connectMs(ip: String, network: Network?): Long? = try
{
val socket = network?.socketFactory?.createSocket() ?: Socket()
socket.use {
val start = System.nanoTime()
it.connect(InetSocketAddress(InetAddress.getByName(ip), 443), 3000)
(System.nanoTime() - start) / 1_000_000
}
}
catch (_: Exception)
{
null
}
private suspend fun reach(context: Context, ip: String?): Reach = coroutineScope {
if (ip == null)
{
return@coroutineScope Reach(null, null, null)
}
val (vpn, direct) = networks(context)
val d = async(Dispatchers.IO) { connectMs(ip, null) }
val dir = async(Dispatchers.IO) { direct?.let { connectMs(ip, it) } }
val v = async(Dispatchers.IO) { vpn?.let { connectMs(ip, it) } }
Reach(d.await(), dir.await(), v.await())
}
private suspend fun checkSite(context: Context, manager: RoutingManager, input: String, rulesActive: Boolean): CheckResult
{
val domain = RoutingManager.normalizeDomain(input) ?: return CheckResult.BadInput
val report = RouteCheck.check(
context, domain,
CharonVpnService.sAppliedGeoExclusionsV4,
CharonVpnService.sAppliedGeoExclusionsV6,
CharonVpnService.sDnsPunchIps,
)
val force = RouteCheck.ranges(manager.forceTunnelCidrs())
val direct = RouteCheck.ranges(manager.directCidrs())
val verdicts = withContext(Dispatchers.Default) {
report.routes.associate { route ->
route.ip to RouteCheck.verdict(
InetAddress.getByName(route.ip),
CharonVpnService.sAppliedGeoExclusionsV4, CharonVpnService.sAppliedGeoExclusionsV6,
CharonVpnService.sDnsPunchIps, force, direct, rulesActive,
)
}
}
return CheckResult.Site(report, verdicts, reach(context, report.vpnDnsIps.firstOrNull() ?: report.routes.firstOrNull()?.ip))
}
private suspend fun checkIp(context: Context, manager: RoutingManager, input: String, rulesActive: Boolean): CheckResult
{
val ip = input.substringBefore('/').trim()
/* only literal addresses: getByName would otherwise resolve a host name */
if (!ip.matches(Regex("[0-9.]+")) && !ip.contains(':'))
{
return CheckResult.BadInput
}
val address = withContext(Dispatchers.IO) { runCatching { InetAddress.getByName(ip) }.getOrNull() } ?: return CheckResult.BadInput
val verdict = RouteCheck.verdict(
address,
CharonVpnService.sAppliedGeoExclusionsV4, CharonVpnService.sAppliedGeoExclusionsV6,
CharonVpnService.sDnsPunchIps,
RouteCheck.ranges(manager.forceTunnelCidrs()), RouteCheck.ranges(manager.directCidrs()), rulesActive,
)
val host = address.hostAddress ?: ip
return CheckResult.Ip(host, verdict, reach(context, host))
}
/* ------------------------------ results ------------------------------ */
@Composable
private fun reasonText(reason: RouteCheck.Reason): String = stringResource(
when (reason)
{
RouteCheck.Reason.DNS -> R.string.route_reason_dns
RouteCheck.Reason.RULE_TUNNEL -> R.string.route_reason_rule_vpn
RouteCheck.Reason.RULE_DIRECT -> R.string.route_reason_rule_direct
RouteCheck.Reason.RU_LIST -> R.string.route_reason_ru
RouteCheck.Reason.DEFAULT -> R.string.route_reason_default
}
)
/** The verdict card of the mockup: label, big route, reachability pill, path diagram, reason. */
@Composable
private fun VerdictCard(direct: Boolean, mixed: Boolean, subject: String, reason: String, reach: Long?, checkedReach: Boolean)
{
val c = LocalShxColors.current
val bg = if (direct) c.successContainer else c.accentContainer
val strong = if (direct) c.onSuccessStrong else c.onAccentContainerStrong
val soft = if (direct) c.onSuccess else c.onAccentContainer
val line = if (direct) c.success else c.accent
val country = remember { VpnStateService.getLiveInstance()?.profile?.name?.take(12) }
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(bg).padding(16.dp),
)
{
Row(verticalAlignment = Alignment.CenterVertically)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.route_check_result).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = soft)
Text(
stringResource(
when
{
mixed -> R.string.route_check_verdict_mixed
direct -> R.string.route_check_verdict_direct
else -> R.string.route_check_verdict_vpn
}
),
style = ShxType.pageTitle,
color = strong,
)
}
if (checkedReach)
{
Text(
text = stringResource(R.string.route_check_resolve_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
if (reach != null) stringResource(R.string.route_check_opens_ms, reach) else stringResource(R.string.route_check_not_opens),
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold),
color = if (reach != null) soft else c.onErrorContainer,
modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(c.card).padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
{
PathNode(stringResource(R.string.route_check_node_phone), filled = false, color = line)
Box(Modifier.weight(1f).height(3.dp).clip(RoundedCornerShape(2.dp)).background(line))
PathNode(
if (direct) stringResource(R.string.route_check_node_provider)
else listOfNotNull(stringResource(R.string.route_check_node_vpn), country).joinToString(" · "),
filled = true, color = line,
)
Box(Modifier.weight(1f).height(3.dp).clip(RoundedCornerShape(2.dp)).background(line))
PathNode(subject, filled = false, color = line)
}
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.route_check_why)) }
append(' ')
append(reason)
},
style = ShxType.note,
color = soft,
)
}
}
@Composable
private fun PathNode(text: String, filled: Boolean, color: Color)
{
val c = LocalShxColors.current
Text(
text,
style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold),
color = if (filled) Color.White else c.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clip(RoundedCornerShape(12.dp)).background(if (filled) color else c.card).padding(horizontal = 10.dp, vertical = 8.dp),
)
}
/** "Direct path for comparison": TCP 443 directly and through the tunnel. */
@Composable
private fun CompareCard(reach: Reach)
{
val c = LocalShxColors.current
ShxSectionHeader(stringResource(R.string.route_check_compare))
ShxPanel(padding = PaddingValues(horizontal = 16.dp, vertical = 4.dp))
{
val rows = listOf(
stringResource(R.string.route_check_verdict_direct) to reach.direct,
stringResource(R.string.route_check_verdict_vpn) to reach.vpn,
)
rows.forEachIndexed { i, (title, ms) ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().height(52.dp))
{
Text(title, style = ShxType.body, color = c.text, modifier = Modifier.weight(1f))
Text(
if (ms != null) stringResource(R.string.diag_ms, ms) else stringResource(R.string.route_check_not_opens),
style = ShxType.sub.copy(fontWeight = FontWeight.ExtraBold),
color = if (ms != null) c.onSuccess else c.error,
)
}
if (i < rows.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
@Composable
private fun RouteCheckRow(label: String, ips: List<String>)
private fun SiteResult(result: CheckResult.Site, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
{
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
val c = LocalShxColors.current
val report = result.report
if (report.routes.isEmpty())
{
ShxNote(stringResource(R.string.route_check_resolve_failed), tone = ShxTone.ERROR)
return
}
val verdicts = result.verdicts.values
val anyDirect = verdicts.any { it.direct }
val allDirect = verdicts.all { it.direct }
val main = verdicts.firstOrNull { it.reason == RouteCheck.Reason.RULE_TUNNEL || it.reason == RouteCheck.Reason.RULE_DIRECT } ?: verdicts.first()
VerdictCard(allDirect, anyDirect && !allDirect, report.domain, reasonText(main.reason), result.reach.viaDefault, checkedReach = true)
if (!report.vpnNetworkFound)
{
ShxNote(stringResource(R.string.route_check_no_vpn), tone = ShxTone.WARNING)
}
else if (result.reach.direct != null || result.reach.vpn != null)
{
CompareCard(result.reach)
}
ShxSectionHeader(stringResource(R.string.route_check_dns_section))
ShxPanel(padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
{
DnsBlock(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
DnsBlock(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
if (report.vpnDnsIps.isNotEmpty() && report.directDnsIps.isNotEmpty() && report.vpnDnsIps.toSet() != report.directDnsIps.toSet())
{
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.route_check_dns_differ_title)) }
append(' ')
append(stringResource(R.string.route_check_dns_differ))
},
style = ShxType.note,
color = c.onWarningContainer,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(c.warningContainer).padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
ShxSectionHeader(stringResource(R.string.route_check_addresses, report.routes.size))
ShxPanel()
{
report.routes.forEachIndexed { i, route ->
val verdict = result.verdicts[route.ip] ?: return@forEachIndexed
AddressRow(route.ip, verdict)
if (i < report.routes.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
ChangeButtons(report.domain, rulesActive, onAlways)
}
@Composable
private fun IpResult(result: CheckResult.Ip, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
{
VerdictCard(result.verdict.direct, false, result.ip, reasonText(result.verdict.reason), result.reach.viaDefault, checkedReach = true)
if (result.reach.direct != null || result.reach.vpn != null)
{
CompareCard(result.reach)
}
ChangeButtons(result.ip, rulesActive, onAlways)
}
@Composable
private fun AppResult(app: InstalledApp, manager: RoutingManager)
{
val c = LocalShxColors.current
val state by manager.state.collectAsStateWithLifecycle()
val config = state.config
val excluded by produceState(emptySet<String>(), config) {
value = withContext(Dispatchers.IO) { manager.computeExcludedPackages() }
}
val direct = config.enabled && app.packageName in excluded
val match = RoutingManager.templateMatch(app.packageName, config)
val reason = when
{
!config.enabled -> stringResource(R.string.route_reason_app_routing_off)
app.packageName in config.tunnelOverrides -> stringResource(R.string.route_reason_app_override)
app.packageName in config.directPackages -> stringResource(R.string.route_reason_app_added)
match != null -> stringResource(R.string.route_reason_app_template, templateLabel(match))
else -> stringResource(R.string.route_reason_app_default)
}
VerdictCard(direct, false, app.label, reason, null, checkedReach = false)
ShxNote(stringResource(R.string.route_check_app_note), tone = ShxTone.NEUTRAL)
Text(stringResource(R.string.route_check_change_for, app.label), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
ChangeButton(stringResource(R.string.route_check_always_direct), c.successContainer, c.onSuccess, Modifier.weight(1f)) { manager.setAppDirect(app.packageName, true) }
ChangeButton(stringResource(R.string.route_check_always_vpn), c.accentMuted, c.textTonal, Modifier.weight(1f)) { manager.setAppDirect(app.packageName, false) }
}
}
@Composable
private fun ChangeButtons(subject: String, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
{
val c = LocalShxColors.current
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp))
{
Text(stringResource(R.string.route_check_change_for, subject), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
ChangeButton(stringResource(R.string.route_check_always_direct), c.successContainer, c.onSuccess, Modifier.weight(1f)) { onAlways(true) }
ChangeButton(stringResource(R.string.route_check_always_vpn), c.accentMuted, c.textTonal, Modifier.weight(1f)) { onAlways(false) }
}
if (!rulesActive)
{
ShxNote(stringResource(R.string.route_rules_need_flexible), tone = ShxTone.WARNING)
}
}
}
@Composable
private fun ChangeButton(text: String, bg: Color, fg: Color, modifier: Modifier, onClick: () -> Unit)
{
Box(
contentAlignment = Alignment.Center,
modifier = modifier.height(52.dp).clip(RoundedCornerShape(16.dp)).background(bg).clickable(role = Role.Button, onClick = onClick),
)
Text(
text = ips.joinToString(", ").ifEmpty { stringResource(R.string.route_check_none) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
{
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = fg)
}
}
@Composable
private fun AddressRow(ip: String, verdict: RouteCheck.Verdict)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(ip, style = ShxType.mono.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold), color = c.text)
Text(reasonText(verdict.reason), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 2)
}
Text(
stringResource(if (verdict.direct) R.string.route_verdict_direct else R.string.route_verdict_tunnel),
style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold),
color = if (verdict.direct) c.onSuccess else c.onAccentContainer,
modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(if (verdict.direct) c.successContainer else c.accentContainer).padding(horizontal = 10.dp, vertical = 4.dp),
)
}
}
@Composable
private fun DnsBlock(label: String, ips: List<String>)
{
val c = LocalShxColors.current
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
{
Text(label, style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft)
Text(ips.joinToString(" · ").ifEmpty { stringResource(R.string.route_check_none) }, style = ShxType.mono.copy(fontSize = 14.sp), color = c.text)
}
}
@Composable
private fun AppSuggestions(apps: List<InstalledApp>, query: String, onPick: (InstalledApp) -> Unit)
{
val c = LocalShxColors.current
val matches = remember(apps, query) {
val q = query.trim()
apps.filter { it.label.contains(q, ignoreCase = true) || it.packageName.contains(q, ignoreCase = true) }.take(6)
}
ShxPanel()
{
if (matches.isEmpty())
{
Text(stringResource(R.string.route_check_bad_app), style = ShxType.body, color = c.textSub, modifier = Modifier.padding(16.dp))
}
matches.forEachIndexed { i, app ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clickable { onPick(app) }.padding(horizontal = 16.dp, vertical = 10.dp),
)
{
AppIcon(app.packageName, 36.dp)
Text(app.label, style = ShxType.rowTitle, color = c.text)
}
if (i < matches.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
@@ -4,155 +4,164 @@
* 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.
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
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.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material.icons.rounded.Shield
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.ui.unit.dp
import org.strongswan.android.logic.routing.AppTraffic
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxRow
import org.strongswan.android.ui.compose.design.ShxScreenTitle
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxTileButton
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.routing.ExceptionsPanel
import org.strongswan.android.ui.compose.routing.RecentAppsCard
import org.strongswan.android.ui.compose.routing.RecentPreview
import org.strongswan.android.ui.compose.routing.RuSitesPanel
import org.strongswan.android.ui.compose.routing.SplitTunnelHero
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.utils.Constants
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.components.PredictiveBackScale
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import org.strongswan.android.ui.compose.routing.DeadHostsPage
import org.strongswan.android.ui.compose.routing.DirectAppsScreen
import org.strongswan.android.ui.compose.routing.RecentAppsPage
import org.strongswan.android.ui.compose.routing.RulesPage
import org.strongswan.android.ui.compose.routing.routingSummary
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.routing.RoutingHeroCard
import org.strongswan.android.ui.compose.routing.RuToggleRow
import org.strongswan.android.ui.compose.routing.RuModeSelector
import org.strongswan.android.ui.compose.routing.RuDetailsDisclosure
import org.strongswan.android.ui.compose.routing.GeoipChipsRow
import org.strongswan.android.ui.compose.routing.RfActionRow
import org.strongswan.android.ui.compose.routing.RuleSetRow
import org.strongswan.android.ui.compose.routing.AddRuleRow
import org.strongswan.android.ui.compose.routing.ForcePrefixRow
import org.strongswan.android.ui.compose.routing.AddForcePrefixRow
import org.strongswan.android.ui.compose.routing.ForcePrefixDialog
/** In-tab pages of the Routing tab; "back" returns to the main page, not out of the tab. */
private enum class RoutingPage { MAIN, APPS, RULES, RECENT, DEAD }
@Composable
fun RoutingScreen()
{
val snackbarHostState = remember { SnackbarHostState() }
var page by rememberSaveable { mutableStateOf(RoutingPage.MAIN) }
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
Scaffold(
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.background,
containerColor = LocalShxColors.current.background,
) { _ ->
RoutingContent(snackbarHostState)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter)
{
PredictiveBackScale(
enabled = page != RoutingPage.MAIN,
onBack = { page = if (page == RoutingPage.DEAD) RoutingPage.RECENT else RoutingPage.MAIN },
modifier = Modifier
.widthIn(max = 640.dp)
.fillMaxSize(),
)
{
AnimatedContent(
targetState = page,
transitionSpec = {
val forward = targetState != RoutingPage.MAIN
(fadeIn(effects) + slideInHorizontally(spatial) { if (forward) it / 8 else -it / 8 }) togetherWith
(fadeOut(effects) + slideOutHorizontally(spatial) { if (forward) -it / 8 else it / 8 })
},
label = "routingPage",
) { current ->
RoutingPageContent(current, snackbarHostState, onNavigate = { page = it }, onBack = { page = RoutingPage.MAIN })
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoutingContent(snackbarHostState: SnackbarHostState)
private fun RoutingPageContent(
page: RoutingPage,
snackbarHostState: SnackbarHostState,
onNavigate: (RoutingPage) -> Unit,
onBack: () -> Unit,
)
{
val context = LocalContext.current
val manager = remember { RoutingManager.getInstance(context) }
@@ -160,58 +169,78 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
val state by manager.state.collectAsStateWithLifecycle()
val ruState by ruList.state.collectAsStateWithLifecycle()
val config = state.config
var editingId by rememberSaveable { mutableStateOf<String?>(null) }
var focusName by rememberSaveable { mutableStateOf(false) }
var pickTarget by remember { mutableStateOf<String?>(null) }
/* force-prefix dialog: null = hidden, "" = add, otherwise the entry id */
var forceDialogId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val id = pickTarget
if (result.resultCode == Activity.RESULT_OK && id != null)
{
val packages = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
?: arrayListOf()
manager.updateRuleSetPackages(id, packages.toSet())
}
pickTarget = null
}
/* real number of apps excluded from the tunnel: prefixes/keywords expanded
/* real packages that bypass the tunnel: categories and templates expanded
* against installed apps (cached per config version inside the manager) */
var excludedApps by remember { mutableStateOf(0) }
LaunchedEffect(config)
{
excludedApps = if (config.enabled)
val directPackages by produceState(initialValue = emptyList<String>(), config) {
value = if (config.enabled)
{
withContext(Dispatchers.IO) { manager.computeExcludedPackages().size }
withContext(Dispatchers.IO) { manager.computeExcludedPackages().filter { it != context.packageName }.sorted() }
}
else
{
0
emptyList()
}
}
fun addNewSet()
/* dead direct destinations the engine keeps refusing and the split of the
* session traffic; polled only while the screen is visible */
val lifecycle = LocalLifecycleOwner.current.lifecycle
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>(), lifecycle)
{
editingId = manager.addRuleSet()
focusName = true
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
{
while (true)
{
val raw = try
{
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
}
catch (_: Throwable)
{
null
}
value = HomeViewModel.parseDeadTargets(raw)
delay(5_000)
}
}
}
val split by produceState<Pair<Long, Long>?>(initialValue = null, lifecycle)
{
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
{
while (true)
{
val stats = try
{
withContext(Dispatchers.Default) {
if (CharonVpnService.isUserspaceRoutingEnabled(context)) CharonVpnService.getRoutingStats() else null
}
}
catch (_: Throwable)
{
null
}
value = if (stats != null && stats.size >= 4 && stats[1] + stats[3] > 0) stats[1] to stats[3] else null
delay(5_000)
}
}
}
fun deleteSetWithUndo(ruleSet: RuleSet)
/* the four busiest apps of the last hour for the «Recent apps» preview */
val recent by produceState(initialValue = emptyList<RecentPreview>(), directPackages, deadTargets)
{
scope.launch {
manager.deleteRuleSet(ruleSet.id)
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.routing_delete_snackbar),
actionLabel = context.getString(R.string.action_undo),
duration = SnackbarDuration.Long,
)
if (result == SnackbarResult.ActionPerformed)
{
manager.restoreRuleSet(ruleSet)
}
value = withContext(Dispatchers.IO) {
val pm = context.packageManager
val failingOwners = deadTargets.filter { !it.viaTunnel }.mapNotNull { it.owner }.toSet()
AppTraffic.since(context, System.currentTimeMillis() - 60L * 60 * 1000).entries
.sortedByDescending { it.value }
.mapNotNull { (pkg, _) ->
val label = runCatching { pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString() }.getOrNull() ?: return@mapNotNull null
RecentPreview(pkg, label, pkg in directPackages, pkg in failingOwners)
}
.take(4)
}
}
@@ -230,331 +259,129 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
snackbarHostState.showSnackbar(message)
}
}
val ruleCount = config.siteRules.size + config.forceTunnelPrefixes.size + config.directPrefixes.size + config.packagePatterns.size
/* the hero says what bypasses the VPN: only the "direct" rules count there */
val directRuleCount = config.siteRules.count { it.direct } + config.directPrefixes.size + config.packagePatterns.size
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = ShxSpacing.xl),
)
when (page)
{
TopAppBar(
title = { Text(stringResource(R.string.routing_title), fontWeight = FontWeight.SemiBold) },
actions = {
IconButton(onClick = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) })
{
Icon(
Icons.Rounded.Route,
contentDescription = stringResource(R.string.route_check_title),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
RoutingPage.APPS -> DirectAppsScreen(onBack = onBack)
RoutingPage.RULES -> RulesPage(onBack = onBack)
RoutingPage.RECENT -> RecentAppsPage(
deadTargets = deadTargets,
onForceTunnel = { dst -> forceTunnel(manager, dst) },
onOpenDead = { onNavigate(RoutingPage.DEAD) },
onBack = onBack,
)
/* ---- hero: master toggle + live summary ---- */
RoutingHeroCard(
RoutingPage.DEAD -> DeadHostsPage(
targets = deadTargets,
onForceTunnel = { dst -> forceTunnel(manager, dst) },
onBack = { onNavigate(RoutingPage.RECENT) },
)
RoutingPage.MAIN -> MainPage(
enabled = config.enabled,
setCount = config.ruleSets.count { it.enabled },
appCount = excludedApps,
onToggle = { manager.setGlobalEnabled(it) },
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
/* kill switch + excluded apps: excluded apps are added to the VPN's disallowed list
* (CharonVpnService.addDisallowedApplication), so the system lockdown does not
* cover them - on a tunnel drop they keep using the normal network */
if (config.enabled && excludedApps > 0 && remember { context.isVpnLockdownEnabled() })
{
Text(
text = stringResource(R.string.routing_killswitch_note, excludedApps),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs, vertical = ShxSpacing.sm),
)
}
/* ---- dead direct destinations the engine keeps refusing ---- */
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>())
{
while (true)
{
val raw = try
{
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
}
catch (_: Throwable)
{
null
}
value = HomeViewModel.parseDeadTargets(raw)
delay(5_000)
}
}
if (deadTargets.isNotEmpty())
{
ShxSection(title = stringResource(R.string.dead_targets_title))
{
Text(
text = stringResource(R.string.dead_targets_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
)
deadTargets.forEachIndexed { index, target ->
ShxSectionDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.error, CircleShape),
)
Column(Modifier.weight(1f)) {
Text(
text = target.dst,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
Text(
text = listOfNotNull(
target.owner,
stringResource(R.string.dead_targets_fails_fmt, target.fails),
stringResource(
R.string.dead_targets_cooldown_fmt,
formatDuration(target.leftMs / 1000),
),
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/* ---- RU networks direct: geoip list + routing mode ---- */
ShxSection(title = stringResource(R.string.routing_section_ru))
{
RuToggleRow(
title = stringResource(R.string.rusplit_toggle_title),
subtitle = stringResource(R.string.rusplit_toggle_summary),
checked = ruState.enabled,
updating = ruState.status == RuIpListStatus.UPDATING,
onCheckedChange = { ruList.setEnabled(it) },
onUpdate = { refreshGeoip() },
)
}
/* ---- rule sets ---- */
ShxSection(title = stringResource(R.string.routing_rules_section))
{
if (config.enabled)
{
RfActionRow(onClick = { manager.applyRfBundle() })
if (config.ruleSets.isNotEmpty())
{
ShxSectionDivider()
}
}
if (config.ruleSets.isEmpty())
{
ShxEmptyState(
icon = Icons.Rounded.AltRoute,
message = stringResource(R.string.routing_empty),
actionLabel = stringResource(R.string.routing_add_rule),
onAction = { addNewSet() },
)
}
else
{
config.ruleSets.forEachIndexed { index, ruleSet ->
RuleSetRow(
ruleSet = ruleSet,
onToggle = { manager.setRuleSetEnabled(ruleSet.id, it) },
onOpen = { editingId = ruleSet.id; focusName = false },
)
if (index < config.ruleSets.lastIndex)
{
ShxSectionDivider()
}
}
ShxSectionDivider()
AddRuleRow(onClick = { addNewSet() })
}
}
/* ---- advanced: userspace mode, GeoIP list, force-tunnel prefixes ---- */
var advancedOpen by rememberSaveable { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { advancedOpen = !advancedOpen }
.padding(horizontal = ShxSpacing.lg + ShxSpacing.lg, vertical = ShxSpacing.lg),
)
{
Text(
text = stringResource(R.string.routing_advanced).uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f).semantics { heading() },
)
Icon(
if (advancedOpen) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(visible = advancedOpen)
{
Column {
if (ruState.enabled)
{
ShxSection(title = stringResource(R.string.routing_section_ru))
{
if (ruState.enabled)
{
RuModeSelector(
mode = ruState.mode,
onModeChange = { ruList.setMode(it) },
)
if (ruState.status == RuIpListStatus.UPDATING)
{
LinearWavyProgressIndicator(
Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.xs),
)
}
GeoipChipsRow(state = ruState)
RuDetailsDisclosure(mode = ruState.mode)
}
}
}
/* ---- force-tunnel prefixes: always through the tunnel ---- */
ShxSection(title = stringResource(R.string.routing_force_section))
{
if (config.forceTunnelPrefixes.isEmpty())
{
Text(
text = stringResource(R.string.routing_force_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
)
}
else
{
config.forceTunnelPrefixes.forEachIndexed { index, entry ->
ForcePrefixRow(
entry = entry,
onToggle = { manager.setForcePrefixEnabled(entry.id, it) },
onEdit = { forceDialogId = entry.id },
onDelete = { manager.deleteForcePrefix(entry.id) },
)
if (index < config.forceTunnelPrefixes.lastIndex)
{
ShxSectionDivider()
}
}
}
ShxSectionDivider()
AddForcePrefixRow(onClick = { forceDialogId = "" })
}
}
}
}
/* ---- rule set editor, hosted outside the scrolling column ---- */
val editing = editingId?.let { id -> config.ruleSets.firstOrNull { it.id == id } }
if (editing != null)
{
RuleSetEditorSheet(
ruleSet = editing,
requestNameFocus = focusName,
onDismiss = { editingId = null; focusName = false },
onRename = { manager.updateRuleSetName(editing.id, it) },
onPickApps = {
pickTarget = editing.id
val intent = Intent(context, SelectedApplicationsActivity::class.java)
intent.putStringArrayListExtra(
VpnProfileDataSource.KEY_SELECTED_APPS_LIST,
ArrayList(editing.packages),
)
pickApps.launch(intent)
},
onTogglePreset = { manager.togglePreset(editing.id, it) },
onAddPrefix = { prefix ->
manager.updateConfig { cfg ->
cfg.copy(ruleSets = cfg.ruleSets.map {
if (it.id == editing.id) it.copy(prefixes = it.prefixes + prefix) else it
})
}
},
onRemovePrefix = { prefix ->
manager.updateConfig { cfg ->
cfg.copy(ruleSets = cfg.ruleSets.map {
if (it.id == editing.id) it.copy(prefixes = it.prefixes - prefix) else it
})
}
},
onAddKeyword = { keyword ->
manager.updateConfig { cfg ->
cfg.copy(ruleSets = cfg.ruleSets.map {
if (it.id == editing.id) it.copy(keywords = it.keywords + keyword.lowercase()) else it
})
}
},
onRemoveKeyword = { keyword ->
manager.updateConfig { cfg ->
cfg.copy(ruleSets = cfg.ruleSets.map {
if (it.id == editing.id) it.copy(keywords = it.keywords - keyword) else it
})
}
},
onDelete = {
deleteSetWithUndo(editing)
editingId = null
focusName = false
},
)
}
/* ---- force prefix add/edit dialog, hosted outside the scrolling column ---- */
if (forceDialogId != null)
{
val initial = if (forceDialogId == "")
{
""
}
else
{
config.forceTunnelPrefixes.firstOrNull { it.id == forceDialogId }?.prefix ?: ""
}
ForcePrefixDialog(
initial = initial,
onConfirm = { cidr ->
if (forceDialogId == "")
{
manager.addForcePrefix(cidr) != null
}
else
{
manager.updateForcePrefix(forceDialogId!!, cidr)
}
},
onDismiss = { forceDialogId = null },
ruState = ruState,
directPackages = directPackages,
ruleCount = ruleCount,
directRuleCount = directRuleCount,
deadCount = deadTargets.count { !it.viaTunnel },
split = split,
recent = recent,
onMasterToggle = manager::setGlobalEnabled,
onRuToggle = ruList::setEnabled,
onRuMode = ruList::setMode,
onRefresh = { refreshGeoip() },
onOpenApps = { onNavigate(RoutingPage.APPS) },
onOpen = onNavigate,
)
}
}
/** "ip:port" of a dead destination -> a /32 (or /128) "always through the VPN" rule. */
private fun forceTunnel(manager: RoutingManager, dst: String)
{
val ip = dst.substringBeforeLast(':').removeSurrounding("[", "]")
manager.addForcePrefix(ip + if (ip.contains(':')) "/128" else "/32")
}
@Composable
private fun MainPage(
enabled: Boolean,
ruState: org.strongswan.android.logic.routing.RuIpListState,
directPackages: List<String>,
ruleCount: Int,
directRuleCount: Int,
deadCount: Int,
split: Pair<Long, Long>?,
recent: List<RecentPreview>,
onMasterToggle: (Boolean) -> Unit,
onRuToggle: (Boolean) -> Unit,
onRuMode: (String) -> Unit,
onRefresh: () -> Unit,
onOpenApps: () -> Unit,
onOpen: (RoutingPage) -> Unit,
)
{
val context = LocalContext.current
val openCheck = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) }
Column(
Modifier
.fillMaxSize()
.statusBarsPadding()
.verticalScroll(rememberScrollState()),
)
{
ShxScreenTitle(stringResource(R.string.routing_title))
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp),
)
{
SplitTunnelHero(
enabled = enabled,
summary = routingSummary(enabled, ruState.enabled, directPackages.size, directRuleCount),
split = split,
onToggle = onMasterToggle,
)
ShxSectionHeader(stringResource(R.string.routing_section_ru))
RuSitesPanel(state = ruState, onToggle = onRuToggle, onModeChange = onRuMode, onRefresh = onRefresh)
ShxSectionHeader(stringResource(R.string.routing_section_exceptions))
ExceptionsPanel(
directPackages = directPackages,
ruleCount = ruleCount,
onApps = onOpenApps,
onRules = { onOpen(RoutingPage.RULES) },
)
/* kill switch + direct apps: the direct apps are added to the VPN's disallowed list
* (CharonVpnService.addDisallowedApplication), so the system lockdown does not cover
* them and they keep using the normal network when the tunnel drops. The RU-sites part
* is routed by IP *inside* the VPN's scope (the RU ranges are simply left out of its
* routes), so under lockdown it is blocked together with everything else. */
if (enabled && directPackages.isNotEmpty() && remember { context.isVpnLockdownEnabled() })
{
ShxNote(stringResource(R.string.routing_killswitch_note), tone = ShxTone.NEUTRAL)
}
ShxSectionHeader(stringResource(R.string.routing_section_check))
ShxTileButton(stringResource(R.string.routing_check_row), ShxIcons.Search, openCheck)
RecentAppsCard(apps = recent, failing = deadCount, onClick = { onOpen(RoutingPage.RECENT) })
if (deadCount > 0)
{
ShxPanel()
{
ShxRow(
title = stringResource(R.string.dead_targets_title),
subtitle = stringResource(R.string.routing_adv_dead_sub),
icon = ShxIcons.Alert,
iconBackground = LocalShxColors.current.warningStrong,
iconTint = LocalShxColors.current.onWarning,
chevron = true,
onClick = { onOpen(RoutingPage.DEAD) },
)
}
}
}
}
}
@@ -1,359 +0,0 @@
/*
* 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 androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.Category
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Editor of one rule set as a modal bottom sheet. The preset picker is a
* second view inside the same sheet (back returns to the editor) so that two
* sheets never stack on top of each other.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RuleSetEditorSheet(
ruleSet: RuleSet,
requestNameFocus: Boolean,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
onPickApps: () -> Unit,
onTogglePreset: (RoutingPresets.Preset) -> Unit,
onAddPrefix: (String) -> Unit,
onRemovePrefix: (String) -> Unit,
onAddKeyword: (String) -> Unit,
onRemoveKeyword: (String) -> Unit,
onDelete: () -> Unit,
)
{
var showPresets by remember { mutableStateOf(false) }
BackHandler(enabled = showPresets) { showPresets = false }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
AnimatedContent(
targetState = showPresets,
transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(120)) },
label = "rule-set-editor",
)
{ presetsVisible ->
if (presetsVisible)
{
PresetPickerContent(
ruleSet = ruleSet,
onToggle = onTogglePreset,
onBack = { showPresets = false },
)
}
else
{
EditorContent(
ruleSet = ruleSet,
requestNameFocus = requestNameFocus,
onDismiss = onDismiss,
onRename = onRename,
onPickApps = onPickApps,
onOpenPresets = { showPresets = true },
onAddPrefix = onAddPrefix,
onRemovePrefix = onRemovePrefix,
onAddKeyword = onAddKeyword,
onRemoveKeyword = onRemoveKeyword,
onDelete = onDelete,
)
}
}
}
}
@Composable
private fun EditorContent(
ruleSet: RuleSet,
requestNameFocus: Boolean,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
onPickApps: () -> Unit,
onOpenPresets: () -> Unit,
onAddPrefix: (String) -> Unit,
onRemovePrefix: (String) -> Unit,
onAddKeyword: (String) -> Unit,
onRemoveKeyword: (String) -> Unit,
onDelete: () -> Unit,
)
{
val nameFocusRequester = remember { FocusRequester() }
LaunchedEffect(ruleSet.id, requestNameFocus)
{
if (requestNameFocus)
{
nameFocusRequester.requestFocus()
}
}
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = ShxSpacing.xl),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = ShxSpacing.xl, end = ShxSpacing.md, bottom = ShxSpacing.sm),
)
{
Text(
text = stringResource(R.string.routing_editor_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss)
{
Text(stringResource(R.string.action_done))
}
}
OutlinedTextField(
value = ruleSet.name,
onValueChange = onRename,
label = { Text(stringResource(R.string.routing_rule_name)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl)
.focusRequester(nameFocusRequester),
shape = MaterialTheme.shapes.medium,
)
EditorRow(
icon = Icons.Rounded.Apps,
title = stringResource(R.string.routing_pick_apps),
subtitle = stringResource(R.string.routing_apps_count, ruleSet.packages.size),
onClick = onPickApps,
)
EditorRow(
icon = Icons.Rounded.Category,
title = stringResource(R.string.routing_presets_selected, countSelectedPresets(ruleSet)),
subtitle = stringResource(R.string.routing_presets_hint),
onClick = onOpenPresets,
)
ChipGroup(
label = stringResource(R.string.routing_prefixes_label),
values = ruleSet.prefixes.sorted(),
text = { it },
onRemove = onRemovePrefix,
addLabel = stringResource(R.string.routing_add_prefix),
onAdd = onAddPrefix,
)
ChipGroup(
label = stringResource(R.string.routing_keywords_label),
values = ruleSet.keywords.sorted(),
text = { "*$it*" },
onRemove = onRemoveKeyword,
addLabel = stringResource(R.string.routing_add_keyword),
onAdd = onAddKeyword,
)
Button(
onClick = onDelete,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.md),
)
{
Icon(
Icons.Rounded.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(ShxSpacing.sm))
Text(stringResource(R.string.routing_delete))
}
}
}
/** Labeled chip group with trailing add field, shared by prefixes and keywords. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ChipGroup(
label: String,
values: List<String>,
text: (String) -> String,
onRemove: (String) -> Unit,
addLabel: String,
onAdd: (String) -> Unit,
)
{
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = ShxSpacing.xl, top = ShxSpacing.lg),
)
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.xs),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.xs),
)
{
values.forEach { value ->
AssistChip(
onClick = { onRemove(value) },
label = { Text(text(value), maxLines = 1) },
trailingIcon = {
Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.size(16.dp))
},
)
}
}
AddChipField(label = addLabel, onAdd = onAdd)
}
/** Editor row: badge, title/subtitle, chevron. */
@Composable
private fun EditorRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ShxSpacing.xl, vertical = 14.dp),
)
{
ShxIconBadge(icon)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** Compact input with an add action; commits on the IME "done" or the trailing button. */
@Composable
private fun AddChipField(
label: String,
onAdd: (String) -> Unit,
)
{
var value by remember { mutableStateOf("") }
val commit = {
if (value.isNotBlank())
{
onAdd(value.trim())
value = ""
}
}
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text(label) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { commit() }),
trailingIcon = {
IconButton(onClick = { commit() }, enabled = value.isNotBlank())
{
Icon(Icons.Rounded.Add, contentDescription = null)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.xs),
shape = MaterialTheme.shapes.medium,
)
}
/** A preset counts as selected when all of its patterns are in the set. */
private fun countSelectedPresets(ruleSet: RuleSet): Int =
RoutingPresets.RF_BUNDLE.count { preset ->
ruleSet.prefixes.containsAll(preset.prefixes) && ruleSet.keywords.containsAll(preset.keywords)
}
@@ -24,6 +24,10 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import org.strongswan.android.R
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.ui.Modifier
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.theme.LocalShxColors
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -34,24 +38,17 @@ fun SecondaryScaffold(
onBack: () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
)
{
/* header of the mockup: 44dp back button + 22/800 title, no tinted bar */
Scaffold(
snackbarHost = snackbarHost,
floatingActionButton = floatingActionButton,
containerColor = LocalShxColors.current.background,
topBar = {
TopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
ShxPageHeader(title = title, onBack = onBack, modifier = Modifier.statusBarsPadding(), actions = actions)
},
content = content,
)
@@ -0,0 +1,643 @@
/*
* 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.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
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.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.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons
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.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxSwitch
import org.strongswan.android.ui.compose.design.ShxToggleRow
import org.strongswan.android.logic.events.NotifPrefs
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxThemeSettings
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.ui.compose.theme.ThemeMode
/** «Appearance» (F_Appearance): three theme cards with a preview, wallpaper colors, language. */
@Composable
fun AppearancePage()
{
val context = LocalContext.current
val c = LocalShxColors.current
val mode by remember { ShxThemeSettings.load(context); ShxThemeSettings.themeMode }.collectAsStateWithLifecycle()
val dynamic by remember { ShxThemeSettings.useDynamicColors }.collectAsStateWithLifecycle()
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
ShxSectionHeader(stringResource(R.string.appearance_theme))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
listOf(ThemeMode.SYSTEM to R.string.theme_system, ThemeMode.LIGHT to R.string.theme_light, ThemeMode.DARK to R.string.theme_dark).forEach { (m, label) ->
val on = mode == m
val shape = RoundedCornerShape(20.dp)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.weight(1f)
.clip(shape)
.background(c.card)
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, shape)
.selectable(selected = on, role = Role.RadioButton) { ShxThemeSettings.setThemeMode(context, m) }
.padding(10.dp),
)
{
ThemePreview(m)
Text(stringResource(label), style = ShxType.chip, color = c.text, maxLines = 1)
}
}
}
ShxPanel(Modifier.padding(top = 4.dp))
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
{
ShxToggleRow(
stringResource(R.string.appearance_wallpaper),
dynamic,
{ ShxThemeSettings.setUseDynamicColors(context, it) },
subtitle = stringResource(R.string.appearance_wallpaper_sub),
divider = true,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(60.dp)
.clickable(role = Role.Button) {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.fromParts("package", context.packageName, null))
else Intent(Settings.ACTION_LOCALE_SETTINGS)
runCatching { context.startActivity(intent) }
}
.padding(start = 16.dp, end = 14.dp),
)
{
Text(stringResource(R.string.appearance_language), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
Text(stringResource(R.string.theme_system), style = ShxType.body, color = c.textSub)
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
}
}
}
}
/** Mini screen of a theme: background, the hero blob, two text lines. */
@Composable
private fun ThemePreview(mode: ThemeMode)
{
val light = Color(0xFFFCF8FF)
val dark = Color(0xFF141318)
val bg = when (mode)
{
ThemeMode.SYSTEM -> Brush.horizontalGradient(0f to light, 0.5f to light, 0.5f to dark, 1f to dark)
ThemeMode.LIGHT -> Brush.linearGradient(listOf(light, light))
ThemeMode.DARK -> Brush.linearGradient(listOf(dark, dark))
}
val accent = if (mode == ThemeMode.DARK) Color(0xFF9C7CFF) else Color(0xFF6D4AFF)
val line = when (mode)
{
ThemeMode.SYSTEM -> Color(0xFFC9C5D0)
ThemeMode.LIGHT -> Color(0xFFE5E1E9)
ThemeMode.DARK -> Color(0xFF36333D)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
modifier = Modifier.fillMaxWidth().height(120.dp).clip(RoundedCornerShape(14.dp)).background(bg).border(1.dp, Color(0xFFE5E1E9), RoundedCornerShape(14.dp)),
)
{
Box(Modifier.size(34.dp).clip(RoundedCornerShape(topStartPercent = 45, topEndPercent = 55, bottomEndPercent = 50, bottomStartPercent = 50)).background(accent))
Box(Modifier.width(56.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(line))
Box(Modifier.width(40.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(line))
}
}
/** «No VPN, no network» (F_KillSwitch): status, three steps, note, button to the Android settings. */
@Composable
fun KillSwitchPage(lockdown: Boolean)
{
val context = LocalContext.current
val c = LocalShxColors.current
Column(Modifier.padding(horizontal = 16.dp).padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(14.dp))
{
val shape = RoundedCornerShape(24.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(shape)
.background(if (lockdown) c.successContainer else c.warningContainer)
.border(1.dp, if (lockdown) c.successContainer else c.warningBorder, shape).padding(16.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(if (lockdown) c.success else c.warning))
{
Icon(ShxIcons.Lock, contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(
stringResource(if (lockdown) R.string.ks_on_title else R.string.ks_off_title),
style = ShxType.cardHeading, color = if (lockdown) c.onSuccessStrong else c.onWarningStrong,
)
Text(
stringResource(if (lockdown) R.string.ks_on_text else R.string.ks_off_text),
style = ShxType.note, color = if (lockdown) c.onSuccess else c.onWarningContainer,
)
}
}
Text(stringResource(R.string.ks_intro), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp), color = c.textTonal, modifier = Modifier.padding(horizontal = 4.dp))
listOf(R.string.ks_step1, R.string.ks_step2, R.string.ks_step3).forEachIndexed { i, step ->
val s = RoundedCornerShape(20.dp)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(s).background(c.card).border(1.dp, c.cardBorder, s).padding(14.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(c.accent))
{
Text("${i + 1}", style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
}
Text(stringResource(step), style = ShxType.note.copy(fontSize = 14.sp), color = c.text)
}
}
ShxNote(stringResource(R.string.ks_note))
ShxButton(
stringResource(R.string.ks_open),
{ runCatching { context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) } },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 16.dp),
)
}
}
/**
* «Help» (F_Help): two big buttons (check the connection, contact support),
* the frequent questions as an accordion, and «About the app».
*/
@Composable
internal fun HelpPage(onUpdates: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
val info = remember { runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull() }
val build = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info?.longVersionCode?.toInt() ?: 0 else @Suppress("DEPRECATION") (info?.versionCode ?: 0)
fun open(url: String) = runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min))
{
HelpTile(ShxIcons.Pulse, stringResource(R.string.help_check), c.accent, Color.White, Modifier.weight(1f).fillMaxHeight()) {
context.startActivity(Intent(context, org.strongswan.android.ui.DiagnosticsActivity::class.java))
}
HelpTile(ShxIcons.Doc, stringResource(R.string.help_write), c.accentContainer, c.onAccentContainer, Modifier.weight(1f).fillMaxHeight()) {
context.startActivity(Intent(context, org.strongswan.android.ui.LogActivity::class.java))
}
}
ShxSectionHeader(stringResource(R.string.help_faq).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
val faq = listOf(
R.string.faq_q1 to R.string.faq_a1, R.string.faq_q2 to R.string.faq_a2, R.string.faq_q_bridge to R.string.faq_a_bridge, R.string.faq_q3 to R.string.faq_a3,
R.string.faq_q4 to R.string.faq_a4, R.string.faq_q5 to R.string.faq_a5,
)
var open by remember { mutableStateOf(0) }
ShxPanel()
{
faq.forEachIndexed { i, (q, a) ->
val expanded = open == i
Column(Modifier.fillMaxWidth())
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp)
.clickable(role = Role.Button) { open = if (expanded) -1 else i }.padding(horizontal = 16.dp, vertical = 10.dp),
)
{
Text(stringResource(q), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
Icon(
ShxIcons.Chevron, contentDescription = null, tint = c.textSub,
modifier = Modifier.size(18.dp).rotate(if (expanded) 270f else 90f),
)
}
if (expanded)
{
Text(
stringResource(a), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 21.sp, fontWeight = FontWeight.Normal), color = c.textSoft,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
)
}
if (i < faq.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
}
}
ShxSectionHeader(stringResource(R.string.help_about).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
ShxPanel()
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(16.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(RoundedCornerShape(15.dp)).background(c.accent))
{
Icon(painterResource(R.drawable.ic_widget_logo_accent), contentDescription = null, tint = Color.Unspecified, modifier = Modifier.size(32.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text("SHX VPN " + (info?.versionName ?: ""), style = ShxType.cardHeading, color = c.text)
Text(stringResource(R.string.help_build, build), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
}
HelpLink(stringResource(R.string.help_updates), onUpdates)
HelpLink(stringResource(R.string.help_licenses)) { open("https://www.gnu.org/licenses/old-licenses/gpl-2.0.html") }
HelpLink(stringResource(R.string.help_source)) { open("https://github.com/strongswan/strongswan") }
}
}
}
@Composable
private fun HelpTile(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String, bg: Color, fg: Color, modifier: Modifier, onClick: () -> Unit)
{
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier.heightIn(min = 84.dp).clip(RoundedCornerShape(22.dp)).background(bg).clickable(role = Role.Button, onClick = onClick).padding(14.dp),
)
{
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = fg, maxLines = 2)
}
}
@Composable
private fun HelpLink(text: String, onClick: () -> Unit)
{
val c = LocalShxColors.current
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().height(52.dp).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 16.dp),
)
{
Text(text, style = ShxType.body.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
}
private enum class NotifTag { ALWAYS, IMPORTANT, QUIET }
/**
* «Notifications» (F_NotifSettings): what is pushed besides the «Events»
* list, in three groups; the status notification cannot be turned off.
*/
@Composable
internal fun NotifSettingsPage()
{
val c = LocalShxColors.current
val context = LocalContext.current
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
Text(
stringResource(R.string.notif_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal), color = c.textSoft,
modifier = Modifier.padding(horizontal = 4.dp),
)
NotifGroup(stringResource(R.string.notif_group_connection))
{
NotifRow(stringResource(R.string.notif_status), stringResource(R.string.notif_status_sub), NotifTag.ALWAYS, null, divider = true)
NotifRow(stringResource(R.string.notif_problems), stringResource(R.string.notif_problems_sub), NotifTag.IMPORTANT, NotifPrefs.PROBLEMS)
}
NotifGroup(stringResource(R.string.notif_group_routing))
{
NotifRow(stringResource(R.string.notif_new_apps), stringResource(R.string.notif_new_apps_sub), NotifTag.QUIET, NotifPrefs.NEW_APPS)
}
NotifGroup(stringResource(R.string.notif_group_other))
{
NotifRow(stringResource(R.string.notif_updates), stringResource(R.string.notif_updates_sub), NotifTag.QUIET, NotifPrefs.UPDATES, divider = true)
NotifRow(stringResource(R.string.notif_weekly), stringResource(R.string.notif_weekly_sub), NotifTag.QUIET, NotifPrefs.WEEKLY)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(18.dp)).background(c.tile)
.clickable(role = Role.Button) {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
else Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null))
runCatching { context.startActivity(intent) }
}
.padding(horizontal = 16.dp),
)
{
Text(stringResource(R.string.notif_android), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.textTonal, modifier = Modifier.weight(1f))
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun NotifGroup(title: String, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit)
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
ShxSectionHeader(title.uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
ShxPanel(content = content)
}
}
@Composable
private fun NotifRow(title: String, sub: String, tag: NotifTag, key: String?, divider: Boolean = false)
{
val c = LocalShxColors.current
val context = LocalContext.current
var on by remember { mutableStateOf(key == null || NotifPrefs.enabled(context, key)) }
val (tagBg, tagFg, tagText) = when (tag)
{
NotifTag.ALWAYS -> Triple(c.accentContainer, c.onAccentContainer, stringResource(R.string.notif_tag_always))
NotifTag.IMPORTANT -> Triple(c.warningStrong, c.onWarning, stringResource(R.string.notif_tag_important))
NotifTag.QUIET -> Triple(c.tile, c.textSoft, stringResource(R.string.notif_tag_quiet))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 66.dp).padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
)
{
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
Text(
tagText.uppercase(), style = ShxType.tag.copy(fontSize = 10.sp), color = tagFg, maxLines = 1, softWrap = false,
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(tagBg).padding(horizontal = 7.dp, vertical = 1.dp),
)
}
Text(sub, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub)
}
ShxSwitch(
checked = on,
onCheckedChange = if (key == null) null else { value ->
on = value
NotifPrefs.set(context, key, value)
if (key == NotifPrefs.WEEKLY) org.strongswan.android.logic.stats.WeeklySummary.schedule(context)
},
)
}
if (divider)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
/** «Allow installing updates» sheet (F_UpdateInstall), before the system page opens. */
@Composable
internal fun InstallPermissionSheet(onOpen: () -> Unit, onDismiss: () -> Unit)
{
val c = LocalShxColors.current
org.strongswan.android.ui.compose.design.ShxSheet(onDismiss = onDismiss)
{
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(horizontal = 4.dp))
{
Text(stringResource(R.string.install_perm_title), style = ShxType.sheetTitle, color = c.text)
Text(stringResource(R.string.install_perm_text), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 21.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
}
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 14.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
{
Text(stringResource(R.string.install_perm_preview).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(c.tile).padding(horizontal = 12.dp, vertical = 10.dp),
)
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.install_perm_switch), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
Text("SHX VPN", style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
/* a picture of the system switch, not a control */
Box(Modifier.size(44.dp, 26.dp).clip(RoundedCornerShape(13.dp)).background(c.accent).padding(3.dp), contentAlignment = Alignment.CenterEnd)
{
Box(Modifier.size(20.dp).clip(CircleShape).background(Color.White))
}
}
Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
}
}
UpdateSafeNote(stringResource(R.string.install_perm_safe))
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_open), onOpen, modifier = Modifier.weight(2f))
}
}
}
}
@Composable
private fun dnsName(choice: org.strongswan.android.logic.protection.DnsChoice): String = when (choice)
{
org.strongswan.android.logic.protection.DnsChoice.PROFILE -> stringResource(R.string.prot_profile)
org.strongswan.android.logic.protection.DnsChoice.CLOUDFLARE -> "Cloudflare"
org.strongswan.android.logic.protection.DnsChoice.ADGUARD -> "AdGuard DNS"
org.strongswan.android.logic.protection.DnsChoice.CUSTOM -> stringResource(R.string.prot_custom)
}
/** «Cloudflare · blocking on» line of the settings list. */
@Composable
internal fun protectionSub(state: org.strongswan.android.logic.protection.ProtectionState?): String
{
state ?: return ""
val blocking = state.blocksAds || state.blocksPhishing
val status = stringResource(if (blocking) R.string.prot_on else R.string.prot_off)
/* German nouns keep their capital */
val locale = androidx.compose.ui.platform.LocalConfiguration.current.locales[0]
return dnsName(state.choice) + " · " + if (locale.language == "de") status else status.lowercase(locale)
}
/** «DNS and ads» (F_Protection): what is blocked, the blocking switches, the DNS server. */
@Composable
internal fun ProtectionPage()
{
val c = LocalShxColors.current
val context = LocalContext.current
val stateNullable by remember { org.strongswan.android.logic.protection.Protection.flow(context) }.collectAsStateWithLifecycle()
val state = stateNullable ?: return
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)
fun update(next: org.strongswan.android.logic.protection.ProtectionState)
{
if (next == state) return
org.strongswan.android.logic.protection.Protection.set(context, next)
/* the TUN device gets its DNS servers when it is built */
val service = org.strongswan.android.logic.VpnStateService.getLiveInstance()
if (service != null && service.state == org.strongswan.android.logic.VpnStateService.State.CONNECTED) service.reconnect()
else android.widget.Toast.makeText(context, applies, android.widget.Toast.LENGTH_SHORT).show()
}
Column(Modifier.padding(horizontal = 16.dp).padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
/* what the resolver blocks (the app cannot count the blocked queries: the resolver answers them) */
val blocked = listOfNotNull(
if (state.blocksAds) stringResource(R.string.prot_list_ads) else null,
if (state.blocksTrackers) stringResource(R.string.prot_list_trackers) else null,
if (state.blocksPhishing) stringResource(R.string.prot_list_phishing) else null,
)
val on = blocked.isNotEmpty()
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(if (on) c.successContainer else c.tile).padding(18.dp),
)
{
Text(stringResource(if (on) R.string.prot_on else R.string.prot_off), style = ShxType.chip, color = if (on) c.onSuccess else c.textSoft)
Text(dnsName(state.choice), style = ShxType.number.copy(fontSize = 30.sp, lineHeight = 34.sp), color = c.text)
Text(
if (on) stringResource(R.string.prot_what, blocked.joinToString(" · ")) else stringResource(R.string.prot_none),
style = ShxType.chip.copy(fontWeight = FontWeight.SemiBold), color = if (on) c.onSuccess else c.textSub,
)
}
ShxSectionHeader(stringResource(R.string.prot_block).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
ShxPanel()
{
ShxToggleRow(stringResource(R.string.prot_ads), state.blocksAds, { update(state.withAds(it)) }, subtitle = stringResource(R.string.prot_ads_sub), divider = true)
ShxToggleRow(stringResource(R.string.prot_trackers), state.blocksTrackers, { update(state.withAds(it)) }, subtitle = stringResource(R.string.prot_trackers_sub), divider = true)
ShxToggleRow(stringResource(R.string.prot_phishing), state.blocksPhishing, { update(state.withPhishing(it)) }, subtitle = stringResource(R.string.prot_phishing_sub))
}
Text(stringResource(R.string.prot_together), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
ShxSectionHeader(stringResource(R.string.prot_dns).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
ShxPanel()
{
val rows = listOf(
org.strongswan.android.logic.protection.DnsChoice.PROFILE to (stringResource(R.string.prot_profile) to stringResource(R.string.prot_profile_sub)),
org.strongswan.android.logic.protection.DnsChoice.CLOUDFLARE to ("Cloudflare" to stringResource(R.string.prot_cloudflare_sub)),
org.strongswan.android.logic.protection.DnsChoice.ADGUARD to ("AdGuard DNS" to stringResource(R.string.prot_adguard_sub)),
org.strongswan.android.logic.protection.DnsChoice.CUSTOM to (stringResource(R.string.prot_custom) to stringResource(R.string.prot_custom_sub)),
)
rows.forEachIndexed { i, (choice, texts) ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(62.dp).selectable(selected = state.choice == choice, role = Role.RadioButton) {
if (choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM && !customValid(custom))
{ /* the field below takes the address first */
org.strongswan.android.logic.protection.Protection.set(context, state.copy(choice = choice))
}
else update(state.copy(choice = choice, phishingFilter = if (choice == org.strongswan.android.logic.protection.DnsChoice.CLOUDFLARE) state.phishingFilter else false))
}.padding(horizontal = 16.dp),
)
{
androidx.compose.material3.RadioButton(selected = state.choice == choice, onClick = null, colors = androidx.compose.material3.RadioButtonDefaults.colors(selectedColor = c.accent))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp))
{
Text(texts.first, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
Text(texts.second, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
}
if (i < rows.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
if (state.choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM)
{
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; probe = null }, placeholder = "9.9.9.9")
val secure = org.strongswan.android.logic.protection.SecureDnsConfig.parse(custom)
ShxButton(
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(),
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)
}
}
}
}
Text(stringResource(R.string.prot_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
}
}
@@ -24,32 +24,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Autorenew
import androidx.compose.material.icons.rounded.BatteryChargingFull
import androidx.compose.material.icons.rounded.Bedtime
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.Cached
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Description
import androidx.compose.material.icons.rounded.FileDownload
import androidx.compose.material.icons.rounded.GppGood
import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.NetworkCheck
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.RestartAlt
import androidx.compose.material.icons.rounded.Security
import androidx.compose.material.icons.rounded.SystemUpdateAlt
import androidx.compose.material.icons.rounded.VerifiedUser
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -77,7 +55,6 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Brightness6
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
@@ -97,19 +74,41 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn
import org.strongswan.android.ui.compose.components.PredictiveBackScale
import androidx.compose.runtime.produceState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxScreenTitle
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.WatchdogReceiver
import org.strongswan.android.logic.connectivity.WhiteListManager
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.UpdateState
import org.strongswan.android.ui.LogActivity
import org.strongswan.android.ui.TrustedCertificatesActivity
import org.strongswan.android.ui.TrustedNetworksActivity
import org.strongswan.android.ui.ConnectivityCheckActivity
import org.strongswan.android.ui.VpnProfileImportActivity
import org.strongswan.android.ui.compose.components.ShxSection
@@ -150,34 +149,20 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
val updateManager = remember { UpdateManager.getInstance(context) }
val updateState by updateManager.state.collectAsStateWithLifecycle()
var showUpdateConfirm by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
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
{
unknownSourcesLauncher.launch(UpdateInstaller.createInstallPermissionIntent(context))
}
}
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle()
androidx.compose.runtime.LaunchedEffect(updatesRequested)
{
if (updatesRequested)
{
pageState = SettingsPage.UPDATES
ShxNavigation.consumeUpdatesPage()
}
}
/* from a sub page "back" returns to the settings list, not out of the tab */
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
@@ -202,65 +187,34 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
Column(
Modifier
.fillMaxSize()
.background(LocalShxColors.current.background)
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(bottom = ShxSpacing.xl),
)
{
if (showTitle || page != SettingsPage.ROOT)
if (page != SettingsPage.ROOT)
{
TopAppBar(
title = {
Text(
stringResource(page.titleRes),
fontWeight = FontWeight.SemiBold,
modifier = Modifier.semantics { heading() },
)
},
navigationIcon = {
if (page != SettingsPage.ROOT)
{
IconButton(onClick = { pageState = SettingsPage.ROOT }) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(android.R.string.cancel))
}
}
},
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
ShxPageHeader(stringResource(page.titleRes), onBack = { pageState = SettingsPage.ROOT })
}
else if (showTitle)
{
ShxScreenTitle(stringResource(R.string.pref_title))
}
if (page == SettingsPage.ROOT)
{
SettingsRoot(onOpen = { pageState = it })
SettingsRoot(onOpen = { pageState = it }, updateState = updateState, versionName = versionName)
}
if (page == SettingsPage.APPEARANCE)
{
ShxSection(title = stringResource(R.string.settings_page_appearance))
{
ThemeModeRow()
var addedRow = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
{
var dynamicColors by remember { ShxThemeSettings.load(context); mutableStateOf(ShxThemeSettings.useDynamicColors.value) }
ShxSettingToggleRow(
icon = Icons.Rounded.Palette,
title = stringResource(R.string.pref_dynamic_colors_title),
subtitle = stringResource(R.string.pref_dynamic_colors_summary),
checked = dynamicColors,
onCheckedChange = {
dynamicColors = it
ShxThemeSettings.setUseDynamicColors(context, it)
},
)
addedRow = true
}
}
AppearancePage()
}
if (page == SettingsPage.AUTOMATION)
if (page == SettingsPage.STABILITY)
{
PermissionsList(Modifier.padding(horizontal = 16.dp).padding(top = 4.dp))
ShxSection(title = stringResource(R.string.settings_section_general))
{
var addedRow = false
@@ -271,14 +225,14 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
ShxSectionDivider()
}
ShxSettingRow(
icon = Icons.Rounded.History,
icon = ShxIcons.Clock,
title = stringResource(R.string.pref_default_vpn_profile),
subtitle = defaultLabel,
onClick = { showDefaultPicker = true },
)
ShxSectionDivider()
ShxSettingToggleRow(
icon = Icons.Rounded.BatteryChargingFull,
icon = ShxIcons.Battery,
title = stringResource(R.string.pref_power_whitelist_title),
subtitle = stringResource(R.string.pref_power_whitelist_summary),
checked = ignorePower,
@@ -293,7 +247,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
* disabled, which breaks watchdog-based recovery of the tunnel */
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.Bedtime,
icon = ShxIcons.Moon,
title = stringResource(R.string.samsung_sleep_title),
subtitle = stringResource(R.string.samsung_sleep_summary),
onClick = { openSamsungBatterySettings(context) },
@@ -302,33 +256,15 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
}
}
if (page == SettingsPage.AUTOMATION)
if (page == SettingsPage.STABILITY)
{
ShxSection(title = stringResource(R.string.settings_section_automation))
{
ShxSettingRow(
icon = Icons.Rounded.Wifi,
title = stringResource(R.string.auto_connect_title),
subtitle = stringResource(R.string.auto_connect_summary),
onClick = {
context.startActivity(Intent(context, TrustedNetworksActivity::class.java))
},
)
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.NetworkCheck,
title = stringResource(R.string.health_check_title),
subtitle = stringResource(R.string.health_check_summary),
onClick = {
context.startActivity(Intent(context, ConnectivityCheckActivity::class.java))
},
)
ShxSectionDivider()
var whiteListEnabled by remember {
mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled)
}
ShxSettingToggleRow(
icon = Icons.Rounded.GppMaybe,
icon = ShxIcons.Alert,
title = stringResource(R.string.whitelist_analysis_title),
subtitle = stringResource(R.string.whitelist_analysis_summary),
checked = whiteListEnabled,
@@ -342,7 +278,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(VpnShortcutManager.getInstance(context).isEnabled())
}
ShxSettingToggleRow(
icon = Icons.Rounded.Bolt,
icon = ShxIcons.Bolt,
title = stringResource(R.string.pref_routines_shortcuts_title),
subtitle = stringResource(R.string.pref_routines_shortcuts_summary),
checked = shortcutsEnabled,
@@ -356,7 +292,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true))
}
ShxSettingToggleRow(
icon = Icons.Rounded.RestartAlt,
icon = ShxIcons.Refresh,
title = stringResource(R.string.watchdog_title),
subtitle = stringResource(R.string.watchdog_summary),
checked = watchdogEnabled,
@@ -382,7 +318,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false))
}
ShxSettingToggleRow(
icon = Icons.Rounded.GppGood,
icon = ShxIcons.Shield,
title = stringResource(R.string.safe_ipv6_title),
subtitle = stringResource(R.string.safe_ipv6_summary),
checked = safeIpv6,
@@ -399,7 +335,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
ShxSection(title = stringResource(R.string.settings_section_certs))
{
ShxSettingRow(
icon = Icons.Rounded.VerifiedUser,
icon = ShxIcons.Cert,
title = stringResource(R.string.trusted_certs_title),
onClick = {
context.startActivity(Intent(context, TrustedCertificatesActivity::class.java))
@@ -407,47 +343,33 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
)
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.Cached,
icon = ShxIcons.Refresh,
title = stringResource(R.string.crl_cache),
onClick = { showCrl = true },
)
}
}
if (page == SettingsPage.SUPPORT)
if (page == SettingsPage.PROTECTION)
{
if (managed.isAllowProfileImport)
{
ShxSection(title = stringResource(R.string.settings_section_profiles))
{
ShxSettingRow(
icon = Icons.Rounded.FileDownload,
title = stringResource(R.string.profile_import),
onClick = {
context.startActivity(Intent(context, VpnProfileImportActivity::class.java))
},
)
}
}
ProtectionPage()
}
if (page == SettingsPage.SUPPORT)
if (page == SettingsPage.NOTIFICATIONS)
{
ShxSection(title = stringResource(R.string.settings_section_support))
NotifSettingsPage()
}
if (page == SettingsPage.HELP)
{
HelpPage(onUpdates = { pageState = SettingsPage.UPDATES })
ShxSection(title = stringResource(R.string.help_for_support))
{
ShxSettingRow(
icon = Icons.Rounded.Description,
title = stringResource(R.string.show_log),
onClick = {
context.startActivity(Intent(context, LogActivity::class.java))
},
)
ShxSectionDivider()
var extendedDiag by remember {
mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false))
}
ShxSettingToggleRow(
icon = Icons.Rounded.BugReport,
icon = ShxIcons.Bug,
title = stringResource(R.string.extended_diag_title),
subtitle = stringResource(R.string.extended_diag_summary),
checked = extendedDiag,
@@ -461,76 +383,25 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
if (page == SettingsPage.UPDATES)
{
ShxSection(title = stringResource(R.string.settings_section_updates))
{
var autoUpdate by remember {
mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true))
}
ShxSettingToggleRow(
icon = Icons.Rounded.Autorenew,
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 = Icons.Rounded.CloudSync,
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 = Icons.Rounded.SystemUpdateAlt,
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 = Icons.Rounded.CloudDownload,
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
}
},
)
}
}
UpdatesPage()
}
if (page == SettingsPage.ROOT || page == SettingsPage.UPDATES) Text(
if (page == SettingsPage.KILLSWITCH)
{
var resumes by remember { mutableStateOf(0) }
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() })
}
if (page == SettingsPage.ROOT) Text(
text = stringResource(R.string.app_version_fmt, versionName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.meta,
color = LocalShxColors.current.textSub,
modifier = Modifier
.fillMaxWidth()
.padding(top = ShxSpacing.lg),
@@ -549,7 +420,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
{
val options = listOf(Constants.PREF_DEFAULT_VPN_PROFILE_MRU to context.getString(R.string.pref_default_vpn_profile_mru)) +
profiles.map { it.getUUID().toString() to (it.name ?: "") }
AlertDialog(
ShxAlertDialog(
onDismissRequest = { showDefaultPicker = false },
title = { Text(stringResource(R.string.pref_default_vpn_profile)) },
text = {
@@ -574,7 +445,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
}
},
confirmButton = {
TextButton(onClick = { showDefaultPicker = false }) {
ShxDialogButton(onClick = { showDefaultPicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -584,7 +455,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
if (showCrl)
{
val files = remember(showCrl) { context.fileList().filter { it.startsWith("crl-") } }
AlertDialog(
ShxAlertDialog(
onDismissRequest = { showCrl = false },
title = { Text(stringResource(R.string.clear_crl_cache_title)) },
text = {
@@ -594,7 +465,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
)
},
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, danger = true, onClick = {
if (files.isEmpty())
{
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.clear_crl_cache_msg_none)) }
@@ -607,89 +478,20 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
}) { Text(stringResource(R.string.clear)) }
},
dismissButton = {
TextButton(onClick = { showCrl = false }) {
ShxDialogButton(onClick = { showCrl = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
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() ?: ""
AlertDialog(
onDismissRequest = { showUpdateConfirm = false },
title = { Text(stringResource(R.string.update_confirm_title, info.versionName)) },
text = { if (notes.isNotEmpty()) { Text(notes) } },
confirmButton = {
TextButton(onClick = {
showUpdateConfirm = false
startUpdate()
}) { Text(stringResource(R.string.update_action)) }
},
dismissButton = {
TextButton(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
* active for our package. Returns false if not determinable.
* Whether the system-level lockdown ("Block connections without VPN") is on for
* us, as the VPN service last saw it (false before the first connection).
*/
internal fun Context.isVpnLockdownEnabled(): Boolean
{
return try
{
Settings.Global.getInt(
contentResolver,
"vpn_lockdown_$packageName",
0
) != 0
}
catch (_: Exception)
{
false
}
}
internal fun Context.isVpnLockdownEnabled(): Boolean = org.strongswan.android.logic.AlwaysOnVpn.isLockdown(this)
/**
* Opens the Samsung battery settings where the user can exclude the app from
@@ -723,38 +525,215 @@ enum class SettingsPage(val titleRes: Int)
ROOT(R.string.pref_title),
APPEARANCE(R.string.settings_page_appearance),
AUTOMATION(R.string.settings_section_automation),
STABILITY(R.string.settings_stability),
SECURITY(R.string.settings_section_security),
UPDATES(R.string.settings_section_updates),
SUPPORT(R.string.settings_section_support),
KILLSWITCH(R.string.settings_killswitch),
HELP(R.string.settings_help),
NOTIFICATIONS(R.string.settings_notifications),
PROTECTION(R.string.prot_title),
}
/** Settings landing list: two quick rows on top, then the five groups. */
/** One row of the settings list (F_Settings): 62dp, 36dp grey icon well, 15/700 title, 12 sub. */
private data class SettingsEntry(
val title: String,
val sub: String,
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val warn: Boolean = false,
val onClick: () -> Unit,
)
/** Settings landing list of the mockup: search, then the four groups. */
@Composable
private fun SettingsRoot(onOpen: (SettingsPage) -> Unit)
private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateState, versionName: String)
{
val context = LocalContext.current
ShxSection(title = stringResource(R.string.settings_quick))
{
KillSwitchRow()
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.Wifi,
title = stringResource(R.string.auto_connect_title),
subtitle = stringResource(R.string.auto_connect_summary),
onClick = { context.startActivity(Intent(context, TrustedNetworksActivity::class.java)) },
)
val c = LocalShxColors.current
var query by rememberSaveable { mutableStateOf("") }
/* re-read on every ON_RESUME: the kill switch and the trusted networks change outside */
var resumes by remember { mutableStateOf(0) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
ShxSection(title = stringResource(R.string.pref_title))
val lockdown = remember(resumes) { context.isVpnLockdownEnabled() }
val trusted = remember(resumes) { org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).currentConfig }
val health = remember(resumes) { org.strongswan.android.logic.connectivity.HealthCheckManager.getInstance(context).currentConfig }
val themeMode by ShxThemeSettings.themeMode.collectAsStateWithLifecycle()
var showKillSwitchDialog by remember { mutableStateOf(false) }
val certs = remember { runCatching { TrustedCertificateManager.getInstance().load().getAllCACertificates().size }.getOrDefault(0) }
val scenarioList by remember { org.strongswan.android.logic.scenarios.ScenarioEngine.flow(context) }.collectAsStateWithLifecycle()
val scenarioCount = scenarioList.count { it.enabled }
val protection by remember { org.strongswan.android.logic.protection.Protection.flow(context) }.collectAsStateWithLifecycle()
/* «2 from profiles · 1 expires soon» */
val certSummary by produceState(0 to 0) {
value = withContext(Dispatchers.IO) { runCatching { org.strongswan.android.ui.certificateSummary(context) }.getOrDefault(0 to 0) }
}
val groups = listOf(
stringResource(R.string.settings_section_automation) to listOf(
SettingsEntry(
stringResource(R.string.scenarios_title),
stringResource(R.string.scenarios_sub, scenarioCount + (if (trusted.enabled && trusted.parkOnTrusted) 1 else 0) + (if (trusted.enabled && trusted.connectOnUntrusted) 1 else 0)),
ShxIcons.Bolt,
) { context.startActivity(Intent(context, org.strongswan.android.ui.ScenariosActivity::class.java)) },
SettingsEntry(
stringResource(R.string.settings_trusted_networks),
if (!trusted.enabled) stringResource(R.string.settings_off)
else trusted.trustedSsids.sorted().joinToString(", ").ifEmpty { stringResource(R.string.settings_trusted_none) },
ShxIcons.Wifi,
) { context.startActivity(Intent(context, org.strongswan.android.ui.ScenariosActivity::class.java)) },
SettingsEntry(
stringResource(R.string.health_check_title),
if (health.enabled) stringResource(R.string.settings_health_sub, health.intervalSec) else stringResource(R.string.settings_off),
ShxIcons.Pulse,
) { context.startActivity(Intent(context, ConnectivityCheckActivity::class.java)) },
),
stringResource(R.string.settings_protection) to listOf(
SettingsEntry(
stringResource(R.string.prot_title),
protectionSub(protection),
ShxIcons.Shield,
) { onOpen(SettingsPage.PROTECTION) },
SettingsEntry(
stringResource(R.string.settings_killswitch),
stringResource(if (lockdown) R.string.settings_killswitch_on else R.string.settings_killswitch_off),
ShxIcons.Lock,
warn = !lockdown,
) { onOpen(SettingsPage.KILLSWITCH) },
SettingsEntry(stringResource(R.string.settings_section_security), stringResource(R.string.settings_security_sub), ShxIcons.Shield) { onOpen(SettingsPage.SECURITY) },
),
stringResource(R.string.settings_app) to listOf(
SettingsEntry(stringResource(R.string.settings_stability), stringResource(R.string.settings_stability_sub), ShxIcons.Phone) { onOpen(SettingsPage.STABILITY) },
SettingsEntry(stringResource(R.string.settings_notifications), stringResource(R.string.settings_notifications_sub), ShxIcons.Bell) {
onOpen(SettingsPage.NOTIFICATIONS)
},
SettingsEntry(
stringResource(R.string.settings_section_updates),
if (updateState is UpdateState.Available) stringResource(R.string.settings_updates_available, (updateState as UpdateState.Available).info.versionName)
else stringResource(R.string.settings_updates_sub, versionName),
ShxIcons.Refresh,
warn = updateState is UpdateState.Available,
) { onOpen(SettingsPage.UPDATES) },
SettingsEntry(
stringResource(R.string.settings_page_appearance),
stringResource(
when (themeMode)
{
ThemeMode.SYSTEM -> R.string.theme_system
ThemeMode.LIGHT -> R.string.theme_light
ThemeMode.DARK -> R.string.theme_dark
}
),
ShxIcons.Moon,
) { onOpen(SettingsPage.APPEARANCE) },
),
stringResource(R.string.settings_section_support) to listOf(
SettingsEntry(stringResource(R.string.settings_help), stringResource(R.string.settings_help_sub), ShxIcons.Help) { onOpen(SettingsPage.HELP) },
SettingsEntry(stringResource(R.string.settings_log), stringResource(R.string.settings_log_sub), ShxIcons.Doc) {
context.startActivity(Intent(context, LogActivity::class.java))
},
SettingsEntry(
stringResource(R.string.trusted_certs_title),
when
{
certSummary.second > 0 -> stringResource(R.string.settings_certs_soon, certSummary.first, certSummary.second)
certSummary.first > 0 -> stringResource(R.string.settings_certs_profiles, certSummary.first)
else -> pluralStringResource(R.plurals.settings_certs_n, certs, certs)
},
ShxIcons.Cert,
) { context.startActivity(Intent(context, TrustedCertificatesActivity::class.java)) },
),
)
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
ShxSettingRow(icon = Icons.Rounded.Palette, title = stringResource(R.string.settings_page_appearance), onClick = { onOpen(SettingsPage.APPEARANCE) })
ShxSectionDivider()
ShxSettingRow(icon = Icons.Rounded.Bolt, title = stringResource(R.string.settings_section_automation), onClick = { onOpen(SettingsPage.AUTOMATION) })
ShxSectionDivider()
ShxSettingRow(icon = Icons.Rounded.Security, title = stringResource(R.string.settings_section_security), onClick = { onOpen(SettingsPage.SECURITY) })
ShxSectionDivider()
ShxSettingRow(icon = Icons.Rounded.SystemUpdateAlt, title = stringResource(R.string.settings_section_updates), onClick = { onOpen(SettingsPage.UPDATES) })
ShxSectionDivider()
ShxSettingRow(icon = Icons.Rounded.Description, title = stringResource(R.string.settings_section_support), onClick = { onOpen(SettingsPage.SUPPORT) })
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(top = 4.dp).fillMaxWidth().height(48.dp).clip(RoundedCornerShape(24.dp)).background(c.tile).padding(horizontal = 16.dp),
)
{
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
Box(Modifier.weight(1f))
{
if (query.isEmpty())
{
Text(stringResource(R.string.settings_search), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium), color = c.textSub)
}
BasicTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
textStyle = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium, color = c.text),
cursorBrush = SolidColor(c.accent),
modifier = Modifier.fillMaxWidth(),
)
}
}
groups.forEach { (title, rows) ->
val shown = rows.filter { query.isBlank() || it.title.contains(query.trim(), true) || it.sub.contains(query.trim(), true) }
if (shown.isNotEmpty())
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
ShxSectionHeader(title)
ShxPanel()
{
shown.forEachIndexed { i, row ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 62.dp).clickable(onClick = row.onClick).padding(horizontal = 14.dp, vertical = 8.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(c.tile))
{
Icon(row.icon, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp))
{
Text(row.title, style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text)
Text(
row.sub,
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
color = if (row.warn) c.warning else c.textSub,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
}
if (i < shown.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
}
}
}
if (showKillSwitchDialog)
{
ShxAlertDialog(
onDismissRequest = { showKillSwitchDialog = false },
title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
confirmButton = {
ShxDialogButton(primary = true, onClick = {
showKillSwitchDialog = false
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
},
dismissButton = {
ShxDialogButton(onClick = { showKillSwitchDialog = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@@ -767,7 +746,7 @@ private fun ThemeModeRow()
remember { ShxThemeSettings.load(context) }
Column(Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md))
{
Text(stringResource(R.string.pref_theme_title), style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.pref_theme_title), style = ShxType.body)
Spacer(Modifier.height(ShxSpacing.sm))
val options = listOf(
ThemeMode.SYSTEM to R.string.theme_system,
@@ -808,25 +787,25 @@ private fun KillSwitchRow()
}
if (showKillSwitchDialog)
{
AlertDialog(
ShxAlertDialog(
onDismissRequest = { showKillSwitchDialog = false },
title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
showKillSwitchDialog = false
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
},
dismissButton = {
TextButton(onClick = { showKillSwitchDialog = false }) {
ShxDialogButton(onClick = { showKillSwitchDialog = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
ShxSettingRow(
icon = Icons.Rounded.Security,
icon = ShxIcons.Shield,
title = stringResource(R.string.kill_switch_title),
subtitle = stringResource(
if (lockdownActive) R.string.kill_switch_summary_on
@@ -0,0 +1,164 @@
/*
* 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.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnProfileExporter
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.home.serverCode
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
/** The profile as .sswan text without the password, compacted for a QR code. */
private suspend fun profileText(context: Context, profile: VpnProfile): String? = withContext(Dispatchers.IO) {
val uri = VpnProfileExporter.exportProfile(context, profile, false) ?: return@withContext null
runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }?.let { JSONObject(it).toString() }
}.getOrNull()
}
/** «Share server» (F_Share): QR of the profile, who it is, and file / copy actions. */
@Composable
fun ShareProfileScreen(profile: VpnProfile, onDismiss: () -> Unit, onSendFile: () -> Unit)
{
val context = LocalContext.current
val c = LocalShxColors.current
val text by produceState<String?>(null, profile) { value = profileText(context, profile) }
/* the logo in the middle needs the high error correction; a bigger profile falls back to a plain code */
val qr by produceState<Pair<android.graphics.Bitmap, Boolean>?>(null, text) {
val t = text ?: return@produceState
value = withContext(Dispatchers.Default) {
ProfileQr.bitmap(t, highCorrection = true)?.let { it to true } ?: ProfileQr.bitmap(t)?.let { it to false }
}
}
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false))
{
Column(Modifier.fillMaxSize().background(c.background).statusBarsPadding().navigationBarsPadding().verticalScroll(rememberScrollState()))
{
ShxPageHeader(stringResource(R.string.share_title), onDismiss)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 28.dp),
)
{
Text(stringResource(R.string.share_hint), style = ShxType.note.copy(fontSize = 15.sp), color = c.textSoft, textAlign = TextAlign.Center)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.shadow(14.dp, RoundedCornerShape(32.dp), ambientColor = c.onAccentContainer.copy(alpha = 0.2f), spotColor = c.onAccentContainer.copy(alpha = 0.2f))
.clip(RoundedCornerShape(32.dp))
.background(Color.White)
.border(1.dp, c.cardBorder, RoundedCornerShape(32.dp))
.padding(22.dp)
.size(252.dp),
)
{
val code = qr
if (code != null)
{
Image(code.first.asImageBitmap(), contentDescription = stringResource(R.string.share_qr_desc, profile.name ?: ""), modifier = Modifier.fillMaxSize())
if (code.second)
{
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(52.dp).clip(RoundedCornerShape(14.dp)).background(Color.White).padding(4.dp).clip(RoundedCornerShape(11.dp)).background(c.accent),
)
{
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp))
}
}
}
else if (text != null)
{
Text(stringResource(R.string.share_too_big), style = ShxType.note, color = c.textSub, textAlign = TextAlign.Center)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(CircleShape).background(c.accentContainer))
{
Text(serverCode(profile, null), style = ShxType.chip.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.ExtraBold), color = c.onAccentContainer)
}
Column(verticalArrangement = Arrangement.spacedBy(1.dp))
{
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.ExtraBold), color = c.text)
Text(stringResource(R.string.share_without_secrets), style = ShxType.meta.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = c.textSub)
}
}
Spacer(Modifier.size(12.dp))
ShxNote(stringResource(R.string.share_cert_note), tone = ShxTone.WARNING)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.share_send_file), onSendFile, height = 52.dp, modifier = Modifier.weight(1f))
ShxButton(
stringResource(R.string.share_copy),
{
text?.let {
(context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.setPrimaryClip(ClipData.newPlainText("sswan", it))
Toast.makeText(context, R.string.share_copied, Toast.LENGTH_SHORT).show()
}
},
style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f), enabled = text != null,
)
}
}
}
}
}
@@ -37,14 +37,15 @@ import androidx.compose.material.icons.outlined.AltRoute
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.Box
@@ -69,10 +70,18 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.layout.size
import androidx.compose.ui.unit.dp
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.ui.LogActivity
import org.strongswan.android.ui.VpnProfileDetailActivity
import org.strongswan.android.ui.compose.theme.ShxSpacing
@@ -98,13 +107,32 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
{
val state by viewModel.ui.collectAsStateWithLifecycle()
val context = LocalContext.current
val addProfile = {
context.startActivity(Intent(context, VpnProfileDetailActivity::class.java))
/* «Add server» sheet: QR, link, file or manual entry */
var addSheetOpen by remember { mutableStateOf(false) }
val addProfile = { addSheetOpen = true }
val addRequested by ShxNavigation.addServer.collectAsStateWithLifecycle()
LaunchedEffect(addRequested)
{
if (addRequested)
{
viewModel.setTab(ShxTab.HOME)
addSheetOpen = true
ShxNavigation.consumeAddServer()
}
}
val fastestRequested by ShxNavigation.fastest.collectAsStateWithLifecycle()
LaunchedEffect(fastestRequested, state.profiles.isNotEmpty())
{
if (fastestRequested && state.profiles.isNotEmpty())
{
viewModel.setTab(ShxTab.HOME)
viewModel.connectFastest(context)
ShxNavigation.consumeFastest()
}
}
val editProfile: (VpnProfile) -> Unit = { profile ->
val intent = Intent(context, VpnProfileDetailActivity::class.java)
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
context.startActivity(intent)
/* F_Profile: the server page, editing goes from there */
context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString()))
}
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
val shareProfile: (VpnProfile) -> Unit = { pendingShare = it }
@@ -115,6 +143,37 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
viewModel.messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
}
/* open-tab requests from other activities (profile form -> Routing) */
val requestedTab by ShxNavigation.requestedTab.collectAsStateWithLifecycle()
LaunchedEffect(requestedTab)
{
requestedTab?.let {
viewModel.setTab(it)
ShxNavigation.consume()
}
}
/* one-time notice after the 2.16 migration of "only selected apps" profiles */
val routingManager = remember { RoutingManager.getInstance(context) }
val routingState by routingManager.state.collectAsStateWithLifecycle()
LaunchedEffect(routingState.migratedOnlyProfiles)
{
if (routingState.migratedOnlyProfiles > 0)
{
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.routing_migrated_only),
actionLabel = context.getString(R.string.dead_targets_open),
duration = SnackbarDuration.Indefinite,
withDismissAction = true,
)
routingManager.consumeMigrationNotice()
if (result == SnackbarResult.ActionPerformed)
{
viewModel.setTab(ShxTab.ROUTING)
}
}
}
/* the home list keeps its scroll state across tab switches */
val homeListState = rememberLazyListState()
@@ -133,11 +192,15 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
navigationSuiteItems = {
ShxNavigationSuiteItems(selected = state.tab, onSelect = viewModel::setTab, colors = navItemColors)
},
containerColor = MaterialTheme.colorScheme.background,
containerColor = LocalShxColors.current.background,
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationBarContainerColor = LocalShxColors.current.tile,
navigationRailContainerColor = LocalShxColors.current.tile,
),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
color = LocalShxColors.current.background,
) {
Box(Modifier.fillMaxSize())
{
@@ -175,6 +238,7 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
onRestore = viewModel::restore,
onShare = shareProfile,
onAdd = addProfile,
onFastest = { viewModel.connectFastest(context) },
)
ShxTab.ROUTING -> RoutingScreen()
ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles)
@@ -186,81 +250,49 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
)
}
pendingShare?.let { profile ->
ShareProfileDialog(
ShareProfileScreen(
profile = profile,
onDismiss = { pendingShare = null },
onConfirm = { includePassword ->
pendingShare = null
viewModel.shareProfile(context, profile, includePassword)
},
onSendFile = { viewModel.shareProfile(context, profile, false) },
)
}
if (addSheetOpen)
{
AddServerSheet(onDismiss = { addSheetOpen = false })
}
/* update sheets, the mandatory update included, over every tab */
UpdateSheetHost()
}
}
}
@Composable
private fun ShareProfileDialog(profile: VpnProfile, onDismiss: () -> Unit, onConfirm: (Boolean) -> Unit)
{
var includePassword by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.share_profile)) },
text = {
Column {
Text(stringResource(R.string.share_profile_password_warning), style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(ShxSpacing.md))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { includePassword = !includePassword },
) {
Checkbox(checked = includePassword, onCheckedChange = { includePassword = it })
Spacer(Modifier.width(ShxSpacing.xs))
Text(stringResource(R.string.share_profile_include_password))
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(includePassword) }) {
Text(stringResource(R.string.share_profile))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
/** Item colors for the adaptive navigation suite, keeping the brand palette. */
/** Item colors of the mockup nav bar: #E7DFFF pill, #1C1B20 selected, #49454F the rest. */
@Composable
private fun shxNavigationItemColors(): NavigationSuiteItemColors
{
val bg = MaterialTheme.colorScheme.surfaceContainer
val indicator = MaterialTheme.colorScheme.primary.copy(alpha = 0.32f).compositeOver(bg)
val c = LocalShxColors.current
return NavigationSuiteItemColors(
navigationBarItemColors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
indicatorColor = indicator,
selectedIconColor = c.text,
unselectedIconColor = c.textSoft,
selectedTextColor = c.text,
unselectedTextColor = c.textSoft,
indicatorColor = c.accentContainer,
),
navigationRailItemColors = NavigationRailItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
indicatorColor = indicator,
selectedIconColor = c.text,
unselectedIconColor = c.textSoft,
selectedTextColor = c.text,
unselectedTextColor = c.textSoft,
indicatorColor = c.accentContainer,
),
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = indicator,
selectedContainerColor = c.accentContainer,
unselectedContainerColor = Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedIconColor = c.text,
unselectedIconColor = c.textSoft,
selectedTextColor = c.text,
unselectedTextColor = c.textSoft,
),
)
}
@@ -282,22 +314,30 @@ private fun NavigationSuiteScope.ShxNavigationSuiteItems(
onClick = { onSelect(tab) },
icon = {
Icon(
imageVector = tab.icon(selected = tab == selected),
imageVector = tab.icon(),
contentDescription = null,
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
stringResource(tab.label()),
style = ShxType.nav.copy(fontWeight = if (tab == selected) FontWeight.ExtraBold else FontWeight.SemiBold),
maxLines = 1,
)
},
label = { Text(stringResource(tab.label())) },
colors = colors,
)
}
}
private fun ShxTab.icon(selected: Boolean): ImageVector = when (this)
/* the line icons of the mockup tab bar */
private fun ShxTab.icon(): ImageVector = when (this)
{
ShxTab.HOME -> if (selected) Icons.Filled.Home else Icons.Outlined.Home
ShxTab.PROFILES -> if (selected) Icons.Filled.Storage else Icons.Outlined.Storage
ShxTab.ROUTING -> if (selected) Icons.Filled.AltRoute else Icons.Outlined.AltRoute
ShxTab.SETTINGS -> if (selected) Icons.Filled.Settings else Icons.Outlined.Settings
ShxTab.HOME -> ShxIcons.Power
ShxTab.PROFILES -> ShxIcons.Globe
ShxTab.ROUTING -> ShxIcons.Route
ShxTab.SETTINGS -> ShxIcons.Sliders
}
private fun ShxTab.label(): Int = when (this)
@@ -0,0 +1,81 @@
/*
* 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.compose
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* One-shot "open this tab" request from outside the main activity's Compose
* tree (e.g. the profile form's link to the Routing tab). ShxApp consumes it.
*/
object ShxNavigation
{
private val _requestedTab = MutableStateFlow<ShxTab?>(null)
val requestedTab: StateFlow<ShxTab?> = _requestedTab
fun request(tab: ShxTab)
{
_requestedTab.value = tab
}
fun consume()
{
_requestedTab.value = null
}
/* «open the Add server sheet» request (end of the first-run screens) */
private val _addServer = MutableStateFlow(false)
val addServer: StateFlow<Boolean> = _addServer
fun requestAddServer()
{
_addServer.value = true
}
fun consumeAddServer()
{
_addServer.value = false
}
/* «connect the fastest server» request (widget) */
private val _fastest = MutableStateFlow(false)
val fastest: StateFlow<Boolean> = _fastest
fun requestFastest()
{
_fastest.value = true
}
fun consumeFastest()
{
_fastest.value = false
}
/* «What is new» of the update notification: the Updates page of Settings */
private val _updatesPage = MutableStateFlow(false)
val updatesPage: StateFlow<Boolean> = _updatesPage
fun requestUpdatesPage()
{
request(ShxTab.SETTINGS)
_updatesPage.value = true
}
fun consumeUpdatesPage()
{
_updatesPage.value = false
}
}
@@ -1,460 +0,0 @@
/*
* 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.compose
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.material3.Button
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.layout.Arrangement
import org.strongswan.android.R
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.components.ShxSettingRow
import org.strongswan.android.ui.compose.components.ShxSettingToggleRow
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.autoconnect.AutoConnectManager
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Trusted networks settings: when enabled, the VPN is started on cellular and untrusted
* Wi-Fi, and auto sessions are stopped on trusted Wi-Fi.
*/
@Composable
fun TrustedNetworksScreen(
profiles: List<VpnProfile>,
currentSsid: String?,
)
{
val context = LocalContext.current
val manager = remember { AutoConnectManager.getInstance(context) }
var config by remember { mutableStateOf(manager.currentConfig) }
var showProfilePicker by remember { mutableStateOf(false) }
var showAddSsid by remember { mutableStateOf(false) }
var pendingDeleteSsid by remember { mutableStateOf<String?>(null) }
var hasBackgroundLocation by remember { mutableStateOf(isBackgroundLocationGranted(context)) }
var askedBackground by remember { mutableStateOf(false) }
val lifecycleOwner = LocalLifecycleOwner.current
fun refreshPermissions()
{
hasBackgroundLocation = isBackgroundLocationGranted(context)
if (isFineLocationGranted(context))
{
manager.reloadConfig()
}
}
val requestBackground = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { refreshPermissions() }
val requestForeground = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) {
refreshPermissions()
if (isFineLocationGranted(context) && !isBackgroundLocationGranted(context) &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{
askedBackground = true
requestBackground.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
fun requestSsidPermissions()
{
if (!isFineLocationGranted(context))
{
requestForeground.launch(wifiSsidPermissions())
return
}
if (!isBackgroundLocationGranted(context) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{
if (askedBackground)
{
openAppDetails(context)
return
}
askedBackground = true
requestBackground.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
DisposableEffect(lifecycleOwner)
{
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME)
{
refreshPermissions()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val selectedProfile = profiles.firstOrNull { it.getUUID().toString() == config.profileUuid }
val enabled = config.enabled
fun update(transform: (AutoConnectManager.Config) -> AutoConnectManager.Config)
{
val updated = transform(config)
manager.updateConfig { updated }
config = updated
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = ShxSpacing.xl),
)
{
ShxSection(title = stringResource(R.string.settings_section_general))
{
ShxSettingToggleRow(
icon = Icons.Rounded.Wifi,
title = stringResource(R.string.auto_connect_title),
subtitle = stringResource(R.string.auto_connect_summary),
checked = enabled,
onCheckedChange = { checked ->
/* the permission is requested from the explainer card below, not
* straight from the toggle */
update { it.copy(enabled = checked) }
},
)
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.Language,
title = stringResource(R.string.auto_connect_profile),
subtitle = selectedProfile?.name
?: stringResource(R.string.auto_connect_profile_none),
enabled = enabled,
onClick = { showProfilePicker = true },
)
}
if (enabled && !hasBackgroundLocation)
{
Spacer(Modifier.size(ShxSpacing.md))
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg),
)
{
Column(
modifier = Modifier.padding(ShxSpacing.lg),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{
Icon(Icons.Rounded.Wifi, contentDescription = null)
Text(
text = stringResource(R.string.auto_connect_location_title),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.semantics { heading() },
)
}
Text(
text = stringResource(R.string.auto_connect_location_why),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.auto_connect_location_banner),
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { requestSsidPermissions() },
modifier = Modifier.align(Alignment.End),
) { Text(stringResource(R.string.auto_connect_location_grant)) }
}
}
}
ShxSection(title = stringResource(R.string.auto_connect_trusted_title))
{
Text(
text = stringResource(R.string.auto_connect_trusted_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
)
if (config.trustedSsids.isEmpty())
{
Text(
text = stringResource(R.string.auto_connect_no_networks),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
}
else
{
val sortedSsids = remember(config.trustedSsids) { config.trustedSsids.sorted() }
sortedSsids.forEachIndexed { index, ssid ->
if (index > 0)
{
ShxSectionDivider()
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg),
)
{
Icon(
Icons.Rounded.Wifi,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Text(
text = ssid,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
IconButton(onClick = { pendingDeleteSsid = ssid }) {
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.delete_profile),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
ShxSectionDivider()
TrustedAddNetworkRow(
currentSsid = currentSsid,
onAddCurrent = { ssid -> update { it.copy(trustedSsids = it.trustedSsids + ssid) } },
onAddManual = { showAddSsid = true },
)
}
}
if (showProfilePicker)
{
AlertDialog(
onDismissRequest = { showProfilePicker = false },
title = { Text(stringResource(R.string.auto_connect_profile)) },
text = {
Column {
profiles.forEach { profile ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
update { it.copy(profileUuid = profile.getUUID().toString()) }
showProfilePicker = false
}
.padding(vertical = 10.dp),
)
{
RadioButton(
selected = config.profileUuid == profile.getUUID().toString(),
onClick = null,
)
Text(profile.name ?: "", modifier = Modifier.padding(start = ShxSpacing.md))
}
}
}
},
confirmButton = {
TextButton(onClick = { showProfilePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
if (showAddSsid)
{
var ssid by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showAddSsid = false },
title = { Text(stringResource(R.string.auto_connect_add_network)) },
text = {
OutlinedTextField(
value = ssid,
onValueChange = { ssid = it },
singleLine = true,
label = { Text(stringResource(R.string.auto_connect_ssid_label)) },
)
},
confirmButton = {
TextButton(
onClick = {
val trimmed = ssid.trim()
if (trimmed.isNotEmpty())
{
update { it.copy(trustedSsids = it.trustedSsids + trimmed) }
}
showAddSsid = false
},
enabled = ssid.isNotBlank(),
) { Text(stringResource(R.string.add_profile_cta)) }
},
dismissButton = {
TextButton(onClick = { showAddSsid = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
pendingDeleteSsid?.let { ssid ->
AlertDialog(
onDismissRequest = { pendingDeleteSsid = null },
title = { Text(stringResource(R.string.auto_connect_remove_network)) },
text = { Text(ssid) },
confirmButton = {
TextButton(onClick = {
update { it.copy(trustedSsids = it.trustedSsids - ssid) }
pendingDeleteSsid = null
}) { Text(stringResource(R.string.delete_profile), color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { pendingDeleteSsid = null }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@Composable
private fun TrustedAddNetworkRow(
currentSsid: String?,
onAddCurrent: (String) -> Unit,
onAddManual: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
)
{
if (currentSsid != null)
{
TextButton(onClick = { onAddCurrent(currentSsid) })
{
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.auto_connect_add_current, currentSsid))
}
}
else
{
TextButton(onClick = onAddManual)
{
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.auto_connect_add_network))
}
}
}
}
private fun isFineLocationGranted(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
private fun isBackgroundLocationGranted(context: Context): Boolean
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
{
return true
}
return ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_BACKGROUND_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
private fun wifiSsidPermissions(): Array<String> = buildList {
add(Manifest.permission.ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
{
add(Manifest.permission.NEARBY_WIFI_DEVICES)
}
}.toTypedArray()
private fun openAppDetails(context: Context)
{
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
)
}
@@ -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
}
@@ -19,16 +19,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.AlertDialog
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import org.strongswan.android.ui.compose.design.ShxFormField
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -44,7 +40,24 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
@@ -105,6 +118,16 @@ object VpnDialogBus
{
_dialog.value = null
}
/* F_VpnConsent: the hint behind the system VPN consent window */
private val _consentHint = MutableStateFlow(false)
val consentHint: StateFlow<Boolean> = _consentHint
@JvmStatic
fun setConsentHint(shown: Boolean)
{
_consentHint.value = shown
}
}
/** Renders the connect-flow dialogs in the app theme inside the transparent activity. */
@@ -121,6 +144,11 @@ fun ComponentActivity.installVpnDialogHost()
fun VpnDialogHost()
{
val dialog by VpnDialogBus.dialog.collectAsStateWithLifecycle()
val consentHint by VpnDialogBus.consentHint.collectAsStateWithLifecycle()
if (consentHint)
{
ConsentHint()
}
when (val d = dialog)
{
is VpnDialog.Confirmation -> ConfirmationUi(d)
@@ -142,7 +170,7 @@ private fun cancelDialog()
@Composable
private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
{
AlertDialog(
ShxAlertDialog(
onDismissRequest = ::cancelDialog,
title = { Text(dialog.title) },
text = { Text(stringResource(dialog.message)) },
@@ -150,17 +178,17 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs)) {
if (dialog.reconnect)
{
TextButton(onClick = {
ShxDialogButton(onClick = {
VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogDisconnect()
}) {
Text(
stringResource(R.string.disconnect),
color = MaterialTheme.colorScheme.error,
color = LocalShxColors.current.error,
)
}
}
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss()
val listener = VpnDialogBus.listener
if (dialog.disconnect)
@@ -175,7 +203,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
}
},
dismissButton = {
TextButton(onClick = ::cancelDialog) {
ShxDialogButton(onClick = ::cancelDialog) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -189,52 +217,48 @@ private fun LoginUi(dialog: VpnDialog.Login)
var password by rememberSaveable { mutableStateOf("") }
var showPassword by rememberSaveable { mutableStateOf(false) }
AlertDialog(
ShxAlertDialog(
onDismissRequest = ::cancelDialog,
title = { Text(stringResource(R.string.login_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) {
OutlinedTextField(
ShxFormField(
label = stringResource(R.string.login_username),
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.login_username)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
enabled = false,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
OutlinedTextField(
ShxFormField(
label = stringResource(R.string.login_password),
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
trailing = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Rounded.Visibility else Icons.Rounded.VisibilityOff,
if (showPassword) ShxIcons.Eye else ShxIcons.EyeOff,
contentDescription = stringResource(
if (showPassword) R.string.login_hide_password else R.string.login_show_password
),
)
}
},
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogLogin(dialog.profileInfo, password)
}) { Text(stringResource(R.string.login_confirm)) }
},
dismissButton = {
TextButton(onClick = ::cancelDialog) {
ShxDialogButton(onClick = ::cancelDialog) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -244,18 +268,18 @@ private fun LoginUi(dialog: VpnDialog.Login)
@Composable
private fun CertificateRequiredUi(dialog: VpnDialog.CertificateRequired)
{
AlertDialog(
ShxAlertDialog(
onDismissRequest = ::cancelDialog,
title = { Text(stringResource(R.string.certificate_required_title)) },
text = { Text(stringResource(R.string.certificate_required_text)) },
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogEditProfile(dialog.uuid)
}) { Text(stringResource(R.string.edit_profile)) }
},
dismissButton = {
TextButton(onClick = ::cancelDialog) {
ShxDialogButton(onClick = ::cancelDialog) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -265,12 +289,12 @@ private fun CertificateRequiredUi(dialog: VpnDialog.CertificateRequired)
@Composable
private fun PowerWhitelistUi()
{
AlertDialog(
ShxAlertDialog(
onDismissRequest = ::cancelDialog,
title = { Text(stringResource(R.string.power_whitelist_title)) },
text = { Text(stringResource(R.string.power_whitelist_text)) },
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogPowerWhitelist()
}) { Text(stringResource(android.R.string.ok)) }
@@ -281,14 +305,47 @@ private fun PowerWhitelistUi()
@Composable
private fun NotSupportedUi(dialog: VpnDialog.NotSupported)
{
AlertDialog(
ShxAlertDialog(
onDismissRequest = ::cancelDialog,
title = { Text(stringResource(R.string.vpn_not_supported_title)) },
text = { Text(stringResource(dialog.message)) },
confirmButton = {
TextButton(onClick = ::cancelDialog) {
ShxDialogButton(primary = true, onClick = ::cancelDialog) {
Text(stringResource(android.R.string.ok))
}
},
)
}
/**
* Shown by the transparent host while Android's own consent dialog is on
* top (F_VpnConsent): a dimmed screen with a card at the top explaining it.
*/
@Composable
private fun ConsentHint()
{
val c = LocalShxColors.current
Box(
Modifier.fillMaxSize().background(Color(0x8C1C1B20)).statusBarsPadding().padding(start = 20.dp, end = 20.dp, top = 24.dp),
)
{
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(c.background).padding(16.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(c.accent))
{
Icon(ShxIcons.Shield, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
{
Text(stringResource(R.string.consent_hint_title), style = ShxType.cardTitle, color = c.text)
Text(
stringResource(R.string.consent_hint_text),
style = ShxType.note.copy(fontSize = 13.sp, lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft,
)
}
}
}
}
Loaded 100 of 361 files, more files were not shown because too many files have changed in this diff. Show more