Author SHA1 Message Date
DenozordecandClaude Opus 5.5 68ee051ab2 android: version 2.19.4 (172) — обновления и отчёты через панель SHX VPN
CodeQL / analyze (python) (push) Skipped
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 4s
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
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (apidoc) (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 (no-testable-ke) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
TKM / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 8s
CIFuzz / pre-check (push) Failing after 3s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (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 0e35a794c9 android: version 2.19.1 (169)
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 (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
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
Linux / latest (apidoc) (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 (no-testable-ke) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
TKM / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 5s
CIFuzz / pre-check (push) Failing after 4s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (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 / analyze (python) (push) Skipped
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 3s
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
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (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
TKM / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 7s
CIFuzz / pre-check (push) Failing after 3s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (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
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 4s
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
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
Linux / latest (apidoc) (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 (no-testable-ke) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
TKM / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 6s
CIFuzz / pre-check (push) Failing after 3s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (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 / analyze (python) (push) Skipped
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 4s
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
CodeQL / pre-check (push) Failing after 5s
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (apidoc) (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 (no-testable-ke) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
TKM / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 5s
CIFuzz / pre-check (push) Failing after 4s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (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 / analyze (python) (push) Skipped
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 4s
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
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (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
TKM / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 6s
CIFuzz / pre-check (push) Failing after 4s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (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 / analyze (python) (push) Skipped
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 4s
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
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (apidoc) (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 (no-testable-ke) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
TKM / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 6s
CIFuzz / pre-check (push) Failing after 3s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (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 / analyze (python) (push) Skipped
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 4s
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
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (apidoc) (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 (no-testable-ke) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
TKM / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 6s
CIFuzz / pre-check (push) Failing after 3s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (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
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 4s
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
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (apidoc) (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 (no-testable-ke) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
TKM / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 5s
CIFuzz / pre-check (push) Failing after 4s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (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
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 4s
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
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (apidoc) (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 (no-testable-ke) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
TKM / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 4s
CIFuzz / pre-check (push) Failing after 3s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (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
DenozordecandClaude Opus 5.5 6a8528fc98 android: 2.15.0 - Material 3 Expressive redesign
CodeQL / analyze (python) (push) Skipped
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 3s
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
CodeQL / pre-check (push) Failing after 3s
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
Linux / latest (apidoc) (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 (no-testable-ke) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
TKM / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 6s
CIFuzz / pre-check (push) Failing after 4s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Toolchain AGP 9.1 / Gradle 9.3 / Kotlin 2.2 / compileSdk 37, minSdk 24,
material3 1.5.0-beta01. Expressive theme and motion, redesigned Connect,
Profiles, Routing and Settings screens, adaptive layouts, home-screen
widget, Live Update download progress, predictive back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:38:00 +07:00
Denozordec 8ef7c0a53b docs: ui plan status - deferred items completed 2026-10-10 00:33:20 +07:00
Denozordec dc6e1e32fb android: predictive back scale for settings pages and remediation detail 2026-10-10 00:33:20 +07:00
Denozordec 2681233449 android: route check results as cards with status pills 2026-10-10 00:32:24 +07:00
Denozordec 7a7673119e android: profile import as an Expressive bottom sheet in a transparent host 2026-10-10 00:32:00 +07:00
Denozordec 8deafeae8e android: trusted networks - inline location explainer card 2026-10-10 00:31:32 +07:00
Denozordec 59a31d6a1d android: app picker - hide system apps by default with a toggle 2026-10-10 00:30:41 +07:00
Denozordec 8bedf432e2 android: log screen - level filter chips, snackbar instead of toast 2026-10-10 00:30:27 +07:00
Denozordec 42cd361c58 android: QS tile - connectivity category, toggleable 2026-10-10 00:29:39 +07:00
Denozordec 87c3d73fbc android: update download progress notification (ProgressStyle / Live Update on API 36+) 2026-10-10 00:29:30 +07:00
Denozordec 564b8aaa8d android: profiles list-detail on expanded width with in-pane profile form 2026-10-10 00:28:47 +07:00
Denozordec 7cc6343ba1 android: build.gradle comment cleanup 2026-10-10 00:27:29 +07:00
Denozordec c9048143cc android: minSdk 24 (material3 1.5 requirement), drop overrideLibrary workaround 2026-10-10 00:27:24 +07:00
Denozordec 203a67ee86 android: a11y (headings, switch role), form snackbar, German strings, smoke tests, docs 2026-10-10 00:21:31 +07:00
Denozordec 3c7c121c7d android: expanded width - two-column Connect, 640dp settings 2026-10-10 00:13:51 +07:00
Denozordec 0e5e28b4da android: spacing tokens - migrate padding/spacing literals and ScreenPadding to ShxSpacing 2026-10-10 00:13:08 +07:00
Denozordec 22e4082825 android: home screen widget (Glance), tile subtitle with profile name 2026-10-10 00:13:07 +07:00
Denozordec 0c3ea75cac android: settings as grouped pages (appearance, automation, security, updates, support), theme mode, snackbars instead of toasts 2026-10-10 00:09:45 +07:00
Denozordec a451386e0a android: split RoutingScreen into routing/{RoutingHero,RuleSetList,AdvancedRouting} 2026-10-10 00:09:44 +07:00
Denozordec 8bd65d535e android: routing - advanced block (RU details, GeoIP, force prefixes), kill switch vs excluded apps note 2026-10-10 00:07:12 +07:00
Denozordec c5585df135 android: profiles - swipe to delete with undo, search only for long lists 2026-10-10 00:06:05 +07:00
DenozordecandClaude Opus 5.5 385819caf0 android: redesigned Connect screen - morphing hero, profile chip + picker sheet, traffic tiles, one banner slot, collapsed availability
Split HomeScreen into home/{HeroConnect,TrafficTiles,AvailabilityCard,HomeBanners}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:05:34 +07:00
DenozordecandClaude Opus 5.5 3f7bd94a69 android: expressive motion - fade-through tabs, back to Connect, LoadingIndicator, wavy progress, snackbar messages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:01:17 +07:00
DenozordecandClaude Opus 5.5 6fd03f37f7 android: toolchain AGP 9.1.1 / Gradle 9.3.1 / Kotlin 2.2.21 / compileSdk 37, BOM 2026.09.00 + material3 1.5.0-beta01
Built-in Kotlin (kotlin-android removed), Java 11 compile options (compose 1.12),
proguard-android-optimize.txt (the non-optimize file is rejected by AGP 9),
ExposedDropdownMenu import, minSdk 23 kept via tools:overrideLibrary for
material3 1.5 libs that declare minSdk 24. -PshortNdkCommands for deep worktree paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:59:09 +07:00
DenozordecandClaude Opus 5.5 65989b3a5a android: shared ui components (SectionCard, ListRow, StatusPill, SnackbarHost)
Replace the duplicate FormSection, ActiveBadge and SnackbarHost call sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:39:35 +07:00
DenozordecandClaude Opus 5.5 5f6726f173 android: drop reui palette, align splash/window/system bars with Compose surface
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:39:35 +07:00
DenozordecandClaude Opus 5.5 fda3ec158c android: expressive theme, tonal palette, ShxSpacing/ShxShapes tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:35:59 +07:00
DenozordecandClaude Opus 5.5 c872246b8c android: compose BOM 2026.06.01 + material3 1.5.0-alpha18 (expressive API)
Drop unused navigation-compose and material3-window-size-class.
alpha19+/beta01 need AGP 9.1 + compileSdk 37, so alpha18 is pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:35:59 +07:00
DenozordecandClaude Opus 5.5 e9a239e419 android: 2.14.6 - reroute long-dead direct hosts through the tunnel
CodeQL / analyze (python) (push) Skipped
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 3s
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 3s
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (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
TKM / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 7s
CIFuzz / pre-check (push) Failing after 5s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Direct destinations that stay unreachable past the cooldown are now
routed through the VPN tunnel until the network changes. The Home
availability card marks such hosts "via tunnel" and offers a one-tap
"Via VPN" action for the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:28:03 +07:00
Denozordec bc256ad369 android: 2.14.2 - fix network label showing cellular while on Wi-Fi
underlyingNetworkLabel() returned the first non-VPN network from
allNetworks, whose order is unspecified - Android keeps the cellular
network agent alive in parallel with Wi-Fi, so on Wi-Fi the details
sheet could show "Сотовая сеть".  Wi-Fi now wins when present (same
precedence as NetworkMonitor's trust decision); cellular is only
reported when no Wi-Fi network exists.
2026-10-05 01:56:46 +07:00
Denozordec 7185aa4ba5 android: 2.14.1 - traffic split, exit IP, dead-host visibility (UX phase 1)
- ConnectionDetailsSheet: session traffic split "via VPN / direct (RU)"
  from the native engine counters (userspace mode only; system-routes
  mode shows no split - the engine never sees that traffic), public exit
  IP + country via cloudflare cdn-cgi/trace through the tunnel, current
  underlying network (Wi-Fi + SSID / cellular)
- ConnectionCard: one-glance "N% direct" caption when the RU split moves
  traffic directly
- dead direct hosts surfaced in UI: native snapshot of the negative-cache
  table via new get_dead_targets (mutex-guarded, sorted by failures) +
  JNI getDeadDirectTargets; section on the Routing tab and a badge in the
  Home availability card naming dst, failure count, remaining cooldown
  and the owning app (from the 2.13.15 owner-attribution cache)
- HomeViewModel: split/exit/deadTargets as separate hot flows; exit
  refreshed with the probe round (60s cache), dead targets every round
2026-10-05 00:56:47 +07:00
Denozordec de5b093d62 android: keep jniLibs compressed after the minSdk 23 bump
minSdk 23 flipped AGP's useLegacyPackaging default to false (uncompressed
.so, extractNativeLibs=false): the APK grew 12 -> 23 MB.  Updates are
downloaded over cellular, so pin useLegacyPackaging = true; APK is back
to ~12 MB (2.14.0 re-published with the same versionCode, sha updated in
the manifest before any device fetched it).
2026-10-05 00:41:51 +07:00
Denozordec 2c69cbe775 android: 2.14.0 - minSdk 23 + adaptive navigation (Stage 3)
- minSdkVersion 21 -> 23 (device is Android 17, no other users); jni
  Application.mk: APP_PLATFORM android-23 + -DHAVE_SIGWAITINFO (the NDK
  sysroot declares sigwaitinfo since API 23, conflicting with strongSwan's
  fallback prototype; the system implementation is used now)
- replace the bottom NavigationBar with NavigationSuiteScaffold (bottom bar
  on phones, navigation rail in landscape/on tablets); item colors keep the
  brand palette via NavigationSuiteItemColors(bar/rail/drawer)
- HomeScreen applies statusBarsPadding itself (the adaptive scaffold no
  longer pads the content area)
- predictive back was already enabled (android:enableOnBackInvokedCallback)
2026-10-05 00:39:09 +07:00
Denozordec 285d14e996 android: 2.13.15 - exponential direct-dst cooldown, double-start guard, flap-aware auto-connect
Log-driven fixes from the 10-04 diag/charon analysis:
- direct_tcp: negative-cache window now escalates 45s x2 up to 5 min
  (Telegram was opening ~3000 connects/h to each of two dead hosts,
  waking the radio for every leaked 45s probe); skip the unconditional
  fresh-socket retry on ECONNREFUSED (a refusal is deterministic on the
  network the socket is bound to; roam-time sockets are already aborted
  by reprotect)
- new dead-destination diag line (dst, fail count, cooldown) via JNI
  callback when the window escalates or hits every 100th failure
- VpnStateService: double-start guard - a second connect() for the same
  profile while a healthy attempt is in flight is ignored (four starters
  raced through here within 20 s after the 2.13.14 self-update)
- NetworkMonitor/AutoConnectManager: flap detection (>=2 kind changes in
  5 min stretches the auto-connect delay to 10 s; a Wi-Fi flap produced
  a 7-second-lived session and a wasted IKE handshake)
- hardcode diag TAGs obfuscated by R8 ("a: update: up to date" ->
  "UpdateManager: ..."), same for UpdateInstaller/VpnShortcutManager
2026-10-04 23:04:01 +07:00
Denozordec 3c73eb5a27 android: 2.13.14 - fix owner attribution protocol, log update events, ESP replay window context 2026-10-04 01:15:25 +07:00
Denozordec 95d88709f9 android: 2.13.11-2.13.13 release snapshot (battery backoff, direct-flow cooldown, owner attribution, rule-set editor) 2026-10-04 01:07:04 +07:00
Denozordec 56f5a00417 android: Ignore Kotlin build scratch dir
CodeQL / pre-check (push) Failing after 3s
CodeQL / analyze (python) (push) Skipped
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 3s
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
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
Linux / latest (apidoc) (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 (no-testable-ke) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
TKM / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 3s
CIFuzz / pre-check (push) Failing after 3s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
2026-09-30 17:38:16 +07:00
Denozordec e9bdf60f82 android: Pin direct-flow sockets to the active network (2.13.10)
CodeQL / analyze (python) (push) Skipped
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 3s
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 5s
SonarCloud / pre-check (push) Canceled after 3s
SonarCloud / sonarcloud (push) Canceled after 0s
Windows / pre-check (push) Canceled after 1s
CodeQL / pre-check (push) Failing after 3s
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (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
TKM / pre-check (push) Canceled after 0s
Windows / cross-compile (win32) (push) Canceled after 0s
Windows / cross-compile (win64) (push) Canceled after 0s
Android / pre-check (push) Failing after 6s
CIFuzz / pre-check (push) Failing after 4s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Canceled after 0s
Windows / native (x86_64, mingw64, win64) (push) Canceled after 0s
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Canceled after 0s
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Diag/charon logs from 2026-09-30 16:45 showed that after a Wi-Fi->cellular
roam every direct-TCP connect failed asynchronously with EHOSTUNREACH for
minutes, while the IKE socket worked and a Java socket explicitly bound to
the underlying network connected fine: a bare VpnService.protect() left
fresh sockets routed over the vanished network.

- CharonVpnService.protectDirect(fd): protect + Network.bindSocket to the
  active non-VPN network (API 23+, best-effort, adoptFd/detachFd)
- charonservice_protect_direct_socket() JNI wrapper; relay and UDP flow
  sockets use it at creation
- direct_tcp: one retry on a fresh socket when an async connect fails;
  reprotect aborts still-connecting relays (RST) instead of letting the
  kernel take ~90s to report the dead route; failures log dst=ip:port
- direct_udp: reprotect closes all flows; they re-open per packet with a
  fresh, network-pinned socket
- TUN reader: EBADF after a reauth re-establish is no longer logged

Published as 2.13.10 (versionCode 147).
2026-09-30 17:37:45 +07:00
Denozordec d12fb96dc2 android: Full Compose rewrite + two-mode RU split with userspace routing
CodeQL / analyze (python) (push) Skipped
FreeBSD / freebsd (14.4) (push) Skipped
Linux / pre-check (push) Failing after 3s
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
CodeQL / pre-check (push) Failing after 3s
CodeQL / analyze (ruby) (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (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
TKM / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Android / pre-check (push) Failing after 5s
CIFuzz / pre-check (push) Failing after 3s
CodeQL / analyze-cpp (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
TKM / tkm (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
CIFuzz / fuzzing (address) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Replaces the fragment-based UI with a full Jetpack Compose app
(HOME/profiles/routing/settings/trusted networks in Compose, adaptive
brand theming, notification/vector drawables), adds the SHX release
pipeline (build-local.sh, UpdateManager self-update from the S3 bucket,
DiagLog diagnostics rotated into diag.log with crash capture) and the
watchdog/auto-connect/reconnect hardening series.

RU-GeoIP split now has two modes (Routing screen):
- OS routes (default): coarsened complement of the RU list installed
  as system routes, bounded by the binder budget (2.12.x behavior)
- Userspace routing (beta): only the default routes point into the
  TUN; every packet is classified natively against the exact RU list
  (ring-buffer queue, fixed flow tables - allocation-free hot path)
  and RU TCP/UDP leaves through protected sockets. TCP is terminated
  by a vendored lwIP 2.2.0 (tun2socks model, err_t renamed to avoid
  the strongSwan typedef clash, wildcard-port listener patch, asserts
  into diag.log). IPv4 addresses are compared in Java's numeric byte
  order and ranges sorted natively; direct flows re-protect on roam;
  relay errors log errno. Route diagnostics block 12s after connect.

Also: SIM2-crash hardening, connectivity watchdog with wakeup alarm,
profile export, connectivity self-check screen, tests.

Published as 2.13.9 (versionCode 146).
2026-09-30 16:50:03 +07:00
771 changed files with 200812 additions and 7806 deletions

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
# Память проекта
Основная работа в репозитории — 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` — это норма, а не ошибка выкладки.
Проверять результат публикации только авторизованно: `aws s3 cp s3://shx-app/latest.json -` (endpoint и ключи из `.env`).
- Сборка и публикация — скриптом `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.*` и
требует отдельного подтверждения.
+40
View File
@@ -0,0 +1,40 @@
<!--
Параметры сборки и публикации обновлений.
Скопируйте этот файл в .env и заполните значения:
cp .env.example .env
.env не попадает в git (добавлен в .gitignore).
Строки в формате KEY=value; комментарии - строки, начинающиеся с #.
-->
# --- S3 (Selectel Object Storage) ---
S3_ENDPOINT=https://s3.ru-7.storage.selcloud.ru
S3_REGION=ru-7
S3_BUCKET=shx-app
# Пусто = файлы кладутся в корень бакета (latest.json и shx-vpn.apk)
S3_PREFIX=
S3_ACCESS_KEY=
S3_SECRET_KEY=
# Если AWS CLI ругается на SSL (self-signed certificate in certificate chain),
# укажите CA-бандл, содержащий корень сертификата эндпоинта
# (для Selectel - GlobalSign Root CA - R6; например бандл из Git for Windows):
# AWS_CA_BUNDLE=C:\Program Files\Git\mingw64\etc\ssl\certs\ca-bundle.crt
# Публичная статичная ссылка на бакет (без завершающего слеша).
# Требуется публичный доступ на чтение бакета в панели Selectel.
# Зашивается в APK как адрес проверки обновлений.
S3_PUBLIC_BASE_URL=https://s3.ru-7.storage.selcloud.ru/shx-app
# --- Подпись APK (опционально) ---
# Если KEYSTORE_FILE не задан, release APK подписывается debug-ключом
# (~/.android/debug.keystore) - это нужно, чтобы обновление встало поверх
# сборок, установленных из Android Studio.
# Путь - относительно каталога android-проекта или абсолютный.
# KEYSTORE_FILE=release.keystore
# KEYSTORE_PASSWORD=
# KEY_ALIAS=
# KEY_PASSWORD=
# --- Текст "что нового" в latest.json (опционально, одна строка без кавычек) ---
# NOTES_EN=
# NOTES_RU=
+8
View File
@@ -7,9 +7,17 @@ app/src/main/obj
app/src/main/jni/openssl/
*.iml
local.properties
.env
*.apk
*.aab
*.apks
*.idsig
*.jks
*.keystore
# local release notes for the update manifest (kept out of the repo)
NOTES_*.txt
keystore/
*.jks
*.keystore
.kotlin/
+235
View File
@@ -0,0 +1,235 @@
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
/* Optional .env next to this project: S3/update settings and signing (see .env.example).
Environment variables take precedence over .env values. */
def dotenv = [:]
def envFile = rootProject.file('.env')
if (envFile.exists()) {
envFile.eachLine { line ->
def trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#') && trimmed.contains('=')) {
def idx = trimmed.indexOf('=')
dotenv[trimmed.substring(0, idx).trim()] = trimmed.substring(idx + 1).trim()
}
}
}
def envOr = { String key, String defaultValue ->
(System.getenv(key) ?: dotenv[key] ?: defaultValue)
}
def updateBaseUrl = envOr('S3_PUBLIC_BASE_URL', '').replace('\\', '\\\\').replace('"', '\\"')
def keystoreFile = envOr('KEYSTORE_FILE', '')
def keystorePassword = envOr('KEYSTORE_PASSWORD', '')
/* note: locals must not be named keyAlias/keyPassword - those names would
shadow the signingConfig methods inside the signingConfigs block */
def keystoreKeyAlias = envOr('KEY_ALIAS', '')
def keystoreKeyPassword = envOr('KEY_PASSWORD', '')
android {
namespace = 'org.strongswan.android'
/* minSdk >= 23 flips AGP's default to uncompressed .so (extractNativeLibs=false):
* the APK grew 12 -> 23 MB. Updates are downloaded over cellular, so keep
* the libraries compressed as before. */
packaging {
jniLibs {
useLegacyPackaging = true
}
}
defaultConfig {
applicationId "one.shx.strongswan.ext"
compileSdk = 37
minSdk = 24
targetSdk = 36
versionCode 172
versionName "2.19.4"
/* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
/* S3 credentials used by the app for authenticated update downloads
(same keys as the publish script). They are obfuscated with XOR +
Base64 at build time to hinder trivial string extraction.
Note: obfuscation is not real protection - an APK can always be
reverse engineered, so rotate these keys if they leak. */
def updateS3Access = envOr('S3_ACCESS_KEY', '')
def updateS3Secret = envOr('S3_SECRET_KEY', '')
def updateXorKey = new byte[16]
System.arraycopy(java.security.MessageDigest.getInstance('SHA-256')
.digest('shx-update-credentials'.getBytes('UTF-8')), 0, updateXorKey, 0, 16)
def xorB64 = { String value ->
if (!value) { return '' }
def bytes = value.getBytes('UTF-8')
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte)(bytes[i] ^ updateXorKey[i % updateXorKey.length])
}
java.util.Base64.getEncoder().encodeToString(bytes)
}
buildConfigField "String", "UPDATE_S3_ACCESS_KEY", "\"${xorB64(updateS3Access)}\""
buildConfigField "String", "UPDATE_S3_SECRET_KEY", "\"${xorB64(updateS3Secret)}\""
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()
/* deep checkout paths (e.g. git worktrees) overflow the Windows command line; opt in with -PshortNdkCommands */
if (project.hasProperty('shortNdkCommands')) {
arguments 'APP_SHORT_COMMANDS=true', 'LOCAL_SHORT_COMMANDS=true'
}
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
}
}
}
ndkVersion = "27.3.13750724"
externalNativeBuild {
ndkBuild {
path 'src/main/jni/Android.mk'
}
}
tasks.withType(JavaCompile).configureEach {
compileTask ->
options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
}
signingConfigs {
release {
if (keystoreFile) {
storeFile rootProject.file(keystoreFile)
storePassword keystorePassword
keyAlias keystoreKeyAlias
keyPassword keystoreKeyPassword
}
}
}
buildTypes {
release {
/* R8 shrinking + resource shrinking: strips dead code/resources and
obfuscates. The JNI surface is kept via proguard-rules.txt.
Smoke-test a release build on a device before publishing. */
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.txt'
if (keystoreFile) {
signingConfig signingConfigs.release
} else {
/* fall back to the debug key so that release APKs update debug-signed
installations in place (no uninstall, data is preserved) */
def debugKeystore = new File(System.getProperty('user.home'), '.android/debug.keystore')
if (debugKeystore.exists()) {
signingConfig signingConfigs.debug
println "WARNING: KEYSTORE_FILE not set (see .env.example) - signing the release APK with the debug key (${debugKeystore})"
} else {
println "WARNING: no signing configuration - the release APK will be UNSIGNED (set KEYSTORE_FILE in .env or run a debug build once)"
}
}
}
}
compileOptions {
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_11
}
buildFeatures {
compose true
buildConfig true
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.core:core:1.17.0'
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'androidx.lifecycle:lifecycle-process:2.9.4'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.9.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4'
implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
implementation 'androidx.work:work-runtime-ktx:2.10.1'
implementation 'com.google.android.material:material:1.13.0'
/* BOM 2026.09.00 = compose 1.12. The Expressive API (MaterialExpressiveTheme,
MotionScheme.expressive, LoadingIndicator, MaterialShapes) lives only in
material3 1.5.0-alpha/beta, so material3 is pinned explicitly (needs AGP 9.1,
compileSdk 37, minSdk 24). Unpin once 1.5.0 is stable. */
def composeBom = platform('androidx.compose:compose-bom:2026.09.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3:1.5.0-beta01'
/* adaptive-navigation-suite (NavigationSuiteScaffold) requires minSdk 23+ (material3 1.5 requires 24) */
implementation 'androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-beta01'
/* list-detail layout for Profiles on expanded widths */
implementation 'androidx.compose.material3.adaptive:adaptive-layout:1.3.0'
implementation 'androidx.compose.material3.adaptive:adaptive-navigation:1.3.0'
implementation 'androidx.compose.material:material-icons-extended'
/* home screen widget (status + connect button) */
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'
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'
androidTestImplementation 'org.assertj:assertj-core:3.27.6'
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,204 @@
/*
* 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.os.Bundle
import android.util.Base64
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.assertj.core.api.Assertions.assertThat
import org.json.JSONObject
import org.junit.Test
import org.junit.runner.RunWith
import org.strongswan.android.data.ManagedVpnProfile
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 java.io.ByteArrayInputStream
import java.security.KeyStore
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class VpnProfileExporterTest
{
private fun sampleProfile(): VpnProfile
{
val profile = VpnProfile()
profile.setUUID(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"))
profile.setName("Shared Profile")
profile.setVpnType(VpnType.IKEV2_EAP)
profile.setGateway("vpn.example.com")
profile.setPort(4500)
profile.setRemoteId(" CN=vpn.example.com")
profile.setLocalId("alice@example.com")
profile.setUsername("alice")
profile.setPassword("secret")
profile.setIkeProposal("aes256-sha2_384-prfsha384-modp2048")
profile.setEspProposal("aes256gcm16-prfsha384-modp2048")
profile.setDnsServers("10.0.0.1 10.0.0.2")
profile.setMTU(1400)
profile.setNATKeepAlive(20)
profile.setIncludedSubnets("10.1.0.0/16")
profile.setExcludedSubnets("10.1.2.0/24")
profile.setSplitTunneling(VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4)
profile.setProxyHost("proxy.example.com")
profile.setProxyPort(8080)
profile.setProxyExclusions("example.org example.net")
profile.setSelectedApps("com.example.app1 com.example.app2")
profile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_ONLY)
profile.setFlags(VpnProfile.FLAGS_SUPPRESS_CERT_REQS or VpnProfile.FLAGS_DISABLE_CRL or
VpnProfile.FLAGS_STRICT_REVOCATION or VpnProfile.FLAGS_IPv6_TRANSPORT)
return profile
}
@Test
fun exportRoundTripMatchesImporterFormat()
{
val json = JSONObject(VpnProfileExporter.serializeProfile(sampleProfile(), true).toString())
assertThat(json.getString("uuid")).isEqualTo("123e4567-e89b-12d3-a456-426614174000")
assertThat(json.getString("name")).isEqualTo("Shared Profile")
assertThat(json.getString("type")).isEqualTo("ikev2-eap")
val remote = json.getJSONObject("remote")
assertThat(remote.getString("addr")).isEqualTo("vpn.example.com")
assertThat(remote.getInt("port")).isEqualTo(4500)
assertThat(remote.getString("id")).isEqualTo(" CN=vpn.example.com")
assertThat(remote.getBoolean("certreq")).isFalse()
assertThat(remote.getJSONObject("revocation").getBoolean("crl")).isFalse()
assertThat(remote.getJSONObject("revocation").getBoolean("strict")).isTrue()
val local = json.getJSONObject("local")
assertThat(local.getString("id")).isEqualTo("alice@example.com")
assertThat(local.getString("eap_id")).isEqualTo("alice")
assertThat(local.getString("shared_secret")).isEqualTo("secret")
assertThat(json.getString("ike-proposal")).isEqualTo("aes256-sha2_384-prfsha384-modp2048")
assertThat(json.getString("esp-proposal")).isEqualTo("aes256gcm16-prfsha384-modp2048")
assertThat(json.getJSONArray("dns-servers").length()).isEqualTo(2)
assertThat(json.getInt("mtu")).isEqualTo(1400)
assertThat(json.getInt("nat-keepalive")).isEqualTo(20)
assertThat(json.getBoolean("ipv6-transport")).isTrue()
val proxy = json.getJSONObject("proxy")
assertThat(proxy.getString("host")).isEqualTo("proxy.example.com")
assertThat(proxy.getInt("port")).isEqualTo(8080)
assertThat(proxy.getJSONArray("exclusions").length()).isEqualTo(2)
val split = json.getJSONObject("split-tunneling")
assertThat(split.getJSONArray("subnets").getString(0)).isEqualTo("10.1.0.0/16")
assertThat(split.getJSONArray("excluded").getString(0)).isEqualTo("10.1.2.0/24")
assertThat(split.getBoolean("block-ipv4")).isTrue()
assertThat(split.has("block-ipv6")).isFalse()
assertThat(json.getJSONArray("apps").length()).isEqualTo(2)
assertThat(json.has("excluded-apps")).isFalse()
}
@Test
fun passwordOmittedWhenNotIncluded()
{
val json = JSONObject(VpnProfileExporter.serializeProfile(sampleProfile(), false).toString())
assertThat(json.getJSONObject("local").has("shared_secret")).isFalse()
assertThat(json.getJSONObject("local").getString("eap_id")).isEqualTo("alice")
}
@Test
fun defaultsAreOmitted()
{
val profile = VpnProfile()
profile.setName("Minimal")
profile.setVpnType(VpnType.IKEV2_EAP)
profile.setGateway("vpn.example.com")
val json = JSONObject(VpnProfileExporter.serializeProfile(profile, false).toString())
assertThat(json.has("local")).isFalse()
assertThat(json.has("ike-proposal")).isFalse()
assertThat(json.has("dns-servers")).isFalse()
assertThat(json.has("mtu")).isFalse()
assertThat(json.has("ipv6-transport")).isFalse()
assertThat(json.getJSONObject("remote").has("certreq")).isFalse()
assertThat(json.getJSONObject("remote").has("revocation")).isFalse()
}
@Test
fun managedUserCertificateIsRepackagedWithoutPassword()
{
val local = Bundle()
local.putString(VpnProfileDataSource.KEY_USER_CERTIFICATE, TEST_P12)
local.putString(VpnProfileDataSource.KEY_USER_CERTIFICATE_PASSWORD, "testpassword")
val bundle = Bundle()
bundle.putString(VpnProfileDataSource.KEY_NAME, "Managed")
bundle.putString(VpnProfileDataSource.KEY_VPN_TYPE, "ikev2-cert")
bundle.putBundle("local", local)
val profile = ManagedVpnProfile(bundle, UUID.randomUUID())
val exported = VpnProfileExporter.userCertificateData(profile)
assertThat(exported).isNotNull()
/* the recipient must be able to open the container without knowing a password */
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(ByteArrayInputStream(Base64.decode(exported, Base64.DEFAULT)), charArrayOf())
val alias = keyStore.aliases().nextElement()
assertThat(keyStore.getKey(alias, charArrayOf())).isNotNull()
assertThat(keyStore.getCertificateChain(alias)).isNotEmpty()
val cert = keyStore.getCertificate(alias) as java.security.cert.X509Certificate
assertThat(cert.getSubjectX500Principal().name).contains("Test User")
}
companion object
{
/** PKCS#12 container with a private key for CN=Test User, password: testpassword */
private val TEST_P12 = """ MIIKOAIBAzCCCe4GCSqGSIb3DQEHAaCCCd8EggnbMIIJ1zCCBCoGCSqGSIb3DQEHBqCCBBswggQXAgEAMIIEEAYJKoZIhvcN
AQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDba1sYbH9bqoGmf+8dPAqZAgIIADAMBggqhkiG9w0CCQUAMB0G
CWCGSAFlAwQBKgQQ6xNPeUT6JH4tibm14NRyZ4CCA6AyC5vk/wmALhgKx3M2jRlOdSXaGp6iLaIt1a6mDAlpULXik9oxmWmh
4m5Q5UTq9nfJBTJkmSv0MKhMOpP8WKijEH92UWqrI+P6VRpDDMM110tySeE3Qv4al/5+RJSVQFyJktSK+2bA4aFlMI+WxZBQ
Ki0l+mPMSEhDkUTVez/UBcx8rYPCFq9Di42VT2DbVfebcvmAfWJwjlIWMixGiWrIpS7U/m5v0RSUQyDSNYnp1b49eDePGLUF
90yEeOxiVjPEaRRPzeaEbBy569ig2BltZDBbCR0Ju+FUSWsyDD7OV/k9ZcmkRT4e2Hp7r9De2UiYS3sD+dGVPTTVVBEMewiv
ujsccRUx83PRlbhQFMQh6WscsBzhPb6K5qgeYQ0xUZLHMfM6657xws2UyhPFnrGfeokZg3r3SihB5XueZV95fVFtDHC0D8gD
tbdnc404XNx+xhB7uf33UZ5KUde7TC068VSXovUuxzgGFa/ZmbICtpumsr+na/+/I09zLttjnZXnr2islywjCwB9pbuzTG2y
N5ZhgsELiNph5C/8sjIxqLNQkzvD1M9K9u5YiaxaUU/ZHxIs9F6QJYa+JqGiafuBcUZuc9kg7oNm94vD69i6Jhg10LKc7My5
B5lU9SPJ/kFjKrmk4dhDYCiTRCI/zOuanmPERdFlLR+SMJe/ACRUFj+O0WT4Sd5eE1byHxzu7Rxtuoc5GAPdQHPzKoDOj5Bc
3h8yH5tTyZEUjy6Q2Svqrn/hxxduYx6zB0IATKiMXp2ADsuM1K35OWLK0WyyGC5C/ONcp84MM6vYMNecvcC8kPyuBsmegzbC
HiScBvVlqsWZUzFIdQ+aIw1c2IY0LHuWksd7QYIJAWDLK3eMk3oNeFO/ao0JLE4N9gUQKLTyMa+sxytc0C9OfJb7PB/kssYW
FC9KmmitkKU+JlM+i0uD9hXdHPThmyO8bPXT2EKM6zB0AWmcAPmPUMedzWREOmuldQNa1OQrIsZjyTOMDIy+l0UPQiFc6Q6e
SdhHlXmzr/BSwC3u/oDfzZRye6crC9qFlLg4SLFSisxF6NC+YzlfVnpXEftc3lTAJpf2UQrgxFPaawgbGK0xGGuesBzmtLFs
J57YD7h9vvqdD4VRCQrWn5Fy3/2rQVCHYZseLGyygWl0RR8C1TcWq/aWlycLIzNQmiGmYSrihA2u9v/2aVr1SyKCJicxBH3l
Xfz4adrs6aeAGC0I6G73zJ+skxud1Y6AMIIFpQYJKoZIhvcNAQcBoIIFlgSCBZIwggWOMIIFigYLKoZIhvcNAQwKAQKgggU5
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQOc129Uu2Ph/kfFdWKDKL+gICCAAwDAYIKoZIhvcNAgkFADAd
BglghkgBZQMEASoEEOpkCTykee2dSnZ0xkWndckEggTQD5YCqwB/3T86lOpmpb+gsKBMp6s48JkxiPhg4FO15aIHfqyg8Get
KkxZCfeNzNszTbT5BApyRvbNedy9EPWluWvdnyYTDjKZvYDw5OvM21k1yX+pScXj58kBx2AMk3/XwFIeFOTx2WXt6mUy4GR7
3pA+Zxe9pWO2SB9Zzn3VrCgj6B8o70fEHRJbt8kYR5uX7S5GVFhwgQbcFc1bmn1o/cDGqKS8U4D+LDR3++m4iQUHblz4r6Vm
7hkknc2qIW116jtO3MYXQ9s+O8uiOlKbRnmQSuFjH4p0KzKl5i2uolj1rB5xHJwrJE4ajv6ubovEU/sUUjlnHScEF2S9qaJ3
+sMlYgQMcvduaGDFx79leD0DdPxONZa8WmxnY7pwYdO7xFHloPfL/bSXnsSTX3cxHVyFj2r1/+MNhYM/BQG82/UfM5/s/1q+
SmZHujO+vpDg1H9lKvIiFv992+oYGuiFpgjrTi3KGe2cMlY3r/LmFJnJkTjfTaTBRQ0UOOcC3+9/neN4dZ6Dhl51wM3ayRtS
puhl5eNG3c/05VbjLaz98I6j8WlpEVHYxMyYXqeWchHPaGMlPeKWEZTZYqly1dodLthvZyCW6oItEFVSCJZtDl0bqYPm/CWv
A2/1H8jwawzUN8KPiDIQScpK3qVR5c1pvol7uQ8P4yk6sIDqRoS27b2pS2vQwJNWIc93daCzk7MpJ/BJNTENhn2hxvlgksSZ
RI/pWJVKe/vtPqDiW1Exe5N3lJ/Pk9fTQ3cCzVUP2S25DCNhdtHmlKGR37y+j/JeAAWXNs0GH0oq48FkWIEWj7gwynxsBlME
xS3O2I1ZsDkzcaJh66tZ0Vyle87VpgZ6wA0NKqbwHdGQlAyVZ944O1jktQH9Do5qvfkkbcOM/yQE31fCTLIkT9hGXXVBG7Vy
071cp5efGkU1bBfAHBmhNP7E3VDogHUbciwRSpw4O8GXtPtP3RMbEUpoqHaX4KRvyK0XKN6ZNKl8lVaDzU8g6TL313H1Z3yJ
LPa0aedC2E4MJiQTVwbinSXhUdsmhVrFP0MfbtssdSLKkk0hNkzqsUExcaoxh51uR2pw5tb1w6x279C7dAV/+GibTeY2yngE
+ikKRIa1YH3KvSZtFskLmXf2kphk1IRZHzQZauTgIwJnY7XFJWdus3HKdqr/XaqrchLfmMXYj/ZG7bS5ssoO7g9JPMD/5ElB
Ur4Gz/1qE88NWBvqt6+guMAB5YvrsaOxxSLYZj6dyeO1bQfxMecuo3txI0lwJWpbkTHOu0rLOtde7OzgPo9qfRPur+rY5JqC
tYOyCzfFFcwILWC92eiGO+N2XUe6k75qVSWpROSwkT/lZFmz6AmeBena8obtOnV6yTWapEPXkGMKP9Cm6rXEVgvZeUh886Bd
5Am/mfAe0cT8hxoutrDXD649xRB4JNq1/jff8jik/kjgpEzk62rz6ix42JxB9gYljfdtekvi5/JfF7ODvij4xO6OzycrH0I/
742vk4swnVwKRQfhCQuaklNrTmpqWsefprLy54EArTjSNEHspZJD/BGNJe8q+ntECKMBqPzTgptuxb9JSWsTnJzYFP6kxYLf
NxQpWPVQz51SkmVzpjRgp/RE3FLmBtponAST69xIQuXz+xinc1asWYgxPjAXBgkqhkiG9w0BCRQxCh4IAHQAZQBzAHQwIwYJ
KoZIhvcNAQkVMRYEFNSUf6nnO3vpRNk70pJS3R58gg9NMEEwMTANBglghkgBZQMEAgEFAAQgL2BlHv8N4AAEaEOTRpzFamcY
l/5PNjlWjlgV252DMCoECGgcyjRqux6dAgIIAA=="""
}
}
@@ -0,0 +1,87 @@
/*
* 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 org.junit.Rule
import org.junit.Test
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.ShxTheme
class ConnectivityCheckScreenTest
{
@get:Rule
val composeRule = createComposeRule()
private fun string(id: Int): String =
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
private fun setContent()
{
composeRule.setContent {
ShxTheme {
ConnectivityCheckScreen()
}
}
composeRule.waitForIdle()
}
@Test
fun showsSectionHeaders()
{
setContent()
composeRule.onNodeWithText(
string(R.string.health_check_targets_title)
).assertIsDisplayed()
composeRule.onNodeWithText(
string(R.string.health_check_server_down_title)
).assertIsDisplayed()
composeRule.onNodeWithText(
string(R.string.health_check_tunnel_dead_title)
).assertIsDisplayed()
}
@Test
fun showsMasterToggle()
{
setContent()
composeRule.onNodeWithText(
string(R.string.health_check_title)
).assertIsDisplayed()
}
@Test
fun showsAggressiveModeToggle()
{
setContent()
composeRule.onNodeWithText(
string(R.string.health_check_aggressive_title)
).assertIsDisplayed()
}
@Test
fun showsTargetToggles()
{
setContent()
composeRule.onNodeWithText(
string(R.string.probe_target_google)
).assertIsDisplayed()
composeRule.onNodeWithText(
string(R.string.probe_target_cloudflare)
).assertIsDisplayed()
}
}
@@ -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)
}
}
@@ -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 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.Rule
import org.junit.Test
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.ShxTheme
/** Smoke tests for the redesigned Profiles, Routing and Settings screens. */
class ScreensSmokeTest
{
@get:Rule
val composeRule = createComposeRule()
private fun string(id: Int): String =
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
@Test
fun profilesShowsEmptyState()
{
composeRule.setContent {
ShxTheme {
ProfilesScreen(
state = HomeUiState(),
onConnect = {},
onDisconnect = {},
onEdit = {},
onDelete = {},
onRestore = {},
onShare = {},
onAdd = {},
)
}
}
composeRule.onNodeWithText(string(R.string.no_profiles)).assertIsDisplayed()
}
@Test
fun routingShowsHeroAndAdvancedBlock()
{
composeRule.setContent {
ShxTheme {
RoutingScreen()
}
}
composeRule.onNodeWithText(string(R.string.routing_title)).assertIsDisplayed()
/* advanced block is collapsed until tapped */
composeRule.onNodeWithText(string(R.string.routing_advanced).uppercase()).assertIsDisplayed()
}
@Test
fun settingsOpensSecurityPageAndGoesBack()
{
composeRule.setContent {
ShxTheme {
SettingsScreen(profiles = emptyList())
}
}
composeRule.onNodeWithText(string(R.string.settings_section_security)).performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText(string(R.string.safe_ipv6_title)).assertIsDisplayed()
}
}
@@ -27,10 +27,25 @@
<!-- 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"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- «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" />
<!-- lets the app install its own updates in the background without the
system confirmation dialog (Android 12+, same package/signature only) -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!-- 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"
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.
@@ -53,6 +68,7 @@
android:allowBackup="false" >
<activity
android:name=".ui.MainActivity"
android:theme="@style/ShxSplashTheme"
android:label="@string/main_activity_name"
android:launchMode="singleTop"
android:exported="true">
@@ -64,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"
@@ -79,6 +107,17 @@
<action android:name="one.shx.strongswan.ext.action.DISCONNECT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="one.shx.strongswan.ext.action.TOGGLE_VPN" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- deep links for automation tools: shxvpn://connect[?profile=<uuid>], shxvpn://disconnect, shxvpn://toggle -->
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="shxvpn" />
</intent-filter>
</activity>
<activity
android:name=".ui.VpnProfileDetailActivity" >
@@ -87,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" >
@@ -100,13 +135,28 @@
android:label="@string/pref_title">
</activity>
<activity
android:name=".ui.TrustedNetworksActivity"
android:label="@string/auto_connect_title">
android:name=".ui.ConnectivityCheckActivity"
android:label="@string/health_check_title">
</activity>
<activity
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"
android:theme="@style/ApplicationTheme.ActionBar" >
android:label="@string/remediation_instructions_title" >
</activity>
<activity
android:name=".ui.VpnProfileSelectActivity"
@@ -121,7 +171,7 @@
<activity
android:name=".ui.VpnProfileImportActivity"
android:label="@string/profile_import"
android:theme="@style/ApplicationTheme.ActionBar"
android:theme="@style/TransparentActivity"
android:taskAffinity=""
android:excludeFromRecents="true"
android:exported="true">
@@ -215,8 +265,26 @@
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.TILE_CATEGORY"
android:value="android.service.quicksettings.CATEGORY_CONNECTIVITY" />
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>
<receiver
android:name=".ui.widget.ShxWidgetReceiver"
android:label="@string/widget_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/shx_widget_info" />
</receiver>
<provider
android:name=".data.LogContentProvider"
android:authorities="one.shx.strongswan.ext.content.log"
@@ -225,6 +293,64 @@
not work (probably too many indirections with ACTION_SEND) so we secure
this provider with a custom ticketing system -->
</provider>
<provider
android:name=".data.ProfileExportProvider"
android:authorities="one.shx.strongswan.ext.content.export"
android:exported="true" >
<!-- same ticketing approach as above for shared VPN profile exports -->
</provider>
<!-- serves downloaded updates to the system package installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- receives the result of the self-update installation -->
<receiver
android:name=".logic.updates.UpdateInstallStatusReceiver"
android:exported="false" />
<!-- restores an expected VPN connection after the hosting process died;
only uses a non-wakeup alarm so it never wakes the device -->
<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 -->
<receiver
android:name=".logic.updates.UpdateReplacedReceiver"
android:exported="false">
<intent-filter>
<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>
@@ -0,0 +1,190 @@
/*
* Copyright (C) 2012 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.data;
import java.io.File;
import java.io.FileNotFoundException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import org.strongswan.android.logic.CharonVpnService;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.os.SystemClock;
import android.provider.OpenableColumns;
public class LogContentProvider extends ContentProvider
{
private static final String AUTHORITY = "one.shx.strongswan.ext.content.log";
/* an Uri is valid for 30 minutes */
private static final long URI_VALIDITY = 30 * 60 * 1000;
private static ConcurrentHashMap<Uri, Long> mUris = new ConcurrentHashMap<Uri, Long>();
/* file served by each Uri (defaults to the charon log for legacy Uris) */
private static ConcurrentHashMap<Uri, File> mFiles = new ConcurrentHashMap<Uri, File>();
private File mLogFile;
public LogContentProvider()
{
}
@Override
public boolean onCreate()
{
mLogFile = new File(getContext().getFilesDir(), CharonVpnService.LOG_FILE);
return true;
}
/**
* The log file can only be accessed by Uris created with this method
* @return null if failed to create the Uri
*/
public static Uri createContentUri()
{
return createContentUri(null);
}
/**
* The log file can only be accessed by Uris created with this method
* @param file file to serve, the charon log if null
* @return null if failed to create the Uri
*/
public static Uri createContentUri(File file)
{
SecureRandom random;
try
{
random = SecureRandom.getInstance("SHA1PRNG");
}
catch (NoSuchAlgorithmException e)
{
return null;
}
Uri uri = Uri.parse("content://" + AUTHORITY + "/" + random.nextLong());
mUris.put(uri, SystemClock.uptimeMillis());
if (file != null)
{ /* ConcurrentHashMap does not accept null values; fileFor() falls
* back to the charon log for legacy Uris */
mFiles.put(uri, file);
}
return uri;
}
@Override
public String getType(Uri uri)
{
/* MIME type for our log file */
return "text/plain";
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder)
{
/* this is called by apps to find out the name and size of the file.
* since we only provide a single file per Uri this is simple to implement */
if (projection == null)
{
projection = new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
}
File file = fileFor(uri);
if (file == null)
{ /* don't check the validity as this information is not really private */
return null;
}
List<String> cols = new ArrayList<>();
List<Object> vals = new ArrayList<>();
for (String col : projection)
{
if (OpenableColumns.DISPLAY_NAME.equals(col))
{
cols.add(OpenableColumns.DISPLAY_NAME);
vals.add(file.getName());
}
else if (OpenableColumns.SIZE.equals(col))
{
cols.add(OpenableColumns.SIZE);
vals.add(file.length());
}
}
MatrixCursor cursor = new MatrixCursor(cols.toArray(new String[0]), 1);
cursor.addRow(vals.toArray());
return cursor;
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException
{
File file = fileFor(uri);
if (file != null)
{
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_CREATE | ParcelFileDescriptor.MODE_READ_ONLY);
}
return super.openFile(uri, mode);
}
/**
* Get the file served by the given Uri, or null if the Uri is invalid or expired.
*/
private File fileFor(Uri uri)
{
Long timestamp = mUris.get(uri);
if (timestamp == null)
{
return null;
}
long elapsed = SystemClock.uptimeMillis() - timestamp;
if (elapsed > 0 && elapsed < URI_VALIDITY)
{ /* we fail if clock wrapped, should happen rarely though */
File file = mFiles.get(uri);
return file != null ? file : mLogFile;
}
mUris.remove(uri);
mFiles.remove(uri);
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values)
{
/* not supported */
return null;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs)
{
/* not supported */
return 0;
}
@Override
public int update(Uri uri, ContentValues values, String selection,
String[] selectionArgs)
{
/* not supported */
return 0;
}
}
@@ -47,7 +47,7 @@ public class ManagedVpnProfile extends VpnProfile
private ManagedTrustedCertificate trustedCertificate;
private ManagedUserCertificate userCertificate;
ManagedVpnProfile(final Bundle bundle, final UUID uuid)
public ManagedVpnProfile(final Bundle bundle, final UUID uuid)
{
int flags = 0;
int splitFlags = 0;
@@ -0,0 +1,194 @@
/*
* 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.data;
import java.io.File;
import java.io.FileNotFoundException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.os.SystemClock;
import android.provider.OpenableColumns;
/**
* Serves exported VPN profile files to other apps. Instead of grantUriPermissions
* (which does not work reliably with ACTION_SEND) this provider is exported but only
* serves files through randomly generated ticket Uris created with createContentUri().
*/
public class ProfileExportProvider extends ContentProvider
{
public static final String MIME_TYPE = "application/vnd.strongswan.profile";
private static final String AUTHORITY = "one.shx.strongswan.ext.content.export";
/* an Uri is valid for 30 minutes */
private static final long URI_VALIDITY = 30 * 60 * 1000;
private static class Ticket
{
final File mFile;
final String mDisplayName;
final long mCreatedAt;
Ticket(File file, String displayName, long createdAt)
{
mFile = file;
mDisplayName = displayName;
mCreatedAt = createdAt;
}
}
private static final ConcurrentHashMap<Uri, Ticket> mTickets = new ConcurrentHashMap<Uri, Ticket>();
public ProfileExportProvider()
{
}
@Override
public boolean onCreate()
{
return true;
}
/**
* The given file can only be accessed by Uris created with this method
* @return null if failed to create the Uri
*/
public static Uri createContentUri(Context context, File file, String displayName)
{
SecureRandom random;
try
{
random = SecureRandom.getInstance("SHA1PRNG");
}
catch (NoSuchAlgorithmException e)
{
return null;
}
Uri uri = Uri.parse("content://" + AUTHORITY + "/" + random.nextLong());
Ticket ticket = new Ticket(exportFile(context, file.getName()), displayName, SystemClock.uptimeMillis());
mTickets.put(uri, ticket);
return uri;
}
/**
* Directory exported profile files are stored in (also used by the exporter).
*/
public static File exportDirectory(Context context)
{
File dir = new File(context.getCacheDir(), "export");
if (!dir.exists())
{
dir.mkdirs();
}
return dir;
}
/**
* The file the exported profile is stored in (resolves against the export directory).
*/
public static File exportFile(Context context, String fileName)
{
return new File(exportDirectory(context), fileName);
}
@Override
public String getType(Uri uri)
{
return MIME_TYPE;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder)
{
/* this is called by apps to find out the name and size of the file */
if (projection == null)
{
projection = new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
}
Ticket ticket = mTickets.get(uri);
if (ticket == null)
{
return null;
}
List<String> cols = new ArrayList<>();
List<Object> vals = new ArrayList<>();
for (String col : projection)
{
if (OpenableColumns.DISPLAY_NAME.equals(col))
{
cols.add(OpenableColumns.DISPLAY_NAME);
vals.add(ticket.mDisplayName);
}
else if (OpenableColumns.SIZE.equals(col))
{
cols.add(OpenableColumns.SIZE);
vals.add(ticket.mFile.length());
}
}
MatrixCursor cursor = new MatrixCursor(cols.toArray(new String[0]), 1);
cursor.addRow(vals.toArray());
return cursor;
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException
{
Ticket ticket = mTickets.get(uri);
if (ticket != null)
{
long elapsed = SystemClock.uptimeMillis() - ticket.mCreatedAt;
if (elapsed >= 0 && elapsed < URI_VALIDITY && ticket.mFile.exists())
{
return ParcelFileDescriptor.open(ticket.mFile, ParcelFileDescriptor.MODE_READ_ONLY);
}
mTickets.remove(uri);
}
return super.openFile(uri, mode);
}
@Override
public Uri insert(Uri uri, ContentValues values)
{
/* not supported */
return null;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs)
{
/* not supported */
return 0;
}
@Override
public int update(Uri uri, ContentValues values, String selection,
String[] selectionArgs)
{
/* not supported */
return 0;
}
}
@@ -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,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"
}
}
@@ -0,0 +1,118 @@
/*
* 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 java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Lightweight file-based lifecycle log (diag.log) for app-level events that
* must remain diagnosable after the hosting process dies: logcat is lost in
* that case and charon.log only covers the daemon. Records process starts,
* watchdog decisions, retry scheduling, network changes, auto-connect and
* foreground-service start failures.
*
* The writer is kept open across writes but flushed on every line, so
* events survive a process kill at any point while avoiding a per-line
* open/stat/close cycle. The file rotates at [MAX_SIZE] (one previous
* generation is kept), tracked by a written-byte counter instead of a stat
* per line. Never throws - logging must not be able to break the caller.
*/
object DiagLog
{
private const val FILE_NAME = "diag.log"
private const val OLD_FILE_NAME = "diag.log.1"
private const val MAX_SIZE = 256L * 1024
@Volatile
private var dir: File? = null
private var writer: FileWriter? = null
private var written = 0L
private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
/** Must be called once at process start (from the Application class). */
@JvmStatic
fun init(context: Context)
{
dir = context.getFilesDir()
}
/** Write one line; never throws. */
@JvmStatic
fun i(tag: String, message: String)
{
val base = dir ?: return
try
{
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)
}
var w = writer
if (w == null)
{
val file = File(base, FILE_NAME)
written = file.length()
w = FileWriter(file, true)
writer = w
}
w.write(line)
w.flush()
written += line.toByteArray(Charsets.UTF_8).size
}
}
catch (_: Exception)
{
}
}
/** Close the writer and move diag.log to diag.log.1; reopened lazily. */
private fun rotate(base: File)
{
writer?.let {
try
{
it.close()
}
catch (_: Exception)
{
}
}
writer = null
try
{
File(base, OLD_FILE_NAME).delete()
File(base, FILE_NAME).renameTo(File(base, OLD_FILE_NAME))
}
catch (_: Exception)
{
}
written = 0
}
/** The log file (may not exist yet). */
@JvmStatic
fun file(context: Context): File = File(context.getFilesDir(), FILE_NAME)
}
@@ -0,0 +1,72 @@
/*
* 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.Context;
import android.content.Intent;
import android.os.Build;
import android.os.SystemClock;
import android.util.Log;
/**
* Retries a foreground service start that the system blocked while the app
* was in the background (Android 12+). Alarms are on the allowlist for such
* starts, so the start is delivered via a one-shot alarm instead.
*
* The alarm is NON-WAKEUP and exact: it never wakes the device and simply
* fires when the device is awake anyway (deferred by Doze until then). It
* replaces any previously scheduled retry, so blocked starts cannot pile up.
*/
final class FgsStartRetry
{
private static final String TAG = FgsStartRetry.class.getSimpleName();
private static final int REQUEST_CODE = 42;
private static final long RETRY_DELAY_MS = 5000;
private FgsStartRetry()
{
}
static void schedule(Context context, Intent intent)
{
AlarmManager am = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
if (am == null)
{
return;
}
PendingIntent pi = PendingIntent.getForegroundService(context, REQUEST_CODE, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
long when = SystemClock.elapsedRealtime() + RETRY_DELAY_MS;
try
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !am.canScheduleExactAlarms())
{
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
}
else
{
am.setExact(AlarmManager.ELAPSED_REALTIME, when, pi);
}
}
catch (SecurityException e)
{ /* the exact-alarm grant was revoked in between; inexact is fine */
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
}
Log.i(TAG, "Foreground service start was blocked, retrying via a non-wakeup alarm");
DiagLog.i(TAG, "FGS start blocked, retry alarm scheduled in " + RETRY_DELAY_MS + "ms");
}
}
@@ -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,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);
}
}
}
@@ -29,6 +29,12 @@ import android.util.Log;
import org.strongswan.android.data.DatabaseHelper;
import org.strongswan.android.data.ManagedConfigurationService;
import org.strongswan.android.logic.autoconnect.AutoConnectManager;
import org.strongswan.android.logic.connectivity.HealthCheckManager;
import org.strongswan.android.logic.connectivity.WhiteListManager;
import org.strongswan.android.logic.routing.RoutingManager;
import org.strongswan.android.logic.routing.RuIpListManager;
import org.strongswan.android.logic.shortcuts.VpnShortcutManager;
import org.strongswan.android.logic.updates.UpdateManager;
import org.strongswan.android.security.LocalCertificateKeyStoreProvider;
import org.strongswan.android.utils.Constants;
@@ -81,6 +87,24 @@ public class StrongSwanApplication extends Application
StrongSwanApplication.mContext = getApplicationContext();
StrongSwanApplication.mInstance = this;
/* file-based lifecycle log; must be initialized before anything logs to it */
DiagLog.init(this);
DiagLog.i(TAG, "process start (pid=" + android.os.Process.myPid() + ")");
/* record every uncaught Java exception in diag.log before the process
* dies: logcat is lost on a process kill, and this is the only way to
* diagnose crashes reported by users without adb */
final Thread.UncaughtExceptionHandler previousHandler =
Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
DiagLog.i(TAG, "UNCAUGHT EXCEPTION on " + thread.getName() + ": " +
Log.getStackTraceString(throwable));
if (previousHandler != null)
{
previousHandler.uncaughtException(thread, throwable);
}
});
mDatabaseHelper = new DatabaseHelper(mContext);
mManagedConfigurationService = new ManagedConfigurationService(mContext);
@@ -97,6 +121,29 @@ public class StrongSwanApplication extends Application
/* starts monitoring networks if the trusted networks feature is enabled */
AutoConnectManager.getInstance(this).start();
/* starts tunnel health checks if the connectivity check feature is enabled */
HealthCheckManager.getInstance(this).start();
/* starts ISP whitelist detection if the feature is enabled */
WhiteListManager.getInstance(this).start();
/* applies global routing rule sets and restarts the tunnel when they change */
RoutingManager.getInstance(this).start();
/* loads the RU geoip list, refreshes it when due and restarts the tunnel on changes */
RuIpListManager.getInstance(this).start();
/* publishes shortcuts for automation tools (e.g. Samsung Modes and Routines) if the feature is enabled */
VpnShortcutManager.getInstance(this).start();
/* 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()
@@ -0,0 +1,423 @@
/*
* 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.Uri;
import android.text.TextUtils;
import android.util.Base64;
import org.json.JSONArray;
import org.json.JSONObject;
import org.strongswan.android.data.ManagedCertificate;
import org.strongswan.android.data.ManagedUserCertificate;
import org.strongswan.android.data.ManagedVpnProfile;
import org.strongswan.android.data.ProfileExportProvider;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
import org.strongswan.android.data.VpnType;
import org.strongswan.android.data.VpnType.VpnTypeFeature;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.Key;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Enumeration;
/**
* Exports a VpnProfile as a VPN profile file in the format used by the original
* strongSwan app (.sswan), with the certificates embedded, so it can be shared
* and imported by any strongSwan-compatible app.
*/
public class VpnProfileExporter
{
/** File name extension used by the original strongSwan app. */
public static final String FILE_EXTENSION = ".sswan";
private VpnProfileExporter()
{
}
/**
* Serialize the given profile to the cache directory and create a shareable
* content Uri for it.
*
* @param includePassword whether a stored password is written to the file (as cleartext)
* @return the content Uri, or null if the profile could not be exported
*/
public static Uri exportProfile(Context context, VpnProfile profile, boolean includePassword)
{
try
{
JSONObject obj = serializeProfile(profile, includePassword);
File file = writeFile(context, profile.getUUID().toString(), obj.toString(2));
return ProfileExportProvider.createContentUri(context, file, displayName(profile.getName()));
}
catch (Exception e)
{
e.printStackTrace();
return null;
}
}
static JSONObject serializeProfile(VpnProfile profile, boolean includePassword) throws IOException
{
if (TextUtils.isEmpty(profile.getGateway()))
{
throw new IOException("profile has no gateway");
}
JSONObject obj = new JSONObject();
try
{
obj.put("uuid", profile.getUUID().toString());
obj.put("name", profile.getName());
obj.put("type", profile.getVpnType().getIdentifier());
obj.put("remote", buildRemote(profile));
JSONObject local = buildLocal(profile, includePassword);
if (local.length() > 0)
{
obj.put("local", local);
}
obj.putOpt("ike-proposal", profile.getIkeProposal());
obj.putOpt("esp-proposal", profile.getEspProposal());
JSONArray dns = toArray(profile.getDnsServers());
if (dns.length() > 0)
{
obj.put("dns-servers", dns);
}
obj.putOpt("mtu", profile.getMTU());
obj.putOpt("nat-keepalive", profile.getNATKeepAlive());
if (hasFlag(profile, VpnProfile.FLAGS_IPv6_TRANSPORT))
{
obj.put("ipv6-transport", true);
}
JSONObject proxy = buildProxy(profile);
if (proxy.length() > 0)
{
obj.put("proxy", proxy);
}
JSONObject split = buildSplitTunneling(profile);
if (split.length() > 0)
{
obj.put("split-tunneling", split);
}
if (profile.getSelectedAppsHandling() == SelectedAppsHandling.SELECTED_APPS_ONLY)
{
JSONArray apps = toArray(profile.getSelectedApps());
if (apps.length() > 0)
{
obj.put("apps", apps);
}
}
else if (profile.getSelectedAppsHandling() == SelectedAppsHandling.SELECTED_APPS_EXCLUDE)
{
JSONArray apps = toArray(profile.getSelectedApps());
if (apps.length() > 0)
{
obj.put("excluded-apps", apps);
}
}
return obj;
}
catch (IOException e)
{
throw e;
}
catch (Exception e)
{
IOException wrapped = new IOException("failed to serialize profile");
wrapped.initCause(e);
throw wrapped;
}
}
private static JSONObject buildRemote(VpnProfile profile) throws Exception
{
JSONObject remote = new JSONObject();
remote.put("addr", profile.getGateway());
remote.putOpt("port", profile.getPort());
remote.putOpt("id", profile.getRemoteId());
String cert = trustedCertificateData(profile);
if (!TextUtils.isEmpty(cert))
{
remote.put("cert", cert);
}
if (hasFlag(profile, VpnProfile.FLAGS_SUPPRESS_CERT_REQS))
{
remote.put("certreq", false);
}
JSONObject revocation = new JSONObject();
if (hasFlag(profile, VpnProfile.FLAGS_DISABLE_CRL))
{
revocation.put("crl", false);
}
if (hasFlag(profile, VpnProfile.FLAGS_DISABLE_OCSP))
{
revocation.put("ocsp", false);
}
if (hasFlag(profile, VpnProfile.FLAGS_STRICT_REVOCATION))
{
revocation.put("strict", true);
}
if (revocation.length() > 0)
{
remote.put("revocation", revocation);
}
return remote;
}
private static JSONObject buildLocal(VpnProfile profile, boolean includePassword) throws Exception
{
JSONObject local = new JSONObject();
local.putOpt("id", profile.getLocalId());
VpnType type = profile.getVpnType();
if (type.has(VpnTypeFeature.USER_PASS))
{
local.putOpt("eap_id", profile.getUsername());
if (includePassword)
{
local.putOpt("shared_secret", profile.getPassword());
}
}
if (type.has(VpnTypeFeature.CERTIFICATE))
{
String p12 = userCertificateData(profile);
if (!TextUtils.isEmpty(p12))
{
local.put("p12", p12);
}
if (hasFlag(profile, VpnProfile.FLAGS_RSA_PSS))
{
local.put("rsa-pss", true);
}
}
return local;
}
private static JSONObject buildProxy(VpnProfile profile) throws Exception
{
JSONObject proxy = new JSONObject();
if (!TextUtils.isEmpty(profile.getProxyHost()))
{
proxy.put("host", profile.getProxyHost());
proxy.putOpt("port", profile.getProxyPort());
JSONArray exclusions = toArray(profile.getProxyExclusions());
if (exclusions.length() > 0)
{
proxy.put("exclusions", exclusions);
}
}
return proxy;
}
private static JSONObject buildSplitTunneling(VpnProfile profile) throws Exception
{
JSONObject split = new JSONObject();
JSONArray included = toArray(profile.getIncludedSubnets());
if (included.length() > 0)
{
split.put("subnets", included);
}
JSONArray excluded = toArray(profile.getExcludedSubnets());
if (excluded.length() > 0)
{
split.put("excluded", excluded);
}
Integer st = profile.getSplitTunneling();
if (st != null)
{
if ((st & VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4) != 0)
{
split.put("block-ipv4", true);
}
if ((st & VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6) != 0)
{
split.put("block-ipv6", true);
}
}
return split;
}
/**
* Data of the CA/server certificate as Base64-encoded DER, or null if unavailable.
*/
private static String trustedCertificateData(VpnProfile profile)
{
if (profile instanceof ManagedVpnProfile)
{ /* the raw certificate is stored with the profile */
ManagedCertificate cert = ((ManagedVpnProfile)profile).getTrustedCertificate();
return cert != null ? cert.getData() : null;
}
String alias = profile.getCertificateAlias();
if (TextUtils.isEmpty(alias))
{
return null;
}
try
{ /* resolves from the app-local certificate store and all system stores */
X509Certificate certificate = TrustedCertificateManager.getInstance().getCACertificateFromAlias(alias);
if (certificate == null)
{
return null;
}
return Base64.encodeToString(certificate.getEncoded(), Base64.NO_WRAP);
}
catch (Exception e)
{
e.printStackTrace();
return null;
}
}
/**
* Data of the user certificate as Base64-encoded PKCS#12, or null if not exportable.
* Only certificates stored by the app itself (managed profiles) can be exported;
* keys installed in the system KeyChain are non-exportable by design.
*/
static String userCertificateData(VpnProfile profile)
{
if (!(profile instanceof ManagedVpnProfile))
{
return null;
}
ManagedUserCertificate cert = ((ManagedVpnProfile)profile).getUserCertificate();
if (cert == null)
{
return null;
}
String repacked = repackageWithoutPassword(cert);
return !TextUtils.isEmpty(repacked) ? repacked : null;
}
/**
* The profile file format has no field for the PKCS#12 password, so the container
* is rewritten with an empty password. The full certificate chain is preserved.
*/
private static String repackageWithoutPassword(ManagedUserCertificate cert)
{
try
{
char[] password = cert.getPrivateKeyPassword() == null ? new char[0]
: cert.getPrivateKeyPassword().toCharArray();
KeyStore in = KeyStore.getInstance("PKCS12");
in.load(new ByteArrayInputStream(Base64.decode(cert.getData(), Base64.DEFAULT)), password);
KeyStore out = KeyStore.getInstance("PKCS12");
out.load(null, null);
for (Enumeration<String> aliases = in.aliases(); aliases.hasMoreElements(); )
{
String alias = aliases.nextElement();
Key key = in.getKey(alias, password);
Certificate[] chain = in.getCertificateChain(alias);
if (key != null)
{
out.setKeyEntry(alias, key, null, chain != null ? chain : new Certificate[0]);
}
else
{
Certificate certificate = in.getCertificate(alias);
if (certificate != null)
{
out.setCertificateEntry(alias, certificate);
}
}
}
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
out.store(outStream, null); /* empty password */
return Base64.encodeToString(outStream.toByteArray(), Base64.NO_WRAP);
}
catch (Exception e)
{
e.printStackTrace();
return null;
}
}
private static boolean hasFlag(VpnProfile profile, int flag)
{
Integer flags = profile.getFlags();
return flags != null && (flags & flag) != 0;
}
/**
* Convert a space-separated string to a JSON array (inverse of what the
* importer joins with spaces).
*/
private static JSONArray toArray(String value)
{
JSONArray array = new JSONArray();
if (!TextUtils.isEmpty(value))
{
for (String item : value.trim().split("\\s+"))
{
if (!TextUtils.isEmpty(item))
{
array.put(item);
}
}
}
return array;
}
private static File writeFile(Context context, String uuid, String data) throws IOException
{
File dir = ProfileExportProvider.exportDirectory(context);
/* drop exports of other profiles so the cache does not grow indefinitely */
File[] files = dir.listFiles();
if (files != null)
{
for (File file : files)
{
file.delete();
}
}
File file = ProfileExportProvider.exportFile(context, uuid + FILE_EXTENSION);
try (FileWriter writer = new FileWriter(file))
{
writer.write(data);
}
return file;
}
/**
* File name offered to the receiving app, derived from the profile name.
*/
private static String displayName(String name)
{
String sanitized = name == null ? "" : name.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_").trim();
sanitized = sanitized.replaceAll("^[. ]+|[. ]+$", "");
if (TextUtils.isEmpty(sanitized))
{
sanitized = "profile";
}
if (sanitized.length() > 100)
{
sanitized = sanitized.substring(0, 100);
}
return sanitized + FILE_EXTENSION;
}
}
@@ -19,6 +19,7 @@ package org.strongswan.android.logic;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Binder;
import android.os.Bundle;
import android.os.Handler;
@@ -26,28 +27,41 @@ import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.SystemClock;
import android.util.Log;
import org.strongswan.android.R;
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;
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;
import java.util.concurrent.Callable;
import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
import org.strongswan.android.utils.Constants;
public class VpnStateService extends Service
{
private final HashSet<VpnStateListener> mListeners = new HashSet<VpnStateListener>();
private final IBinder mBinder = new LocalBinder();
/* the instance of this service in the current process, or null if it is
* not running; used by the watchdog to tell a live process from a fresh
* one (the receiver runs in the same, single process) */
private static volatile VpnStateService sInstance;
private long mConnectionID = 0;
private Handler mHandler;
private VpnProfile mProfile;
@@ -56,12 +70,41 @@ public class VpnStateService extends Service
private ImcState mImcState = ImcState.UNKNOWN;
private final LinkedList<RemediationInstruction> mRemediationInstructions = new LinkedList<RemediationInstruction>();
private static final long RETRY_INTERVAL = 1000;
/* while a retry is still far away the countdown only ticks in coarse steps,
* saving main-thread wakeups (and notification rebuilds) while waiting */
private static final long RETRY_COARSE_INTERVAL = 5000;
private static final long RETRY_COARSE_THRESHOLD = 15000;
/* while an ISP whitelist is active the retry countdown is frozen at this interval */
private static final long RETRY_SUPPRESS_MS = 10000;
/* cap the retry interval at 2 minutes */
private static final long MAX_RETRY_INTERVAL = 120000;
/* if the daemon neither completes nor reports a failure within this window
* we surface it as UNREACHABLE so the retry machinery takes over */
private static final long CONNECTING_TIMEOUT = 90000;
/* minimum interval between network-change-triggered immediate retries */
private static final long NETWORK_RETRY_DEBOUNCE_MS = 5000;
private static final int RETRY_MSG = 1;
private static final int CONNECTING_TIMEOUT_MSG = 2;
private final RetryTimeoutProvider mTimeoutProvider = new RetryTimeoutProvider();
private long mRetryTimeout;
private long mRetryIn;
/* true while the countdown is parked because the ISP whitelist blocks the server */
private boolean mRetryFrozen;
/* timestamp of the last network-change-triggered immediate retry */
private long mLastNetworkRetry;
/* double-start guard: profile and issue time of the last connect() that
* got through, cleared by disconnect() */
private static final long CONNECT_DEDUPE_MS = 10000;
private String mLastConnectUuid;
private long mLastConnectIssued;
private static final String TAG = VpnStateService.class.getSimpleName();
/** Tick size for the retry countdown: coarse steps early, seconds at the end. */
private static long retryStep(long retryIn)
{
return Math.min(retryIn > RETRY_COARSE_THRESHOLD ? RETRY_COARSE_INTERVAL : RETRY_INTERVAL, retryIn);
}
public enum State
{
@@ -107,6 +150,7 @@ public class VpnStateService extends Service
@Override
public void onCreate()
{
sInstance = this;
/* this handler allows us to notify listeners from the UI thread and
* not from the threads that actually report any state changes */
mHandler = new RetryHandler(getMainLooper(), this);
@@ -121,6 +165,20 @@ public class VpnStateService extends Service
@Override
public void onDestroy()
{
if (sInstance == this)
{
sInstance = null;
}
}
/**
* The instance of this service in the current process, or null if it is
* not (yet) running. Since the app is single-process, the watchdog
* receiver can use this to detect that the previous process died.
*/
public static VpnStateService getLiveInstance()
{
return sInstance;
}
/**
@@ -144,6 +202,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.
*
@@ -267,9 +346,48 @@ public class VpnStateService extends Service
*/
public void disconnect()
{
/* reset any potential retry timer and error state */
disconnect(false);
}
/**
* Disconnect any existing connection and shutdown the daemon, the
* VpnService is not stopped but it is reset so new connections can be
* started.
*
* @param autoManaged true if this was done by the trusted-networks
* auto-connect (the tunnel is parked on purpose and is expected to come
* back when the network no longer matches, so the watchdog stays armed)
*/
public void disconnect(boolean autoManaged)
{
/* reset any potential retry timer and error state, and re-arm the
* double-start guard (a connect after a deliberate disconnect is a
* fresh request and must not be deduped) */
resetRetryTimer();
setError(ErrorState.NO_ERROR);
mLastConnectIssued = 0;
if (autoManaged)
{ /* the auto-connect feature parked the tunnel on a trusted network;
* mark the session as expected so the watchdog can restore it if
* this process dies before the network changes (auto-connect dies
* with the process and would otherwise never reconnect) */
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, true).apply();
WatchdogReceiver.schedule(this);
DiagLog.i(TAG, "auto-connect parked the tunnel, watchdog stays armed");
}
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();
WatchdogReceiver.cancel(this);
DiagLog.i(TAG, "user disconnect");
}
/* as soon as the TUN device is created by calling establish() on the
* VpnService.Builder object the system binds to the service and keeps
@@ -280,7 +398,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() + ")");
}
}
/**
@@ -291,9 +417,25 @@ public class VpnStateService extends Service
* @param fromScratch true if this is a manual retry/reconnect or a completely new connection
*/
public void connect(Bundle profileInfo, boolean fromScratch)
{
connect(profileInfo, fromScratch, false);
}
/**
* Connect (or reconnect) a profile
*
* @param profileInfo optional profile info (basically the UUID and password), taken from the
* previous profile if null
* @param fromScratch true if this is a manual retry/reconnect or a completely new connection
* @param preserveLog true if this is an automatic start whose charon log should
* be kept (e.g. recovery after the process died)
*/
public void connect(Bundle profileInfo, boolean fromScratch, boolean preserveLog)
{
/* 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)
{
@@ -302,6 +444,31 @@ public class VpnStateService extends Service
/* pass the previous password along */
profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, mProfile.getPassword());
}
String uuid = profileInfo.getString(VpnProfileDataSource.KEY_UUID);
if (uuid == null && mProfile != null)
{
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
* the daemon - after an app self-update up to four starters (update
* receiver, process-start auto-connect, watchdog, UI) raced through
* 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)))
{
DiagLog.i(TAG, "connect: same profile already connecting, ignored");
return;
}
if (fromScratch)
{
/* reset if this is a manual retry or a new connection */
@@ -311,8 +478,48 @@ public class VpnStateService extends Service
{ /* mark this as an automatic retry */
profileInfo.putBoolean(CharonVpnService.KEY_IS_RETRY, true);
}
if (preserveLog)
{ /* don't wipe the previous session's log; needed to diagnose why an
* automatic reconnect was necessary in the first place */
profileInfo.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true);
}
intent.putExtras(profileInfo);
ContextCompat.startForegroundService(context, intent);
try
{
ContextCompat.startForegroundService(context, intent);
}
catch (IllegalStateException e)
{ /* Android 12+ forbids starting a foreground service from the
* background (e.g. auto-connect on a network change or from the
* quick settings tile). Exact alarms are on the allowlist for
* such starts, so deliver the start via a one-shot alarm. */
DiagLog.i(TAG, "FGS start blocked while in background, scheduling FgsStartRetry");
FgsStartRetry.schedule(context, intent);
}
DiagLog.i(TAG, "connect uuid=" + profileInfo.getString(VpnProfileDataSource.KEY_UUID) +
" fromScratch=" + fromScratch + " preserveLog=" + preserveLog);
/* remember that a connection is expected so the watchdog can restore
* it if this process dies (Always-on VPN is restarted by the system
* and bypasses this path anyway) */
if (uuid != null)
{
mLastConnectUuid = uuid;
mLastConnectIssued = SystemClock.uptimeMillis();
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
if (!uuid.equals(prefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null)))
{
prefs.edit().putString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, uuid).apply();
}
/* a real connection supersedes an auto-parked session: with the
* expected profile set the watchdog restores this connection
* regardless of the network (a session the user started manually
* comes back even on a trusted Wi-Fi) */
if (prefs.getBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false))
{
prefs.edit().putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply();
}
WatchdogReceiver.schedule(context);
}
}
/**
@@ -346,6 +553,63 @@ public class VpnStateService extends Service
connect(null, true);
}
/**
* Reconnect to the previous profile without user interaction (health
* check, routing managers). Unlike reconnect() this never launches UI
* and keeps the charon log of the failed session so an automatic
* reconnect can still be diagnosed afterwards.
*/
public void reconnectPreservingLog()
{
if (mProfile == null)
{
return;
}
connect(null, true, true);
}
/**
* Called by CharonVpnService when the set of underlying (physical)
* networks below the tunnel changed (e.g. Wi-Fi was lost, cellular became
* available).
*
* While the retry machinery is waiting out a backoff that was sized for
* the previous network, reconnect immediately: the backoff is meaningless
* after a network change, and waiting it out just delays the handover.
*/
public void onUnderlyingNetworksChanged()
{
mHandler.post(new Runnable()
{
@Override
public void run()
{
if (mState != State.CONNECTING || mError == ErrorState.NO_ERROR || mRetryTimeout <= 0)
{ /* either a connection attempt is already running (no retry
* pending) or we are not retrying at all - nothing to do */
return;
}
long now = SystemClock.uptimeMillis();
if (now - mLastNetworkRetry < NETWORK_RETRY_DEBOUNCE_MS)
{ /* network callbacks can be chatty during a handover */
return;
}
if (WhiteListManager.getInstance(VpnStateService.this).shouldBlockReconnect())
{ /* same gate as the regular retry countdown */
return;
}
mLastNetworkRetry = now;
Log.i(TAG, "Underlying networks changed while retrying, reconnecting immediately");
DiagLog.i(TAG, "network changed during backoff, retrying immediately");
mRetryTimeout = 0;
mRetryIn = 0;
mRetryFrozen = false;
mHandler.removeMessages(RETRY_MSG);
connect(null, false);
}
});
}
/**
* 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.
@@ -365,15 +629,17 @@ 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(),
widgetProfile != null ? widgetProfile.getName() : null,
widgetProfile != null ? widgetProfile.getUUID().toString() : null);
}
}
catch (Exception e)
{
e.printStackTrace();
DiagLog.i(TAG, "state change failed: " + e);
}
}
});
@@ -402,6 +668,7 @@ public class VpnStateService extends Service
VpnStateService.this.mError = ErrorState.NO_ERROR;
VpnStateService.this.mImcState = ImcState.UNKNOWN;
VpnStateService.this.mRemediationInstructions.clear();
scheduleConnectingTimeout();
return true;
}
});
@@ -428,6 +695,20 @@ 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 */
scheduleConnectingTimeout();
}
else
{
cancelConnectingTimeout();
}
return true;
}
return false;
@@ -451,6 +732,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);
@@ -460,6 +749,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;
@@ -525,11 +825,16 @@ public class VpnStateService extends Service
private void setRetryTimer(ErrorState error)
{
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
mRetryFrozen = false;
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;
}
mHandler.sendMessageAtTime(mHandler.obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_INTERVAL);
mHandler.sendMessageAtTime(mHandler.obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + retryStep(mRetryIn));
}
/**
@@ -537,8 +842,30 @@ public class VpnStateService extends Service
*/
private void resetRetryTimer()
{
mHandler.removeMessages(RETRY_MSG);
mRetryTimeout = 0;
mRetryIn = 0;
mRetryFrozen = false;
}
/**
* Arm the one-shot CONNECTING timeout. Uses a handler message counted in
* uptime, so it never wakes the device and simply freezes while it dozes.
* Called from the main thread (via notifyListeners) only.
*/
private void scheduleConnectingTimeout()
{
mHandler.removeMessages(CONNECTING_TIMEOUT_MSG);
mHandler.sendMessageAtTime(mHandler.obtainMessage(CONNECTING_TIMEOUT_MSG),
SystemClock.uptimeMillis() + CONNECTING_TIMEOUT);
}
/**
* Disarm the CONNECTING timeout. Called from the main thread only.
*/
private void cancelConnectingTimeout()
{
mHandler.removeMessages(CONNECTING_TIMEOUT_MSG);
}
/**
@@ -557,26 +884,61 @@ public class VpnStateService extends Service
@Override
public void handleMessage(Message msg)
{
/* handle retry countdown */
if (mService.get().mRetryTimeout <= 0)
VpnStateService service = mService.get();
if (msg.what == CONNECTING_TIMEOUT_MSG)
{
if (service != null && service.mState == State.CONNECTING &&
service.mError == ErrorState.NO_ERROR)
{ /* the daemon neither completed the connection nor reported a
* failure; surface it as UNREACHABLE so the regular retry
* machinery (with its whitelist handling) takes over */
service.setError(ErrorState.UNREACHABLE);
}
return;
}
if (service == null)
{
return;
}
mService.get().mRetryIn -= RETRY_INTERVAL;
if (mService.get().mRetryIn > 0)
/* handle retry countdown */
if (service.mRetryTimeout <= 0)
{
return;
}
if (service.mRetryFrozen)
{ /* the countdown is parked while the ISP whitelist blocks the server;
* re-check once per suppression period and go back to sleep - this
* reaches connect() again on its own once the whitelist clears */
if (WhiteListManager.getInstance(service).shouldBlockReconnect())
{
sendMessageAtTime(obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_SUPPRESS_MS);
return;
}
service.connect(null, false);
return;
}
service.mRetryIn -= retryStep(service.mRetryIn);
if (service.mRetryIn > 0)
{
/* calculate next interval before notifying listeners */
long next = SystemClock.uptimeMillis() + RETRY_INTERVAL;
long next = SystemClock.uptimeMillis() + retryStep(service.mRetryIn);
for (VpnStateListener listener : mService.get().mListeners)
{
listener.stateChanged();
}
service.notifyStateListeners();
sendMessageAtTime(obtainMessage(RETRY_MSG), next);
}
else
{
mService.get().connect(null, false);
if (WhiteListManager.getInstance(service).shouldBlockReconnect())
{ /* the ISP whitelist blocks the server, reconnecting is pointless;
* park the countdown until the whitelist clears instead of
* ticking every second */
service.mRetryFrozen = true;
service.mRetryIn = RETRY_SUPPRESS_MS;
service.notifyStateListeners();
sendMessageAtTime(obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_SUPPRESS_MS);
return;
}
service.connect(null, false);
}
}
}
@@ -0,0 +1,474 @@
/*
* 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.content.SharedPreferences;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.os.Build;
import android.os.SystemClock;
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;
/**
* Restores the VPN connection after the hosting process died. The process
* hosting CharonVpnService dies completely on a native crash of the daemon,
* and the service is deliberately NOT_STICKY, so without this nothing would
* bring the tunnel back.
*
* Energy design: the maintenance alarm is a NON-WAKEUP exact alarm, so it
* never wakes the device; it fires only while the device is awake anyway
* (Doze defers it until then). A second WAKEUP safety-net alarm bounds the
* recovery time while the device sleeps (e.g. the process was evicted on a
* trusted Wi-Fi overnight). Once a state proves steady - the tunnel parked
* on a trusted network, or a healthy established connection - both alarms
* back off (5 min maintenance, 30 min/1 h safety net), because in those
* states a live process recovers via its own callbacks within seconds and
* the alarms only guard a dead one. The checks themselves are a couple of
* memory and preference reads - no network probes, no wake locks, no
* periodic work while the connection is healthy. A crash is therefore
* repaired within seconds of the next time the device is used.
*
* Trusted-networks auto sessions: while parked on a trusted Wi-Fi the VPN is
* expected to be DOWN (the auto session is "expected" in the sense that the
* tunnel must come back when the network changes). If the process died
* while parked, auto-connect died with it, so on cellular this receiver
* restarts the tunnel using the auto-connect profile; on Wi-Fi the decision
* belongs to auto-connect (which starts with the fresh process and knows
* about SSIDs), so we stay out of its way.
*/
public class WatchdogReceiver extends BroadcastReceiver
{
private static final String TAG = WatchdogReceiver.class.getSimpleName();
private static final int REQUEST_CODE = 43;
private static final int WAKEUP_REQUEST_CODE = 44;
private static final long WATCHDOG_INTERVAL_MS = 60000;
/* wakeup safety net; allow-while-idle alarms are throttled to a 9-minute
* minimum interval in Doze, so 10 minutes is effectively exact enough */
private static final long WATCHDOG_WAKEUP_INTERVAL_MS = 600000;
/* after this many consecutive checks that find the tunnel parked on a
* trusted network, stretch the maintenance interval: while parked there
* is nothing to do (auto-connect owns the return via its callbacks) and
* the 60 s cadence only burns battery and floods diag.log overnight.
* The wakeup safety net is stretched to 30 minutes as well: a live
* process reacts to network changes within seconds, so it only guards
* the rare "process died, then the user left the trusted Wi-Fi" corner.
* Static on purpose: a parked tunnel implies a live process, so the
* counter survives; a dead process starts fresh with the short interval. */
private static final int PARKED_BACKOFF_AFTER = 5;
private static final long WATCHDOG_PARKED_INTERVAL_MS = 5 * 60000;
private static final long WATCHDOG_PARKED_WAKEUP_INTERVAL_MS = 30 * 60000;
/* steady state of an established connection: charon's own DPD/rekey and
* the in-process retry machinery own recovery, so the alarms only guard
* a dead process - repaired within seconds of the device being used.
* The wakeup alarm in Doze is stretched to an hour (from 10 minutes),
* cutting the overnight CPU wakeups while the tunnel is healthy. */
private static final int CONNECTED_BACKOFF_AFTER = 5;
private static final long WATCHDOG_CONNECTED_INTERVAL_MS = 5 * 60000;
private static final long WATCHDOG_CONNECTED_WAKEUP_INTERVAL_MS = 60 * 60000;
private static volatile int sParkedChecks;
private static volatile int sConnectedChecks;
/** cadence announced in diag.log last ("parked"/"connected"/null), so a
* steady state is logged once on entry instead of on every fire */
private static volatile String sLastCadence;
/** What onReceive decided to do. */
public enum Action
{
/** nothing expected / feature off / nothing to do */
NONE,
/** a connection is expected but the process is gone - restart it */
RESTART,
/** an auto session is expected, but Wi-Fi is up - auto-connect owns the decision */
SKIP_WIFI,
}
/**
* Pure decision logic, unit-testable.
*
* @param featureEnabled the watchdog feature is enabled
* @param expectedProfileSet an explicit connection is expected (UUID stored)
* @param autoExpected an auto session is expected to return when the network allows it
* @param whitelistBlocks the ISP whitelist currently blocks the server
* @param serviceTracking the live VpnStateService tracks a connection (state != DISABLED)
* @param processAlive the hosting process is alive (live service instance found)
* @param wifiPresent any Wi-Fi network is currently available
* @param autoConnectProfileConfigured auto-connect has a profile configured
*/
static Action decideAction(boolean featureEnabled, boolean expectedProfileSet, boolean autoExpected,
boolean whitelistBlocks, boolean serviceTracking, boolean processAlive,
boolean wifiPresent, boolean autoConnectProfileConfigured)
{
if (!featureEnabled || (!expectedProfileSet && !autoExpected))
{
return Action.NONE;
}
if (whitelistBlocks)
{ /* the ISP whitelist parks the in-process retry machinery too */
return Action.NONE;
}
if (serviceTracking)
{ /* the process is alive and the service is actively tracking a
* connection; its own retry and timeout logic handles recovery */
return Action.NONE;
}
if (!expectedProfileSet && processAlive)
{ /* an auto session is expected but only parked (process alive);
* auto-connect is running and reconnects via its own callbacks */
return Action.NONE;
}
if (autoExpected && wifiPresent && autoConnectProfileConfigured)
{ /* the auto session was parked on a Wi-Fi; whether the tunnel
* belongs up here is auto-connect's call (SSID-based), and it
* starts with any fresh process */
return Action.SKIP_WIFI;
}
return Action.RESTART;
}
/**
* Whether a check counts towards the steady-state backoff of a live
* process: connected, retrying/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 failed retries
* or whitelist blocking only burns battery.
*/
static boolean isAliveSteady(Action action, boolean processAlive, boolean parked)
{
return action == Action.NONE && processAlive && !parked;
}
@Override
public void onReceive(Context context, Intent intent)
{
context = context.getApplicationContext();
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String uuid = prefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null);
boolean autoExpected = prefs.getBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false);
boolean enabled = prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true);
if (!enabled || (uuid == null && !autoExpected))
{ /* nothing is expected to be connected (or the feature is off);
* schedule() re-arms the alarms when that changes */
return;
}
reschedule(context);
/* 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, whitelistBlocks, serviceTracking,
processAlive, wifiPresent, autoProfileConfigured);
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
boolean alive = isAliveSteady(action, processAlive, parked);
if (parked)
{
sParkedChecks++;
sConnectedChecks = 0;
}
else if (alive)
{
sConnectedChecks++;
sParkedChecks = 0;
}
else
{ /* any other outcome is interesting - go back to the fast cadence */
sParkedChecks = 0;
sConnectedChecks = 0;
}
boolean parkedSteady = parked && sParkedChecks >= PARKED_BACKOFF_AFTER;
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 */
if (!cadence.equals(sLastCadence))
{
DiagLog.i(TAG, "steady state (" + cadence + "), backing off checks to " +
(parkedSteady ? "5m/30m" : "5m/60m"));
}
}
else
{
DiagLog.i(TAG, "check: uuid=" + (uuid != null) + " autoExpected=" + autoExpected +
" processAlive=" + processAlive + " tracking=" + serviceTracking +
" whitelist=" + whitelistBlocks + " wifi=" + wifiPresent + " -> " + action);
}
sLastCadence = cadence;
switch (action)
{
case NONE:
case SKIP_WIFI:
return;
case RESTART:
{
boolean autoRestart = uuid == null;
if (uuid == null)
{
uuid = resolveAutoProfile(context, prefs);
if (uuid == null)
{
DiagLog.i(TAG, "auto session expected but no profile configured, not restarting");
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);
try
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
context.startForegroundService(start);
}
else
{
context.startService(start);
}
}
catch (IllegalStateException e)
{ /* background start blocked (should not happen from an exact alarm,
* but stay on the safe side) */
DiagLog.i(TAG, "FGS start blocked, falling back to FgsStartRetry");
FgsStartRetry.schedule(context, start);
}
}
}
}
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.
*/
private static String resolveAutoProfile(Context context, SharedPreferences prefs)
{
String uuid = AutoConnectManager.getInstance(context).getCurrentConfig().getProfileUuid();
if (uuid == null)
{
uuid = prefs.getString(Constants.PREF_MRU_VPN_PROFILE, null);
}
return uuid;
}
/**
* Whether any Wi-Fi network is currently available. A cheap transport
* check - deliberately no SSID lookup (slow and needs location).
*/
private static boolean isWifiPresent(Context context)
{
ConnectivityManager cm = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE);
if (cm == null)
{
return false;
}
try
{
for (Network network : cm.getAllNetworks())
{
NetworkCapabilities caps = cm.getNetworkCapabilities(network);
if (caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
!caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
return true;
}
}
}
catch (Exception e)
{
e.printStackTrace();
}
return false;
}
/**
* Arm the next maintenance check after a state transition (connect,
* park, settings toggle). Resets the steady-state counters so the
* checks return to the fast cadence until the new state proves itself
* stable again, then delegates to reschedule().
*/
public static void schedule(Context context)
{
sParkedChecks = 0;
sConnectedChecks = 0;
sLastCadence = null;
reschedule(context);
}
/**
* Arm the next maintenance check with the cadence the steady-state
* counters currently ask for. A one-shot non-wakeup alarm (fires only
* while the device is awake) plus a one-shot wakeup safety net that
* bounds the recovery time while the device sleeps. No-op when the
* feature is disabled or no connection is expected.
*/
private static void reschedule(Context context)
{
context = context.getApplicationContext();
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
boolean autoExpected = prefs.getBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false);
if (prefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null) == null && !autoExpected ||
!prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true))
{
return;
}
AlarmManager am = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
if (am == null)
{
return;
}
boolean parkedBackoff = sParkedChecks >= PARKED_BACKOFF_AFTER;
boolean connectedBackoff = sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
long interval = parkedBackoff ? WATCHDOG_PARKED_INTERVAL_MS
: connectedBackoff ? WATCHDOG_CONNECTED_INTERVAL_MS : WATCHDOG_INTERVAL_MS;
long wakeupInterval = parkedBackoff ? WATCHDOG_PARKED_WAKEUP_INTERVAL_MS
: connectedBackoff ? WATCHDOG_CONNECTED_WAKEUP_INTERVAL_MS : WATCHDOG_WAKEUP_INTERVAL_MS;
arm(am, pendingIntent(context, REQUEST_CODE), SystemClock.elapsedRealtime() + interval, false);
arm(am, pendingIntent(context, WAKEUP_REQUEST_CODE), SystemClock.elapsedRealtime() + wakeupInterval, true);
}
/**
* Set a one-shot alarm. Wakeup alarms use allow-while-idle variants so
* they also fire in Doze (throttled to a 9-minute minimum there); regular
* ones use exact alarms that never wake the device.
*/
private static void arm(AlarmManager am, PendingIntent pi, long when, boolean wakeup)
{
try
{
boolean exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms();
if (wakeup)
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
{
if (exact)
{
am.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
}
else
{
am.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
}
}
else
{
am.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
}
}
else
{
if (exact)
{
am.setExact(AlarmManager.ELAPSED_REALTIME, when, pi);
}
else
{
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
}
}
}
catch (SecurityException e)
{ /* the exact-alarm grant was revoked in between; inexact is fine */
if (wakeup && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
{
am.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
}
else if (!wakeup)
{
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
}
}
}
/**
* Remove the maintenance alarms (explicit disconnect).
*/
public static void cancel(Context context)
{
context = context.getApplicationContext();
AlarmManager am = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
if (am == null)
{
return;
}
am.cancel(pendingIntent(context, REQUEST_CODE));
am.cancel(pendingIntent(context, WAKEUP_REQUEST_CODE));
}
private static PendingIntent pendingIntent(Context context, int requestCode)
{
return PendingIntent.getBroadcast(context, requestCode,
new Intent(context, WatchdogReceiver.class),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
}
@@ -24,16 +24,25 @@ import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.preference.PreferenceManager
import org.json.JSONArray
import org.json.JSONException
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
import org.strongswan.android.ui.VpnProfileControlActivity
import org.strongswan.android.utils.Constants
import java.util.concurrent.atomic.AtomicBoolean
/**
@@ -51,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
@@ -78,6 +91,10 @@ 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
private val monitor = NetworkMonitor(context, { config.trustedSsids }, ::onNetworkChanged)
private val lifecycleBound = AtomicBoolean(false)
@@ -108,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
}
@@ -136,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
{
@@ -145,6 +183,18 @@ class AutoConnectManager private constructor(
{
}
service = null
/* the feature no longer manages sessions: drop the auto-expected marker
* and keep the watchdog armed only if an explicit connection is expected */
val defaultPrefs = PreferenceManager.getDefaultSharedPreferences(context)
defaultPrefs.edit().putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply()
if (defaultPrefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null) == null)
{
WatchdogReceiver.cancel(context)
}
else
{
WatchdogReceiver.schedule(context)
}
}
fun reloadConfig()
@@ -156,8 +206,8 @@ class AutoConnectManager private constructor(
}
else
{
stop()
autoSession.set(false)
scenariosChanged()
}
}
@@ -184,8 +234,8 @@ class AutoConnectManager private constructor(
}
else
{
stop()
autoSession.set(false)
scenariosChanged()
}
}
@@ -200,27 +250,62 @@ 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(config, network, vpnActive, autoSession)
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()
{
evaluate()
}
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
@@ -232,8 +317,16 @@ class AutoConnectManager private constructor(
Action.DISCONNECT ->
{
Log.i(TAG, "Trusted network detected, disconnecting")
DiagLog.i(TAG, "trusted network detected, parking the tunnel")
autoSession.set(false)
svc.disconnect()
lastParkAt = SystemClock.elapsedRealtime()
/* mark the auto session as expected so the watchdog can restore
* it if the process dies while parked and the device later
* moves to cellular (auto-connect dies with the process) */
PreferenceManager.getDefaultSharedPreferences(context).edit()
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, true).apply()
svc.disconnect(true)
Events.autoConnect(context, parked = true)
}
Action.IGNORE ->
{
@@ -248,6 +341,21 @@ class AutoConnectManager private constructor(
{
return
}
/* Wi-Fi at the edge of range flaps with a ~10-15 s period: a park
* followed by a quick "untrusted" flip - or a network kind that keeps
* bouncing - must not bounce the tunnel through full reconnects (one
* such flap produced a 7-second-lived session and a wasted IKE
* handshake). Require a longer stable-untrusted window before
* starting (the runnable re-evaluates and quietly gives up if a
* trusted Wi-Fi returned in between). */
val delay = if (SystemClock.elapsedRealtime() - lastParkAt < RECENT_PARK_WINDOW_MS || monitor.isFlapping())
{
FLAP_CONNECT_DELAY_MS
}
else
{
CONNECT_DELAY_MS
}
handler.postDelayed({
pendingEvaluate.set(false)
val svc = service
@@ -263,7 +371,7 @@ class AutoConnectManager private constructor(
startVpn(svc, uuid)
}
}
}, CONNECT_DELAY_MS)
}, delay)
}
private fun startVpn(svc: VpnStateService, uuid: String)
@@ -277,11 +385,26 @@ 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")
/* a real connection is expected again; connect() re-arms the
* watchdog with the profile, so the auto-parked marker goes away */
PreferenceManager.getDefaultSharedPreferences(context).edit()
.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)
@@ -313,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)
@@ -327,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()
}
@@ -354,16 +481,23 @@ class AutoConnectManager private constructor(
private const val PREFS_NAME = "one.shx.strongswan.ext.AUTO_CONNECT"
private const val KEY_CONFIG = "config"
private const val CONNECT_DELAY_MS = 3000L
/* anti-flap: a start right after a trusted-Wi-Fi park waits longer
* (the network must stay cellular through the whole window) */
private const val FLAP_CONNECT_DELAY_MS = 10_000L
private const val RECENT_PARK_WINDOW_MS = 120_000L
/**
* Pure decision function: trusted Wi-Fi means VPN down, everything else
* means VPN up.
* Pure decision function, unit-testable. Trusted Wi-Fi parks only
* sessions that the automation started itself; a connection the user
* started manually stays up until the user disconnects it, regardless
* of the network.
*/
fun evaluateDecision(
config: Config,
network: NetworkKind?,
vpnActive: Boolean,
autoSession: Boolean,
whiteListBlocksReconnect: Boolean = false,
): Action
{
if (!config.enabled || config.profileUuid == null)
@@ -373,12 +507,14 @@ class AutoConnectManager private constructor(
return when (network)
{
null -> Action.IGNORE
NetworkKind.TRUSTED_WIFI -> if (vpnActive) Action.DISCONNECT else Action.IGNORE
NetworkKind.TRUSTED_WIFI ->
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) Action.CONNECT else Action.IGNORE
NetworkKind.UNTRUSTED_WIFI, NetworkKind.CELLULAR -> if (!vpnActive) Action.CONNECT else Action.IGNORE
NetworkKind.UNKNOWN_WIFI -> if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
NetworkKind.UNTRUSTED_WIFI, NetworkKind.CELLULAR ->
if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
}
}
@@ -23,7 +23,9 @@ import android.net.wifi.WifiInfo
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import org.strongswan.android.logic.DiagLog
import java.util.concurrent.ConcurrentHashMap
/**
@@ -50,6 +52,8 @@ class NetworkMonitor(
private var registered = false
private var lastReported: AutoConnectManager.NetworkKind? = null
private var ssidRetries = 0
/* rolling log of network-kind change times, for flap detection (main thread only) */
private val kindChangeTimes = ArrayDeque<Long>()
private val events = object
{
@@ -73,12 +77,12 @@ class NetworkMonitor(
{
networks.remove(network)
ssids.remove(network)
scheduleEvaluate()
}
else
else if (remember(network, capabilities))
{
remember(network, capabilities)
scheduleEvaluate()
}
scheduleEvaluate()
}
}
@@ -133,6 +137,7 @@ class NetworkMonitor(
ssids.clear()
lastReported = null
ssidRetries = 0
kindChangeTimes.clear()
}
fun forceEvaluate()
@@ -189,12 +194,39 @@ class NetworkMonitor(
return null
}
private fun remember(network: Network, caps: NetworkCapabilities)
/**
* Store the latest state of a network. Returns whether anything the
* trust decision depends on changed: capability callbacks fire on every
* signal-strength/validation tick, and re-evaluating on those only burns
* battery - the debounced runnable wakes the main thread and rescans all
* networks for no result. Only a transport change or a new SSID counts.
*/
private fun remember(network: Network, caps: NetworkCapabilities): Boolean
{
val previous = networks[network]
val oldSsid = ssids[network]
val ssid = SsidReader.fromWifiInfo(caps.transportInfo as? WifiInfo)
networks[network] = caps
SsidReader.fromWifiInfo(caps.transportInfo as? WifiInfo)?.let { ssids[network] = it }
if (ssid != null)
{
ssids[network] = ssid
}
if (previous == null)
{
return true
}
if (!sameTransports(previous, caps))
{
return true
}
return ssid != null && ssid != oldSsid
}
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) &&
a.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == b.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
private fun ssidFor(network: Network, caps: NetworkCapabilities): String?
{
ssids[network]?.let { return it }
@@ -275,11 +307,44 @@ class NetworkMonitor(
if (kind != lastReported)
{
Log.i(TAG, "Network kind $lastReported -> $kind")
DiagLog.i(TAG, "network kind $lastReported -> $kind")
lastReported = kind
recordKindChange(SystemClock.elapsedRealtime())
onNetworkChanged(kind)
}
}
/**
* Remember a kind change for [isFlapping], dropping entries that fell out
* of the flap window (plus a hard cap so a pathological callback storm
* cannot grow the log unbounded).
*/
private fun recordKindChange(now: Long)
{
kindChangeTimes.addLast(now)
while (kindChangeTimes.size > KIND_CHANGE_LOG_MAX ||
(kindChangeTimes.isNotEmpty() && now - kindChangeTimes.first() > FLAP_WINDOW_MS))
{
kindChangeTimes.removeFirst()
}
}
/**
* Whether the network kind is bouncing: at least [FLAP_MIN_CHANGES] kind
* changes (including the latest one) within [FLAP_WINDOW_MS]. Callers
* stretch out auto-connect delays while the underlying network settles
* instead of bouncing the tunnel through short-lived sessions.
*/
fun isFlapping(): Boolean
{
val now = SystemClock.elapsedRealtime()
while (kindChangeTimes.isNotEmpty() && now - kindChangeTimes.first() > FLAP_WINDOW_MS)
{
kindChangeTimes.removeFirst()
}
return kindChangeTimes.size >= FLAP_MIN_CHANGES
}
private val evaluateRunnable = Runnable { evaluateNow() }
companion object
@@ -288,5 +353,8 @@ class NetworkMonitor(
private const val DEBOUNCE_MS = 1500L
private const val SSID_RETRY_MS = 400L
private const val SSID_RETRY_MAX = 8
private const val FLAP_WINDOW_MS = 5 * 60_000L
private const val FLAP_MIN_CHANGES = 2
private const val KIND_CHANGE_LOG_MAX = 16
}
}
@@ -0,0 +1,227 @@
/*
* 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.connectivity
import android.content.Context
import android.net.Network
import android.os.Build
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Socket
import java.net.URL
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
/** What a probe measures. External targets go through the tunnel, SERVER goes direct. */
enum class ProbeTarget
{
SERVER,
GOOGLE,
YOUTUBE,
CLOUDFLARE,
}
data class ProbeResult(
val target: ProbeTarget,
val ok: Boolean,
val rttMs: Long?,
val timestampMs: Long = System.currentTimeMillis(),
/** true when the probe bypassed the tunnel (server ping). */
val direct: Boolean = target == ProbeTarget.SERVER,
)
/** Public exit (as seen by the internet on the other end of the tunnel). */
data class ExitInfo(
val ip: String,
val country: String?,
)
/**
* TCP-connect "ping" for external targets and ICMP ping for the VPN server.
*
* External targets use raw IPs (8.8.8.8/1.1.1.1) to avoid DNS as a failure
* source; youtube.com additionally exercises the tunnel's DNS.
*/
object ConnectivityProbe
{
private const val GOOGLE_HOST = "8.8.8.8"
private const val GOOGLE_PORT = 53
private const val CLOUDFLARE_HOST = "1.1.1.1"
private const val CLOUDFLARE_PORT = 53
private const val YOUTUBE_HOST = "youtube.com"
private const val YOUTUBE_PORT = 443
private const val PING_BINARY = "/system/bin/ping"
suspend fun probe(target: ProbeTarget, timeoutMs: Int, context: Context, gateway: String?): ProbeResult =
when (target)
{
ProbeTarget.SERVER ->
{
val ping = pingServerDirect(context, gateway, timeoutMs)
ProbeResult(target = target, ok = ping != null, rttMs = ping?.first)
}
ProbeTarget.GOOGLE -> tcpPing(ProbeTarget.GOOGLE, GOOGLE_HOST, GOOGLE_PORT, timeoutMs)
ProbeTarget.CLOUDFLARE -> tcpPing(ProbeTarget.CLOUDFLARE, CLOUDFLARE_HOST, CLOUDFLARE_PORT, timeoutMs)
ProbeTarget.YOUTUBE -> tcpPing(ProbeTarget.YOUTUBE, YOUTUBE_HOST, YOUTUBE_PORT, timeoutMs)
}
/**
* TCP connect timing, used as "ping". The socket is created on the default
* network, which is the VPN tunnel while connected, so the RTT reflects
* the tunnel path to the target.
*/
suspend fun tcpPing(target: ProbeTarget, host: String, port: Int, timeoutMs: Int, network: Network? = null): ProbeResult =
withContext(Dispatchers.IO)
{
val started = System.nanoTime()
try
{
/* the socket factory of the target network; createSocket() can
* 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()
/* 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)
{
ProbeResult(target = target, ok = false, rttMs = null, direct = network != null)
}
}
/**
* Direct ICMP ping to the VPN server via /system/bin/ping. The gateway is
* resolved through the underlying (non-VPN) network so the tunnel's DNS is
* never used, and the ICMP packet bypasses the TUN interface. Returns
* (rttMs, resolvedIp) or null on failure.
*/
suspend fun pingServerDirect(context: Context, gateway: String?, timeoutMs: Int): Pair<Long, String>? =
withContext(Dispatchers.IO)
{
val host = gateway?.trim().takeIf { !it.isNullOrBlank() } ?: return@withContext null
val network = UnderlyingNetwork.resolve(context)
val address = UnderlyingNetwork.resolveHost(network, host)?.firstOrNull()
?: return@withContext null
val ip = address.hostAddress ?: return@withContext null
val seconds = ((timeoutMs + 999) / 1000).coerceAtLeast(1)
val process = try
{
ProcessBuilder(PING_BINARY, "-c", "1", "-W", seconds.toString(), ip)
.redirectErrorStream(true)
.start()
}
catch (_: IOException)
{
return@withContext null
}
try
{
val output = process.inputStream.bufferedReader().readText()
if (!waitForOrTimeout(process, timeoutMs + 1000L) || process.exitValue() != 0)
{
return@withContext null
}
parsePingRtt(output)?.let { it to ip }
}
finally
{
process.destroy()
}
}
/** Extracts "time=xx.x ms" from ping output. Visible for tests. */
fun parsePingRtt(output: String): Long?
{
val match = Regex("time[=<]([0-9]+(?:\\.[0-9]+)?)\\s*ms", RegexOption.IGNORE_CASE).find(output) ?: return null
return match.groupValues[1].toDoubleOrNull()?.toLong()
}
/**
* Resolve the public exit IP (and country code) as seen from the other
* end of the tunnel, via Cloudflare's cdn-cgi/trace. The plain
* connection rides the VPN (the app's default network while connected),
* so the answer reflects the tunnel egress. Returns null on any failure.
*/
suspend fun traceExit(timeoutMs: Int): ExitInfo? = withContext(Dispatchers.IO)
{
try
{
val connection = URL("https://www.cloudflare.com/cdn-cgi/trace").openConnection()
as HttpsURLConnection
try
{
connection.connectTimeout = timeoutMs
connection.readTimeout = timeoutMs
connection.instanceFollowRedirects = false
val text = connection.inputStream.bufferedReader().use { it.readText() }
parseTraceExit(text)
}
finally
{
connection.disconnect()
}
}
catch (_: Exception)
{
null
}
}
/** Extracts ip= / loc= from a cdn-cgi/trace body. Visible for tests. */
fun parseTraceExit(text: String): ExitInfo?
{
var ip: String? = null
var loc: String? = null
text.lineSequence().forEach { line ->
val value = line.substringAfter('=', "").trim()
when
{
line.startsWith("ip=") -> ip = value.takeIf { it.isNotEmpty() }
line.startsWith("loc=") -> loc = value.takeIf { it.isNotEmpty() }
}
}
return ip?.let { ExitInfo(ip = it, country = loc) }
}
private fun waitForOrTimeout(process: Process, timeoutMs: Long): Boolean
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
return process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
}
/* pre-O: no timed waitFor() - poll coarsely instead of spinning */
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline)
{
try
{
process.exitValue()
return true
}
catch (_: IllegalThreadStateException)
{
}
Thread.sleep(250)
}
return false
}
}
@@ -0,0 +1,879 @@
/*
* 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.connectivity
import android.app.Service
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
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.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
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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
{
RECONNECT,
IGNORE,
}
/** When the VPN server itself is probed directly (outside the tunnel). */
enum class ServerCheckMode
{
/** Probe the server on every cycle. */
ALWAYS,
/** Only consider a server failure when the tunnel is dead too (protects against servers that block ICMP). */
WITH_TUNNEL,
/** Never probe the server. */
OFF,
}
data class HealthConfig(
val enabled: Boolean = false,
val intervalSec: Int = 30,
val aggressiveMode: Boolean = false,
/** Stretch intervals and pause aggressive mode while the screen is off, power saver is on or the battery is low. */
val adaptiveMode: Boolean = true,
val timeoutMs: Int = 3000,
val targets: Set<ProbeTarget> = setOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE),
val serverCheckMode: ServerCheckMode = ServerCheckMode.WITH_TUNNEL,
val serverAction: HealthAction = HealthAction.RECONNECT,
val serverMaxAttempts: Int = 3,
val tunnelDeadAction: HealthAction = HealthAction.RECONNECT,
val tunnelDeadMaxAttempts: Int = 3,
val partialDownAction: HealthAction = HealthAction.IGNORE,
)
/** Device power state that modulates probe intensity in adaptive mode. */
data class PowerState(
val screenOn: Boolean = true,
val powerSaveMode: Boolean = false,
val batteryLow: Boolean = false,
)
/**
* Watches tunnel health while the VPN is connected: probes external targets
* through the tunnel and the VPN server directly on the underlying network.
* Based on [decide] it may reconnect, with per-rule attempt budgets and a
* confirming probe (unless aggressive mode) to avoid false reconnects.
*
* The process stays alive with the VPN foreground service, so checks continue
* with the screen off. No alarms or wakelocks: plain coroutines that simply
* follow process liveness, and Doze may stretch intervals - acceptable for a
* safety net on top of IKE DPD.
*
* With adaptive mode enabled the probe intensity follows the device power
* state: screen off stretches the interval, power saver or a low battery
* additionally disables fast polls (see [adaptToPowerState]).
*/
class HealthCheckManager private constructor(
private val context: Context,
) : VpnStateService.VpnStateListener
{
enum class Decision
{
RECONNECT_SERVER,
RECONNECT_TUNNEL,
IGNORE,
}
private val handler = Handler(Looper.getMainLooper())
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val scope = CoroutineScope(SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate)
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
private var pendingDecision: Decision? = null
private var serverAttempts = 0
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()
{
override fun onReceive(ctx: Context, intent: Intent)
{
when (intent.action)
{
Intent.ACTION_SCREEN_ON -> powerState = powerState.copy(screenOn = true)
Intent.ACTION_SCREEN_OFF -> powerState = powerState.copy(screenOn = false)
PowerManager.ACTION_POWER_SAVE_MODE_CHANGED -> refreshPowerSaveMode()
Intent.ACTION_BATTERY_LOW -> powerState = powerState.copy(batteryLow = true)
Intent.ACTION_BATTERY_OKAY -> powerState = powerState.copy(batteryLow = false)
}
}
}
/* event trigger: the system re-validates networks (e.g. on screen unlock) */
private var vpnNetwork: Network? = null
private var vpnValidated = false
private val vpnCallback = object : ConnectivityManager.NetworkCallback()
{
override fun onAvailable(network: Network)
{
vpnNetwork = network
vpnValidated = false
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
{
if (network != vpnNetwork)
{
return
}
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
if (vpnValidated && !validated)
{
Log.i(TAG, "VPN network lost validation, triggering immediate check")
triggerImmediateCheck()
}
vpnValidated = validated
}
override fun onLost(network: Network)
{
if (network == vpnNetwork)
{
vpnNetwork = null
vpnValidated = false
}
}
}
private val connection = object : ServiceConnection
{
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
{
service = (binder as VpnStateService.LocalBinder).service
service?.registerListener(this@HealthCheckManager)
onStateChanged()
}
override fun onServiceDisconnected(name: ComponentName?)
{
service = null
}
}
fun start()
{
config = loadConfig()
if (!config.enabled)
{
return
}
if (running)
{
return
}
running = true
context.bindService(
Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE
)
registerVpnCallback()
registerPowerReceiver()
}
fun stop()
{
if (!running)
{
return
}
running = false
cancelCycles()
unregisterVpnCallback()
unregisterPowerReceiver()
service?.unregisterListener(this)
try
{
context.unbindService(connection)
}
catch (_: IllegalArgumentException)
{
}
service = null
lastState = null
resetCounters()
}
fun reloadConfig()
{
config = loadConfig()
if (config.enabled)
{
start()
/* re-arm with the new settings even if the state is unchanged */
lastState = null
onStateChanged()
}
else
{
stop()
}
}
val currentConfig: HealthConfig
get()
{
if (!running)
{
config = loadConfig()
}
return config
}
fun updateConfig(transform: (HealthConfig) -> HealthConfig)
{
val updated = transform(config)
saveConfig(updated)
config = updated
if (updated.enabled)
{
start()
/* re-arm with the new settings even if the state is unchanged */
lastState = null
onStateChanged()
}
else
{
stop()
}
}
override fun stateChanged()
{
handler.post { onStateChanged() }
}
private fun onStateChanged()
{
if (!running)
{
return
}
val state = service?.state
val previous = lastState
lastState = state
when (state)
{
VpnStateService.State.CONNECTED ->
{
if (previous != VpnStateService.State.CONNECTED)
{ /* let the fresh tunnel settle before the first probe */
scheduleCycle(INITIAL_DELAY_MS)
}
}
VpnStateService.State.DISABLED ->
{
cancelCycles()
resetCounters()
}
else -> cancelCycles()
}
}
private fun triggerImmediateCheck()
{
if (!running || service?.state != VpnStateService.State.CONNECTED)
{
return
}
scope.launch {
val ran = tryRunCycle()
if (!ran)
{
/* a cycle is already in flight; its result will reschedule */
}
}
}
/**
* Runs one probe cycle unless another one is already running.
* Returns false when a cycle was already in progress.
*/
private suspend fun tryRunCycle(): Boolean
{
if (!cycleMutex.tryLock())
{
return false
}
try
{
runCycleLocked()
return true
}
finally
{
cycleMutex.unlock()
}
}
private suspend fun runCycleLocked()
{
val svc = service ?: return
if (svc.state != VpnStateService.State.CONNECTED)
{
return
}
val gateway = svc.profile?.gateway
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 externalResults = coroutineScope {
targets.map { target ->
async { ConnectivityProbe.probe(target, timeout, context, gateway) }
}.map { it.await() }
}
val externalOk = externalResults.count { it.ok }
val externalTotal = externalResults.size
/* 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)
when
{
healthy ->
{
pendingDecision = null
resetCounters()
scheduleCycle(effectiveIntervalSec(adaptToPowerState(config, powerState)) * 1000L)
}
decision == Decision.IGNORE ->
{
pendingDecision = null
scheduleFastPoll()
}
else -> handleFailure(decision)
}
}
private fun handleFailure(decision: Decision)
{
if (needsConfirmation(adaptToPowerState(config, powerState)) && pendingDecision == null)
{
/* first failing cycle: confirm with a single probe before acting */
pendingDecision = decision
Log.i(TAG, "Check failed ($decision), scheduling confirmation probe")
confirmJob?.cancel()
confirmJob = scope.launch {
delay(CONFIRM_DELAY_MS)
if (pendingDecision == decision)
{
confirmAndAct(decision)
}
}
scheduleFastPoll()
return
}
act(decision)
}
private suspend fun confirmAndAct(decision: Decision)
{
val svc = service ?: return
if (svc.state != VpnStateService.State.CONNECTED)
{
pendingDecision = null
return
}
val gateway = svc.profile?.gateway
/* single probe of the failing dimension, not a whole cycle */
val confirmed = when (decision)
{
Decision.RECONNECT_SERVER ->
!ConnectivityProbe.probe(ProbeTarget.SERVER, config.timeoutMs, context, gateway).ok
else ->
{
val target = config.targets.firstOrNull() ?: ProbeTarget.GOOGLE
!ConnectivityProbe.probe(target, config.timeoutMs, context, gateway).ok
}
}
if (confirmed)
{
Log.i(TAG, "Failure confirmed, reconnecting ($decision)")
act(decision)
}
else
{
Log.i(TAG, "Confirmation probe succeeded, treating previous failure as transient")
pendingDecision = null
fastPollShifts = 0
scheduleCycle(effectiveIntervalSec(adaptToPowerState(config, powerState)) * 1000L)
}
}
private fun act(decision: Decision)
{
pendingDecision = null
if (decision != Decision.IGNORE && WhiteListManager.getInstance(context).shouldBlockReconnect())
{ /* the ISP whitelist explains the failing probes, restarting the tunnel
* will not help; re-check soon so we act once the whitelist clears */
Log.i(TAG, "Whitelist active, suppressing reconnect ($decision)")
scheduleFastPoll()
return
}
when (decision)
{
Decision.RECONNECT_SERVER ->
{
if (serverAttempts < config.serverMaxAttempts)
{
serverAttempts++
Log.i(TAG, "Reconnecting, server unreachable (attempt $serverAttempts/${config.serverMaxAttempts})")
service?.reconnectPreservingLog()
Events.healthReconnect(context, serverDown = true)
}
}
Decision.RECONNECT_TUNNEL ->
{
if (tunnelAttempts < config.tunnelDeadMaxAttempts)
{
tunnelAttempts++
Log.i(TAG, "Reconnecting, tunnel dead (attempt $tunnelAttempts/${config.tunnelDeadMaxAttempts})")
service?.reconnectPreservingLog()
Events.healthReconnect(context, serverDown = false)
}
}
Decision.IGNORE ->
{
}
}
}
private fun scheduleCycle(delayMs: Long)
{
cycleJob?.cancel()
cycleJob = scope.launch {
delay(delayMs)
tryRunCycle()
}
}
/**
* Schedules the next cycle while a failure is being observed: fast at
* first, then with exponential backoff up to the regular interval so a
* persisting failure doesn't keep probing (and waking the radio) at full
* rate. Under power saver or a low battery fast polls are skipped
* entirely.
*/
private fun scheduleFastPoll()
{
val adapted = adaptToPowerState(config, powerState)
val intervalMs = if (powerState.powerSaveMode || powerState.batteryLow)
{
adapted.intervalSec * 1000L
}
else
{
minOf(FAST_POLL_MS * (1L shl minOf(fastPollShifts, FAST_POLL_MAX_SHIFTS)), adapted.intervalSec * 1000L)
}
fastPollShifts++
scheduleCycle(intervalMs)
}
private fun refreshPowerSaveMode()
{
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
powerState = powerState.copy(powerSaveMode = pm?.isPowerSaveMode ?: false)
}
private fun registerPowerReceiver()
{
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
powerState = PowerState(
screenOn = pm?.isInteractive ?: true,
powerSaveMode = pm?.isPowerSaveMode ?: false,
batteryLow = isBatteryLowNow(),
)
/* 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)
addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
addAction(Intent.ACTION_BATTERY_LOW)
addAction(Intent.ACTION_BATTERY_OKAY)
}
try
{
context.registerReceiver(powerReceiver, filter, null, handler)
}
catch (e: Exception)
{
Log.w(TAG, "Failed to register power state receiver", e)
}
}
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
{
context.unregisterReceiver(powerReceiver)
}
catch (_: Exception)
{
}
}
private fun cancelCycles()
{
cycleJob?.cancel()
cycleJob = null
confirmJob?.cancel()
confirmJob = null
pendingDecision = null
}
private fun resetCounters()
{
serverAttempts = 0
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()
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
.build()
try
{
cm.registerNetworkCallback(request, vpnCallback, handler)
}
catch (e: Exception)
{
Log.w(TAG, "Failed to register VPN network callback", e)
}
}
private fun unregisterVpnCallback()
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
try
{
cm.unregisterNetworkCallback(vpnCallback)
}
catch (_: Exception)
{
}
vpnNetwork = null
vpnValidated = false
}
private fun loadConfig(): HealthConfig
{
val json = prefs.getString(KEY_CONFIG, null) ?: return HealthConfig()
return try
{
val obj = JSONObject(json)
HealthConfig(
enabled = obj.optBoolean("enabled", false),
intervalSec = obj.optInt("intervalSec", 30),
aggressiveMode = obj.optBoolean("aggressiveMode", false),
adaptiveMode = obj.optBoolean("adaptiveMode", true),
timeoutMs = obj.optInt("timeoutMs", 3000),
targets = obj.optJSONArray("targets")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optString(i).let { name -> ProbeTarget.entries.firstOrNull { it.name == name } }
}.filter { it != ProbeTarget.SERVER }.toSet()
} ?: setOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE),
serverCheckMode = ServerCheckMode.entries.firstOrNull { it.name == obj.optString("serverCheckMode") }
?: ServerCheckMode.WITH_TUNNEL,
serverAction = HealthAction.entries.firstOrNull { it.name == obj.optString("serverAction") }
?: HealthAction.RECONNECT,
serverMaxAttempts = obj.optInt("serverMaxAttempts", 3),
tunnelDeadAction = HealthAction.entries.firstOrNull { it.name == obj.optString("tunnelDeadAction") }
?: HealthAction.RECONNECT,
tunnelDeadMaxAttempts = obj.optInt("tunnelDeadMaxAttempts", 3),
partialDownAction = HealthAction.entries.firstOrNull { it.name == obj.optString("partialDownAction") }
?: HealthAction.IGNORE,
)
}
catch (_: JSONException)
{
HealthConfig()
}
}
private fun saveConfig(config: HealthConfig)
{
val obj = JSONObject()
obj.put("enabled", config.enabled)
obj.put("intervalSec", config.intervalSec)
obj.put("aggressiveMode", config.aggressiveMode)
obj.put("adaptiveMode", config.adaptiveMode)
obj.put("timeoutMs", config.timeoutMs)
obj.put("targets", JSONArray(config.targets.map { it.name }.sorted()))
obj.put("serverCheckMode", config.serverCheckMode.name)
obj.put("serverAction", config.serverAction.name)
obj.put("serverMaxAttempts", config.serverMaxAttempts)
obj.put("tunnelDeadAction", config.tunnelDeadAction.name)
obj.put("tunnelDeadMaxAttempts", config.tunnelDeadMaxAttempts)
obj.put("partialDownAction", config.partialDownAction.name)
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
}
companion object
{
private const val TAG = "HealthCheckManager"
private const val PREFS_NAME = "one.shx.strongswan.ext.HEALTH_CHECK"
private const val KEY_CONFIG = "config"
private const val INITIAL_DELAY_MS = 2000L
private const val CONFIRM_DELAY_MS = 2000L
private const val FAST_POLL_MS = 5000L
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
/** Probe interval in adaptive mode while power saver is on or the battery is low. */
internal const val POWER_SAVE_INTERVAL_SEC = 300
/** Effective interval between cycles: aggressive mode overrides the user interval. */
fun effectiveIntervalSec(config: HealthConfig): Int =
if (config.aggressiveMode) AGGRESSIVE_INTERVAL_SEC else config.intervalSec
/**
* Pure function: the config actually applied for the given device power
* state. With adaptive mode on, aggressive mode is paused and the
* interval stretched while the screen is off; under power saver or a low
* battery the interval goes to the maximum (fast polls are skipped in
* [scheduleFastPoll] based on the same state).
*/
fun adaptToPowerState(config: HealthConfig, power: PowerState): HealthConfig
{
if (!config.adaptiveMode)
{
return config
}
return when
{
power.powerSaveMode || power.batteryLow ->
config.copy(aggressiveMode = false, intervalSec = maxOf(config.intervalSec, POWER_SAVE_INTERVAL_SEC))
!power.screenOn ->
config.copy(aggressiveMode = false, intervalSec = maxOf(config.intervalSec, SCREEN_OFF_MIN_INTERVAL_SEC))
else -> config
}
}
/** Aggressive mode reconnects without a confirming probe. */
fun needsConfirmation(config: HealthConfig): Boolean = !config.aggressiveMode
/**
* Pure decision function, unit-testable.
*
* @param serverDown null when the server is not probed, true when the direct ping failed
* @param externalOk how many external targets answered through the tunnel
* @param externalTotal how many external targets were probed
* @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?,
externalOk: Int,
externalTotal: Int,
serverAttempts: Int,
tunnelAttempts: Int,
): Decision
{
if (!config.enabled)
{
return Decision.IGNORE
}
val tunnelDead = externalTotal > 0 && externalOk == 0
val serverFail = config.serverCheckMode != ServerCheckMode.OFF &&
serverDown == true &&
(config.serverCheckMode == ServerCheckMode.ALWAYS ||
(config.serverCheckMode == ServerCheckMode.WITH_TUNNEL && tunnelDead))
if (serverFail && config.serverAction == HealthAction.RECONNECT &&
serverAttempts < config.serverMaxAttempts)
{
return Decision.RECONNECT_SERVER
}
if (tunnelDead && config.tunnelDeadAction == HealthAction.RECONNECT &&
tunnelAttempts < config.tunnelDeadMaxAttempts)
{
return Decision.RECONNECT_TUNNEL
}
val partial = externalTotal > 0 && externalOk in 1 until externalTotal
if (partial && config.partialDownAction == HealthAction.RECONNECT &&
tunnelAttempts < config.tunnelDeadMaxAttempts)
{
return Decision.RECONNECT_TUNNEL
}
return Decision.IGNORE
}
@Volatile
private var instance: HealthCheckManager? = null
@JvmStatic
fun getInstance(context: Context): HealthCheckManager =
instance ?: synchronized(this) {
instance ?: HealthCheckManager(context.applicationContext).also { instance = it }
}
}
}
@@ -0,0 +1,88 @@
/*
* 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.connectivity
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
/**
* Finds the underlying (non-VPN) network so the VPN server can be reached
* directly, outside the tunnel. Wi-Fi is preferred over cellular, mirroring
* [org.strongswan.android.logic.autoconnect.NetworkMonitor].
*/
object UnderlyingNetwork
{
fun resolve(context: Context): Network?
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null
var wifi: Network? = null
var cellular: Network? = null
var validatedWifi: Network? = null
var validatedCellular: Network? = null
for (network in cm.allNetworks)
{
val caps = try
{
cm.getNetworkCapabilities(network)
}
catch (_: Exception)
{
null
} ?: continue
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
{
continue
}
/* captive portals answer connects for anything, so an unvalidated
* network would probe as "everything reachable" and hide blocking */
val validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
{
wifi = network
if (validated)
{
validatedWifi = network
}
}
else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
{
cellular = network
if (validated)
{
validatedCellular = network
}
}
}
return validatedWifi ?: validatedCellular ?: wifi ?: cellular
}
/**
* Resolves a hostname through the given network so the VPN tunnel's DNS
* is never used for the direct server probe.
*/
fun resolveHost(network: Network?, host: String): List<java.net.InetAddress>?
{
return try
{
if (network != null) network.getAllByName(host).toList() else java.net.InetAddress.getAllByName(host).toList()
}
catch (_: Exception)
{
null
}
}
}
@@ -0,0 +1,734 @@
/*
* 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.connectivity
import android.app.Service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
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
{
/** No usable data yet. */
UNKNOWN,
/** Foreign hosts unreachable directly while domestic ones still answer. */
ACTIVE,
/** Foreign hosts reachable directly. */
INACTIVE,
}
/**
* A probe endpoint, serialized to preferences as "host:port" (IPv4 only).
*/
data class ProbeEndpoint(val host: String, val port: Int)
{
fun serialize(): String = "$host:$port"
companion object
{
fun parse(value: String): ProbeEndpoint?
{
val index = value.lastIndexOf(':')
if (index <= 0)
{
return null
}
val host = value.substring(0, index)
val port = value.substring(index + 1).toIntOrNull() ?: return null
if (host.isEmpty() || port !in 1..65535)
{
return null
}
return ProbeEndpoint(host, port)
}
}
}
data class WhiteListConfig(
val enabled: Boolean = false,
/** Overrides for the foreign probe endpoints as "host:port"; empty keeps the built-in defaults. */
val foreignTargets: List<String> = emptyList(),
)
data class WhiteListState(
val config: WhiteListConfig = WhiteListConfig(),
val status: WhiteListStatus = WhiteListStatus.UNKNOWN,
val foreignDirectOk: Boolean? = null,
val domesticDirectOk: Boolean? = null,
val foreignDirectRttMs: Long? = null,
val domesticDirectRttMs: Long? = null,
val lastCheckMs: Long = 0,
)
/**
* Result of one direct probe round (all probes run outside the tunnel),
* one entry per configured foreign target. Failed probes report false/null
* RTT; when there is no usable network at all every entry is null.
*/
data class WhiteListRound(
val foreignOks: List<Boolean?>,
val foreignRttMs: List<Long?>,
val domesticOk: Boolean?,
val domesticRttMs: Long?,
)
{
/** Any foreign target reachable; null while nothing conclusive was probed. */
val foreignDirectOk: Boolean?
get() = when
{
foreignOks.isEmpty() -> null
foreignOks.any { it == true } -> true
foreignOks.all { it == false } -> false
else -> null
}
/** Fastest reply among the reachable foreign targets. */
val foreignDirectRttMs: Long?
get() = foreignRttMs.filterNotNull().minOrNull()
}
/**
* Detects "white listing" by ISPs (foreign hosts blocked directly, domestic
* hosts still reachable) by probing several foreign endpoints and one
* domestic host DIRECTLY on the underlying network, never through the
* tunnel. One reachable foreign host disproves whitelisting; ACTIVE is only
* reported after every foreign probe failed (each retried once) in two
* consecutive rounds, so a flaky round cannot produce a false verdict.
*
* While the whitelist is [WhiteListStatus.ACTIVE], reconnecting to the VPN
* server is pointless (the server is unreachable), so reconnection attempts
* are paused: the VpnStateService retry countdown freezes, HealthCheckManager
* skips its reconnect actions and AutoConnectManager ignores connect requests.
* As soon as the status becomes [WhiteListStatus.INACTIVE] those paths resume
* by themselves and the connection is (re)established. The ACTIVE verdict
* fails open when it has gone stale, so the tunnel can never be parked
* indefinitely by a wrong verdict.
*
* Battery: fully event-driven, no polling loop of its own. Automatic probes
* run on underlying network changes, on tunnel loss, and at most once per
* minute while the UI is visible (tunnel up or down); the availability card's
* refresh button triggers one immediately. The automatic recheck while the
* whitelist stays active and the tunnel is down follows a capped exponential
* backoff. No wakelocks or alarms: plain coroutines that follow process
* liveness.
*/
class WhiteListManager private constructor(private val context: Context) : VpnStateService.VpnStateListener
{
private val handler = Handler(Looper.getMainLooper())
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val cycleMutex = Mutex()
@Volatile
private var config = WhiteListConfig()
private var running = false
@Volatile
private var machine = StatusMachine()
@Volatile
private var lastCheckElapsed = 0L
@Volatile
private var backoffAttempt = 0
private var recheckJob: Job? = null
private var service: VpnStateService? = null
private var lastSeenState: VpnStateService.State? = null
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()
{
override fun onAvailable(network: Network)
{
onNetworkEvent()
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
{ /* 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()
}
}
private val connection = object : ServiceConnection
{
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
{
service = (binder as VpnStateService.LocalBinder).service
service?.registerListener(this@WhiteListManager)
}
override fun onServiceDisconnected(name: ComponentName?)
{
service = null
}
}
fun start()
{
config = loadConfig()
if (!config.enabled)
{
return
}
if (running)
{
return
}
running = true
publish()
context.bindService(Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE)
registerNetworkCallback()
Log.i(TAG, "Started")
}
fun stop()
{
if (!running)
{
return
}
running = false
recheckJob?.cancel()
recheckJob = null
unregisterNetworkCallback()
lastCapabilities.clear()
service?.unregisterListener(this)
try
{
context.unbindService(connection)
}
catch (_: IllegalArgumentException)
{
}
service = null
lastSeenState = null
machine = StatusMachine()
backoffAttempt = 0
lastCheckElapsed = 0L
publish()
Log.i(TAG, "Stopped")
}
fun reloadConfig()
{
config = loadConfig()
if (config.enabled)
{
start()
}
else
{
stop()
}
}
val currentConfig: WhiteListConfig
get()
{
if (!running)
{
config = loadConfig()
}
return config
}
fun updateConfig(transform: (WhiteListConfig) -> WhiteListConfig)
{
val updated = transform(config)
saveConfig(updated)
config = updated
if (updated.enabled)
{
start()
scope.launch { runCheck(force = true) }
}
else
{
stop()
}
}
/**
* Whether reconnect attempts should currently be paused. Cheap, safe from
* any thread. Fails open once the ACTIVE verdict went stale: an
* unverifiable status must not keep the tunnel down forever; the
* triggered recheck restores ACTIVE if the whitelist is still in effect.
*/
fun shouldBlockReconnect(): Boolean
{
if (!running || !config.enabled)
{
return false
}
if (machine.status != WhiteListStatus.ACTIVE)
{
return false
}
if (SystemClock.elapsedRealtime() - lastCheckElapsed > ACTIVE_TTL_MS)
{
scope.launch { runCheck(force = false) }
return false
}
return true
}
/** Called when the app becomes visible: refresh the cached status if stale. */
fun refreshIfStale()
{
if (!running || !config.enabled)
{
return
}
scope.launch { runCheck(force = false) }
}
/** Runs a probe round immediately (e.g. the user tapped refresh). */
fun checkNow()
{
if (!running || !config.enabled)
{
return
}
scope.launch { runCheck(force = true) }
}
override fun stateChanged()
{
handler.post { onStateChanged() }
}
private fun onStateChanged()
{
if (!running)
{
return
}
val svc = service ?: return
val state = svc.state
val changed = state != lastSeenState
lastSeenState = state
if (state == VpnStateService.State.CONNECTED)
{ /* tunnel up: nothing to reconnect, probes are not needed */
recheckJob?.cancel()
return
}
/* the retry countdown notifies listeners every second; only act on transitions */
if (!changed)
{
return
}
backoffAttempt = 0
/* 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()
{
if (!running)
{
return
}
backoffAttempt = 0
if (service?.state == VpnStateService.State.CONNECTED)
{ /* nothing to reconnect while the tunnel is up */
return
}
scope.launch { runCheck(force = false) }
}
/** The configured foreign probe endpoints, falling back to the built-in defaults. */
private fun effectiveForeignTargets(): List<ProbeEndpoint> =
config.foreignTargets.mapNotNull { ProbeEndpoint.parse(it) }.ifEmpty { DEFAULT_FOREIGN_TARGETS }
private suspend fun runCheck(force: Boolean)
{
if (!running)
{
return
}
if (!cycleMutex.tryLock())
{
return
}
try
{
val now = SystemClock.elapsedRealtime()
if (!force && now - lastCheckElapsed < FRESH_MS)
{
return
}
val targets = effectiveForeignTargets()
var round = probeRound(context, targets)
lastCheckElapsed = SystemClock.elapsedRealtime()
var candidate = decide(round.foreignOks, round.domesticOk)
if (candidate == WhiteListStatus.ACTIVE && machine.status != WhiteListStatus.ACTIVE)
{ /* confirm a first ACTIVE verdict with a second round: one flaky
* round must not flip the status and pause reconnects */
Log.i(TAG, "ACTIVE candidate, confirming with a second round")
delay(ACTIVE_CONFIRM_DELAY_MS)
round = probeRound(context, targets)
lastCheckElapsed = SystemClock.elapsedRealtime()
candidate = decide(round.foreignOks, round.domesticOk)
}
val foreignLog = round.foreignOks.mapIndexed { index, ok ->
"${targets.getOrNull(index)?.host ?: index}=${ok ?: "?"}/${round.foreignRttMs.getOrNull(index) ?: "-"}ms"
}
Log.i(TAG, "Direct round: ${foreignLog.joinToString(" ")}, " +
"domestic=${round.domesticOk}/${round.domesticRttMs ?: "-"}ms -> $candidate")
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++
scheduleRecheck(nextBackoffMs(backoffAttempt))
}
else
{
backoffAttempt = 0
recheckJob?.cancel()
recheckJob = null
}
if (before.status == WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.INACTIVE)
{ /* the ISP cleared the whitelist; let the automation reconsider connecting */
Log.i(TAG, "Whitelist cleared, re-evaluating auto connect")
handler.post {
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).reevaluate()
}
}
}
finally
{
cycleMutex.unlock()
}
}
private fun scheduleRecheck(delayMs: Long)
{
recheckJob?.cancel()
recheckJob = scope.launch {
delay(delayMs)
/* forced: the first backoff steps are shorter than FRESH_MS, an
* unforced recheck returned early and ended the chain */
runCheck(force = true)
}
}
private fun publish(round: WhiteListRound? = null)
{
val previous = _state.value
_state.value = WhiteListState(
config = config,
status = machine.status,
foreignDirectOk = round?.foreignDirectOk,
domesticDirectOk = round?.domesticOk,
foreignDirectRttMs = round?.foreignDirectRttMs,
domesticDirectRttMs = round?.domesticRttMs,
lastCheckMs = if (round != null || previous.lastCheckMs == 0L) System.currentTimeMillis() else previous.lastCheckMs,
)
}
private fun registerNetworkCallback()
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
try
{
cm.registerNetworkCallback(request, networkCallback, handler)
}
catch (e: Exception)
{
Log.w(TAG, "Failed to register network callback", e)
}
}
private fun unregisterNetworkCallback()
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
try
{
cm.unregisterNetworkCallback(networkCallback)
}
catch (_: Exception)
{
}
}
private fun loadConfig(): WhiteListConfig
{
val json = prefs.getString(KEY_CONFIG, null) ?: return WhiteListConfig()
return try
{
val obj = JSONObject(json)
val targets = obj.optJSONArray("foreignTargets")?.let { arr ->
(0 until arr.length()).mapNotNull { ProbeEndpoint.parse(arr.optString(it))?.serialize() }
}.orEmpty()
WhiteListConfig(enabled = obj.optBoolean("enabled", false), foreignTargets = targets)
}
catch (_: JSONException)
{
WhiteListConfig()
}
}
private fun saveConfig(config: WhiteListConfig)
{
val obj = JSONObject()
obj.put("enabled", config.enabled)
/* custom targets are stored only when set, so code updates to the
* built-in defaults keep reaching existing installs */
if (config.foreignTargets.isNotEmpty())
{
val arr = JSONArray()
config.foreignTargets.forEach { arr.put(it) }
obj.put("foreignTargets", arr)
}
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
}
/**
* Tracks the reported status with hysteresis: leaving UNKNOWN needs a
* single confirming round, flipping between ACTIVE and INACTIVE needs
* [CONFIRMATIONS] consecutive rounds so one dropped probe does not flap
* the status. (The manager additionally confirms a first ACTIVE verdict
* with a second probe round before it ever reaches this machine.)
*/
data class StatusMachine(
val status: WhiteListStatus = WhiteListStatus.UNKNOWN,
val activeStreak: Int = 0,
val inactiveStreak: Int = 0,
)
{
fun advance(candidate: WhiteListStatus): StatusMachine
{
val activeStreak = if (candidate == WhiteListStatus.ACTIVE) this.activeStreak + 1 else 0
val inactiveStreak = if (candidate == WhiteListStatus.INACTIVE) this.inactiveStreak + 1 else 0
val confirmations = if (status == WhiteListStatus.UNKNOWN) 1 else CONFIRMATIONS_REQUIRED
val status = when
{
this.status != WhiteListStatus.ACTIVE && activeStreak >= confirmations -> WhiteListStatus.ACTIVE
this.status == WhiteListStatus.ACTIVE && inactiveStreak >= confirmations -> WhiteListStatus.INACTIVE
else -> this.status
}
return StatusMachine(status, activeStreak, inactiveStreak)
}
}
companion object
{
private const val TAG = "WhiteListManager"
private const val PREFS_NAME = "one.shx.strongswan.ext.WHITE_LIST"
private const val KEY_CONFIG = "config"
/** Rounds required to flip between ACTIVE and INACTIVE. */
const val CONFIRMATIONS_REQUIRED = 2
/**
* Foreign endpoints probed directly over TCP/443, so port-53 filtering
* cannot fake a verdict: Quad9 and OpenDNS DNS-over-HTTPS plus GitHub.
* All are normally reachable from Russian networks but get cut off by
* whitelist-style blocking. 8.8.8.8 and 1.1.1.1 are NOT usable here:
* both are blocked in RF even without any whitelisting.
*/
val DEFAULT_FOREIGN_TARGETS = listOf(
ProbeEndpoint("9.9.9.9", 443),
ProbeEndpoint("208.67.222.222", 443),
ProbeEndpoint("140.82.121.4", 443),
)
/**
* A domestic endpoint whitelists keep reachable (Yandex DNS), probed
* by IP; distinguishes "foreign blocked" from "no network at all".
*/
val DOMESTIC_TARGET = ProbeEndpoint("77.88.8.8", 53)
private const val PROBE_TIMEOUT_MS = 3000
/** A failed foreign probe is retried once before it counts as blocked. */
private const val PROBE_RETRY_DELAY_MS = 700L
/** A first ACTIVE verdict is re-probed after this delay to confirm. */
private const val ACTIVE_CONFIRM_DELAY_MS = 2000L
/** 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
* cap plus the probe duration so the scheduled rechecks keep the
* verdict fresh without gaps.
*/
private const val ACTIVE_TTL_MS = 7 * 60_000L
private val BACKOFF_STEPS_MS = longArrayOf(15_000L, 30_000L, 60_000L, 120_000L, 300_000L)
/**
* Pure classification of one probe round: whitelisting means every
* foreign host failed (each after a retry) while the domestic one
* answered. One reachable foreign host disproves it, anything else
* (no network, domestic failure) is inconclusive.
*/
fun decide(foreignOks: List<Boolean?>, domesticOk: Boolean?): WhiteListStatus = when
{
foreignOks.isEmpty() -> WhiteListStatus.UNKNOWN
foreignOks.any { it == true } -> WhiteListStatus.INACTIVE
domesticOk == true && foreignOks.all { it == false } -> WhiteListStatus.ACTIVE
else -> WhiteListStatus.UNKNOWN
}
/** Capped exponential backoff for rechecks while the whitelist stays active. */
fun nextBackoffMs(attempt: Int): Long =
BACKOFF_STEPS_MS[(attempt - 1).coerceIn(0, BACKOFF_STEPS_MS.lastIndex)]
/**
* Probes all configured foreign targets and the domestic one
* concurrently, directly on the underlying network: the sockets are
* created from [Network.socketFactory], so the tunnel is never used.
* Foreign probes that fail are retried once. All results are null
* when there is no usable network.
*/
suspend fun probeRound(context: Context, foreignTargets: List<ProbeEndpoint> = DEFAULT_FOREIGN_TARGETS): WhiteListRound
{
val network = UnderlyingNetwork.resolve(context)
?: return WhiteListRound(
foreignOks = List(foreignTargets.size) { null },
foreignRttMs = List(foreignTargets.size) { null },
domesticOk = null,
domesticRttMs = null,
)
return coroutineScope {
val foreign = foreignTargets.map { async { probeDirect(network, it) } }
val domestic = async { probeDirect(network, DOMESTIC_TARGET) }
var foreignResults = foreign.awaitAll()
if (foreignResults.any { !it.first })
{ /* a single lost packet must not look like blocking */
foreignResults = foreignResults.mapIndexed { index, result ->
async {
if (result.first)
{
result
}
else
{
delay(PROBE_RETRY_DELAY_MS)
probeDirect(network, foreignTargets[index])
}
}
}.awaitAll()
}
val domesticResult = domestic.await()
WhiteListRound(
foreignOks = foreignResults.map { it.first },
foreignRttMs = foreignResults.map { it.second },
domesticOk = domesticResult.first,
domesticRttMs = domesticResult.second,
)
}
}
private suspend fun probeDirect(network: Network, target: ProbeEndpoint): Pair<Boolean, Long?>
{ /* the target tag is result metadata only, unused for direct probes */
val result = ConnectivityProbe.tcpPing(ProbeTarget.GOOGLE, target.host, target.port, PROBE_TIMEOUT_MS, network)
return result.ok to result.rttMs
}
@Volatile
private var instance: WhiteListManager? = null
@JvmStatic
fun getInstance(context: Context): WhiteListManager =
instance ?: synchronized(this) {
instance ?: WhiteListManager(context.applicationContext).also { instance = it }
}
}
}
@@ -0,0 +1,438 @@
/*
* 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,
}
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,186 @@
/*
* 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,
)
}
}
@@ -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,116 @@
/*
* 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)
/** null: keep the profile's (or the server's) DNS */
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 servers replacing the profile's ones, or null. */
@JvmStatic
fun dnsServers(context: Context): List<String>? = get(context).servers()
}
@@ -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()}*"
}
@@ -0,0 +1,273 @@
/*
* 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.content.Context
import android.net.Network
import android.net.NetworkCapabilities
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.utils.IPRange
import java.net.InetAddress
/**
* Checks how a domain is routed for regular applications: which addresses
* the tunnel's DNS and the direct network's DNS return for it, and whether
* each address is tunneled or bypassed under the currently applied RU
* exclusion set. This is a diagnostic view - it reflects the configuration
* the VPN has installed, not a live traffic probe.
*/
object RouteCheck
{
/** Where an address goes under the currently applied exclusion set. */
enum class RouteKind { TUNNEL, DIRECT_RU, TUNNEL_DNS }
data class IpRoute(val ip: String, val kind: RouteKind, val resolvers: Set<String>)
data class Report(
val domain: String,
val vpnNetworkFound: Boolean,
val directNetworkFound: Boolean,
val vpnDnsIps: List<String>,
val directDnsIps: List<String>,
val routes: List<IpRoute>,
)
/**
* Resolves the domain through the tunnel's DNS (the VPN network's
* resolvers) and through the direct network's DNS, then classifies every
* unique address against the currently applied exclusions.
*/
suspend fun check(
context: Context,
domain: String,
exclusionsV4: List<IPRange>,
exclusionsV6: List<IPRange>,
dnsPunchBlocks: Set<String>,
): Report = withContext(Dispatchers.IO)
{
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager
?: return@withContext Report(
domain = domain,
vpnNetworkFound = false,
directNetworkFound = false,
vpnDnsIps = emptyList(),
directDnsIps = emptyList(),
routes = emptyList(),
)
val networks = cm.allNetworks.toList()
val vpn = networks.firstOrNull {
cm.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
}
val direct = networks.firstOrNull {
val caps = cm.getNetworkCapabilities(it) ?: return@firstOrNull false
!caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
(caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
}
val vpnDnsIps = resolve(vpn, domain)
val directDnsIps = resolve(direct, domain)
val routes = (vpnDnsIps + directDnsIps).distinct().map { ip ->
val address = InetAddress.getByName(ip)
IpRoute(
ip = ip,
kind = classify(address, exclusionsV4, exclusionsV6, dnsPunchBlocks),
resolvers = buildSet {
if (ip in vpnDnsIps) add("vpn")
if (ip in directDnsIps) add("direct")
},
)
}
Report(
domain = domain,
vpnNetworkFound = vpn != null,
directNetworkFound = direct != null,
vpnDnsIps = vpnDnsIps,
directDnsIps = directDnsIps,
routes = routes,
)
}
/** 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(
address: InetAddress,
exclusionsV4: List<IPRange>,
exclusionsV6: List<IPRange>,
dnsPunchBlocks: Set<String>,
): RouteKind
{
if (insidePunchBlock(dnsPunchBlocks, address))
{
return RouteKind.TUNNEL_DNS
}
val exclusions = if (address is java.net.Inet4Address) exclusionsV4 else exclusionsV6
if (containsAddress(exclusions, address))
{
return RouteKind.DIRECT_RU
}
return RouteKind.TUNNEL
}
/** Whether the address falls into one of the DNS punch blocks ("a.b.c.d/prefix"). */
private fun insidePunchBlock(blocks: Set<String>, address: InetAddress): Boolean
{
if (blocks.isEmpty())
{
return false
}
val value = address.address
for (block in blocks)
{
val slash = block.lastIndexOf('/')
if (slash <= 0)
{
continue
}
val prefix = block.substring(slash + 1).toIntOrNull() ?: continue
val base = runCatching { InetAddress.getByName(block.substring(0, slash)).address }.getOrNull() ?: continue
if (base.size != value.size || prefix > value.size * 8)
{
continue
}
var equal = true
for (i in value.indices)
{
val keep = (prefix - i * 8).coerceIn(0, 8)
val mask = if (keep == 8) 0xff else ((0xff shl (8 - keep)) and 0xff)
if ((base[i].toInt() and mask) != (value[i].toInt() and mask))
{
equal = false
break
}
}
if (equal)
{
return true
}
}
return false
}
/** Whether any of the ranges (inclusive bounds) contains the address. */
@JvmStatic
fun containsAddress(ranges: List<IPRange>, address: InetAddress): Boolean
{
val value = address.address
for (range in ranges)
{
val from = range.from.address
val to = range.to.address
if (from.size != value.size || to.size != value.size)
{
continue
}
if (lexCompare(from, value) <= 0 && lexCompare(value, to) <= 0)
{
return true
}
}
return false
}
/** Unsigned lexicographic byte comparison of two addresses. */
private fun lexCompare(a: ByteArray, b: ByteArray): Int
{
for (i in a.indices)
{
val diff = (a[i].toInt() and 0xff) - (b[i].toInt() and 0xff)
if (diff != 0)
{
return diff
}
}
return 0
}
private fun resolve(network: Network?, domain: String): List<String>
{
if (network == null)
{
return emptyList()
}
return try
{
network.getAllByName(domain).mapNotNull { it.hostAddress }.filter { it.isNotBlank() }
}
catch (_: Exception)
{
emptyList()
}
}
}
@@ -0,0 +1,972 @@
/*
* 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.Service
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
import org.strongswan.android.data.VpnProfileSqlDataSource
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.routing.RoutingPresets.matches
import java.util.UUID
/**
* A named set of applications that bypassed the VPN tunnel (pre-2.16 model).
* Only used to migrate old settings, see [RoutingMigration.migrateRuleSets].
*/
data class RuleSet(
val id: String,
val name: String,
val enabled: Boolean = true,
/** Concrete package names. */
val packages: Set<String> = emptySet(),
/** Package name prefixes, expanded against installed apps at connect time. */
val prefixes: Set<String> = emptySet(),
/** Substrings matched anywhere in the package name (case-insensitive). */
val keywords: Set<String> = emptySet(),
)
/** A network prefix that must always go through the tunnel (userspace mode). */
data class ForceTunnelPrefix(
val id: String,
/** Validated CIDR or single address, e.g. "130.49.213.176/32". */
val prefix: String,
val enabled: Boolean = true,
)
/**
* The one global answer to "what goes around the VPN" for apps:
* [effectiveExcludes] = ([directPackages] + apps matched by [enabledCategories]
* and [packagePatterns]) - [tunnelOverrides].
*/
data class RoutingConfig(
val enabled: Boolean = true,
/** Apps the user switched to "direct" one by one. */
val directPackages: Set<String> = emptySet(),
/** Apps switched back to "via VPN" although a category or pattern covers them. */
val tunnelOverrides: Set<String> = emptySet(),
/** Enabled built-in categories ([RoutingPresets] ids). */
val enabledCategories: Set<String> = emptySet(),
/** User package patterns, see [PackagePatterns]. */
val packagePatterns: Set<String> = emptySet(),
/** Patterns the user switched off without deleting them. */
val disabledPatterns: Set<String> = emptySet(),
val forceTunnelPrefixes: List<ForceTunnelPrefix> = emptyList(),
/** User addresses that always go direct (same shape as the force-tunnel entries). */
val directPrefixes: List<ForceTunnelPrefix> = emptyList(),
/** Per-site rules; the engine routes by address, so sites are resolved, see [SiteRule]. */
val siteRules: List<SiteRule> = emptyList(),
val version: Int = RoutingManager.CONFIG_VERSION,
)
/**
* "This site always goes direct / through the VPN". The userspace router
* decides per destination address, so the rule applies to the addresses the
* domain (and its www. name) resolves to; they are refreshed when the
* tunnel comes up and every [RoutingManager.SITE_REFRESH_MS] while it is up.
*/
data class SiteRule(
val id: String,
val domain: String,
val direct: Boolean,
val enabled: Boolean = true,
)
/** Why a package is (not) direct, for the per-app reason line. */
data class TemplateMatch(
/** Built-in category id (may be [RoutingPresets.NAMESPACE]) or null. */
val category: String? = null,
/** User pattern or null. */
val pattern: String? = null,
)
data class RoutingState(
val config: RoutingConfig = RoutingConfig(),
/** Number of profiles whose "only selected apps" list was converted at migration (one-time notice). */
val migratedOnlyProfiles: Int = 0,
)
/**
* Global per-app routing: one list of applications that must NOT use the VPN
* tunnel (they go direct). [CharonVpnService.BuilderCache] merges the
* effective package list into the TUN builder on every (re)connect.
*
* When the list changes while the VPN is connected the connection is
* restarted automatically (debounced), because per-app routing only applies
* when the TUN interface is created.
*
* The constructor migrates pre-2.16 settings (rule sets, per-profile app
* lists) once, so it has to run before the first VPN start - it does, from
* StrongSwanApplication.onCreate.
*/
class RoutingManager private constructor(private val context: Context)
{
private val handler = Handler(Looper.getMainLooper())
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
/* read from the charon and IO threads in computeExcludedPackages() */
@Volatile
private var config = RoutingConfig()
private var service: VpnStateService? = null
private var migratedOnlyProfiles = 0
/* package cache: expanded patterns against installed apps, keyed by config version */
@Volatile
private var configVersion = 0L
@Volatile
private var packageCache: Pair<Long, Set<String>>? = null
private val _state = MutableStateFlow(RoutingState())
val state: StateFlow<RoutingState> = _state
/* resolved addresses of the site rules, domain -> CIDRs; read by the push on any thread */
@Volatile
private var siteAddresses: Map<String, List<String>> = emptyMap()
private val _siteAddresses = MutableStateFlow<Map<String, List<String>>>(emptyMap())
/** Resolved addresses per site rule domain, for the Rules screen. */
val resolvedSites: StateFlow<Map<String, List<String>>> = _siteAddresses
private var siteRefreshJob: Job? = null
/* site rules are resolved again whenever the tunnel comes up */
private var lastState: VpnStateService.State? = null
private val stateListener = VpnStateService.VpnStateListener {
val state = service?.state
if (state == VpnStateService.State.CONNECTED && lastState != VpnStateService.State.CONNECTED && config.siteRules.isNotEmpty())
{
refreshSites()
}
lastState = state
}
private val connection = object : ServiceConnection
{
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
{
service = (binder as VpnStateService.LocalBinder).service
service?.registerListener(stateListener)
stateListener.stateChanged()
}
override fun onServiceDisconnected(name: ComponentName?)
{
service = null
}
}
private val reconnectRunnable = Runnable {
val svc = service ?: return@Runnable
if (svc.state == VpnStateService.State.CONNECTED)
{
Log.i(TAG, "Routing rules changed, reconnecting to apply them")
svc.reconnectPreservingLog()
}
}
/* new/removed apps change what categories and patterns match */
private val packagesReceiver = object : BroadcastReceiver()
{
override fun onReceive(context: Context?, intent: Intent?)
{
if (intent?.action == Intent.ACTION_PACKAGE_ADDED && !intent.getBooleanExtra(Intent.EXTRA_REPLACING, false))
{
intent.data?.schemeSpecificPart?.let { suggestDirect(it) }
}
invalidateCache()
/* a full PackageManager scan: never on the main thread */
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
}
}
init
{
config = loadConfig()
migrateProfiles()
migratedOnlyProfiles = prefs.getInt(KEY_NOTICE_ONLY, 0)
publish()
}
fun start()
{
context.bindService(Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE)
val filter = IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}
context.registerReceiver(packagesReceiver, filter)
/* resolve the site rules once so the Rules screen shows their addresses */
refreshSites()
}
fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
{
val previous = config
val updated = transform(previous)
if (updated == previous)
{ /* e.g. a chip tapped that is already in that state: no reconnect */
return
}
config = updated
saveConfig(updated)
invalidateCache()
publish()
if (updated.enabled)
{ /* warm the package cache (off the main thread) so the connect path stays fast */
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
}
if (updated.enabled || previous.enabled)
{ /* also when routing was just switched off: the excluded apps keep
* bypassing the tunnel until it is re-established */
scheduleReconnect()
}
}
/**
* A new app that a built-in RU category would cover, while that category
* is off and the app is not direct: ask in «Events» whether it should be.
*/
private fun suggestDirect(pkg: String)
{
val current = config
if (!current.enabled || pkg == context.packageName || pkg in current.directPackages || pkg in current.tunnelOverrides)
{
return
}
val category = RoutingPresets.CATEGORIES.firstOrNull { RoutingPresets.matchesCategory(it, pkg) } ?: return
if (category in current.enabledCategories || templateMatch(pkg, current) != null)
{
return
}
val pm = context.packageManager
val label = try
{
pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString()
}
catch (_: PackageManager.NameNotFoundException)
{
return
}
org.strongswan.android.logic.events.Events.newRuApp(context, pkg, label)
}
/* ------------------------- UI-facing helpers ------------------------- */
fun setGlobalEnabled(enabled: Boolean)
{
updateConfig { it.copy(enabled = enabled) }
}
/**
* The per-app switch: "direct" adds the app (or drops its tunnel
* override when a category/pattern already covers it); "via VPN" removes
* it, keeping a personal override when a category/pattern covers it.
*/
fun setAppDirect(pkg: String, direct: Boolean)
{
updateConfig { config ->
val covered = templateMatch(pkg, config) != null
if (direct)
{
config.copy(
enabled = true,
tunnelOverrides = config.tunnelOverrides - pkg,
directPackages = if (covered) config.directPackages else config.directPackages + pkg,
)
}
else
{
config.copy(
directPackages = config.directPackages - pkg,
tunnelOverrides = if (covered) config.tunnelOverrides + pkg else config.tunnelOverrides,
)
}
}
}
/** Switches a built-in category on or off. Switching on also clears personal "via VPN" exceptions inside it. */
fun setCategoryEnabled(category: String, enabled: Boolean)
{
updateConfig { config ->
if (enabled)
{
config.copy(
enabled = true,
enabledCategories = config.enabledCategories + category,
tunnelOverrides = config.tunnelOverrides.filterNot { RoutingPresets.matchesCategory(category, it) }.toSet(),
)
}
else
{
config.copy(enabledCategories = config.enabledCategories - category)
}
}
}
/** Quick action: every RF category plus the ru.* namespace ("all RF apps"). */
fun enableAllRfApps()
{
updateConfig { config ->
config.copy(
enabled = true,
enabledCategories = config.enabledCategories + RoutingPresets.ALL_CATEGORIES,
tunnelOverrides = config.tunnelOverrides.filterNot { pkg ->
RoutingPresets.ALL_CATEGORIES.any { RoutingPresets.matchesCategory(it, pkg) }
}.toSet(),
)
}
}
/** Adds a package pattern; false if the input is invalid. */
fun addPattern(input: String): Boolean
{
val pattern = PackagePatterns.normalize(input) ?: return false
updateConfig { it.copy(packagePatterns = it.packagePatterns + pattern) }
return true
}
fun removePattern(pattern: String)
{
updateConfig { it.copy(packagePatterns = it.packagePatterns - pattern, disabledPatterns = it.disabledPatterns - pattern) }
}
/** Switches a package pattern off (kept in the list) or back on. */
fun setPatternEnabled(pattern: String, enabled: Boolean)
{
updateConfig { it.copy(disabledPatterns = if (enabled) it.disabledPatterns - pattern else it.disabledPatterns + pattern) }
}
/**
* Imports the split-tunneling apps of a profile file (JSON import): the
* per-profile setting no longer exists, so the apps join the global
* direct list ("excluded-apps" as is, "apps" as every other app).
*/
fun importProfileApps(handling: SelectedAppsHandling, selected: Set<String>)
{
val added = RoutingMigration.profileAppsToDirect(
handling, selected,
if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) loadInstalledPackages() else emptySet(),
context.packageName)
if (added.isNotEmpty())
{
updateConfig { it.copy(enabled = true, directPackages = it.directPackages + added, tunnelOverrides = it.tunnelOverrides - added) }
}
}
/** The one-time "your 'only selected apps' profile was converted" notice has been shown. */
fun consumeMigrationNotice()
{
if (migratedOnlyProfiles != 0)
{
migratedOnlyProfiles = 0
prefs.edit().putInt(KEY_NOTICE_ONLY, 0).apply()
publish()
}
}
/* --------------------- force-tunnel prefixes --------------------- */
/**
* Adds a force-tunnel prefix (CIDR or bare address). Returns null on an
* invalid prefix, otherwise the new entry's id.
*/
fun addForcePrefix(cidr: String): String?
{
val normalized = normalizePrefix(cidr) ?: return null
updateConfigNoReconnect { config ->
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes + ForceTunnelPrefix(
id = UUID.randomUUID().toString(), prefix = normalized))
}
return config.forceTunnelPrefixes.lastOrNull { it.prefix == normalized }?.id
}
/** Replaces the prefix of an existing entry; false if the new one is invalid. */
fun updateForcePrefix(id: String, cidr: String): Boolean
{
val normalized = normalizePrefix(cidr) ?: return false
updateConfigNoReconnect { config ->
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.map {
if (it.id == id) it.copy(prefix = normalized) else it })
}
return true
}
fun deleteForcePrefix(id: String)
{
updateConfigNoReconnect { config ->
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.filter { it.id != id })
}
}
fun setForcePrefixEnabled(id: String, enabled: Boolean)
{
updateConfigNoReconnect { config ->
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.map {
if (it.id == id) it.copy(enabled = enabled) else it })
}
}
/**
* Enabled force-tunnel prefixes as validated CIDR strings (userspace
* routing feeds them into the native force set), including the
* addresses of "through the VPN" site rules.
*/
fun forceTunnelCidrs(): List<String> =
config.forceTunnelPrefixes.filter { it.enabled }.map { it.prefix } + siteCidrs(direct = false)
/** Enabled direct addresses plus the addresses of "direct" site rules. */
fun directCidrs(): List<String> =
config.directPrefixes.filter { it.enabled }.map { it.prefix } + siteCidrs(direct = true)
private fun siteCidrs(direct: Boolean): List<String>
{
val resolved = siteAddresses
return config.siteRules.filter { it.enabled && it.direct == direct }.flatMap { resolved[it.domain].orEmpty() }
}
/* ------------------------- direct addresses ------------------------- */
/** Adds a direct address/network; null if invalid. */
fun addDirectPrefix(cidr: String): String?
{
val normalized = normalizePrefix(cidr) ?: return null
val id = UUID.randomUUID().toString()
updateConfigNoReconnect { config ->
config.copy(directPrefixes = config.directPrefixes + ForceTunnelPrefix(id = id, prefix = normalized))
}
return id
}
fun deleteDirectPrefix(id: String)
{
updateConfigNoReconnect { config -> config.copy(directPrefixes = config.directPrefixes.filter { it.id != id }) }
}
fun setDirectPrefixEnabled(id: String, enabled: Boolean)
{
updateConfigNoReconnect { config ->
config.copy(directPrefixes = config.directPrefixes.map { if (it.id == id) it.copy(enabled = enabled) else it })
}
}
/* ------------------------- site rules ------------------------- */
/** Adds (or replaces) the rule for [input]'s domain; null if it is not a domain. */
fun addSiteRule(input: String, direct: Boolean): String?
{
val domain = normalizeDomain(input) ?: return null
val id = UUID.randomUUID().toString()
updateConfigNoReconnect { config ->
config.copy(siteRules = config.siteRules.filter { it.domain != domain } + SiteRule(id, domain, direct))
}
refreshSites()
return id
}
fun deleteSiteRule(id: String)
{
updateConfigNoReconnect { config -> config.copy(siteRules = config.siteRules.filter { it.id != id }) }
}
fun setSiteRuleEnabled(id: String, enabled: Boolean)
{
updateConfigNoReconnect { config ->
config.copy(siteRules = config.siteRules.map { if (it.id == id) it.copy(enabled = enabled) else it })
}
}
/**
* Resolves the site rules again and pushes the result to the engine.
* Called when a rule is added and when the tunnel comes up; repeats every
* [SITE_REFRESH_MS] while there are rules and the VPN is connected.
*/
fun refreshSites()
{
siteRefreshJob?.cancel()
if (config.siteRules.isEmpty())
{
siteAddresses = emptyMap()
_siteAddresses.value = emptyMap()
return
}
siteRefreshJob = scope.launch {
while (true)
{
val domains = config.siteRules.map { it.domain }.distinct()
val previous = siteAddresses
val resolved = withContext(Dispatchers.IO) { domains.associateWith { resolveSite(it) } }
/* keep the previous answer for a domain that failed to resolve this time */
val merged = resolved.mapValues { (domain, cidrs) -> cidrs.ifEmpty { previous[domain].orEmpty() } }
if (merged != previous)
{
siteAddresses = merged
_siteAddresses.value = merged
pushUserspaceRouting()
}
if (service?.state != VpnStateService.State.CONNECTED)
{
break
}
delay(SITE_REFRESH_MS)
/* nobody looks at the screen: resolving only wakes the radio and
* CPU, so wait for it to come on (re-checked every few minutes),
* but never let the answers get older than the cap */
var waited = SITE_REFRESH_MS
while (waited < SITE_REFRESH_SCREEN_OFF_MAX_MS && !isScreenOn())
{
delay(SITE_SCREEN_RECHECK_MS)
waited += SITE_SCREEN_RECHECK_MS
}
}
}
}
private fun isScreenOn(): Boolean =
(context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager)?.isInteractive ?: true
/**
* Like [updateConfig], but force prefixes apply on the fly through the
* atomic set swap of the userspace router - no reconnect needed.
*/
private fun updateConfigNoReconnect(transform: (RoutingConfig) -> RoutingConfig)
{
config = transform(config)
saveConfig(config)
publish()
pushUserspaceRouting()
}
private fun pushUserspaceRouting()
{
try
{
org.strongswan.android.logic.CharonVpnService.pushUserspaceRoutingData(context)
}
catch (e: Throwable)
{
Log.w(TAG, "Failed to push userspace routing data", e)
}
}
/* ------------------------- connect-time merge ------------------------- */
/**
* Packages that must bypass the tunnel right now. Empty when routing is
* disabled. Safe from any thread; cached per config version (the cache is
* computed on first use, e.g. on the charon thread while the TUN is built).
*/
fun computeExcludedPackages(): Set<String>
{
/* version before config: updateConfig() writes config first, so this
* order can at worst cache a newer config under an older version
* (recomputed next time), never an old config under the new one */
val version = configVersion
val current = config
if (!current.enabled)
{
return emptySet()
}
packageCache?.let { (cachedVersion, packages) ->
if (cachedVersion == version)
{
return packages
}
}
synchronized(this)
{
packageCache?.let { (cachedVersion, packages) ->
if (cachedVersion == version)
{
return packages
}
}
val packages = effectiveExcludes(current, loadInstalledPackages())
packageCache = version to packages
return packages
}
}
private fun invalidateCache()
{
configVersion++
packageCache = null
}
private fun loadInstalledPackages(): Set<String> = loadInternetPackages(context.packageManager)
private fun publish()
{
_state.value = RoutingState(config = config, migratedOnlyProfiles = migratedOnlyProfiles)
}
private fun scheduleReconnect()
{
handler.removeCallbacks(reconnectRunnable)
handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS)
}
/* ------------------------- persistence + migration ------------------------- */
private fun loadConfig(): RoutingConfig
{
val json = prefs.getString(KEY_CONFIG, null) ?: return RoutingConfig()
return try
{
val obj = JSONObject(json)
if (obj.optInt("version", 1) >= CONFIG_VERSION)
{
parseConfig(obj)
}
else
{
migrateLegacyConfig(json, obj)
}
}
catch (_: JSONException)
{
RoutingConfig()
}
}
private fun parseConfig(obj: JSONObject) = RoutingConfig(
enabled = obj.optBoolean("enabled", true),
directPackages = obj.optJSONArray("directPackages").toStringSet(),
tunnelOverrides = obj.optJSONArray("tunnelOverrides").toStringSet(),
enabledCategories = obj.optJSONArray("enabledCategories").toStringSet(),
packagePatterns = obj.optJSONArray("packagePatterns").toStringSet(),
disabledPatterns = obj.optJSONArray("disabledPatterns").toStringSet(),
forceTunnelPrefixes = parseForcePrefixes(obj),
directPrefixes = parseForcePrefixes(obj, "directPrefixes"),
siteRules = obj.optJSONArray("siteRules")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { o ->
SiteRule(
id = o.optString("id"),
domain = o.optString("domain"),
direct = o.optBoolean("direct"),
enabled = o.optBoolean("enabled", true),
).takeIf { it.domain.isNotBlank() }
}
}
} ?: emptyList(),
version = CONFIG_VERSION,
)
private fun parseForcePrefixes(obj: JSONObject, key: String = "forceTunnelPrefixes"): List<ForceTunnelPrefix> =
obj.optJSONArray(key)?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { o ->
ForceTunnelPrefix(
id = o.optString("id"),
prefix = o.optString("prefix"),
enabled = o.optBoolean("enabled", true),
)
}
}
} ?: emptyList()
/** Rule sets (version 1) -> direct apps + package patterns; the old JSON is kept in a backup key. */
private fun migrateLegacyConfig(json: String, obj: JSONObject): RoutingConfig
{
if (!prefs.contains(KEY_CONFIG_BACKUP))
{
prefs.edit().putString(KEY_CONFIG_BACKUP, json).commit()
}
val ruleSets = obj.optJSONArray("ruleSets")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { o ->
RuleSet(
id = o.optString("id"),
name = o.optString("name"),
enabled = o.optBoolean("enabled", true),
packages = o.optJSONArray("packages").toStringSet(),
prefixes = o.optJSONArray("prefixes").toStringSet(),
keywords = o.optJSONArray("keywords").toStringSet(),
)
}
}
} ?: emptyList()
val migrated = RoutingMigration.migrateRuleSets(ruleSets)
val result = RoutingConfig(
enabled = obj.optBoolean("enabled", true),
directPackages = migrated.directPackages,
enabledCategories = migrated.categories,
packagePatterns = migrated.patterns,
forceTunnelPrefixes = parseForcePrefixes(obj),
)
Log.i(TAG, "Migrated ${ruleSets.size} rule sets to ${result.directPackages.size} apps, " +
"${result.packagePatterns.size} patterns, ${result.enabledCategories.size} categories")
saveConfig(result, commit = true)
return result
}
/**
* Per-profile app handling is gone: EXCLUDE lists become direct apps, an
* ONLY list becomes "every other installed internet app" (snapshot), and
* the profile is reset to DISABLE. Runs once (flag in prefs); a failure
* leaves the flag unset so the next start retries.
*/
private fun migrateProfiles()
{
if (prefs.getBoolean(KEY_PROFILES_MIGRATED, false))
{
return
}
try
{
val source = VpnProfileSqlDataSource()
source.open()
val direct = HashSet<String>()
var onlyConverted = 0
try
{
val installed by lazy { loadInstalledPackages() }
for (profile in source.allVpnProfiles)
{
val handling = profile.selectedAppsHandling
if (handling == SelectedAppsHandling.SELECTED_APPS_DISABLE)
{
continue
}
val added = RoutingMigration.profileAppsToDirect(
handling, profile.selectedAppsSet, if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) installed else emptySet(),
context.packageName)
direct.addAll(added)
if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY && added.isNotEmpty())
{
onlyConverted++
}
profile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE)
source.updateVpnProfile(profile)
}
}
finally
{
source.close()
}
if (direct.isNotEmpty())
{ /* the old per-profile excludes applied regardless of the global switch */
config = config.copy(enabled = true, directPackages = config.directPackages + direct,
tunnelOverrides = config.tunnelOverrides - direct)
saveConfig(config, commit = true)
}
prefs.edit()
.putBoolean(KEY_PROFILES_MIGRATED, true)
.putInt(KEY_NOTICE_ONLY, onlyConverted)
.commit()
Log.i(TAG, "Migrated profile apps: ${direct.size} direct apps, $onlyConverted 'only' profiles converted")
}
catch (e: Throwable)
{
Log.w(TAG, "Profile app migration failed, will retry on next start", e)
}
}
private fun saveConfig(config: RoutingConfig, commit: Boolean = false)
{
val obj = JSONObject()
obj.put("version", CONFIG_VERSION)
obj.put("enabled", config.enabled)
obj.put("directPackages", JSONArray(config.directPackages.sorted()))
obj.put("tunnelOverrides", JSONArray(config.tunnelOverrides.sorted()))
obj.put("enabledCategories", JSONArray(config.enabledCategories.sorted()))
obj.put("packagePatterns", JSONArray(config.packagePatterns.sorted()))
obj.put("disabledPatterns", JSONArray(config.disabledPatterns.sorted()))
val forcePrefixes = JSONArray()
for (entry in config.forceTunnelPrefixes)
{
val o = JSONObject()
o.put("id", entry.id)
o.put("prefix", entry.prefix)
o.put("enabled", entry.enabled)
forcePrefixes.put(o)
}
obj.put("forceTunnelPrefixes", forcePrefixes)
val directPrefixes = JSONArray()
for (entry in config.directPrefixes)
{
directPrefixes.put(JSONObject().put("id", entry.id).put("prefix", entry.prefix).put("enabled", entry.enabled))
}
obj.put("directPrefixes", directPrefixes)
val sites = JSONArray()
for (rule in config.siteRules)
{
sites.put(JSONObject().put("id", rule.id).put("domain", rule.domain).put("direct", rule.direct).put("enabled", rule.enabled))
}
obj.put("siteRules", sites)
val editor = prefs.edit().putString(KEY_CONFIG, obj.toString())
if (commit) editor.commit() else editor.apply()
}
private fun JSONArray?.toStringSet(): Set<String> =
this?.let { arr -> (0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotBlank() } }.toSet() } ?: emptySet()
companion object
{
private const val TAG = "RoutingManager"
private const val PREFS_NAME = "one.shx.strongswan.ext.ROUTING"
private const val KEY_CONFIG = "config"
private const val KEY_CONFIG_BACKUP = "config_v1_backup"
private const val KEY_PROFILES_MIGRATED = "profiles_migrated_v2"
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
private const val RECONNECT_DEBOUNCE_MS = 2000L
const val SITE_REFRESH_MS = 30L * 60 * 1000
/* with the screen off the refresh waits for it, up to this age */
private const val SITE_REFRESH_SCREEN_OFF_MAX_MS = 3L * 60 * 60 * 1000
private const val SITE_SCREEN_RECHECK_MS = 5L * 60 * 1000
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
const val CONFIG_VERSION = 2
/** Validates and canonicalizes a CIDR/address; null if invalid. */
fun normalizePrefix(cidr: String): String? = try
{
org.strongswan.android.utils.IPRange(cidr.trim()).toString()
}
catch (_: Exception)
{
null
}
/**
* "https://www.Example.com/path" -> "example.com"; null if the input
* is not a plausible host name (an address belongs in an address rule).
*/
fun normalizeDomain(input: String): String?
{
var host = input.trim().lowercase()
host = host.substringAfter("://").substringBefore('/').substringBefore('?').substringBefore(':')
host = host.removePrefix("*.").removePrefix("www.").trimEnd('.')
val label = Regex("[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?")
val labels = host.split('.')
if (labels.size < 2 || labels.any { !label.matches(it) } || labels.last().all { it.isDigit() })
{
return null
}
return host
}
/** The addresses [domain] and www.[domain] resolve to, as host CIDRs. */
private fun resolveSite(domain: String): List<String> =
listOf(domain, "www.$domain").flatMap { name ->
try
{
java.net.InetAddress.getAllByName(name).toList()
}
catch (_: Exception)
{
emptyList()
}
}.map { it.hostAddress + if (it is java.net.Inet6Address) "/128" else "/32" }.distinct()
/** All installed apps that hold the INTERNET permission. */
fun loadInternetPackages(pm: PackageManager): Set<String> = try
{
pm.getInstalledApplications(0)
.asSequence()
.filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED }
.map { it.packageName }
.toSet()
}
catch (e: Exception)
{
Log.w(TAG, "Failed to list installed packages", e)
emptySet()
}
/** Built-in categories that are enabled in [config] and match [pkg], visible ones first. */
private fun matchingCategory(pkg: String, config: RoutingConfig): String?
{
val visible = RoutingPresets.CATEGORIES.firstOrNull {
it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
}
if (visible != null)
{
return visible
}
return RoutingPresets.NAMESPACE.takeIf {
it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
}
}
/**
* Why a category or pattern covers [pkg] (ignoring personal overrides
* and the explicit list), or null if none does.
*/
fun templateMatch(pkg: String, config: RoutingConfig): TemplateMatch?
{
val pattern = (config.packagePatterns - config.disabledPatterns).firstOrNull { PackagePatterns.matches(it, pkg) }
val category = matchingCategory(pkg, config)
return if (category == null && pattern == null) null else TemplateMatch(category, pattern)
}
/**
* All packages that bypass the tunnel: explicit direct apps plus the
* installed apps matched by enabled categories and package patterns,
* minus personal "via VPN" overrides. Explicit apps that are not
* installed are dropped (unless the installed list is unavailable).
*/
fun effectiveExcludes(config: RoutingConfig, installed: Set<String>): Set<String>
{
val result = HashSet<String>()
result.addAll(if (installed.isEmpty()) config.directPackages else config.directPackages.filter { it in installed })
result.addAll(PackagePatterns.matchAll(config.packagePatterns - config.disabledPatterns, installed))
for (category in config.enabledCategories)
{
val presets = RoutingPresets.presetsOf(category)
for (pkg in installed)
{
if (presets.any { it.matches(pkg) })
{
result.add(pkg)
}
}
}
result.removeAll(config.tunnelOverrides)
return result
}
@Volatile
private var instance: RoutingManager? = null
@JvmStatic
fun getInstance(context: Context): RoutingManager =
instance ?: synchronized(this) {
instance ?: RoutingManager(context.applicationContext).also { instance = it }
}
}
}
@@ -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()
}
}
@@ -0,0 +1,138 @@
/*
* 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
/**
* 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
* "yandex" also catches e.g. com.yandex.browser.
*
* Deliberately short or ambiguous strings (yota, cian, mts, ivi, mkb) are
* used as prefixes only, to avoid false positives.
*/
object RoutingPresets
{
/* 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.*", NAMESPACE, prefixes = listOf("ru.")),
/* ecosystems */
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("Сбер", 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", 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("МТС", 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("РЖД", 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ГИС", 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")),
)
}
@@ -0,0 +1,794 @@
/*
* 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.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.utils.Constants
import org.strongswan.android.utils.IPRange
import org.strongswan.android.utils.IPRangeSet
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.Proxy
import java.net.URL
import java.security.MessageDigest
/** Refresh status of the RU geoip list. */
enum class RuIpListStatus
{
IDLE, UPDATING, UP_TO_DATE, ERROR,
}
data class RuIpListState(
val enabled: Boolean = false,
val status: RuIpListStatus = RuIpListStatus.IDLE,
val lastUpdatedMs: Long = 0L,
val ipv4Ranges: Int = 0,
val ipv6Ranges: Int = 0,
val invalidLines: Int = 0,
val mode: String = Constants.RUSPLIT_MODE_SYSTEM,
)
/**
* RU geoip list (beta): downloads the list of Russian IP networks from
* MetaCubeX/meta-rules-dat so [CharonVpnService.BuilderCache] can keep those
* ranges out of the tunnel routes - traffic to RU addresses bypasses the
* VPN while everything else goes through the tunnel.
*
* The list is large (thousands of CIDRs), so it is merged into a sorted,
* non-overlapping form once, and then rounded up ([fitExclusionsToComplement])
* until the complement - the routes left for the tunnel - is small enough
* to install as regular routes on every Android version. excludeRoute() is
* deliberately never used: on some devices (One UI was observed) any
* excluded route breaks the resolver's DNS-over-TLS transport for every
* server, which kills strict private DNS. With plain routes the RU space
* is simply absent from the VPN table, and bypassed traffic falls through
* to the physical network. When the list or the toggle changes while
* connected, the connection is restarted (debounced) because routes only
* apply when the TUN interface is created.
*/
class RuIpListManager private constructor(private val context: Context)
{
private val handler = Handler(Looper.getMainLooper())
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var service: VpnStateService? = null
/** Sorted, non-overlapping RU ranges; immutable snapshots swapped on update. */
@Volatile
private var excludedV4: List<IPRange> = emptyList()
@Volatile
private var excludedV6: List<IPRange> = emptyList()
@Volatile
private var updating = false
private val _state = MutableStateFlow(RuIpListState())
val state: StateFlow<RuIpListState> = _state
private val connection = object : ServiceConnection
{
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
{
service = (binder as VpnStateService.LocalBinder).service
}
override fun onServiceDisconnected(name: ComponentName?)
{
service = null
}
}
private val reconnectRunnable = Runnable {
val svc = service ?: return@Runnable
if (svc.state == VpnStateService.State.CONNECTED)
{
if (CharonVpnService.isUserspaceRoutingEnabled(context))
{ /* mode B: the classification set is swapped atomically in
* native code, no reestablish needed */
CharonVpnService.pushUserspaceRoutingData(context)
Log.i(TAG, "RU IP list changed, userspace routing set hot-swapped")
}
else
{
Log.i(TAG, "RU IP list changed, reconnecting to apply it")
svc.reconnectPreservingLog()
}
}
}
fun start()
{
context.bindService(Intent(context, VpnStateService::class.java), connection, android.app.Service.BIND_AUTO_CREATE)
publish(
enabled = prefs.getBoolean(Constants.PREF_RUSPLIT_GEOIP_ENABLED, false),
mode = prefs.getString(Constants.PREF_RUSPLIT_MODE, Constants.RUSPLIT_MODE_SYSTEM)
?: Constants.RUSPLIT_MODE_SYSTEM,
)
scope.launch {
loadFromDisk()
maybeAutoCheck()
}
}
fun setEnabled(enabled: Boolean)
{
prefs.edit().putBoolean(Constants.PREF_RUSPLIT_GEOIP_ENABLED, enabled).apply()
publish(enabled = enabled)
if (isUserspaceMode())
{ /* the OS routes do not depend on the toggle in this mode, the
* classification set is hot-swapped in native code instead */
CharonVpnService.pushUserspaceRoutingData(context)
}
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()
}
}
/** Sorted non-overlapping RU IPv4 ranges; empty when disabled or not loaded. */
fun getExcludedV4(): List<IPRange> = if (_state.value.enabled) excludedV4 else emptyList()
/** Sorted non-overlapping RU IPv6 ranges; empty when disabled or not loaded. */
fun getExcludedV6(): List<IPRange> = if (_state.value.enabled) excludedV6 else emptyList()
/**
* Downloads and applies a fresh list. Called from the UI (manual refresh,
* regardless of the toggle) and internally when the automatic check is due.
* @return true when a new list was downloaded and applied
*/
suspend fun updateNow(): Boolean
{
if (updating)
{
return false
}
updating = true
publish(status = RuIpListStatus.UPDATING)
return try
{
val (text, sha256) = withContext(Dispatchers.IO) { fetchList() }
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")
}
withContext(Dispatchers.IO) { saveToDisk(text) }
excludedV4 = parsed.v4
excludedV6 = parsed.v6
val now = System.currentTimeMillis()
prefs.edit()
.putLong(PREF_LAST_UPDATE, now)
.putString(PREF_SHA256, sha256)
.remove(PREF_LAST_FAIL)
.apply()
publish(
status = RuIpListStatus.UP_TO_DATE,
lastUpdatedMs = now,
ipv4Ranges = parsed.v4.size,
ipv6Ranges = parsed.v6.size,
invalidLines = parsed.invalid,
)
Log.i(TAG, "RU geoip list applied: ${parsed.v4.size} IPv4, ${parsed.v6.size} IPv6 ranges")
if (_state.value.enabled)
{
scheduleReconnect()
}
true
}
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
}
finally
{
updating = false
}
}
/* ------------------------------ internals ------------------------------ */
private suspend fun loadFromDisk()
{
val file = listFile()
if (file.isFile)
{
try
{
/* 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(
ipv4Ranges = parsed.v4.size,
ipv6Ranges = parsed.v6.size,
invalidLines = parsed.invalid,
)
}
catch (e: Exception)
{
Log.w(TAG, "Failed to load the saved RU geoip list", e)
}
}
publish(
status = if (_state.value.ipv4Ranges > 0 || _state.value.ipv6Ranges > 0) RuIpListStatus.UP_TO_DATE else RuIpListStatus.IDLE,
lastUpdatedMs = prefs.getLong(PREF_LAST_UPDATE, 0L),
)
}
private fun maybeAutoCheck()
{
if (!prefs.getBoolean(Constants.PREF_RUSPLIT_GEOIP_ENABLED, false))
{
return
}
if (isAutoCheckDue(System.currentTimeMillis(), prefs.getLong(PREF_LAST_UPDATE, 0L),
prefs.getLong(PREF_LAST_FAIL, 0L)))
{
scope.launch { updateNow() }
}
}
/** Fetches the list from the primary source, falling back to the mirror. */
private fun fetchList(): Pair<String, String>
{
var lastError: Exception? = null
for (url in listOf(Constants.RUSPLIT_GEOIP_URL, Constants.RUSPLIT_GEOIP_MIRROR_URL))
{
try
{
return fetch(url)
}
catch (e: Exception)
{
Log.w(TAG, "Fetch failed: $url", e)
lastError = e
}
}
throw lastError ?: IOException("no source available")
}
private fun fetch(url: String): Pair<String, String>
{
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")
try
{
val code = conn.responseCode
if (code != HttpURLConnection.HTTP_OK)
{
throw IOException("HTTP $code from $url")
}
val bytes = conn.inputStream.use { it.readBytes() }
val text = bytes.toString(Charsets.UTF_8)
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
val sha256 = digest.joinToString("") { "%02x".format(it) }
if (!text.contains('/'))
{ /* guard against an HTML error page served with status 200 */
throw IOException("not a CIDR list: $url")
}
return text to sha256
}
finally
{
conn.disconnect()
}
}
private fun saveToDisk(text: String)
{
val dir = File(context.filesDir, DIR)
if (!dir.exists() && !dir.mkdirs())
{
throw IOException("cannot create ${dir.name}")
}
val target = listFile()
val tmp = File(dir, "$FILE_NAME.tmp")
try
{
tmp.writeText(text, Charsets.UTF_8)
if (!tmp.renameTo(target))
{
target.delete()
if (!tmp.renameTo(target))
{
throw IOException("cannot replace ${target.name}")
}
}
}
finally
{
tmp.delete()
}
}
private fun listFile(): File = File(File(context.filesDir, DIR), FILE_NAME)
private fun scheduleReconnect()
{
handler.removeCallbacks(reconnectRunnable)
handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS)
}
/** Mode changes always alter the OS route scheme, so they force a reconnect. */
private val forceReconnectRunnable = Runnable {
val svc = service ?: return@Runnable
if (svc.state == VpnStateService.State.CONNECTED)
{
Log.i(TAG, "routing mode changed, reconnecting to apply it")
svc.reconnectPreservingLog()
}
}
private fun isUserspaceMode(): Boolean =
Constants.RUSPLIT_MODE_USERSPACE == _state.value.mode
/**
* Switches how the RU split is routed when the toggle is on:
* [Constants.RUSPLIT_MODE_SYSTEM] installs the coarsened complement as
* OS routes, [Constants.RUSPLIT_MODE_USERSPACE] classifies per packet
* in native code. Requires a reconnect because the OS routes change.
*/
fun setMode(mode: String)
{
if (_state.value.mode == mode)
{
return
}
prefs.edit().putString(Constants.PREF_RUSPLIT_MODE, mode).apply()
publish(mode = mode)
if (_state.value.enabled)
{
handler.removeCallbacks(reconnectRunnable)
handler.removeCallbacks(forceReconnectRunnable)
handler.postDelayed(forceReconnectRunnable, RECONNECT_DEBOUNCE_MS)
}
}
private fun publish(
enabled: Boolean = _state.value.enabled,
status: RuIpListStatus = _state.value.status,
lastUpdatedMs: Long = _state.value.lastUpdatedMs,
ipv4Ranges: Int = _state.value.ipv4Ranges,
ipv6Ranges: Int = _state.value.ipv6Ranges,
invalidLines: Int = _state.value.invalidLines,
mode: String = _state.value.mode,
)
{
_state.value = RuIpListState(enabled, status, lastUpdatedMs, ipv4Ranges, ipv6Ranges, invalidLines, mode)
}
companion object
{
private const val TAG = "RuIpListManager"
private const val DIR = "rusplit"
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
data class Parsed(val v4: List<IPRange>, val v6: List<IPRange>, val invalid: Int)
/**
* Parses a CIDR list tolerantly: blank lines and #-comments are
* ignored, a leading '+' include marker is stripped, unparsable and
* '-'-negated lines are counted but never fail the whole list.
*/
fun parse(text: String): Parsed
{
val v4 = ArrayList<IPRange>()
val v6 = ArrayList<IPRange>()
var invalid = 0
for (raw in text.lineSequence())
{
var line = raw
val comment = line.indexOf('#')
if (comment >= 0)
{
line = line.substring(0, comment)
}
line = line.trim()
if (line.isEmpty())
{
continue
}
if (line[0] == '+')
{
line = line.substring(1).trim()
if (line.isEmpty())
{
continue
}
}
if (line[0] == '-')
{ /* negations are not part of a plain geoip list */
invalid++
continue
}
try
{
val range = IPRange(line)
when (range.from)
{
is Inet4Address -> v4.add(range)
is Inet6Address -> v6.add(range)
else -> invalid++
}
}
catch (e: Exception)
{
invalid++
}
}
return Parsed(mergeRanges(v4), mergeRanges(v6), invalid)
}
/** Sorts by address and merges overlapping/adjacent ranges linearly. */
fun mergeRanges(ranges: List<IPRange>): List<IPRange>
{
if (ranges.isEmpty())
{
return emptyList()
}
val sorted = ranges.toMutableList()
sorted.sort()
val result = ArrayList<IPRange>(sorted.size)
for (range in sorted)
{
val last = result.lastOrNull()
val merged = last?.merge(range)
if (merged != null)
{
result[result.size - 1] = merged
}
else
{
result.add(range)
}
}
return result
}
/**
* Routes to install for the TUN builder: the given ranges minus the
* (sorted, non-overlapping) exclusions, split into proper subnets,
* or null if that would exceed [maxRoutes] routes. Applied directly
* instead of via IPRangeSet.remove(), which is quadratic in the
* number of excluded ranges.
*/
@JvmStatic
fun subtractAndSplit(ranges: IPRangeSet, exclusions: List<IPRange>, maxRoutes: Int): List<IPRange>?
{
if (exclusions.isEmpty())
{
return ranges.subnets().toList()
}
val pieces = ArrayList<IPRange>()
for (range in ranges)
{
pieces.addAll(subtract(range, exclusions))
}
val result = ArrayList<IPRange>()
for (piece in pieces)
{
result.addAll(piece.toSubnets())
if (result.size > maxRoutes)
{
return null
}
}
return result
}
/** The common part of two ranges, or null if they are disjoint. */
@JvmStatic
fun intersect(a: IPRange, b: IPRange): IPRange?
{
val from = if (compare(a.from, b.from) > 0) a.from else b.from
val to = if (compare(a.to, b.to) < 0) a.to else b.to
return if (compare(from, to) <= 0) IPRange(from, to) else null
}
/**
* The range rounded up to whole prefixes no longer than [maxPrefix].
* The result always contains the original range.
*/
@JvmStatic
fun roundUp(range: IPRange, maxPrefix: Int): IPRange =
IPRange(alignDown(range.from, maxPrefix), alignUp(range.to, maxPrefix))
/** Zeroes all host bits beyond [maxPrefix]. */
private fun alignDown(address: InetAddress, maxPrefix: Int): InetAddress
{
val bytes = address.address
for (i in bytes.indices)
{
val keep = (maxPrefix - i * 8).coerceIn(0, 8)
val mask = if (keep == 8) 0xff else (0xff shl (8 - keep)) and 0xff
bytes[i] = (bytes[i].toInt() and mask).toByte()
}
return InetAddress.getByAddress(bytes)
}
/** Sets all host bits beyond [maxPrefix], i.e. the last address of the containing prefix. */
private fun alignUp(address: InetAddress, maxPrefix: Int): InetAddress
{
val bytes = address.address
for (i in bytes.indices)
{
val keep = (maxPrefix - i * 8).coerceIn(0, 8)
val mask = if (keep == 8) 0xff else (0xff shl (8 - keep)) and 0xff
bytes[i] = ((bytes[i].toInt() and mask) or (mask.inv() and 0xff)).toByte()
}
return InetAddress.getByAddress(bytes)
}
/**
* Exclusion lists adjusted so the complement route set - what remains
* of the tunnel routes after subtracting the exclusions from both
* address families - is small enough for the system to install as
* regular routes: ranges are rounded up to whole prefixes of
* decreasing length (down to [MIN_FIT_PREFIX4]/[MIN_FIT_PREFIX6]) and
* re-merged, widening the bypassed space a little. Installing the
* complement instead of using excludeRoute() is deliberate: on some
* devices (One UI was observed) ANY excluded route breaks the
* resolver's DNS-over-TLS transport, while plain route sets of this
* size are universally supported. Returns null when even the
* coarsest acceptable rounding does not fit; the caller then tunnels
* all traffic instead of risking a failed TUN setup.
*/
@JvmStatic
fun fitExclusionsToComplement(
ranges4: IPRangeSet?,
ranges6: IPRangeSet?,
exact4: List<IPRange>,
exact6: List<IPRange>,
): Pair<List<IPRange>, List<IPRange>>?
{
if (exact4.isEmpty() && exact6.isEmpty())
{
return Pair(exact4, exact6)
}
if (complementBytes(ranges4, ranges6, exact4, exact6) <= ROUTE_BYTES_BUDGET)
{
return Pair(exact4, exact6)
}
var len4 = 32
var len6 = 128
var cur4 = exact4
var cur6 = exact6
while (complementBytes(ranges4, ranges6, cur4, cur6) > ROUTE_BYTES_BUDGET)
{
if (len4 <= MIN_FIT_PREFIX4 && len6 <= MIN_FIT_PREFIX6)
{
return null
}
/* always round the original list, and coarsen whichever family
* currently contributes more to the transaction */
if (len4 > MIN_FIT_PREFIX4 && (len6 <= MIN_FIT_PREFIX6 || complementBytes4(ranges4, cur4) >= complementBytes6(ranges6, cur6)))
{
len4--
cur4 = mergeRanges(exact4.map { roundUp(it, len4) })
}
else
{
len6--
cur6 = mergeRanges(exact6.map { roundUp(it, len6) })
}
}
return Pair(cur4, cur6)
}
/** Worst-case marshalled size of a single route entry in the VpnConfig parcel. */
private const val V4_ROUTE_BYTES = 64L
private const val V6_ROUTE_BYTES = 80L
/**
* Keep the estimated size of the complement routes below this, so the
* system reliably receives them (binder transactions are limited to
* 1 MB; the rest of the config needs room, too). With the real RU
* list this lands at roughly 3-4 thousand routes (about 230 KB):
* IPv4 rounded to /16, IPv6 to /22.
*/
private const val ROUTE_BYTES_BUDGET = 350_000L
/** Coarser rounding than this bypasses too much address space. */
private const val MIN_FIT_PREFIX4 = 16
private const val MIN_FIT_PREFIX6 = 22
/** Estimated marshalled size of the complement routes of one family. */
private fun complementBytes4(ranges: IPRangeSet?, exclusions: List<IPRange>): Long =
complementSubnets(ranges, exclusions) * V4_ROUTE_BYTES
private fun complementBytes6(ranges: IPRangeSet?, exclusions: List<IPRange>): Long =
complementSubnets(ranges, exclusions) * V6_ROUTE_BYTES
private fun complementBytes(
ranges4: IPRangeSet?,
ranges6: IPRangeSet?,
exclusions4: List<IPRange>,
exclusions6: List<IPRange>,
): Long = complementBytes4(ranges4, exclusions4) + complementBytes6(ranges6, exclusions6)
/** Number of proper subnets the complement of the exclusions consists of. */
private fun complementSubnets(ranges: IPRangeSet?, exclusions: List<IPRange>): Int
{
if (ranges == null)
{
return 0
}
var count = 0
for (range in ranges)
{
for (piece in subtract(range, exclusions))
{
count += piece.toSubnets().size
}
}
return count
}
/** The part of `range` not covered by the sorted, non-overlapping `exclusions`. */
fun subtract(range: IPRange, exclusions: List<IPRange>): List<IPRange>
{
val result = ArrayList<IPRange>()
var cursor = range.from
val end = range.to
for (exclusion in exclusions)
{
if (compare(cursor, exclusion.to) > 0)
{ /* entirely before the cursor */
continue
}
if (compare(exclusion.from, end) > 0)
{ /* sorted, so nothing further can overlap */
break
}
val from = if (compare(exclusion.from, cursor) > 0) exclusion.from else cursor
if (compare(from, cursor) > 0)
{ /* uncovered gap in front of the exclusion */
result.add(IPRange(cursor, dec(from)))
}
if (compare(exclusion.to, end) >= 0)
{ /* the rest of the range is covered */
return result
}
cursor = inc(exclusion.to)
}
if (compare(cursor, end) <= 0)
{
result.add(IPRange(cursor, end))
}
return result
}
/** Unsigned lexicographic byte comparison of two addresses. */
private fun compare(a: InetAddress, b: InetAddress): Int
{
val x = a.address
val y = b.address
if (x.size != y.size)
{
return if (x.size < y.size) -1 else 1
}
for (i in x.indices)
{
val diff = (x[i].toInt() and 0xff) - (y[i].toInt() and 0xff)
if (diff != 0)
{
return diff
}
}
return 0
}
private fun dec(address: InetAddress): InetAddress
{
val bytes = address.address
for (i in bytes.indices.reversed())
{
bytes[i] = (bytes[i].toInt() - 1).toByte()
if (bytes[i] != 0xff.toByte())
{
break
}
}
return InetAddress.getByAddress(bytes)
}
private fun inc(address: InetAddress): InetAddress
{
val bytes = address.address
for (i in bytes.indices.reversed())
{
bytes[i] = (bytes[i].toInt() + 1).toByte()
if (bytes[i].toInt() != 0)
{
break
}
}
return InetAddress.getByAddress(bytes)
}
@Volatile
private var instance: RuIpListManager? = null
@JvmStatic
fun getInstance(context: Context): RuIpListManager =
instance ?: synchronized(this) {
instance ?: RuIpListManager(context.applicationContext).also { instance = it }
}
}
}
@@ -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,212 @@
/*
* 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.shortcuts
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.preference.PreferenceManager
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.ui.VpnProfileControlActivity
import org.strongswan.android.utils.Constants
import org.strongswan.android.utils.DefaultVpnProfile
/**
* Publishes dynamic launcher shortcuts that automation tools (e.g. Samsung
* Modes and Routines) can execute as routine actions: connecting the default
* or a specific profile, disconnecting and toggling the VPN.
*
* All shortcuts target VpnProfileControlActivity with EXTRA_SILENT set, so
* routines run without confirmation dialogs (one-time consent dialogs aside).
*/
class VpnShortcutManager private constructor(private val context: Context)
{
companion object
{
/* hardcoded: the runtime class name is obfuscated by R8 in release builds */
private const val TAG = "VpnShortcutManager"
/** slots always occupied by the connect/disconnect/toggle shortcuts */
private const val RESERVED_SLOTS = 3
private var mInstance: VpnShortcutManager? = null
@JvmStatic
fun getInstance(context: Context): VpnShortcutManager
{
return mInstance ?: VpnShortcutManager(context.applicationContext).also { mInstance = it }
}
}
private val mPrefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
private val mProfilesChangedReceiver = object : BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent)
{
refresh()
}
}
fun isEnabled(): Boolean
{
return mPrefs.getBoolean(Constants.PREF_PUBLISH_SHORTCUTS, true)
}
/**
* Enable or disable the automation shortcuts and update the published set
*/
fun setEnabled(enabled: Boolean)
{
mPrefs.edit().putBoolean(Constants.PREF_PUBLISH_SHORTCUTS, enabled).apply()
refresh()
}
/**
* (Re)publish the automation shortcuts, or remove them if the feature is disabled
*/
fun refresh()
{
StrongSwanApplication.getInstance().getExecutor().execute { updateShortcuts() }
}
/**
* Start listening for profile changes; called from StrongSwanApplication
*/
fun start()
{
LocalBroadcastManager.getInstance(context).registerReceiver(
mProfilesChangedReceiver, IntentFilter(Constants.VPN_PROFILES_CHANGED))
refresh()
}
private fun updateShortcuts()
{
try
{
if (!isEnabled())
{
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
return
}
publishShortcuts()
}
catch (e: Exception)
{
Log.e(TAG, "Failed to update automation shortcuts", e)
}
}
private fun publishShortcuts()
{
val max = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
if (max <= 0)
{ /* dynamic shortcuts are not supported on this API level */
return
}
val shortcuts = ArrayList<ShortcutInfoCompat>(max)
shortcuts.add(buildFunctionShortcut("disconnect_vpn", R.string.shortcut_disconnect_vpn,
VpnProfileControlActivity.DISCONNECT, null))
if (shortcuts.size < max)
{
shortcuts.add(buildFunctionShortcut("toggle_vpn", R.string.shortcut_toggle_vpn,
VpnProfileControlActivity.TOGGLE_VPN, null))
}
if (shortcuts.size < max)
{
shortcuts.add(buildFunctionShortcut("connect_vpn", R.string.shortcut_connect_vpn,
VpnProfileControlActivity.START_PROFILE, null))
}
val profileSlots = (max - RESERVED_SLOTS).coerceAtLeast(0)
if (profileSlots > 0)
{
val profiles = loadProfiles()
val defaultUuid = DefaultVpnProfile.getDefaultProfileUuid(context)
profiles.sortWith(compareBy(
{ it.getUUID().toString() != defaultUuid },
{ it.name?.lowercase() ?: it.getUUID().toString() }))
for (profile in profiles.take(profileSlots))
{
if (shortcuts.size >= max)
{
break
}
shortcuts.add(buildProfileShortcut(profile))
}
}
ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)
}
private fun buildFunctionShortcut(id: String, labelRes: Int, action: String, profileUuid: String?): ShortcutInfoCompat
{
val label = context.getString(labelRes)
return buildShortcut(id, label, label, action, profileUuid)
}
private fun buildProfileShortcut(profile: VpnProfile): ShortcutInfoCompat
{
val name = profile.name?.takeIf { it.isNotBlank() }
?: profile.gateway?.takeIf { it.isNotBlank() }
?: profile.getUUID().toString()
val longLabel = context.getString(R.string.shortcut_connect_profile_fmt, name)
return buildShortcut("connect_" + profile.getUUID(), name, longLabel,
VpnProfileControlActivity.START_PROFILE, profile.getUUID().toString())
}
private fun buildShortcut(id: String, shortLabel: String, longLabel: String, action: String, profileUuid: String?): ShortcutInfoCompat
{
val intent = Intent(context, VpnProfileControlActivity::class.java)
.setAction(action)
.putExtra(VpnProfileControlActivity.EXTRA_SILENT, true)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (profileUuid != null)
{
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profileUuid)
}
return ShortcutInfoCompat.Builder(context, id)
.setShortLabel(shortLabel)
.setLongLabel(longLabel)
.setIntent(intent)
.setLongLived(true)
.setIcon(IconCompat.createWithResource(context, R.mipmap.ic_shortcut))
.build()
}
private fun loadProfiles(): MutableList<VpnProfile>
{
val source = VpnProfileSource(context)
try
{
source.open()
return source.allVpnProfiles
}
finally
{
source.close()
}
}
}
@@ -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,96 @@
/*
* 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.net.URL
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Minimal AWS Signature Version 4 signer for authenticated GET requests to an
* S3-compatible storage (no request body, no query parameters).
*/
object S3Signer
{
private const val ALGORITHM = "AWS4-HMAC-SHA256"
private val EMPTY_BODY_SHA256 = sha256Hex(ByteArray(0))
/**
* Computes the headers that must be set on the request:
* x-amz-date, x-amz-content-sha256 and Authorization
*/
fun signGet(url: URL, region: String, accessKey: String, secretKey: String, nowMillis: Long): Map<String, String>
{
val timestamp = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(nowMillis))
val dateStamp = timestamp.substring(0, 8)
val host = url.host.let { if (url.port in -1..0 || url.port == 443) it else "$it:${url.port}" }
val path = url.path?.ifEmpty { "/" } ?: "/"
val canonicalHeaders = "host:$host\n" +
"x-amz-content-sha256:$EMPTY_BODY_SHA256\n" +
"x-amz-date:$timestamp\n"
val signedHeaders = "host;x-amz-content-sha256;x-amz-date"
val canonicalRequest = "GET\n$path\n\n$canonicalHeaders\n$signedHeaders\n$EMPTY_BODY_SHA256"
val scope = "$dateStamp/$region/s3/aws4_request"
val stringToSign = "$ALGORITHM\n$timestamp\n$scope\n${sha256Hex(canonicalRequest.toByteArray())}"
val signingKey = deriveSigningKey(secretKey, dateStamp, region, "s3")
val signature = hex(hmacSha256(signingKey, stringToSign.toByteArray()))
return mapOf(
"x-amz-date" to timestamp,
"x-amz-content-sha256" to EMPTY_BODY_SHA256,
"Authorization" to "$ALGORITHM Credential=$accessKey/$scope, SignedHeaders=$signedHeaders, Signature=$signature",
)
}
/**
* The HMAC-SHA256 key chain described by AWS:
* kDate -> kRegion -> kService -> kSigning
*/
fun deriveSigningKey(secretKey: String, dateStamp: String, region: String, service: String): ByteArray
{
var key = hmacSha256(("AWS4$secretKey").toByteArray(), dateStamp.toByteArray())
key = hmacSha256(key, region.toByteArray())
key = hmacSha256(key, service.toByteArray())
return hmacSha256(key, "aws4_request".toByteArray())
}
internal fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray
{
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(data)
}
private fun sha256Hex(data: ByteArray): String
{
return hex(MessageDigest.getInstance("SHA-256").digest(data))
}
internal fun hex(bytes: ByteArray): String
{
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -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")
}
@@ -0,0 +1,45 @@
/*
* 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 android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* Periodic background update check. Runs via WorkManager, so it survives the
* process being killed by the system (an in-process timer does not - the
* system reclaims backgrounded processes aggressively, especially with the
* VPN disconnected). The actual check is throttled by
* [UpdateManager.checkIfDue] through the shared preference timestamp.
*/
class UpdateCheckWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
{
override suspend fun doWork(): Result
{
/* 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()
}
companion object
{
const val WORK_NAME = "update-check"
}
}
@@ -0,0 +1,74 @@
/*
* 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 android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.widget.Toast
import androidx.core.content.IntentCompat
import org.strongswan.android.R
/**
* Receives the result of the self-update installation from PackageInstaller,
* shows the system confirmation dialog when required and notifies the user
* about the outcome
*/
class UpdateInstallStatusReceiver : BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent)
{
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
when (status)
{
PackageInstaller.STATUS_PENDING_USER_ACTION ->
{
/* the system requires the user to confirm the installation */
android.util.Log.w("UpdateInstall", "System required user action, the silent install was overridden")
val confirmation = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java)
if (confirmation != null)
{
confirmation.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try
{
context.startActivity(confirmation)
}
catch (e: Exception)
{
Toast.makeText(context,
context.getString(R.string.update_install_failed_fmt, ""),
Toast.LENGTH_LONG).show()
}
}
}
PackageInstaller.STATUS_SUCCESS ->
{
Toast.makeText(context, R.string.update_install_success, Toast.LENGTH_LONG).show()
UpdateManager.getInstance(context).onInstallFinished()
}
else ->
{
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
Toast.makeText(
context,
context.getString(R.string.update_install_failed_fmt, message ?: ""),
Toast.LENGTH_LONG
).show()
UpdateManager.getInstance(context).onInstallFinished()
}
}
}
}
@@ -0,0 +1,144 @@
/*
* 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 android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.Log
import androidx.core.content.FileProvider
import java.io.File
/**
* Installs a downloaded update APK as an in-place update (same package, higher
* versionCode, same signing key - the app data is preserved, nothing is uninstalled).
*/
object UpdateInstaller
{
/* hardcoded: the runtime class name is obfuscated by R8 in release builds */
private const val TAG = "UpdateInstaller"
fun install(context: Context, apk: File)
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
try
{
installViaSession(context, apk)
return
}
catch (e: Exception)
{
/* fall through to the package-archive intent below */
Log.e(TAG, "PackageInstaller session failed, falling back", e)
}
}
installViaIntent(context, apk)
}
private fun installViaSession(context: Context, apk: File)
{
val packageInstaller = context.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
{
/* the app updates itself (same package, same signing key, higher
* versionCode); with UPDATE_PACKAGES_WITHOUT_USER_ACTION the
* system applies the update without any confirmation dialog */
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
{ /* claim update ownership (Android 14+): without it, newer Android
* versions (and vendors like Samsung) override the silent install
* and show the Play Protect confirmation on every update */
params.setRequestUpdateOwnership(true)
}
val sessionId = packageInstaller.createSession(params)
Log.i(TAG, "Committing install session $sessionId (silent self-update)")
val session = packageInstaller.openSession(sessionId)
try
{
session.openWrite("shx-vpn", 0, apk.length()).use { output ->
apk.inputStream().use { input ->
input.copyTo(output, 64 * 1024)
}
session.fsync(output)
}
val statusIntent = Intent(context, UpdateInstallStatusReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(context, sessionId, statusIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
session.commit(pendingIntent.intentSender)
}
catch (e: Exception)
{
try
{
packageInstaller.abandonSession(sessionId)
}
catch (ignored: Exception)
{
}
throw e
}
finally
{
session.close()
}
}
/**
* Fallback for older devices (or a failing session): open the downloaded
* APK with the system package installer
*/
private fun installViaIntent(context: Context, apk: File)
{
val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", apk)
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
/** Whether the session-based install may be used right away */
fun canInstall(context: Context): Boolean
{
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
context.packageManager.canRequestPackageInstalls()
}
/**
* Intent for the system page that grants this app the "install unknown
* apps" permission (required since Android 8 for the session-based install)
*/
fun createInstallPermissionIntent(context: Context): Intent
{
return Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + context.packageName))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Opens the system page that grants this app the "install unknown apps"
* permission (required since Android 8 for the session-based install)
*/
fun requestInstallPermission(context: Context)
{
context.startActivity(createInstallPermissionIntent(context))
}
}
@@ -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,39 @@
/*
* 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 android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.strongswan.android.logic.autoconnect.AutoConnectManager
/**
* Runs after the app updated itself (the system kills the process during the
* install, so this is the first code of the new version that runs): clears
* the update state and lets the auto-connect logic restore the VPN connection
* if it was up before the update.
*/
class UpdateReplacedReceiver : BroadcastReceiver()
{
override fun onReceive(context: Context, intent: Intent)
{
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED)
{
return
}
UpdateManager.getInstance(context).onInstallFinished()
AutoConnectManager.getInstance(context).reevaluate()
}
}
@@ -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()
}
}
@@ -1,82 +0,0 @@
/*
* Copyright (C) 2014 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;
import android.app.Dialog;
import android.content.Context;
import android.content.DialogInterface;
import android.os.Bundle;
import org.strongswan.android.R;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatDialogFragment;
/**
* Class that displays a confirmation dialog to delete a selected local
* certificate.
*/
public class CertificateDeleteConfirmationDialog extends AppCompatDialogFragment
{
public static final String ALIAS = "alias";
OnCertificateDeleteListener mListener;
/**
* Interface that can be implemented by parent activities to get the
* alias of the certificate to delete, if the user confirms the deletion.
*/
public interface OnCertificateDeleteListener
{
public void onDelete(String alias);
}
@Override
public void onAttach(Context context)
{
super.onAttach(context);
if (context instanceof OnCertificateDeleteListener)
{
mListener = (OnCertificateDeleteListener)context;
}
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
{
return new AlertDialog.Builder(getActivity())
.setIcon(android.R.drawable.ic_dialog_alert)
.setTitle(R.string.delete_certificate_question)
.setMessage(R.string.delete_certificate)
.setPositiveButton(R.string.delete_profile, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int whichButton)
{
if (mListener != null)
{
mListener.onDelete(getArguments().getString(ALIAS));
}
}
})
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which)
{
dismiss();
}
}).create();
}
}
@@ -23,29 +23,23 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import org.strongswan.android.R
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.connectivity.HealthCheckManager
import org.strongswan.android.ui.compose.ConnectivityCheckScreen
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()
class ConnectivityCheckActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
AutoConnectManager.getInstance(this).reloadConfig()
val source = VpnProfileSource(this).open()
val profiles = source.allVpnProfiles
source.close()
val currentSsid = SsidReader.from(this)
HealthCheckManager.getInstance(this).reloadConfig()
setContent {
ShxTheme {
SecondaryScaffold(title = stringResource(R.string.auto_connect_title), onBack = { finish() }) { padding ->
SecondaryScaffold(title = stringResource(R.string.health_check_title), onBack = { finish() }) { padding ->
Box(Modifier.padding(padding)) {
TrustedNetworksScreen(profiles = profiles, currentSsid = currentSsid)
ConnectivityCheckScreen()
}
}
}
@@ -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)) },
)
}
}
}
}
}
@@ -1,225 +0,0 @@
/*
* Copyright (C) 2013 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;
import android.app.Service;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.view.GestureDetector;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnTouchListener;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.strongswan.android.R;
import org.strongswan.android.logic.VpnStateService;
import org.strongswan.android.logic.VpnStateService.VpnStateListener;
import org.strongswan.android.logic.imc.ImcState;
import org.strongswan.android.logic.imc.RemediationInstruction;
import java.util.ArrayList;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
public class ImcStateFragment extends Fragment implements VpnStateListener
{
private int mColorIsolate;
private int mColorBlock;
private boolean mVisible;
private TextView mStateView;
private TextView mAction;
private LinearLayout mButton;
private VpnStateService mService;
private final ServiceConnection mServiceConnection = new ServiceConnection()
{
@Override
public void onServiceDisconnected(ComponentName name)
{
mService = null;
}
@Override
public void onServiceConnected(ComponentName name, IBinder service)
{
mService = ((VpnStateService.LocalBinder)service).getService();
if (mVisible)
{
mService.registerListener(ImcStateFragment.this);
updateView();
}
}
};
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
mColorIsolate = ContextCompat.getColor(getActivity(), R.color.warning_text);
mColorBlock = ContextCompat.getColor(getActivity(), R.color.error_text);
/* bind to the service only seems to work from the ApplicationContext */
Context context = getActivity().getApplicationContext();
context.bindService(new Intent(context, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
/* hide it initially */
getParentFragmentManager().beginTransaction().hide(this).commit();
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState)
{
View view = inflater.inflate(R.layout.imc_state_fragment, container, false);
mButton = (LinearLayout)view.findViewById(R.id.imc_state_button);
mButton.setOnClickListener(new OnClickListener()
{
@Override
public void onClick(View v)
{
Intent intent;
if (mService != null && !mService.getRemediationInstructions().isEmpty())
{
intent = new Intent(getActivity(), RemediationInstructionsActivity.class);
intent.putParcelableArrayListExtra(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS,
new ArrayList<RemediationInstruction>(mService.getRemediationInstructions()));
}
else
{
intent = new Intent(getActivity(), LogActivity.class);
}
startActivity(intent);
}
});
final GestureDetector gestures = new GestureDetector(getActivity(), new GestureDetector.SimpleOnGestureListener()
{
/* a better value would be getScaledTouchExplorationTapSlop() but that is hidden */
private final int mMinDistance = ViewConfiguration.get(getActivity()).getScaledTouchSlop() * 4;
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)
{
if (Math.abs(e1.getX() - e2.getX()) >= mMinDistance)
{ /* only if the user swiped a minimum horizontal distance */
if (mService != null)
{
mService.setImcState(ImcState.UNKNOWN);
}
return true;
}
return false;
}
});
mButton.setOnTouchListener(new OnTouchListener()
{
@Override
public boolean onTouch(View v, MotionEvent event)
{
return gestures.onTouchEvent(event);
}
});
mStateView = (TextView)view.findViewById(R.id.imc_state);
mAction = (TextView)view.findViewById(R.id.action);
return view;
}
@Override
public void onResume()
{
super.onResume();
mVisible = true;
if (mService != null)
{
mService.registerListener(this);
updateView();
}
}
@Override
public void onPause()
{
super.onPause();
mVisible = false;
if (mService != null)
{
mService.unregisterListener(this);
}
}
@Override
public void onDestroy()
{
super.onDestroy();
if (mService != null)
{
getActivity().getApplicationContext().unbindService(mServiceConnection);
}
}
@Override
public void stateChanged()
{
updateView();
}
public void updateView()
{
FragmentManager fm = getParentFragmentManager();
if (fm == null)
{
return;
}
FragmentTransaction ft = fm.beginTransaction();
switch (mService.getImcState())
{
case UNKNOWN:
case ALLOW:
ft.hide(this);
break;
case ISOLATE:
mStateView.setText(R.string.imc_state_isolate);
mStateView.setTextColor(mColorIsolate);
ft.show(this);
break;
case BLOCK:
mStateView.setText(R.string.imc_state_block);
mStateView.setTextColor(mColorBlock);
ft.show(this);
break;
}
ft.commit();
mAction.setText(mService.getRemediationInstructions().isEmpty() ? R.string.show_log
: R.string.show_remediation_instructions);
}
}
@@ -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.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))
}
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,20 +35,53 @@ 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
import kotlinx.coroutines.withContext
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
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.ShxTheme
import java.io.BufferedReader
@@ -57,23 +90,61 @@ import java.io.FileReader
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)
setContent {
ShxTheme {
LogScreen(onBack = { finish() }, onSend = { sendLog() })
val report: ReportViewModel = viewModel()
/* without the panel API in this build the report goes by email, as before */
LogScreen(onBack = { finish() }, onSend = { if (ReportViewModel.apiReady(this)) report.open() else sendLog("") }, messages = messages)
ReportSheet(report, profile = ::activeProfile, onMail = { comment -> report.close(); sendLog(comment) })
}
}
}
private fun sendLog()
override fun onDestroy()
{
val logfile = File(filesDir, CharonVpnService.LOG_FILE)
if (!logfile.exists() || logfile.length() == 0L)
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 */
val files = listOf(
File(filesDir, CharonVpnService.LOG_FILE),
DiagLog.file(this),
).filter { it.exists() && it.length() > 0L }
if (files.isEmpty())
{
Toast.makeText(this, R.string.empty_log, Toast.LENGTH_SHORT).show()
messages.tryEmit(R.string.empty_log)
return
}
var version = ""
@@ -84,11 +155,23 @@ class LogActivity : AppCompatActivity()
catch (_: PackageManager.NameNotFoundException)
{
}
val intent = Intent(Intent.ACTION_SEND)
val uris = ArrayList(files.mapNotNull { file -> LogContentProvider.createContentUri(file) })
val intent = Intent(if (uris.size > 1) Intent.ACTION_SEND_MULTIPLE else Intent.ACTION_SEND)
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"
intent.putExtra(Intent.EXTRA_STREAM, LogContentProvider.createContentUri())
if (comment.isNotBlank())
{
intent.putExtra(Intent.EXTRA_TEXT, comment)
}
if (uris.size > 1)
{
intent.putExtra(Intent.EXTRA_STREAM, uris)
}
else
{
intent.putExtra(Intent.EXTRA_STREAM, uris.first())
}
startActivity(Intent.createChooser(intent, getString(R.string.send_log)))
}
}
@@ -98,8 +181,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
@@ -107,34 +197,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()
}
}
}
@@ -148,63 +268,181 @@ 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)
{
IMPORTANT(R.string.log_filter_important),
ALL(R.string.log_filter_everything),
ERROR(R.string.log_filter_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 ") || 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]
}
@Composable
private fun LogScreen(onBack: () -> Unit, onSend: () -> Unit, viewModel: LogViewModel = viewModel())
private fun LogScreen(
onBack: () -> Unit,
onSend: () -> Unit,
messages: kotlinx.coroutines.flow.Flow<Int>,
viewModel: LogViewModel = viewModel(),
)
{
val context = LocalContext.current
val lines by viewModel.lines.collectAsStateWithLifecycle()
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.IMPORTANT) }
val lines = remember(allLines, filter) {
when (filter)
{
LogLevelFilter.IMPORTANT -> allLines.filter { it.isImportantLine() }
LogLevelFilter.ALL -> allLines
LogLevelFilter.ERROR -> allLines.filter { it.isErrorLine() }
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(Unit) {
viewModel.start(File(context.filesDir, CharonVpnService.LOG_FILE))
}
LaunchedEffect(lines.size) {
if (lines.isNotEmpty())
{
listState.scrollToItem(lines.lastIndex)
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())
{
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 ->
if (lines.isEmpty())
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize().padding(padding).padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
)
{
Text(
stringResource(R.string.empty_log),
modifier = Modifier.padding(padding).padding(24.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else
{
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 12.dp),
) {
items(lines.size) { index ->
val line = lines[index]
val isError = line.contains(" ERROR ", ignoreCase = false)
Text(
line,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
),
color = if (isError) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(vertical = 2.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
LogLevelFilter.entries.forEach { level ->
ShxChip(stringResource(level.labelRes), filter == level, { filter = level })
}
}
/* the dark console: grey time, coloured message */
Box(Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(Color(0xFF1C1B20)))
{
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)
}
}
}
}
}
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)
@@ -1,295 +0,0 @@
/*
* Copyright (C) 2012-2018 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;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.os.FileObserver;
import android.os.Handler;
import android.os.Looper;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import org.strongswan.android.R;
import org.strongswan.android.logic.CharonVpnService;
import org.strongswan.android.utils.Utils;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.StringReader;
import java.util.ArrayList;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.fragment.app.Fragment;
public class LogFragment extends Fragment
{
private static String SCROLL_POSITION = "SCROLL_POSITION";
private String mLogFilePath;
private Handler mLogHandler;
private ListView mLog;
private LogAdapter mLogAdapter;
private FileObserver mDirectoryObserver;
private int mScrollPosition;
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
mLogFilePath = getActivity().getFilesDir() + File.separator + CharonVpnService.LOG_FILE;
mLogHandler = new Handler(Looper.getMainLooper());
File logdir = getActivity().getFilesDir();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{
mDirectoryObserver = new LogDirectoryObserver(logdir);
}
else
{
mDirectoryObserver = new LogDirectoryObserver(logdir.getAbsolutePath());
}
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
{
View view = inflater.inflate(R.layout.log_fragment, null);
mLogAdapter = new LogAdapter(getActivity());
mLog = view.findViewById(R.id.log);
mLog.setAdapter(mLogAdapter);
Utils.applyWindowInsetsAsPaddingForLists(mLog);
mScrollPosition = -1;
if (savedInstanceState != null)
{
mScrollPosition = savedInstanceState.getInt(SCROLL_POSITION, mScrollPosition);
}
return view;
}
@Override
public void onSaveInstanceState(Bundle outState)
{
super.onSaveInstanceState(outState);
if (mLog.getLastVisiblePosition() == (mLogAdapter.getCount() - 1))
{
outState.putInt(SCROLL_POSITION, -1);
}
else
{
outState.putInt(SCROLL_POSITION, mLog.getFirstVisiblePosition());
}
}
@Override
public void onStart()
{
super.onStart();
mLogAdapter.restart();
mDirectoryObserver.startWatching();
}
@Override
public void onStop()
{
super.onStop();
mDirectoryObserver.stopWatching();
mLogAdapter.stop();
}
private class LogAdapter extends ArrayAdapter<String> implements Runnable
{
private BufferedReader mReader;
private Thread mThread;
private volatile boolean mRunning;
public LogAdapter(@NonNull Context context)
{
super(context, R.layout.log_list_item, R.id.log_line);
}
public void restart()
{
if (mRunning)
{
stop();
}
clear();
try
{
mReader = new BufferedReader(new FileReader(mLogFilePath));
}
catch (FileNotFoundException e)
{
mReader = new BufferedReader(new StringReader(""));
}
mRunning = true;
mThread = new Thread(this);
mThread.start();
}
public void stop()
{
try
{
mRunning = false;
mThread.interrupt();
mThread.join();
}
catch (InterruptedException e)
{
}
}
private void logLines(final ArrayList<String> lines)
{
mLogHandler.post(() -> {
boolean scroll = getCount() == 0;
setNotifyOnChange(false);
for (String line : lines)
{
if (getResources().getConfiguration().screenWidthDp < 600)
{ /* strip off prefix (month=3, day=2, time=8, thread=2, spaces=3) */
line = line.length() > 18 ? line.substring(18) : line;
}
add(line);
}
notifyDataSetChanged();
if (scroll)
{ /* scroll to the bottom or saved position after adding the first batch */
mLogHandler.post(() -> mLog.setSelection(mScrollPosition == -1 ? getCount() - 1 : mScrollPosition));
}
});
}
@Override
public void run()
{
ArrayList<String> lines = null;
while (mRunning)
{
try
{ /* this works as long as the file is not truncated */
String line = mReader.readLine();
if (line == null)
{
if (lines != null)
{
logLines(lines);
lines = null;
}
/* wait until there is more to log */
Thread.sleep(1000);
}
else
{
if (lines == null)
{
lines = new ArrayList<>();
}
lines.add(line);
}
}
catch (Exception e)
{
break;
}
}
if (lines != null)
{
logLines(lines);
}
}
}
/**
* FileObserver that checks for changes regarding the log file. Since charon
* truncates it (for which there is no explicit event) we check for any modification
* to the file, keep track of the file size and reopen it if it got smaller.
*/
private class LogDirectoryObserver extends FileObserver
{
private static final int mMask = FileObserver.CREATE | FileObserver.MODIFY | FileObserver.DELETE;
private final File mFile = new File(mLogFilePath);
private long mSize = mFile.length();
@SuppressWarnings("deprecation")
public LogDirectoryObserver(String path)
{
super(path, mMask);
}
@RequiresApi(api = Build.VERSION_CODES.Q)
public LogDirectoryObserver(File path)
{
super(path, mMask);
}
@Override
public void onEvent(int event, String path)
{
if (path == null || !path.equals(CharonVpnService.LOG_FILE))
{
return;
}
switch (event)
{ /* even though we only subscribed for these we check them,
* as strange events are sometimes received */
case FileObserver.CREATE:
case FileObserver.DELETE:
restartLogReader();
break;
case FileObserver.MODIFY:
/* if the size got smaller reopen the log file, as it was probably truncated */
long size = mFile.length();
if (size < mSize)
{
restartLogReader();
}
mSize = size;
break;
}
}
private void restartLogReader()
{
/* we are called from a separate thread, so we use the handler */
mLogHandler.post(new Runnable() {
@Override
public void run()
{
mLogAdapter.restart();
}
});
}
}
}
@@ -16,15 +16,52 @@
package org.strongswan.android.ui;
import android.content.Intent;
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;
public class MainActivity extends AppCompatActivity
{
public static final String CONTACT_EMAIL = "android@strongswan.org";
public static final String CONTACT_EMAIL = "support@shx.one";
/**
* Set as an intent extra by the "update ready" notification; starts the
* installation of the already downloaded update
*/
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
@@ -34,7 +71,81 @@ public class MainActivity extends AppCompatActivity
@Override
public void onCreate(Bundle savedInstanceState)
{
/* must run before super.onCreate() so the system splash hands over cleanly */
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
protected void onResume()
{
super.onResume();
/* catch up on due update checks right away (the periodic check
* otherwise waits for the WorkManager tick or the next relaunch) */
UpdateManager.getInstance(this).checkIfDue();
}
@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)
{
if (intent != null && intent.getBooleanExtra(EXTRA_INSTALL_UPDATE, false))
{
intent.removeExtra(EXTRA_INSTALL_UPDATE);
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))
}
}
}
@@ -1,141 +0,0 @@
/*
* Copyright (C) 2013-2016 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;
import android.os.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.strongswan.android.R;
import org.strongswan.android.logic.imc.RemediationInstruction;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.ListFragment;
public class RemediationInstructionFragment extends ListFragment
{
public static final String ARG_REMEDIATION_INSTRUCTION = "instruction";
private RemediationInstruction mInstruction = null;
private TextView mTitle;
private TextView mDescription;
private TextView mHeader;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
{
/* while the documentation recommends to include "@android:layout/list_content" to retain
* the default functionality, this does not actually work with the ListFragment provided by
* the support library as it builds the view manually and uses different IDs */
View layout = inflater.inflate(R.layout.remediation_instruction, container, false);
FrameLayout list = (FrameLayout)layout.findViewById(R.id.list_container);
list.addView(super.onCreateView(inflater, list, savedInstanceState));
return layout;
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
{
super.onViewCreated(view, savedInstanceState);
if (savedInstanceState != null)
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
mInstruction = getInstructionCompat(savedInstanceState);
}
else
{
mInstruction = savedInstanceState.getParcelable(ARG_REMEDIATION_INSTRUCTION, RemediationInstruction.class);
}
}
/* show dividers only between list items */
getListView().setHeaderDividersEnabled(false);
getListView().setFooterDividersEnabled(false);
/* don't show loader while adapter is not set */
setListShown(true);
mTitle = (TextView)getView().findViewById(R.id.title);
mDescription = (TextView)getView().findViewById(R.id.description);
mHeader = (TextView)getView().findViewById(R.id.list_header);
}
@Override
public void onSaveInstanceState(Bundle outState)
{
super.onSaveInstanceState(outState);
outState.putParcelable(ARG_REMEDIATION_INSTRUCTION, mInstruction);
}
@Override
public void onStart()
{
super.onStart();
Bundle args = getArguments();
if (args != null)
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
mInstruction = getInstructionCompat(args);
}
else
{
mInstruction = args.getParcelable(ARG_REMEDIATION_INSTRUCTION, RemediationInstruction.class);
}
}
updateView(mInstruction);
}
public void updateView(RemediationInstruction instruction)
{
mInstruction = instruction;
if (mInstruction != null)
{
mTitle.setText(mInstruction.getTitle());
mDescription.setText(mInstruction.getDescription());
if (mInstruction.getHeader() != null)
{
mHeader.setText(mInstruction.getHeader());
setListAdapter(new ArrayAdapter<String>(getActivity(),
android.R.layout.simple_list_item_1, mInstruction.getItems()));
}
else
{
mHeader.setText("");
setListAdapter(null);
}
}
else
{
mTitle.setText("");
mDescription.setText("");
mHeader.setText("");
setListAdapter(null);
}
}
@SuppressWarnings("deprecation")
private static RemediationInstruction getInstructionCompat(Bundle bundle)
{
return bundle.getParcelable(ARG_REMEDIATION_INSTRUCTION);
}
}
@@ -1,107 +0,0 @@
/*
* Copyright (C) 2013 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;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.WindowCompat;
import android.view.MenuItem;
import org.strongswan.android.R;
import org.strongswan.android.logic.imc.RemediationInstruction;
import org.strongswan.android.ui.RemediationInstructionsFragment.OnRemediationInstructionSelectedListener;
import java.util.ArrayList;
public class RemediationInstructionsActivity extends AppCompatActivity implements OnRemediationInstructionSelectedListener
{
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.remediation_instructions);
WindowCompat.enableEdgeToEdge(getWindow());
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
if (savedInstanceState != null)
{ /* only update if we're not restoring */
return;
}
RemediationInstructionsFragment frag = (RemediationInstructionsFragment)getSupportFragmentManager().findFragmentById(R.id.remediation_instructions_fragment);
if (frag != null)
{ /* two-pane layout, update fragment */
Bundle extras = getIntent().getExtras();
ArrayList<RemediationInstruction> list = null;
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU)
{
list = RemediationInstructionsFragment.getInstructionsCompat(extras);
}
else
{
list = extras.getParcelableArrayList(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS,
RemediationInstruction.class);
}
frag.updateView(list);
}
else
{ /* one-pane layout, create fragment */
frag = new RemediationInstructionsFragment();
frag.setArguments(getIntent().getExtras());
getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, frag).commit();
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item)
{
switch (item.getItemId())
{
case android.R.id.home:
/* one-pane layout, pop possible fragment from stack, finish otherwise */
if (!getSupportFragmentManager().popBackStackImmediate())
{
finish();
}
getSupportActionBar().setTitle(getTitle());
return true;
default:
return super.onOptionsItemSelected(item);
}
}
@Override
public void onRemediationInstructionSelected(RemediationInstruction instruction)
{
RemediationInstructionFragment frag = (RemediationInstructionFragment)getSupportFragmentManager().findFragmentById(R.id.remediation_instruction_fragment);
if (frag != null)
{ /* two-pane layout, update directly */
frag.updateView(instruction);
}
else
{ /* one-pane layout, replace fragment */
frag = new RemediationInstructionFragment();
Bundle args = new Bundle();
args.putParcelable(RemediationInstructionFragment.ARG_REMEDIATION_INSTRUCTION, instruction);
frag.setArguments(args);
getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container, frag).addToBackStack(null).commit();
getSupportActionBar().setTitle(instruction.getTitle());
}
}
}
@@ -0,0 +1,236 @@
/*
* Copyright (C) 2013 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
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.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
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
/**
* Shows the remediation instructions received from the server after a failed
* IMC/IMV compliance check. Single-pane Compose master-detail: the list is
* replaced by the selected instruction (with back navigation).
*/
class RemediationInstructionsActivity : AppCompatActivity()
{
private var selected by mutableStateOf<RemediationInstruction?>(null)
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
Surface(Modifier.fillMaxSize(), color = LocalShxColors.current.background) {
RemediationUi(
instructions = loadInstructions(),
selected = selected,
onSelect = { selected = it },
onFinish = { finish() },
)
}
}
}
}
private fun loadInstructions(): ArrayList<RemediationInstruction>
{
val extras = getIntent().getExtras() ?: return arrayListOf()
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
@Suppress("DEPRECATION")
extras.getParcelableArrayList(RemediationInstructionsExtra.KEY) ?: arrayListOf()
}
else
{
extras.getParcelableArrayList(RemediationInstructionsExtra.KEY, RemediationInstruction::class.java)
?: arrayListOf()
}
}
}
private object RemediationInstructionsExtra
{
/* former RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS */
const val KEY = "instructions"
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RemediationUi(
instructions: List<RemediationInstruction>,
selected: RemediationInstruction?,
onSelect: (RemediationInstruction?) -> Unit,
onFinish: () -> Unit,
)
{
PredictiveBackScale(enabled = selected != null, onBack = { onSelect(null) }, modifier = Modifier.fillMaxSize())
{
Column(Modifier.fillMaxSize())
{
ShxPageHeader(
selected?.getTitle() ?: stringResource(R.string.remediation_instructions_title),
onBack = { if (selected != null) onSelect(null) else onFinish() },
)
AnimatedContent(
targetState = selected,
transitionSpec = {
val forward = targetState != null
val direction = if (forward) 1 else -1
(slideInHorizontally(tween(240)) { it / 4 * direction } + fadeIn(tween(240))) togetherWith
(slideOutHorizontally(tween(180)) { -it / 4 * direction } + fadeOut(tween(160)))
},
label = "remediationPane",
) { instruction ->
if (instruction == null)
{
if (instructions.isEmpty())
{
ShxEmptyState(
icon = ShxIcons.Check,
message = stringResource(R.string.remediation_instructions_title),
modifier = Modifier.padding(ShxSpacing.lg),
)
}
else
{
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(instructions) { index, item ->
ShxCard(
modifier = Modifier
.padding(horizontal = ShxSpacing.lg, vertical = 6.dp)
.animateItem(),
) {
Column(
Modifier
.fillMaxWidth()
.clickable { onSelect(item) }
.padding(ShxSpacing.lg),
) {
Text(item.getTitle(), style = ShxType.cardTitle)
if (!item.getDescription().isNullOrEmpty())
{
Spacer(Modifier.height(ShxSpacing.xs))
Text(
item.getDescription(),
style = ShxType.note,
color = LocalShxColors.current.textSub,
)
}
}
}
}
}
}
}
else
{
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = ShxSpacing.lg),
) {
Text(
instruction.getDescription(),
style = ShxType.note,
modifier = Modifier.padding(top = ShxSpacing.sm),
)
if (!instruction.getHeader().isNullOrEmpty())
{
Text(
instruction.getHeader(),
style = ShxType.section,
color = LocalShxColors.current.textSub,
modifier = Modifier.padding(top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
Column(Modifier.padding(vertical = ShxSpacing.xs)) {
instruction.getItems().forEachIndexed { index, item ->
Text(
item,
style = ShxType.body,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
if (index < instruction.getItems().lastIndex)
{
ShxSectionDivider(indent = 16.dp)
}
}
}
}
}
Spacer(Modifier.height(ShxSpacing.xl))
}
}
}
}
}
}
@@ -1,147 +0,0 @@
/*
* Copyright (C) 2013 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;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.widget.ListView;
import org.strongswan.android.R;
import org.strongswan.android.logic.imc.RemediationInstruction;
import org.strongswan.android.ui.adapter.RemediationInstructionAdapter;
import java.util.ArrayList;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.ListFragment;
public class RemediationInstructionsFragment extends ListFragment
{
public static final String EXTRA_REMEDIATION_INSTRUCTIONS = "instructions";
private static final String KEY_POSITION = "position";
private ArrayList<RemediationInstruction> mInstructions = null;
private OnRemediationInstructionSelectedListener mListener;
private RemediationInstructionAdapter mAdapter;
private int mCurrentPosition = -1;
/**
* The activity containing this fragment should implement this interface
*/
public interface OnRemediationInstructionSelectedListener
{
public void onRemediationInstructionSelected(RemediationInstruction instruction);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
{
super.onViewCreated(view, savedInstanceState);
if (savedInstanceState != null)
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
mInstructions = getInstructionsCompat(savedInstanceState);
}
else
{
mInstructions = savedInstanceState.getParcelableArrayList(EXTRA_REMEDIATION_INSTRUCTIONS, RemediationInstruction.class);
}
mCurrentPosition = savedInstanceState.getInt(KEY_POSITION);
}
}
@Override
public void onSaveInstanceState(Bundle outState)
{
super.onSaveInstanceState(outState);
outState.putParcelableArrayList(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS, mInstructions);
outState.putInt(KEY_POSITION, mCurrentPosition);
}
@Override
public void onAttach(Context context)
{
super.onAttach(context);
if (context instanceof OnRemediationInstructionSelectedListener)
{
mListener = (OnRemediationInstructionSelectedListener)context;
}
}
@Override
public void onStart()
{
super.onStart();
boolean two_pane = getParentFragmentManager().findFragmentById(R.id.remediation_instruction_fragment) != null;
if (two_pane)
{ /* two-pane layout, make list items selectable */
getListView().setChoiceMode(ListView.CHOICE_MODE_SINGLE);
}
Bundle args = getArguments();
if (mInstructions == null && args != null)
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
mInstructions = getInstructionsCompat(args);
}
else
{
mInstructions = args.getParcelableArrayList(EXTRA_REMEDIATION_INSTRUCTIONS, RemediationInstruction.class);
}
}
updateView(mInstructions);
if (two_pane && mCurrentPosition == -1 && mInstructions.size() > 0)
{ /* two-pane layout, select first instruction */
mCurrentPosition = 0;
mListener.onRemediationInstructionSelected(mInstructions.get(0));
}
getListView().setItemChecked(mCurrentPosition, true);
}
@Override
public void onListItemClick(ListView l, View v, int position, long id)
{
mCurrentPosition = position;
mListener.onRemediationInstructionSelected(mInstructions.get(position));
getListView().setItemChecked(position, true);
}
public void updateView(ArrayList<RemediationInstruction> instructions)
{
if (mAdapter == null)
{
mAdapter = new RemediationInstructionAdapter(getActivity());
setListAdapter(mAdapter);
}
mInstructions = instructions;
mAdapter.setData(mInstructions);
}
@SuppressWarnings("deprecation")
public static ArrayList<RemediationInstruction> getInstructionsCompat(Bundle bundle)
{
return bundle.getParcelableArrayList(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS);
}
}
@@ -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),
)
}
}
@@ -0,0 +1,59 @@
/*
* 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.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.RouteCheckMode
import org.strongswan.android.ui.compose.RouteCheckScreen
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxTheme
/** «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 {
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,190 +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
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.CircularProgressIndicator
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.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)
}
val filtered = remember(apps, query) {
if (query.isBlank()) apps
else apps.filter { it.toString().contains(query, ignoreCase = true) || it.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 = 16.dp, vertical = 8.dp).fillMaxWidth(),
)
if (loading)
{
CircularProgressIndicator(Modifier.padding(24.dp).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) },
)
}
}
}
}
}
}
@@ -1,286 +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;
import android.Manifest;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Pair;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.Filter;
import android.widget.ListView;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.ui.adapter.SelectedApplicationEntry;
import org.strongswan.android.ui.adapter.SelectedApplicationsAdapter;
import org.strongswan.android.utils.Utils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SearchView;
import androidx.core.view.MenuProvider;
import androidx.fragment.app.ListFragment;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.AsyncTaskLoader;
import androidx.loader.content.Loader;
public class SelectedApplicationsListFragment extends ListFragment implements MenuProvider, LoaderManager.LoaderCallbacks<Pair<List<SelectedApplicationEntry>, List<String>>>, SearchView.OnQueryTextListener
{
private SelectedApplicationsAdapter mAdapter;
private SortedSet<String> mSelection;
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
{
super.onViewCreated(view, savedInstanceState);
requireActivity().addMenuProvider(this, getViewLifecycleOwner());
getListView().setClipToPadding(false);
Utils.applyWindowInsetsAsPaddingForLists(getListView());
final boolean readOnly = getActivity().getIntent().getBooleanExtra(VpnProfileDataSource.KEY_READ_ONLY, false);
getListView().setChoiceMode(readOnly ? ListView.CHOICE_MODE_NONE : ListView.CHOICE_MODE_MULTIPLE);
mAdapter = new SelectedApplicationsAdapter(getActivity());
mAdapter.setReadOnly(readOnly);
setListAdapter(mAdapter);
setListShown(false);
ArrayList<String> selection;
if (savedInstanceState == null)
{
selection = getActivity().getIntent().getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST);
}
else
{
selection = savedInstanceState.getStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST);
}
mSelection = new TreeSet<>(selection);
LoaderManager.getInstance(this).initLoader(0, null, this);
}
@Override
public void onSaveInstanceState(Bundle outState)
{
super.onSaveInstanceState(outState);
outState.putStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, new ArrayList<>(mSelection));
}
/**
* Returns the package names of all selected apps
*/
public ArrayList<String> getSelectedApplications()
{
return new ArrayList<>(mSelection);
}
/**
* Track check state as ListView is unable to do that when using filters
*/
@Override
public void onListItemClick(ListView l, View v, int position, long id)
{
if (mAdapter.isReadOnly())
{
return;
}
super.onListItemClick(l, v, position, id);
SelectedApplicationEntry item = (SelectedApplicationEntry)getListView().getItemAtPosition(position);
item.setSelected(!item.isSelected());
if (item.isSelected())
{
mSelection.add(item.getInfo().packageName);
}
else
{
mSelection.remove(item.getInfo().packageName);
}
}
/**
* Manually set the check state as ListView is unable to track that when using filters
*/
private void setCheckState()
{
for (int i = 0; i < getListView().getCount(); i++)
{
SelectedApplicationEntry item = mAdapter.getItem(i);
getListView().setItemChecked(i, item.isSelected());
}
}
@Override
public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater)
{
MenuItem item = menu.add(R.string.search);
item.setIcon(android.R.drawable.ic_menu_search);
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
SearchView sv = new SearchView(getActivity());
sv.setOnQueryTextListener(this);
item.setActionView(sv);
}
@Override
public boolean onMenuItemSelected(@NonNull MenuItem menuItem)
{
return false;
}
@Override
public Loader<Pair<List<SelectedApplicationEntry>, List<String>>> onCreateLoader(int id, Bundle args)
{
return new InstalledPackagesLoader(getActivity(), mSelection);
}
@Override
public void onLoadFinished(Loader<Pair<List<SelectedApplicationEntry>, List<String>>> loader, Pair<List<SelectedApplicationEntry>, List<String>> data)
{
mAdapter.setData(data.first);
mSelection.removeAll(data.second);
setCheckState();
if (isResumed())
{
setListShown(true);
}
else
{
setListShownNoAnimation(true);
}
}
@Override
public void onLoaderReset(Loader<Pair<List<SelectedApplicationEntry>, List<String>>> loader)
{
mAdapter.setData(null);
}
@Override
public boolean onQueryTextSubmit(String query)
{
return true;
}
@Override
public boolean onQueryTextChange(String newText)
{
String search = TextUtils.isEmpty(newText) ? null : newText;
mAdapter.getFilter().filter(search, new Filter.FilterListener()
{
@Override
public void onFilterComplete(int count)
{
setCheckState();
}
});
return true;
}
public static class InstalledPackagesLoader extends AsyncTaskLoader<Pair<List<SelectedApplicationEntry>, List<String>>>
{
private final PackageManager mPackageManager;
private final SortedSet<String> mSelection;
private Pair<List<SelectedApplicationEntry>, List<String>> mData;
public InstalledPackagesLoader(Context context, SortedSet<String> selection)
{
super(context);
mPackageManager = context.getPackageManager();
mSelection = selection;
}
@Override
public Pair<List<SelectedApplicationEntry>, List<String>> loadInBackground()
{
List<SelectedApplicationEntry> apps = new ArrayList<>();
SortedSet<String> seen = new TreeSet<>();
for (ApplicationInfo info : mPackageManager.getInstalledApplications(PackageManager.GET_META_DATA))
{
/* skip apps that can't access the network anyway */
if (mPackageManager.checkPermission(Manifest.permission.INTERNET, info.packageName) == PackageManager.PERMISSION_GRANTED)
{
SelectedApplicationEntry entry = new SelectedApplicationEntry(mPackageManager, info);
entry.setSelected(mSelection.contains(info.packageName));
apps.add(entry);
seen.add(info.packageName);
}
}
Collections.sort(apps);
/* check for selected packages that don't exist anymore */
List<String> missing = new ArrayList<>();
for (String pkg : mSelection)
{
if (!seen.contains(pkg))
{
missing.add(pkg);
}
}
return new Pair<>(apps, missing);
}
@Override
protected void onStartLoading()
{
if (mData != null)
{ /* if we have data ready, deliver it directly */
deliverResult(mData);
}
if (takeContentChanged() || mData == null)
{
forceLoad();
}
}
@Override
public void deliverResult(Pair<List<SelectedApplicationEntry>, List<String>> data)
{
if (isReset())
{
return;
}
mData = data;
if (isStarted())
{ /* if it is started we deliver the data directly,
* otherwise this is handled in onStartLoading */
super.deliverResult(data);
}
}
@Override
protected void onReset()
{
mData = null;
super.onReset();
}
}
}
@@ -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))
}
}
}
}
@@ -15,9 +15,14 @@ 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.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.SettingsScreen
@@ -29,15 +34,28 @@ class SettingsActivity : AppCompatActivity()
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val source = VpnProfileSource(this).open()
val profiles = source.allVpnProfiles
source.close()
setContent {
ShxTheme {
/* the profile list is only needed for the default-profile picker;
* load it off the main thread */
val profiles by produceState<List<VpnProfile>>(emptyList(), this@SettingsActivity) {
value = withContext(Dispatchers.IO) {
val source = VpnProfileSource(this@SettingsActivity).open()
try
{
source.allVpnProfiles
}
finally
{
source.close()
}
}
}
SecondaryScaffold(title = stringResource(R.string.pref_title), onBack = { finish() }) { padding ->
Box(Modifier.padding(padding)) {
SettingsScreen(profiles = profiles, showTitle = false)
}
org.strongswan.android.ui.compose.UpdateSheetHost()
}
}
}
@@ -1,155 +0,0 @@
/*
* Copyright (C) 2018 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;
import static org.strongswan.android.utils.Constants.PREF_DEFAULT_VPN_PROFILE;
import static org.strongswan.android.utils.Constants.PREF_DEFAULT_VPN_PROFILE_MRU;
import static org.strongswan.android.utils.Constants.PREF_IGNORE_POWER_WHITELIST;
import android.content.SharedPreferences;
import android.os.Build;
import android.os.Bundle;
import org.strongswan.android.R;
import org.strongswan.android.data.ManagedConfiguration;
import org.strongswan.android.data.ManagedConfigurationService;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnProfileSource;
import org.strongswan.android.logic.StrongSwanApplication;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceManager;
import androidx.preference.SwitchPreference;
public class SettingsFragment extends PreferenceFragmentCompat implements Preference.OnPreferenceChangeListener
{
private ManagedConfigurationService mManagedConfigurationService;
private ListPreference mDefaultVPNProfile;
private SwitchPreference mIgnorePowerWhitelist;
@Override
public void onCreatePreferences(Bundle bundle, String s)
{
mManagedConfigurationService = StrongSwanApplication.getInstance().getManagedConfigurationService();
mManagedConfigurationService.updateSettings();
setPreferencesFromResource(R.xml.settings, s);
mDefaultVPNProfile = findPreference(PREF_DEFAULT_VPN_PROFILE);
mDefaultVPNProfile.setOnPreferenceChangeListener(this);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
{
mDefaultVPNProfile.setEnabled(false);
}
mIgnorePowerWhitelist = findPreference(PREF_IGNORE_POWER_WHITELIST);
}
@Override
public void onResume()
{
super.onResume();
VpnProfileDataSource profiles = new VpnProfileSource(getActivity());
profiles.open();
List<VpnProfile> all = profiles.getAllVpnProfiles();
Collections.sort(all, new Comparator<VpnProfile>()
{
@Override
public int compare(VpnProfile lhs, VpnProfile rhs)
{
return lhs.getName().compareToIgnoreCase(rhs.getName());
}
});
ArrayList<CharSequence> entries = new ArrayList<>();
ArrayList<CharSequence> entryvalues = new ArrayList<>();
entries.add(getString(R.string.pref_default_vpn_profile_mru));
entryvalues.add(PREF_DEFAULT_VPN_PROFILE_MRU);
for (VpnProfile profile : all)
{
entries.add(profile.getName());
entryvalues.add(profile.getUUID().toString());
}
profiles.close();
final ManagedConfiguration managedConfiguration = mManagedConfigurationService.getManagedConfiguration();
if (entries.size() <= 1 || !managedConfiguration.isAllowSettingsAccess())
{
mDefaultVPNProfile.setEnabled(false);
}
else
{
mDefaultVPNProfile.setEnabled(true);
mDefaultVPNProfile.setEntries(entries.toArray(new CharSequence[0]));
mDefaultVPNProfile.setEntryValues(entryvalues.toArray(new CharSequence[0]));
}
if (!managedConfiguration.isAllowSettingsAccess())
{
mIgnorePowerWhitelist.setEnabled(false);
}
SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(getActivity());
setCurrentProfileName(pref.getString(PREF_DEFAULT_VPN_PROFILE, PREF_DEFAULT_VPN_PROFILE_MRU));
}
@Override
public boolean onPreferenceChange(Preference preference, Object newValue)
{
if (preference == mDefaultVPNProfile)
{
setCurrentProfileName((String)newValue);
}
return true;
}
private void setCurrentProfileName(String uuid)
{
VpnProfileDataSource profiles = new VpnProfileSource(getActivity());
profiles.open();
if (!uuid.equals(PREF_DEFAULT_VPN_PROFILE_MRU))
{
VpnProfile current = profiles.getVpnProfile(uuid);
if (current != null)
{
mDefaultVPNProfile.setSummary(current.getName());
}
else
{
mDefaultVPNProfile.setSummary(R.string.profile_not_found);
}
}
else
{
mDefaultVPNProfile.setSummary(R.string.pref_default_vpn_profile_mru);
}
profiles.close();
}
}
@@ -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))
}
}
}
}
@@ -1,248 +0,0 @@
/*
* Copyright (C) 2014 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;
import android.app.Dialog;
import android.content.ActivityNotFoundException;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.widget.Toast;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.logic.TrustedCertificateManager;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.security.KeyStore;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDialogFragment;
import androidx.fragment.app.FragmentTransaction;
public class TrustedCertificateImportActivity extends AppCompatActivity
{
private static final String DIALOG_TAG = "Dialog";
private Uri mCertificateUri;
private final ActivityResultLauncher<Intent> mOpenDocument = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == RESULT_OK && result.getData() != null)
{
mCertificateUri = result.getData().getData();
return;
}
finish();
}
);
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
if (savedInstanceState != null)
{ /* do nothing when we are restoring */
return;
}
Intent intent = getIntent();
String action = intent.getAction();
if (Intent.ACTION_VIEW.equals(action))
{
importCertificate(intent.getData());
}
else
{
Intent openIntent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
openIntent.setType("*/*");
try
{
mOpenDocument.launch(openIntent);
}
catch (ActivityNotFoundException e)
{ /* some devices are unable to browse for files */
finish();
}
}
}
@Override
protected void onPostResume()
{
super.onPostResume();
if (mCertificateUri != null)
{
importCertificate(mCertificateUri);
mCertificateUri = null;
}
}
/**
* Import the file pointed to by the given URI as a certificate.
*
* @param uri
*/
private void importCertificate(Uri uri)
{
X509Certificate certificate = parseCertificate(uri);
if (certificate == null)
{
Toast.makeText(this, R.string.cert_import_failed, Toast.LENGTH_LONG).show();
finish();
return;
}
/* Ask the user whether to import the certificate. This is particularly
* necessary because the import activity can be triggered by any app on
* the system. Also, if our app is the only one that is registered to
* open certificate files by MIME type the user would have no idea really
* where the file was imported just by reading the Toast we display. */
ConfirmImportDialog dialog = new ConfirmImportDialog();
Bundle args = new Bundle();
args.putSerializable(VpnProfileDataSource.KEY_CERTIFICATE, certificate);
dialog.setArguments(args);
FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
ft.add(dialog, DIALOG_TAG);
ft.commit();
}
/**
* Load the file from the given URI and try to parse it as X.509 certificate.
*
* @param uri
* @return certificate or null
*/
private X509Certificate parseCertificate(Uri uri)
{
X509Certificate certificate = null;
try
{
CertificateFactory factory = CertificateFactory.getInstance("X.509");
InputStream in = getContentResolver().openInputStream(uri);
certificate = (X509Certificate)factory.generateCertificate(in);
/* we don't check whether it's actually a CA certificate or not */
}
catch (CertificateException e)
{
e.printStackTrace();
}
catch (FileNotFoundException e)
{
e.printStackTrace();
}
return certificate;
}
/**
* Try to store the given certificate in the KeyStore.
*
* @param certificate
* @return whether it was successfully stored
*/
private boolean storeCertificate(X509Certificate certificate)
{
try
{
KeyStore store = KeyStore.getInstance("LocalCertificateStore");
store.load(null, null);
store.setCertificateEntry(null, certificate);
TrustedCertificateManager.getInstance().reset();
return true;
}
catch (Exception e)
{
e.printStackTrace();
return false;
}
}
/**
* Class that displays a confirmation dialog when a certificate should get
* imported. If the user confirms the import we try to store it.
*/
public static class ConfirmImportDialog extends AppCompatDialogFragment
{
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
{
final X509Certificate certificate;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
certificate = getCertificateCompat(getArguments());
}
else
{
certificate = getArguments().getSerializable(VpnProfileDataSource.KEY_CERTIFICATE, X509Certificate.class);
}
return new AlertDialog.Builder(getActivity())
.setIcon(R.mipmap.ic_app)
.setTitle(R.string.import_certificate)
.setMessage(certificate.getSubjectDN().toString())
.setPositiveButton(R.string.import_certificate, new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int whichButton)
{
TrustedCertificateImportActivity activity = (TrustedCertificateImportActivity)getActivity();
if (activity.storeCertificate(certificate))
{
Toast.makeText(getActivity(), R.string.cert_imported_successfully, Toast.LENGTH_LONG).show();
getActivity().setResult(RESULT_OK);
}
else
{
Toast.makeText(getActivity(), R.string.cert_import_failed, Toast.LENGTH_LONG).show();
}
getActivity().finish();
}
})
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int which)
{
getActivity().finish();
}
}).create();
}
@Override
public void onCancel(DialogInterface dialog)
{
getActivity().finish();
}
@SuppressWarnings("deprecation")
private static X509Certificate getCertificateCompat(Bundle bundle)
{
return (X509Certificate)bundle.getSerializable(VpnProfileDataSource.KEY_CERTIFICATE);
}
}
}
@@ -0,0 +1,205 @@
/*
* Copyright (C) 2014 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
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import java.io.FileNotFoundException
import java.io.InputStream
import java.security.KeyStore
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
class TrustedCertificateImportActivity : AppCompatActivity()
{
private var certificateUri: Uri? = null
private var certificate by mutableStateOf<X509Certificate?>(null)
private val mOpenDocument = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK && result.data != null)
{
certificateUri = result.data!!.data
return@registerForActivityResult
}
finish()
}
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
if (savedInstanceState != null)
{ /* do nothing when we are restoring */
return
}
setContent {
ShxTheme {
certificate?.let { cert -> ConfirmImportUi(cert) }
}
}
val intent = getIntent()
val action = intent.getAction()
if (Intent.ACTION_VIEW == action)
{
intent.getData()?.let { importCertificate(it) }
}
else
{
val openIntent = Intent(Intent.ACTION_OPEN_DOCUMENT)
openIntent.setType("*/*")
try
{
mOpenDocument.launch(openIntent)
}
catch (e: ActivityNotFoundException)
{ /* some devices are unable to browse for files */
finish()
}
}
}
override fun onPostResume()
{
super.onPostResume()
certificateUri?.let {
importCertificate(it)
certificateUri = null
}
}
/**
* Import the file pointed to by the given URI as a certificate.
*/
private fun importCertificate(uri: Uri)
{
val certificate = parseCertificate(uri)
if (certificate == null)
{
Toast.makeText(this, R.string.cert_import_failed, Toast.LENGTH_LONG).show()
finish()
return
}
/* Ask the user whether to import the certificate. This is particularly
* necessary because the import activity can be triggered by any app on
* the system. Also, if our app is the only one that is registered to
* open certificate files by MIME type the user would have no idea really
* where the file was imported just by reading the Toast we display. */
this.certificate = certificate
}
/**
* Load the file from the given URI and try to parse it as X.509 certificate.
*
* @return certificate or null
*/
private fun parseCertificate(uri: Uri): X509Certificate?
{
var certificate: X509Certificate? = null
try
{
val factory = CertificateFactory.getInstance("X.509")
val input = getContentResolver().openInputStream(uri)
certificate = factory.generateCertificate(input) as X509Certificate
/* we don't check whether it's actually a CA certificate or not */
}
catch (e: CertificateException)
{
e.printStackTrace()
}
catch (e: FileNotFoundException)
{
e.printStackTrace()
}
return certificate
}
/**
* Try to store the given certificate in the KeyStore.
*
* @return whether it was successfully stored
*/
private fun storeCertificate(certificate: X509Certificate): Boolean
{
return try
{
val store = KeyStore.getInstance("LocalCertificateStore")
store.load(null, null)
store.setCertificateEntry(null, certificate)
TrustedCertificateManager.getInstance().reset()
true
}
catch (e: Exception)
{
e.printStackTrace()
false
}
}
/**
* Confirmation dialog shown before a certificate gets imported.
*/
@androidx.compose.runtime.Composable
private fun ConfirmImportUi(certificate: X509Certificate)
{
ShxAlertDialog(
onDismissRequest = { finish() },
title = { Text(stringResource(R.string.import_certificate)) },
text = { Text(certificate.getSubjectDN().toString()) },
confirmButton = {
ShxDialogButton(primary = true, onClick = {
if (storeCertificate(certificate))
{
Toast.makeText(this, R.string.cert_imported_successfully, Toast.LENGTH_LONG).show()
setResult(RESULT_OK)
}
else
{
Toast.makeText(this, R.string.cert_import_failed, Toast.LENGTH_LONG).show()
}
finish()
}) { Text(stringResource(R.string.import_certificate)) }
},
dismissButton = {
ShxDialogButton(onClick = { finish() }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
private fun stringResource(res: Int): String = getString(res)
}
@@ -1,285 +0,0 @@
/*
* Copyright (C) 2012-2015 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;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.ListView;
import android.widget.SearchView;
import android.widget.SearchView.OnQueryTextListener;
import org.strongswan.android.R;
import org.strongswan.android.logic.TrustedCertificateManager;
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource;
import org.strongswan.android.security.TrustedCertificateEntry;
import org.strongswan.android.ui.adapter.TrustedCertificateAdapter;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Hashtable;
import java.util.List;
import java.util.Map.Entry;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.MenuProvider;
import androidx.fragment.app.ListFragment;
import androidx.lifecycle.Lifecycle;
import androidx.loader.app.LoaderManager;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
import androidx.loader.content.AsyncTaskLoader;
import androidx.loader.content.Loader;
public class TrustedCertificateListFragment extends ListFragment implements MenuProvider, LoaderCallbacks<List<TrustedCertificateEntry>>, OnQueryTextListener
{
public static final String EXTRA_CERTIFICATE_SOURCE = "certificate_source";
private OnTrustedCertificateSelectedListener mListener;
private TrustedCertificateAdapter mAdapter;
private TrustedCertificateSource mSource = TrustedCertificateSource.SYSTEM;
/**
* The activity containing this fragment should implement this interface
*/
public interface OnTrustedCertificateSelectedListener
{
public void onTrustedCertificateSelected(TrustedCertificateEntry selected);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
{
super.onViewCreated(view, savedInstanceState);
requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED);
setEmptyText(getString(R.string.no_certificates));
mAdapter = new TrustedCertificateAdapter(getActivity());
setListAdapter(mAdapter);
setListShown(false);
Bundle arguments = getArguments();
if (arguments != null)
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
mSource = getCertificateSourceCompat(arguments);
}
else
{
mSource = arguments.getSerializable(EXTRA_CERTIFICATE_SOURCE, TrustedCertificateSource.class);
}
}
LoaderManager.getInstance(this).initLoader(0, null, this);
}
@Override
public void onDestroy()
{
super.onDestroy();
}
@Override
public void onAttach(Context context)
{
super.onAttach(context);
if (context instanceof OnTrustedCertificateSelectedListener)
{
mListener = (OnTrustedCertificateSelectedListener)context;
}
}
@Override
public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater)
{
MenuItem item = menu.add(R.string.search);
item.setIcon(android.R.drawable.ic_menu_search);
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
SearchView sv = new SearchView(getActivity());
sv.setOnQueryTextListener(this);
item.setActionView(sv);
}
@Override
public boolean onMenuItemSelected(@NonNull MenuItem menuItem)
{
return false;
}
@Override
public boolean onQueryTextSubmit(String query)
{ /* already handled when the text changes */
return true;
}
@Override
public boolean onQueryTextChange(String newText)
{
String search = TextUtils.isEmpty(newText) ? null : newText;
mAdapter.getFilter().filter(search);
return true;
}
@Override
public Loader<List<TrustedCertificateEntry>> onCreateLoader(int id, Bundle args)
{ /* we don't need the id as we have only one loader */
return new CertificateListLoader(getActivity(), mSource);
}
@Override
public void onLoadFinished(Loader<List<TrustedCertificateEntry>> loader, List<TrustedCertificateEntry> data)
{
mAdapter.setData(data);
if (isResumed())
{
setListShown(true);
}
else
{
setListShownNoAnimation(true);
}
}
@Override
public void onLoaderReset(Loader<List<TrustedCertificateEntry>> loader)
{
mAdapter.setData(null);
}
@Override
public void onListItemClick(ListView l, View v, int position, long id)
{
if (mListener != null)
{
mListener.onTrustedCertificateSelected(mAdapter.getItem(position));
}
}
public static class CertificateListLoader extends AsyncTaskLoader<List<TrustedCertificateEntry>>
{
private List<TrustedCertificateEntry> mData;
private final TrustedCertificateSource mSource;
private TrustedCertificateManagerObserver mObserver;
public CertificateListLoader(Context context, TrustedCertificateSource source)
{
super(context);
mSource = source;
}
@Override
public List<TrustedCertificateEntry> loadInBackground()
{
TrustedCertificateManager certman = TrustedCertificateManager.getInstance().load();
Hashtable<String, X509Certificate> certificates = certman.getCACertificates(mSource);
List<TrustedCertificateEntry> selected;
selected = new ArrayList<TrustedCertificateEntry>();
for (Entry<String, X509Certificate> entry : certificates.entrySet())
{
selected.add(new TrustedCertificateEntry(entry.getKey(), entry.getValue()));
}
Collections.sort(selected);
return selected;
}
@Override
protected void onStartLoading()
{
if (mData != null)
{ /* if we have data ready, deliver it directly */
deliverResult(mData);
}
if (takeContentChanged() || mData == null)
{
forceLoad();
}
}
@Override
public void deliverResult(List<TrustedCertificateEntry> data)
{
if (isReset())
{
return;
}
mData = data;
if (isStarted())
{ /* if it is started we deliver the data directly,
* otherwise this is handled in onStartLoading */
if (mObserver == null)
{
mObserver = new TrustedCertificateManagerObserver();
TrustedCertificateManager.getInstance().addObserver(mObserver);
}
super.deliverResult(data);
}
}
@Override
protected void onReset()
{
if (mObserver != null)
{
TrustedCertificateManager.getInstance().deleteObserver(mObserver);
mObserver = null;
}
mData = null;
super.onReset();
}
@Override
protected void onAbandon()
{
if (mObserver != null)
{
TrustedCertificateManager.getInstance().deleteObserver(mObserver);
mObserver = null;
}
}
private class TrustedCertificateManagerObserver implements PropertyChangeListener
{
private ForceLoadContentObserver mContentObserver = new ForceLoadContentObserver();
@Override
public void propertyChange(PropertyChangeEvent evt)
{
mContentObserver.onChange(false);
}
}
}
@SuppressWarnings("deprecation")
private static TrustedCertificateSource getCertificateSourceCompat(Bundle bundle)
{
return (TrustedCertificateSource)bundle.getSerializable(EXTRA_CERTIFICATE_SOURCE);
}
}
@@ -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
}
@@ -17,12 +17,10 @@
package org.strongswan.android.ui;
import android.Manifest;
import android.app.Dialog;
import android.app.Service;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.SharedPreferences;
@@ -34,11 +32,7 @@ import android.os.Bundle;
import android.os.IBinder;
import android.os.PowerManager;
import android.provider.Settings;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import android.widget.Toast;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnProfileDataSource;
@@ -46,25 +40,33 @@ import org.strongswan.android.data.VpnProfileSource;
import org.strongswan.android.data.VpnType.VpnTypeFeature;
import org.strongswan.android.logic.VpnStateService;
import org.strongswan.android.logic.VpnStateService.State;
import org.strongswan.android.ui.compose.VpnDialog;
import org.strongswan.android.ui.compose.VpnDialogBus;
import org.strongswan.android.ui.compose.VpnDialogListener;
import org.strongswan.android.ui.compose.VpnDialogsKt;
import org.strongswan.android.utils.Constants;
import org.strongswan.android.utils.DefaultVpnProfile;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDialogFragment;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import androidx.preference.PreferenceManager;
public class VpnProfileControlActivity extends AppCompatActivity
public class VpnProfileControlActivity extends AppCompatActivity implements VpnDialogListener
{
public static final String START_PROFILE = "one.shx.strongswan.ext.action.START_PROFILE";
public static final String DISCONNECT = "one.shx.strongswan.ext.action.DISCONNECT";
public static final String TOGGLE_VPN = "one.shx.strongswan.ext.action.TOGGLE_VPN";
public static final String EXTRA_VPN_PROFILE_UUID = "one.shx.strongswan.ext.VPN_PROFILE_UUID";
/**
* If set to true, no confirmation dialogs are shown when replacing or
* disconnecting an active connection (used by automation tools)
*/
public static final String EXTRA_SILENT = "one.shx.strongswan.ext.SILENT";
private static final String EXTRA_VPN_PROFILE_ID = "one.shx.strongswan.ext.VPN_PROFILE_ID";
private static final String WAITING_FOR_RESULT = "WAITING_FOR_RESULT";
@@ -73,11 +75,13 @@ public class VpnProfileControlActivity extends AppCompatActivity
private static final String PROFILE_CERTIFICATE_MISSING = "CERTIFICATE_MISSING";
private static final String PROFILE_RECONNECT = "RECONNECT";
private static final String PROFILE_DISCONNECT = "DISCONNECT";
private static final String DIALOG_TAG = "Dialog";
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()
{
@Override
@@ -98,13 +102,14 @@ public class VpnProfileControlActivity extends AppCompatActivity
new ActivityResultContracts.StartActivityForResult(),
result -> {
mWaitingForResult = false;
VpnDialogBus.INSTANCE.setConsentHint(false);
if (result.getResultCode() == RESULT_OK && mProfileInfo != null)
{
onVpnServicePrepared();
}
else
{ /* this happens if the always-on VPN feature is activated by a different app or the user declined */
VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported_no_permission);
showNotSupported(R.string.vpn_not_supported_no_permission);
}
}
);
@@ -137,8 +142,10 @@ public class VpnProfileControlActivity extends AppCompatActivity
{
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);
}
@Override
@@ -152,10 +159,14 @@ public class VpnProfileControlActivity extends AppCompatActivity
protected void onDestroy()
{
super.onDestroy();
if (mService != null)
VpnDialogBus.INSTANCE.setListener(null);
mProfileExecutor.shutdown();
if (mBound)
{
this.unbindService(mServiceConnection);
mBound = false;
}
mService = null;
}
/**
@@ -198,13 +209,13 @@ public class VpnProfileControlActivity extends AppCompatActivity
catch (IllegalStateException ex)
{
/* this happens if the always-on VPN feature (Android 4.2+) is activated */
VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported_during_lockdown);
showNotSupported(R.string.vpn_not_supported_during_lockdown);
return;
}
catch (NullPointerException ex)
{
/* not sure when this happens exactly, but apparently it does */
VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported);
showNotSupported(R.string.vpn_not_supported);
return;
}
/* store profile info until the user grants us permission */
@@ -214,6 +225,7 @@ public class VpnProfileControlActivity extends AppCompatActivity
try
{
mWaitingForResult = true;
VpnDialogBus.INSTANCE.setConsentHint(true);
mPrepareVpnService.launch(intent);
}
catch (ActivityNotFoundException ex)
@@ -222,7 +234,8 @@ public class VpnProfileControlActivity extends AppCompatActivity
* don't have the VPN components built into the system image.
* com.android.vpndialogs/com.android.vpndialogs.ConfirmDialog
* will not be found then */
VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported);
VpnDialogBus.INSTANCE.setConsentHint(false);
showNotSupported(R.string.vpn_not_supported);
mWaitingForResult = false;
}
}
@@ -289,14 +302,7 @@ public class VpnProfileControlActivity extends AppCompatActivity
if (!pm.isIgnoringBatteryOptimizations(this.getPackageName()) &&
!pref.getBoolean(Constants.PREF_IGNORE_POWER_WHITELIST, false))
{
if (getSupportFragmentManager().isStateSaved())
{ /* we might get called via service connection and manual onActivityResult()
* call when the activity is not active anymore and fragment transactions
* would cause an illegalStateException */
return false;
}
PowerWhitelistRequired whitelist = new PowerWhitelistRequired();
whitelist.show(getSupportFragmentManager(), DIALOG_TAG);
VpnDialogBus.INSTANCE.show(VpnDialog.PowerWhitelist.INSTANCE);
return false;
}
}
@@ -337,20 +343,57 @@ public class VpnProfileControlActivity extends AppCompatActivity
profile.getUserCertificateAlias() == null);
profileInfo.putString(PROFILE_NAME, profile.getName());
removeFragmentByTag(DIALOG_TAG);
VpnDialogBus.INSTANCE.dismiss();
if (isConnected())
{
if (getIntent().getBooleanExtra(EXTRA_SILENT, false))
{ /* automation tools replace the active connection without asking */
startVpnProfile(profileInfo);
return;
}
profileInfo.putBoolean(PROFILE_RECONNECT, mService.getProfile().getUUID().equals(profile.getUUID()));
ConfirmationDialog dialog = new ConfirmationDialog();
dialog.setArguments(profileInfo);
dialog.show(this.getSupportFragmentManager(), DIALOG_TAG);
showConfirmationDialog(profileInfo);
return;
}
startVpnProfile(profileInfo);
}
/**
* Build and show the confirmation dialog for the given profile data.
*
* @param profileInfo data about the profile; PROFILE_RECONNECT and
* PROFILE_DISCONNECT select the dialog variant
*/
private void showConfirmationDialog(Bundle profileInfo)
{
boolean reconnect = profileInfo.getBoolean(PROFILE_RECONNECT);
boolean disconnect = profileInfo.getBoolean(PROFILE_DISCONNECT);
String title;
int message;
int button;
if (reconnect)
{
title = getString(R.string.vpn_connected);
message = R.string.vpn_profile_connected;
button = R.string.reconnect;
}
else if (disconnect)
{
title = getString(R.string.disconnect_question);
message = R.string.disconnect_active_connection;
button = R.string.disconnect;
}
else
{
title = getString(R.string.connect_profile_question, profileInfo.getString(PROFILE_NAME));
message = R.string.replaces_active_connection;
button = R.string.connect;
}
VpnDialogBus.INSTANCE.show(new VpnDialog.Confirmation(profileInfo, title, message, button, reconnect, disconnect));
}
/**
* Start the given VPN profile asking the user for a password if required.
*
@@ -360,17 +403,15 @@ public class VpnProfileControlActivity extends AppCompatActivity
{
if (profileInfo.getBoolean(PROFILE_CERTIFICATE_MISSING))
{
CertificateRequiredDialog dialog = new CertificateRequiredDialog();
dialog.setArguments(profileInfo);
dialog.show(getSupportFragmentManager(), DIALOG_TAG);
VpnDialogBus.INSTANCE.show(new VpnDialog.CertificateRequired(
profileInfo, profileInfo.getString(VpnProfileDataSource.KEY_UUID)));
return;
}
if (profileInfo.getBoolean(PROFILE_REQUIRES_PASSWORD) &&
profileInfo.getString(VpnProfileDataSource.KEY_PASSWORD) == null)
{
LoginDialog login = new LoginDialog();
login.setArguments(profileInfo);
login.show(getSupportFragmentManager(), DIALOG_TAG);
VpnDialogBus.INSTANCE.show(new VpnDialog.Login(
profileInfo, profileInfo.getString(VpnProfileDataSource.KEY_USERNAME)));
return;
}
prepareVpnService(profileInfo);
@@ -384,30 +425,50 @@ public class VpnProfileControlActivity extends AppCompatActivity
*/
private void startVpnProfile(Intent intent)
{
VpnProfile profile = null;
VpnProfileDataSource dataSource = new VpnProfileSource(this);
dataSource.open();
String profileUUID = intent.getStringExtra(EXTRA_VPN_PROFILE_UUID);
if (profileUUID == null)
{
profileUUID = intent.getStringExtra(EXTRA_VPN_PROFILE_ID);
}
if (profileUUID != null)
{
profile = dataSource.getVpnProfile(profileUUID);
}
dataSource.close();
final String uuid = profileUUID != null ? profileUUID
: DefaultVpnProfile.getDefaultProfileUuid(this);
mProfileExecutor.execute(() -> {
VpnProfile profile = loadProfile(uuid);
runOnUiThread(() -> {
if (isFinishing() || isDestroyed())
{ /* rotated or finished while the query ran */
return;
}
if (profile != null)
{
startVpnProfile(profile);
}
else
{
Toast.makeText(this, R.string.profile_not_found, Toast.LENGTH_LONG).show();
finish();
}
});
});
}
if (profile != null)
/**
* Load a profile from the database. Blocking - run on a background thread.
*
* @param uuid UUID of the profile, may be null
* @return the profile or null if not found
*/
private VpnProfile loadProfile(String uuid)
{
if (uuid == null)
{
startVpnProfile(profile);
}
else
{
Toast.makeText(this, R.string.profile_not_found, Toast.LENGTH_LONG).show();
finish();
return null;
}
VpnProfileDataSource dataSource = new VpnProfileSource(this);
dataSource.open();
VpnProfile profile = dataSource.getVpnProfile(uuid);
dataSource.close();
return profile;
}
/**
@@ -417,29 +478,38 @@ public class VpnProfileControlActivity extends AppCompatActivity
*/
private void disconnect(Intent intent)
{
VpnProfile profile = null;
removeFragmentByTag(DIALOG_TAG);
VpnDialogBus.INSTANCE.dismiss();
String profileUUID = intent.getStringExtra(EXTRA_VPN_PROFILE_UUID);
if (profileUUID == null)
{
profileUUID = intent.getStringExtra(EXTRA_VPN_PROFILE_ID);
}
if (profileUUID != null)
{
VpnProfileDataSource dataSource = new VpnProfileSource(this);
dataSource.open();
profile = dataSource.getVpnProfile(profileUUID);
dataSource.close();
}
final String uuid = profileUUID;
final Intent startIntent = intent;
mProfileExecutor.execute(() -> {
VpnProfile profile = loadProfile(uuid);
runOnUiThread(() -> {
if (!isFinishing() && !isDestroyed())
{
handleDisconnect(startIntent, profile);
}
});
});
}
/**
* Continue the disconnect flow on the main thread once the profile is loaded.
*/
private void handleDisconnect(Intent intent, VpnProfile profile)
{
if (mService != null)
{
if (mService.getState() == State.CONNECTED ||
mService.getState() == State.CONNECTING)
{
if (profile != null && profile.equals(mService.getProfile()))
if (intent.getBooleanExtra(EXTRA_SILENT, false) ||
(profile != null && profile.equals(mService.getProfile())))
{ /* allow explicit termination without confirmation */
mService.disconnect();
finish();
@@ -447,10 +517,7 @@ public class VpnProfileControlActivity extends AppCompatActivity
}
Bundle args = new Bundle();
args.putBoolean(PROFILE_DISCONNECT, true);
ConfirmationDialog dialog = new ConfirmationDialog();
dialog.setArguments(args);
dialog.show(this.getSupportFragmentManager(), DIALOG_TAG);
showConfirmationDialog(args);
}
else
{
@@ -466,268 +533,139 @@ public class VpnProfileControlActivity extends AppCompatActivity
{
Intent intent = getIntent();
if (START_PROFILE.equals(intent.getAction()))
{
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(intent.getAction()))
else if (DISCONNECT.equals(action))
{
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(this);
disconnect(intent);
}
}
/**
* Dismiss dialog if shown
*/
public void removeFragmentByTag(String tag)
{
FragmentManager fm = getSupportFragmentManager();
Fragment login = fm.findFragmentByTag(tag);
if (login != null)
else if (TOGGLE_VPN.equals(action))
{
FragmentTransaction ft = fm.beginTransaction();
ft.remove(login);
ft.commit();
toggleVpn(intent);
}
}
/**
* Class that displays a confirmation dialog if a VPN profile is already connected
* and then initiates the selected VPN profile if the user confirms the dialog.
* Map the shxvpn:// deep links (connect/disconnect/toggle) to the actions
* above, carrying over the optional profile parameter of the URI
*
* @param intent Intent that caused us to start this
* @return the action to execute
*/
public static class ConfirmationDialog extends AppCompatDialogFragment
private String normalizeAction(Intent intent)
{
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
Uri data = intent.getData();
if (data == null || !"shxvpn".equals(data.getScheme()))
{
final Bundle profileInfo = getArguments();
int icon = android.R.drawable.ic_dialog_alert;
String title = String.format(getString(R.string.connect_profile_question), profileInfo.getString(PROFILE_NAME));
int message = R.string.replaces_active_connection;
int button = R.string.connect;
if (profileInfo.getBoolean(PROFILE_RECONNECT))
return intent.getAction();
}
switch (data.getHost())
{
case "connect":
{
icon = android.R.drawable.ic_dialog_info;
title = getString(R.string.vpn_connected);
message = R.string.vpn_profile_connected;
button = R.string.reconnect;
}
else if (profileInfo.getBoolean(PROFILE_DISCONNECT))
{
title = getString(R.string.disconnect_question);
message = R.string.disconnect_active_connection;
button = R.string.disconnect;
}
DialogInterface.OnClickListener connectListener = new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int which)
String profile = data.getQueryParameter("profile");
if (profile != null)
{
VpnProfileControlActivity activity = (VpnProfileControlActivity)getActivity();
activity.startVpnProfile(profileInfo);
intent.putExtra(EXTRA_VPN_PROFILE_UUID, profile);
}
};
DialogInterface.OnClickListener disconnectListener = new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int which)
{
VpnProfileControlActivity activity = (VpnProfileControlActivity)getActivity();
if (activity.mService != null)
{
activity.mService.disconnect();
}
activity.finish();
}
};
DialogInterface.OnClickListener cancelListener = new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int which)
{
getActivity().finish();
}
};
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity())
.setIcon(icon)
.setTitle(title)
.setMessage(message);
if (profileInfo.getBoolean(PROFILE_DISCONNECT))
{
builder.setPositiveButton(button, disconnectListener);
return START_PROFILE;
}
else
{
builder.setPositiveButton(button, connectListener);
}
if (profileInfo.getBoolean(PROFILE_RECONNECT))
{
builder.setNegativeButton(R.string.disconnect, disconnectListener);
builder.setNeutralButton(android.R.string.cancel, cancelListener);
}
else
{
builder.setNegativeButton(android.R.string.cancel, cancelListener);
}
return builder.create();
}
@Override
public void onCancel(DialogInterface dialog)
{
getActivity().finish();
case "disconnect":
return DISCONNECT;
case "toggle":
return TOGGLE_VPN;
default:
return intent.getAction();
}
}
/**
* Class that displays a login dialog and initiates the selected VPN
* profile if the user confirms the dialog.
* Toggle the VPN connection: disconnect it if it is active (without asking),
* otherwise connect to the given or the default/most recently used profile
*
* @param intent Intent that caused us to start this
*/
public static class LoginDialog extends AppCompatDialogFragment
private void toggleVpn(Intent intent)
{
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
intent.putExtra(EXTRA_SILENT, true);
if (mService != null && (mService.getState() == State.CONNECTED ||
mService.getState() == State.CONNECTING))
{
final Bundle profileInfo = getArguments();
LayoutInflater inflater = getActivity().getLayoutInflater();
View view = inflater.inflate(R.layout.login_dialog, null);
EditText username = view.findViewById(R.id.username);
username.setText(profileInfo.getString(VpnProfileDataSource.KEY_USERNAME));
final EditText password = view.findViewById(R.id.password);
AlertDialog.Builder adb = new AlertDialog.Builder(getActivity());
adb.setView(view);
adb.setTitle(getString(R.string.login_title));
adb.setPositiveButton(R.string.login_confirm, new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int whichButton)
{
VpnProfileControlActivity activity = (VpnProfileControlActivity)getActivity();
profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, password.getText().toString().trim());
activity.prepareVpnService(profileInfo);
}
});
adb.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int which)
{
getActivity().finish();
}
});
return adb.create();
}
@Override
public void onCancel(DialogInterface dialog)
{
getActivity().finish();
disconnect(intent);
return;
}
startVpnProfile(intent);
}
/**
* Class that displays a dialog warning about a missing certificate.
* The confirmation dialog was confirmed: start the profile or disconnect.
*/
public static class CertificateRequiredDialog extends AppCompatDialogFragment
@Override
public void onVpnDialogConfirm(Bundle profileInfo)
{
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
{
final Bundle profileInfo = getArguments();
startVpnProfile(profileInfo);
}
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity())
.setTitle(R.string.certificate_required_title)
.setMessage(R.string.certificate_required_text)
.setNegativeButton(android.R.string.cancel, (dialog, which) -> getActivity().finish())
.setPositiveButton(R.string.edit_profile, (dialog, which) -> {
Intent editIntent = new Intent(getActivity(), VpnProfileDetailActivity.class);
editIntent.putExtra(VpnProfileDataSource.KEY_UUID, profileInfo.getString(VpnProfileDataSource.KEY_UUID));
startActivity(editIntent);
getActivity().finish();
});
return builder.create();
}
@Override
public void onCancel(DialogInterface dialog)
@Override
public void onVpnDialogDisconnect()
{
if (mService != null)
{
getActivity().finish();
mService.disconnect();
}
finish();
}
/**
* Class that displays a warning before asking the user to add the app to the
* device's power whitelist.
* The login dialog provided the missing password.
*/
public static class PowerWhitelistRequired extends AppCompatDialogFragment
@Override
public void onVpnDialogLogin(Bundle profileInfo, String password)
{
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
{
return new AlertDialog.Builder(getActivity())
.setTitle(R.string.power_whitelist_title)
.setMessage(R.string.power_whitelist_text)
.setPositiveButton(android.R.string.ok, (dialog, id) -> {
VpnProfileControlActivity activity = (VpnProfileControlActivity)getActivity();
activity.mWaitingForResult = true;
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:" + activity.getPackageName()));
activity.mAddToPowerWhitelist.launch(intent);
}).create();
}
profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, password != null ? password.trim() : "");
prepareVpnService(profileInfo);
}
@Override
public void onCancel(@NonNull DialogInterface dialog)
{
getActivity().finish();
}
@Override
public void onVpnDialogEditProfile(String uuid)
{
Intent editIntent = new Intent(this, VpnProfileDetailActivity.class);
editIntent.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
startActivity(editIntent);
finish();
}
/**
* Class representing an error message which is displayed if VpnService is
* not supported on the current device.
* The user agreed to be added to the power whitelist.
*/
public static class VpnNotSupportedError extends AppCompatDialogFragment
@Override
public void onVpnDialogPowerWhitelist()
{
static final String ERROR_MESSAGE_ID = "one.shx.strongswan.ext.VpnNotSupportedError.MessageId";
mWaitingForResult = true;
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:" + getPackageName()));
mAddToPowerWhitelist.launch(intent);
}
public static void showWithMessage(AppCompatActivity activity, int messageId)
{
Bundle bundle = new Bundle();
bundle.putInt(ERROR_MESSAGE_ID, messageId);
VpnNotSupportedError dialog = new VpnNotSupportedError();
dialog.setArguments(bundle);
dialog.show(activity.getSupportFragmentManager(), DIALOG_TAG);
}
/**
* Show the "VPN not supported" error dialog with the given message.
*
* @param messageId resource of the error message
*/
private void showNotSupported(int messageId)
{
VpnDialogBus.INSTANCE.show(new VpnDialog.NotSupported(messageId));
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
{
final Bundle arguments = getArguments();
final int messageId = arguments.getInt(ERROR_MESSAGE_ID);
return new AlertDialog.Builder(getActivity())
.setTitle(R.string.vpn_not_supported_title)
.setMessage(messageId)
.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener()
{
@Override
public void onClick(DialogInterface dialog, int id)
{
getActivity().finish();
}
}).create();
}
@Override
public void onCancel(DialogInterface dialog)
{
getActivity().finish();
}
@Override
public void onVpnDialogCancel()
{
finish();
}
}
@@ -14,17 +14,20 @@ import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.ui.compose.ProfileFormScreen
import org.strongswan.android.ui.compose.ProfileFormState
import org.strongswan.android.ui.compose.isValid
import org.strongswan.android.ui.compose.toFormState
import org.strongswan.android.ui.compose.toProfile
@@ -32,11 +35,28 @@ import org.strongswan.android.ui.compose.validate
import org.strongswan.android.ui.compose.theme.ShxTheme
import org.strongswan.android.utils.Constants
/** Holds the form state so entered values survive activity recreation (rotation). */
class ProfileFormViewModel(initial: ProfileFormState) : ViewModel()
{
var state by mutableStateOf(initial)
}
class VpnProfileDetailActivity : AppCompatActivity()
{
private lateinit var dataSource: VpnProfileDataSource
private var existing: VpnProfile? = null
private val formViewModel: ProfileFormViewModel by viewModels {
object : ViewModelProvider.Factory
{
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ProfileFormViewModel(existing?.toFormState() ?: ProfileFormState()) as T
}
}
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
@@ -53,28 +73,29 @@ class VpnProfileDetailActivity : AppCompatActivity()
return
}
}
val initial = existing?.toFormState() ?: org.strongswan.android.ui.compose.ProfileFormState()
val title = if (existing != null) existing!!.name ?: getString(R.string.add_profile)
else getString(R.string.add_profile)
setContent {
ShxTheme {
var state by remember { mutableStateOf(initial) }
ProfileFormScreen(
title = title,
state = state,
onChange = { state = it },
state = formViewModel.state,
onChange = { formViewModel.state = it },
onCancel = { finish() },
messages = messages,
onSave = {
val state = formViewModel.state
val checked = state.validate(this)
state = checked
formViewModel.state = checked
if (!checked.isValid())
{
Toast.makeText(this, R.string.alert_text_no_input_gateway, Toast.LENGTH_SHORT).show()
messages.tryEmit(R.string.alert_text_no_input_gateway)
return@ProfileFormScreen
}
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)
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2016-2025 Tobias Brunner
* Copyright (C) 2012-2020 Tobias Brunner
*
* Copyright (C) secunet Security Networks AG
*
@@ -28,21 +28,13 @@ import android.security.KeyChainAliasCallback;
import android.security.KeyChainException;
import android.text.TextUtils;
import android.util.Base64;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.RelativeLayout;
import android.widget.TextView;
import android.widget.Toast;
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;
@@ -50,8 +42,10 @@ 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.widget.TextInputLayoutHelper;
import org.strongswan.android.ui.compose.ImportUiModel;
import org.strongswan.android.ui.compose.ImportScreenKt;
import org.strongswan.android.utils.Constants;
import org.strongswan.android.utils.IPRangeSet;
import org.strongswan.android.utils.Utils;
@@ -77,9 +71,7 @@ import javax.net.ssl.SSLHandshakeException;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.WindowCompat;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.AsyncTaskLoader;
import androidx.loader.content.Loader;
@@ -98,31 +90,15 @@ public class VpnProfileImportActivity extends AppCompatActivity
private TrustedCertificateEntry mCertEntry;
private TrustedCertificateEntry mUserCertEntry;
private String mUserCertLoading;
private boolean mHideImport;
private androidx.core.widget.ContentLoadingProgressBar mProgressBar;
private TextView mExistsWarning;
private TextView mSharedSecretWarning;
private ViewGroup mBasicDataGroup;
private TextView mName;
private TextView mGateway;
private TextView mSelectVpnType;
private ViewGroup mUsernamePassword;
private EditText mUsername;
private TextInputLayoutHelper mUsernameWrap;
private EditText mPassword;
private ViewGroup mUserCertificate;
private RelativeLayout mSelectUserCert;
private Button mImportUserCert;
private ViewGroup mRemoteCertificate;
private RelativeLayout mRemoteCert;
private final ImportUiModel mUi = new ImportUiModel();
private final ActivityResultLauncher<Intent> mImportPKCS12 = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == RESULT_OK)
{ /* no need to import twice */
mImportUserCert.setEnabled(false);
mSelectUserCert.performClick();
mUi.setShowImportUserCert(false);
chooseUserCertificate();
}
}
);
@@ -199,58 +175,12 @@ public class VpnProfileImportActivity extends AppCompatActivity
{
super.onCreate(savedInstanceState);
ActionBar bar = getSupportActionBar();
if (bar != null)
{
bar.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp);
bar.setDisplayHomeAsUpEnabled(true);
}
mDataSource = new VpnProfileSource(this);
mDataSource.open();
setContentView(R.layout.profile_import_view);
WindowCompat.enableEdgeToEdge(getWindow());
mProgressBar = findViewById(R.id.progress_bar);
mExistsWarning = findViewById(R.id.exists_warning);
mSharedSecretWarning = findViewById(R.id.shared_secret_warning);
mBasicDataGroup = findViewById(R.id.basic_data_group);
mName = findViewById(R.id.name);
mGateway = findViewById(R.id.gateway);
mSelectVpnType = findViewById(R.id.vpn_type);
mUsernamePassword = findViewById(R.id.username_password_group);
mUsername = findViewById(R.id.username);
mUsernameWrap = findViewById(R.id.username_wrap);
mPassword = findViewById(R.id.password);
mUserCertificate = findViewById(R.id.user_certificate_group);
mSelectUserCert = findViewById(R.id.select_user_certificate);
mImportUserCert = findViewById(R.id.import_user_certificate);
mRemoteCertificate = findViewById(R.id.remote_certificate_group);
mRemoteCert = findViewById(R.id.remote_certificate);
mExistsWarning.setVisibility(View.GONE);
mSharedSecretWarning.setVisibility(View.GONE);
mBasicDataGroup.setVisibility(View.GONE);
mUsernamePassword.setVisibility(View.GONE);
mUserCertificate.setVisibility(View.GONE);
mRemoteCertificate.setVisibility(View.GONE);
mSelectUserCert.setOnClickListener(new SelectUserCertOnClickListener());
mImportUserCert.setOnClickListener(new View.OnClickListener()
{
@Override
public void onClick(View v)
{
Intent intent = KeyChain.createInstallIntent();
intent.putExtra(KeyChain.EXTRA_NAME, getString(R.string.profile_cert_alias, mProfile.getName()));
intent.putExtra(KeyChain.EXTRA_PKCS12, mProfile.PKCS12);
mImportPKCS12.launch(intent);
}
});
ImportScreenKt.installImportScreen(this, mUi,
this::saveProfile, this::finish,
this::importPkcs12, this::chooseUserCertificate, this::retryLoad);
Intent intent = getIntent();
String action = intent.getAction();
@@ -280,7 +210,10 @@ public class VpnProfileImportActivity extends AppCompatActivity
{
LoaderManager.getInstance(this).initLoader(USER_CERT_LOADER, null, mUserCertificateLoaderCallbacks);
}
mImportUserCert.setEnabled(!savedInstanceState.getBoolean(PKCS12_INSTALLED));
if (savedInstanceState.getBoolean(PKCS12_INSTALLED))
{
mUi.setShowImportUserCert(false);
}
}
}
@@ -299,50 +232,49 @@ public class VpnProfileImportActivity extends AppCompatActivity
{
outState.putString(VpnProfileDataSource.KEY_USER_CERTIFICATE, mUserCertEntry.getAlias());
}
outState.putBoolean(PKCS12_INSTALLED, !mImportUserCert.isEnabled());
outState.putBoolean(PKCS12_INSTALLED, !mUi.getShowImportUserCert());
}
@Override
public boolean onCreateOptionsMenu(Menu menu)
private void importPkcs12()
{
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.profile_import, menu);
if (mHideImport)
if (mProfile == null)
{
MenuItem item = menu.findItem(R.id.menu_accept);
item.setVisible(false);
return;
}
return true;
Intent intent = KeyChain.createInstallIntent();
intent.putExtra(KeyChain.EXTRA_NAME, getString(R.string.profile_cert_alias, mProfile.getName()));
intent.putExtra(KeyChain.EXTRA_PKCS12, mProfile.PKCS12);
mImportPKCS12.launch(intent);
}
@Override
public boolean onOptionsItemSelected(MenuItem item)
{
switch (item.getItemId())
{
case android.R.id.home:
finish();
return true;
case R.id.menu_accept:
saveProfile();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
private Uri mLastUri;
private void loadProfile(Uri uri)
{
mProgressBar.show();
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)
{
mProgressBar.hide();
mUi.setLoading(false);
mProfile = null;
if (data != null && data.ThrownException == null)
@@ -354,10 +286,8 @@ public class VpnProfileImportActivity extends AppCompatActivity
}
catch (JSONException e)
{
mExistsWarning.setVisibility(View.VISIBLE);
mExistsWarning.setText(e.getLocalizedMessage());
mHideImport = true;
invalidateOptionsMenu();
mUi.setExistsWarning(e.getLocalizedMessage());
mUi.setHideImport(true);
return;
}
}
@@ -387,44 +317,34 @@ 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());
mExistsWarning.setVisibility(mExisting != null ? View.VISIBLE : View.GONE);
mUi.setExistsWarning(mExisting != null ? getString(R.string.profile_import_exists) : null);
mBasicDataGroup.setVisibility(View.VISIBLE);
mName.setText(mProfile.getName());
mGateway.setText(mProfile.getGateway());
mSelectVpnType.setText(getResources().getStringArray(R.array.vpn_types)[mProfile.getVpnType().ordinal()]);
mUi.setShowForm(true);
mUi.setName(mProfile.getName());
mUi.setGateway(mProfile.getGateway());
mUi.setVpnType(getResources().getStringArray(R.array.vpn_types)[mProfile.getVpnType().ordinal()]);
mUsernamePassword.setVisibility(mProfile.getVpnType().has(VpnTypeFeature.USER_PASS) ? View.VISIBLE : View.GONE);
mUi.setRequireUserPass(mProfile.getVpnType().has(VpnTypeFeature.USER_PASS));
if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS))
{
mUsername.setText(mProfile.getUsername());
if (!TextUtils.isEmpty(mProfile.getUsername()))
{
mUsername.setEnabled(false);
}
mPassword.setText(mProfile.getPassword());
mUi.setUsername(mProfile.getUsername() != null ? mProfile.getUsername() : "");
mUi.setUsernameEditable(TextUtils.isEmpty(mProfile.getUsername()));
mUi.setPassword(mProfile.getPassword() != null ? mProfile.getPassword() : "");
mUi.setPasswordEditable(TextUtils.isEmpty(mProfile.getPassword()));
if (!TextUtils.isEmpty(mProfile.getPassword()))
{
mPassword.setEnabled(false);
mSharedSecretWarning.setVisibility(View.VISIBLE);
mUi.setSharedSecretWarning(true);
}
}
mUserCertificate.setVisibility(mProfile.getVpnType().has(VpnTypeFeature.CERTIFICATE) ? View.VISIBLE : View.GONE);
mRemoteCertificate.setVisibility(mProfile.Certificate != null ? View.VISIBLE : View.GONE);
mImportUserCert.setVisibility(mProfile.PKCS12 != null ? View.VISIBLE : View.GONE);
mUi.setRequireUserCert(mProfile.getVpnType().has(VpnTypeFeature.CERTIFICATE));
mUi.setShowRemoteCert(mProfile.Certificate != null);
mUi.setShowImportUserCert(mProfile.PKCS12 != null);
if (mProfile.getVpnType().has(VpnTypeFeature.CERTIFICATE))
{ /* try to load an existing certificate with the default name */
@@ -446,13 +366,14 @@ public class VpnProfileImportActivity extends AppCompatActivity
store.load(null, null);
String alias = store.getCertificateAlias(certificate);
mCertEntry = new TrustedCertificateEntry(alias, certificate);
((TextView)mRemoteCert.findViewById(android.R.id.text1)).setText(mCertEntry.getSubjectPrimary());
((TextView)mRemoteCert.findViewById(android.R.id.text2)).setText(mCertEntry.getSubjectSecondary());
mUi.setShowRemoteCert(true);
mUi.setRemoteCertPrimary(mCertEntry.getSubjectPrimary());
mUi.setRemoteCertSecondary(mCertEntry.getSubjectSecondary());
}
catch (CertificateException | NoSuchAlgorithmException | KeyStoreException | IOException e)
{
e.printStackTrace();
mRemoteCertificate.setVisibility(View.GONE);
mUi.setShowRemoteCert(false);
}
}
}
@@ -468,22 +389,40 @@ public class VpnProfileImportActivity extends AppCompatActivity
{
if (mUserCertLoading != null)
{
((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setText(mUserCertLoading);
((TextView)mSelectUserCert.findViewById(android.R.id.text2)).setText(R.string.loading);
mUi.setUserCertLoading(true);
mUi.setUserCertAlias(mUserCertLoading);
mUi.setUserCertSubject(getString(R.string.loading));
}
else if (mUserCertEntry != null)
{ /* clear any errors and set the new data */
((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setError(null);
((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setText(mUserCertEntry.getAlias());
((TextView)mSelectUserCert.findViewById(android.R.id.text2)).setText(mUserCertEntry.getCertificate().getSubjectDN().toString());
mUi.setUserCertLoading(false);
mUi.setUserCertError(false);
mUi.setUserCertAlias(mUserCertEntry.getAlias());
mUi.setUserCertSubject(mUserCertEntry.getCertificate().getSubjectDN().toString());
}
else
{
((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setText(R.string.profile_user_select_certificate_label);
((TextView)mSelectUserCert.findViewById(android.R.id.text2)).setText(R.string.profile_user_select_certificate);
mUi.setUserCertLoading(false);
mUi.setUserCertAlias(null);
mUi.setUserCertSubject(null);
}
}
private void chooseUserCertificate()
{
String alias = null;
if (mUserCertEntry != null)
{
alias = mUserCertEntry.getAlias();
mUserCertEntry = null;
}
else if (mProfile != null)
{
alias = getString(R.string.profile_cert_alias, mProfile.getName());
}
KeyChain.choosePrivateKeyAlias(this, new UserCertAliasCallback(), null, null, null, -1, alias);
}
private ParsedVpnProfile parseProfile(JSONObject obj) throws JSONException
{
UUID uuid;
@@ -701,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());
@@ -725,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);
@@ -732,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();
@@ -748,15 +704,15 @@ public class VpnProfileImportActivity extends AppCompatActivity
boolean valid = true;
if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS))
{
if (mUsername.getText().toString().trim().isEmpty())
if (mUi.getUsername().trim().isEmpty())
{
mUsernameWrap.setError(getString(R.string.alert_text_no_input_username));
mUi.setUsernameError(true);
valid = false;
}
}
if (mProfile.getVpnType().has(VpnTypeFeature.CERTIFICATE) && mUserCertEntry == null)
{ /* let's show an error icon */
((TextView)mSelectUserCert.findViewById(android.R.id.text1)).setError("");
{
mUi.setUserCertError(true);
valid = false;
}
return valid;
@@ -767,10 +723,16 @@ 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(mUsername.getText().toString().trim());
String password = mPassword.getText().toString().trim();
mProfile.setUsername(mUi.getUsername().trim());
String password = mUi.getPassword().trim();
password = password.isEmpty() ? null : password;
mProfile.setPassword(password);
}
@@ -906,26 +868,10 @@ public class VpnProfileImportActivity extends AppCompatActivity
}
/**
* Ask the user to select an available certificate.
* Receives the alias chosen in the KeyChain dialog and loads the certificate.
*/
private class SelectUserCertOnClickListener implements View.OnClickListener, KeyChainAliasCallback
private class UserCertAliasCallback implements KeyChainAliasCallback
{
@Override
public void onClick(View v)
{
String alias = null;
if (mUserCertEntry != null)
{
alias = mUserCertEntry.getAlias();
mUserCertEntry = null;
}
else if (mProfile != null)
{
alias = getString(R.string.profile_cert_alias, mProfile.getName());
}
KeyChain.choosePrivateKeyAlias(VpnProfileImportActivity.this, this, null, null, null, -1, alias);
}
@Override
public void alias(final String alias)
{
@@ -17,18 +17,41 @@ 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
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
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
@@ -41,11 +64,22 @@ class VpnProfileSelectActivity : AppCompatActivity()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setResult(RESULT_CANCELED)
val source = VpnProfileSource(this).open()
val profiles = source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
source.close()
setContent {
ShxTheme {
/* 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.sortedBy { it.name?.lowercase() ?: "" }
}
finally
{
source.close()
}
}
}
ProfileSelectScreen(
profiles = profiles,
onBack = { finish() },
@@ -75,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))
}
}
}
}
@@ -24,7 +24,6 @@ import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.SharedPreferences;
import android.graphics.drawable.Icon;
import android.os.Build;
import android.os.IBinder;
@@ -38,9 +37,7 @@ 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.utils.Constants;
import androidx.preference.PreferenceManager;
import org.strongswan.android.utils.DefaultVpnProfile;
@TargetApi(Build.VERSION_CODES.N)
public class VpnTileService extends TileService implements VpnStateService.VpnStateListener
@@ -48,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
@@ -74,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();
@@ -86,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;
}
@@ -99,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)
{
@@ -121,13 +127,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
private VpnProfile getProfile()
{
SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(this);
String uuid = pref.getString(Constants.PREF_DEFAULT_VPN_PROFILE, null);
if (uuid == null || uuid.equals(Constants.PREF_DEFAULT_VPN_PROFILE_MRU))
/* updateTile() runs on every state notification (including retry
* countdown ticks): query the database once per listening session */
if (!mDefaultProfileLoaded)
{
uuid = pref.getString(Constants.PREF_MRU_VPN_PROFILE, null);
mDefaultProfile = mDataSource != null ? mDataSource.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(this)) : null;
mDefaultProfileLoaded = true;
}
return mDataSource != null ? mDataSource.getVpnProfile(uuid) : null;
return mDefaultProfile;
}
@Override
@@ -145,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())
@@ -205,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
{
@@ -248,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
{
@@ -262,23 +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)
{
tile.setLabel(profile.getName());
tile.setSubtitle(subtitle);
}
tile.updateTile();
}
@@ -1,53 +0,0 @@
/*
* Copyright (C) 2016-2019 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.Context;
import android.widget.ArrayAdapter;
import org.strongswan.android.security.TrustedCertificateEntry;
public class CertificateIdentitiesAdapter extends ArrayAdapter<String>
{
TrustedCertificateEntry mCertificate;
public CertificateIdentitiesAdapter(Context context)
{
super(context, android.R.layout.simple_dropdown_item_1line);
extractIdentities();
}
/**
* Set a new certificate for this adapter.
*
* @param certificate the certificate to extract identities from (null to clear)
*/
public void setCertificate(TrustedCertificateEntry certificate)
{
mCertificate = certificate;
clear();
extractIdentities();
}
private void extractIdentities()
{
if (mCertificate != null)
{
addAll(mCertificate.getSubjectAltNames());
}
}
}
@@ -1,72 +0,0 @@
/*
* Copyright (C) 2013 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 java.util.List;
import org.strongswan.android.R;
import org.strongswan.android.logic.imc.RemediationInstruction;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;
public class RemediationInstructionAdapter extends ArrayAdapter<RemediationInstruction>
{
public RemediationInstructionAdapter(Context context)
{
super(context, 0);
}
/**
* Set new data for this adapter.
*
* @param data the new data (null to clear)
*/
public void setData(List<RemediationInstruction> data)
{
clear();
if (data != null)
{
addAll(data);
}
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
View view;
if (convertView != null)
{
view = convertView;
}
else
{
LayoutInflater inflater = LayoutInflater.from(getContext());
view = inflater.inflate(R.layout.remediation_instruction_item, parent, false);
}
RemediationInstruction item = getItem(position);
TextView text = (TextView)view.findViewById(android.R.id.text1);
text.setText(item.getTitle());
text = (TextView)view.findViewById(android.R.id.text2);
text.setText(item.getDescription());
return view;
}
}
@@ -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());
}
}
@@ -1,175 +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.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.TextView;
import org.strongswan.android.R;
import org.strongswan.android.ui.widget.CheckableLinearLayout;
import java.util.ArrayList;
import java.util.List;
public class SelectedApplicationsAdapter extends BaseAdapter implements Filterable
{
private final Context mContext;
private final Object mLock = new Object();
private final List<SelectedApplicationEntry> mData;
private List<SelectedApplicationEntry> mDataFiltered;
private SelectedApplicationsFilter mFilter;
private boolean mReadOnly;
public SelectedApplicationsAdapter(Context context)
{
mContext = context;
mData = mDataFiltered = new ArrayList<>();
}
/**
* Set new data for this adapter.
*
* @param data the new data (null to clear)
*/
public void setData(List<SelectedApplicationEntry> data)
{
synchronized (mLock)
{
mData.clear();
mDataFiltered = mData;
if (data != null)
{
mData.addAll(data);
}
}
notifyDataSetChanged();
}
@Override
public int getCount()
{
return mDataFiltered.size();
}
@Override
public SelectedApplicationEntry getItem(int position)
{
return mDataFiltered.get(position);
}
@Override
public long getItemId(int position)
{
return mDataFiltered.get(position).toString().hashCode();
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
View view;
if (convertView != null)
{
view = convertView;
}
else
{
LayoutInflater inflater = LayoutInflater.from(mContext);
view = inflater.inflate(R.layout.selected_application_item, parent, false);
}
SelectedApplicationEntry item = getItem(position);
CheckableLinearLayout checkable = (CheckableLinearLayout)view;
checkable.setChecked(item.isSelected());
checkable.setEnabled(!mReadOnly);
ImageView icon = view.findViewById(R.id.app_icon);
icon.setImageDrawable(item.getIcon());
TextView text = view.findViewById(R.id.app_name);
text.setText(item.toString());
return view;
}
@Override
public Filter getFilter()
{
if (mFilter == null)
{
mFilter = new SelectedApplicationsFilter();
}
return mFilter;
}
public boolean isReadOnly()
{
return mReadOnly;
}
public void setReadOnly(final boolean readOnly)
{
this.mReadOnly = readOnly;
}
private class SelectedApplicationsFilter extends Filter
{
@Override
protected FilterResults performFiltering(CharSequence constraint)
{
FilterResults results = new FilterResults();
ArrayList<SelectedApplicationEntry> data, filtered;
synchronized (mLock)
{
data = new ArrayList<>(mData);
}
if (TextUtils.isEmpty(constraint))
{
filtered = data;
}
else
{
String filter = constraint.toString().toLowerCase();
filtered = new ArrayList<>();
for (SelectedApplicationEntry entry : data)
{
if (entry.toString().toLowerCase().contains(filter))
{
filtered.add(entry);
}
}
}
results.values = filtered;
results.count = filtered.size();
return results;
}
@Override
@SuppressWarnings("unchecked")
protected void publishResults(CharSequence constraint, FilterResults results)
{
mDataFiltered = (List<SelectedApplicationEntry>)results.values;
notifyDataSetChanged();
}
}
}
@@ -1,72 +0,0 @@
/*
* Copyright (C) 2012 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 java.util.List;
import org.strongswan.android.R;
import org.strongswan.android.security.TrustedCertificateEntry;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;
public class TrustedCertificateAdapter extends ArrayAdapter<TrustedCertificateEntry>
{
public TrustedCertificateAdapter(Context context)
{
super(context, R.layout.trusted_certificates_item);
}
/**
* Set new data for this adapter.
*
* @param data the new data (null to clear)
*/
public void setData(List<TrustedCertificateEntry> data)
{
clear();
if (data != null)
{
addAll(data);
}
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
View view;
if (convertView != null)
{
view = convertView;
}
else
{
LayoutInflater inflater = LayoutInflater.from(getContext());
view = inflater.inflate(R.layout.trusted_certificates_item, parent, false);
}
TrustedCertificateEntry item = getItem(position);
TextView text = (TextView)view.findViewById(R.id.subject_primary);
text.setText(item.getSubjectPrimary());
text = (TextView)view.findViewById(R.id.subject_secondary);
text.setText(item.getSubjectSecondary());
return view;
}
}
@@ -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),
)
}
}
}
@@ -0,0 +1,348 @@
/*
* 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.foundation.clickable
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.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.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.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
import org.strongswan.android.ui.compose.components.ShxSettingToggleRow
import org.strongswan.android.logic.connectivity.HealthAction
import org.strongswan.android.logic.connectivity.HealthCheckManager
import org.strongswan.android.logic.connectivity.HealthConfig
import org.strongswan.android.logic.connectivity.ProbeTarget
import org.strongswan.android.logic.connectivity.ServerCheckMode
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Connectivity check settings: what to probe, how often, and what to do when
* the server or the internet through the tunnel is unreachable.
*/
@Composable
fun ConnectivityCheckScreen()
{
val context = LocalContext.current
val manager = remember { HealthCheckManager.getInstance(context) }
var config by remember { mutableStateOf(manager.currentConfig) }
var showIntervalPicker by remember { mutableStateOf(false) }
fun update(transform: (HealthConfig) -> HealthConfig)
{
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 = ShxIcons.Pulse,
title = stringResource(R.string.health_check_title),
subtitle = stringResource(R.string.health_check_summary),
checked = config.enabled,
onCheckedChange = { checked -> update { it.copy(enabled = checked) } },
)
ShxSectionDivider()
ShxSettingRow(
icon = ShxIcons.Clock,
title = stringResource(R.string.health_check_interval),
subtitle = intervalLabel(config.intervalSec, config.aggressiveMode),
enabled = config.enabled && !config.aggressiveMode,
onClick = { if (config.enabled && !config.aggressiveMode) showIntervalPicker = true },
)
ShxSectionDivider()
ShxSettingToggleRow(
icon = ShxIcons.Bolt,
title = stringResource(R.string.health_check_aggressive_title),
subtitle = stringResource(R.string.health_check_aggressive_summary),
checked = config.aggressiveMode,
onCheckedChange = { aggressive -> update { it.copy(aggressiveMode = aggressive) } },
)
ShxSectionDivider()
ShxSettingToggleRow(
icon = ShxIcons.Battery,
title = stringResource(R.string.health_check_adaptive_title),
subtitle = stringResource(R.string.health_check_adaptive_summary),
checked = config.adaptiveMode,
onCheckedChange = { adaptive -> update { it.copy(adaptiveMode = adaptive) } },
)
}
ShxSection(title = stringResource(R.string.health_check_targets_title))
{
ShxSettingToggleRow(
icon = ShxIcons.Shield,
title = stringResource(R.string.health_check_server_title),
subtitle = stringResource(R.string.health_check_server_summary),
checked = config.serverCheckMode != ServerCheckMode.OFF,
onCheckedChange = { on ->
update {
it.copy(serverCheckMode = if (on) ServerCheckMode.WITH_TUNNEL else ServerCheckMode.OFF)
}
},
)
listOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE).forEach { target ->
ShxSectionDivider()
ShxSettingToggleRow(
icon = ShxIcons.Globe,
title = targetLabel(target),
subtitle = null,
checked = config.targets.contains(target),
onCheckedChange = { on ->
update { it.copy(targets = if (on) it.targets + target else it.targets - target) }
},
)
}
}
ShxSection(title = stringResource(R.string.health_check_server_down_title))
{
Column(Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp))
{
Text(
stringResource(R.string.health_check_mode),
style = ShxType.body,
color = if (config.enabled) LocalShxColors.current.text else LocalShxColors.current.textSub,
)
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(top = ShxSpacing.sm))
{
ServerCheckMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = config.serverCheckMode == mode,
onClick = { if (config.enabled) update { it.copy(serverCheckMode = mode) } },
enabled = config.enabled,
shape = SegmentedButtonDefaults.itemShape(index = index, count = ServerCheckMode.entries.size),
label = { Text(serverModeShortLabel(mode), maxLines = 1) },
)
}
}
}
ShxSectionDivider()
ShxSettingRow(
icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.serverAction),
enabled = config.enabled,
onClick = {
if (config.enabled)
{
update {
it.copy(
serverAction = if (it.serverAction == HealthAction.RECONNECT) HealthAction.IGNORE else HealthAction.RECONNECT
)
}
}
},
)
ShxSectionDivider()
AttemptsRow(
title = stringResource(R.string.health_check_max_attempts),
attempts = config.serverMaxAttempts,
enabled = config.enabled && config.serverAction == HealthAction.RECONNECT,
onDecrement = { update { it.copy(serverMaxAttempts = (it.serverMaxAttempts - 1).coerceAtLeast(1)) } },
onIncrement = { update { it.copy(serverMaxAttempts = (it.serverMaxAttempts + 1).coerceAtMost(5)) } },
)
}
ShxSection(title = stringResource(R.string.health_check_tunnel_dead_title))
{
ShxSettingRow(
icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.tunnelDeadAction),
enabled = config.enabled,
onClick = {
if (config.enabled)
{
update {
it.copy(
tunnelDeadAction = if (it.tunnelDeadAction == HealthAction.RECONNECT) HealthAction.IGNORE else HealthAction.RECONNECT
)
}
}
},
)
ShxSectionDivider()
AttemptsRow(
title = stringResource(R.string.health_check_max_attempts),
attempts = config.tunnelDeadMaxAttempts,
enabled = config.enabled && config.tunnelDeadAction == HealthAction.RECONNECT,
onDecrement = { update { it.copy(tunnelDeadMaxAttempts = (it.tunnelDeadMaxAttempts - 1).coerceAtLeast(1)) } },
onIncrement = { update { it.copy(tunnelDeadMaxAttempts = (it.tunnelDeadMaxAttempts + 1).coerceAtMost(5)) } },
)
}
ShxSection(title = stringResource(R.string.health_check_partial_title))
{
ShxSettingRow(
icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.partialDownAction),
enabled = config.enabled,
onClick = {
if (config.enabled)
{
update {
it.copy(
partialDownAction = if (it.partialDownAction == HealthAction.RECONNECT) HealthAction.IGNORE else HealthAction.RECONNECT
)
}
}
},
)
}
}
if (showIntervalPicker)
{
val options = listOf(15, 30, 60, 300)
ShxAlertDialog(
onDismissRequest = { showIntervalPicker = false },
title = { Text(stringResource(R.string.health_check_interval)) },
text = {
Column {
options.forEach { seconds ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
update { it.copy(intervalSec = seconds) }
showIntervalPicker = false
}
.padding(vertical = 10.dp),
)
{
RadioButton(selected = config.intervalSec == seconds, onClick = null)
Text(intervalLabel(seconds, false), modifier = Modifier.padding(start = ShxSpacing.md))
}
}
}
},
confirmButton = {
ShxDialogButton(onClick = { showIntervalPicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@Composable
private fun intervalLabel(seconds: Int, aggressive: Boolean): String = when
{
aggressive -> stringResource(R.string.health_check_interval_aggressive)
seconds < 60 -> stringResource(R.string.health_check_interval_seconds, seconds)
else -> stringResource(R.string.health_check_interval_minutes, seconds / 60)
}
/** Short labels for the segmented control (full ones don't fit a segment). */
@Composable
private fun serverModeShortLabel(mode: ServerCheckMode): String = when (mode)
{
ServerCheckMode.ALWAYS -> stringResource(R.string.health_check_mode_short_always)
ServerCheckMode.WITH_TUNNEL -> stringResource(R.string.health_check_mode_short_tunnel_down)
ServerCheckMode.OFF -> stringResource(R.string.health_check_mode_short_never)
}
@Composable
private fun actionLabel(action: HealthAction): String = when (action)
{
HealthAction.RECONNECT -> stringResource(R.string.health_action_reconnect)
HealthAction.IGNORE -> stringResource(R.string.health_action_ignore)
}
@Composable
private fun targetLabel(target: ProbeTarget): String = when (target)
{
ProbeTarget.GOOGLE -> stringResource(R.string.probe_target_google)
ProbeTarget.YOUTUBE -> stringResource(R.string.probe_target_youtube)
ProbeTarget.CLOUDFLARE -> stringResource(R.string.probe_target_cloudflare)
ProbeTarget.SERVER -> stringResource(R.string.probe_target_server)
}
@Composable
private fun AttemptsRow(
title: String,
attempts: Int,
enabled: Boolean,
onDecrement: () -> Unit,
onIncrement: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
{
Text(
title,
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(ShxIcons.Minus, contentDescription = stringResource(R.string.health_check_attempt_minus))
}
Text(
text = attempts.toString(),
style = ShxType.cardTitle,
modifier = Modifier.padding(horizontal = ShxSpacing.sm),
)
IconButton(onClick = onIncrement, enabled = enabled && attempts < 5) {
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
Loaded 100 of 771 files, more files were not shown because too many files have changed in this diff. Show more