Author SHA1 Message Date
DenozordecandClaude Opus 5.5 68ee051ab2 android: version 2.19.4 (172) — обновления и отчёты через панель SHX VPN
Android / pre-check (push) Failing after 8s
CIFuzz / pre-check (push) Failing after 3s
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 3s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (address) (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 / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (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)
Android / pre-check (push) Failing after 5s
CIFuzz / pre-check (push) Failing after 4s
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (address) (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 / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:55:25 +07:00
DenozordecandClaude Sonnet 5.5 5ffb5a2dbb android: «How traffic goes» no longer says the app is not found on the first try: the check waits for the installed-apps list instead of a fixed 300 ms delay
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:50:50 +07:00
DenozordecandClaude Sonnet 5.5 ae06a58013 android: version 2.19.0 (168)
Android / pre-check (push) Failing after 7s
CIFuzz / pre-check (push) Failing after 3s
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
macOS / pre-check (push) Failing after 5s
Linux / alpine (push) Skipped
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 4s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (address) (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 / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (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)
Android / pre-check (push) Failing after 6s
CIFuzz / pre-check (push) Failing after 3s
CodeQL / pre-check (push) Failing after 4s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 4s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (address) (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 / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:42:48 +07:00
DenozordecandClaude Sonnet 5.5 228f9f6004 android: deleting or updating a profile no longer crashes when its data source was already closed
A profile keeps the VpnProfileSqlDataSource it was loaded from, and close() nulls its database, so insert/update/delete on that profile hit a NullPointerException and the profile stayed in place. The data source now reopens the database on demand.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:41:29 +07:00
DenozordecandClaude Sonnet 5.5 6756d2fca5 android: version 2.18.1 (166)
Android / pre-check (push) Failing after 5s
CIFuzz / pre-check (push) Failing after 4s
CodeQL / pre-check (push) Failing after 5s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 3s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 4s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 4s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Android / android (push) Skipped
CIFuzz / fuzzing (address) (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 / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Windows / native (i686, mingw32, win32) (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
64 changed files with 4601 additions and 430 deletions

No files matched your search

+18
View File
@@ -40,3 +40,21 @@
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`) `local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows). и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
- Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись. - Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись.
## Android-приложение: выкладка через панель SHX VPN
Когда владелец просит **«собери и выложи»** (или «собери и загрузи в панель»), выкладка идёт через панель
`https://apk.shnt.top`, а не через `aws s3 cp`. Сама просьба и есть подтверждение владельца на загрузку.
- Команда (из `src/frontends/android`, с `ORG_GRADLE_PROJECT_shortNdkCommands=true` при сборке из worktree):
`./gradlew uploadShx` — собирает `assembleRelease` и загружает APK **черновиком** в канал `beta`, тип `phone`.
- Опубликовать сразу — только если владелец сказал «опубликуй»: `./gradlew uploadShx -PshxPublish -PshxRollout=<процент>`.
Без явного процента спросить его. Канал релиза — `-PshxChannel=release`, другой тип — `-PshxType=<id>`.
- Заметки «Что нового» берутся из `NOTES_RU` в `.env`; другие — `-PshxNotes="..."`. Перед выкладкой проверить, что
`NOTES_RU` и `versionCode` в `app/build.gradle` относятся к этому релизу (панель отклонит уже загруженный код версии).
- Нужны `SHX_URL` и `SHX_TOKEN` (заданы у пользователя Windows; скрипт `scripts/upload-apk.mjs` читает их и из реестра,
если процесс запущен раньше). Не печатать значение токена.
- Подпись: release APK подписывается keystore из `.env` (`KEYSTORE_FILE`); неподписанный APK задача не загружает.
- После загрузки сообщить владельцу ссылку на версию из вывода скрипта и статус (черновик или текущая с процентом).
- Сборка для старых приложений 2.19.x (`latest.json` в корне бакета) по-прежнему идёт через `build-local.*` и
требует отдельного подтверждения.
+46 -2
View File
@@ -45,8 +45,8 @@ android {
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode 165 versionCode 172
versionName "2.18.0" versionName "2.19.4"
/* Base URL of the update storage, trailing slash included */ /* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\"" buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
@@ -74,6 +74,17 @@ android {
buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\"" buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\""
buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\"" buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\""
/* SHX update API (web panel). When UPDATE_API_URL, UPDATE_API_PUBLIC_KEY and UPDATE_API_HMAC_KEY are all set,
the app checks for updates there; the bucket path above stays as a fallback while the API is unreachable.
UPDATE_API_PUBLIC_KEY is the raw Ed25519 public key (base64, 32 bytes) shown on the panel's "API updates" page;
the HMAC key is only a guard against casual requests (an APK can always be unpacked), integrity rests on the
manifest signature and on the SHA-256 plus APK signature checks. */
buildConfigField "String", "UPDATE_API_URL", "\"${envOr('UPDATE_API_URL', '')}\""
buildConfigField "String", "UPDATE_API_PUBLIC_KEY", "\"${envOr('UPDATE_API_PUBLIC_KEY', '')}\""
buildConfigField "String", "UPDATE_API_HMAC_KEY", "\"${xorB64(envOr('UPDATE_API_HMAC_KEY', ''))}\""
/* public download page, opened by «Download from the site» when an update fails */
buildConfigField "String", "UPDATE_DOWNLOAD_PAGE_URL", "\"${envOr('UPDATE_DOWNLOAD_PAGE_URL', 'https://get.shnt.top/get/universal')}\""
externalNativeBuild { externalNativeBuild {
ndkBuild { ndkBuild {
arguments '-j' + Runtime.runtime.availableProcessors() arguments '-j' + Runtime.runtime.availableProcessors()
@@ -175,6 +186,8 @@ dependencies {
implementation 'androidx.activity:activity-compose:1.11.0' implementation 'androidx.activity:activity-compose:1.11.0'
/* QR: scanning profiles (camera) and showing a profile as a QR code */ /* QR: scanning profiles (camera) and showing a profile as a QR code */
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
/* Ed25519 verification of the signed update manifest (java.security Ed25519 needs API 33, minSdk is 24) */
implementation 'net.i2p.crypto:eddsa:0.3.0'
debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-tooling'
implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.ui:ui-tooling-preview'
@@ -182,6 +195,8 @@ dependencies {
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.assertj:assertj-core:3.27.6' testImplementation 'org.assertj:assertj-core:3.27.6'
testImplementation 'org.mockito:mockito-core:5.20.0' testImplementation 'org.mockito:mockito-core:5.20.0'
/* the android.jar org.json stubs throw on the JVM; the real implementation is needed for the update API tests */
testImplementation 'org.json:json:20240303'
androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test:runner:1.7.0' androidTestImplementation 'androidx.test:runner:1.7.0'
@@ -189,3 +204,32 @@ dependencies {
androidTestImplementation composeBom androidTestImplementation composeBom
androidTestImplementation 'androidx.compose.ui:ui-test-junit4' androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
} }
/* Publish to the SHX VPN panel (https://apk.shnt.top): build the release APK and upload it in one step.
./gradlew uploadShx -> draft in the beta channel, type phone
./gradlew uploadShx -PshxPublish -PshxRollout=25
./gradlew uploadShx -PshxChannel=release -PshxType=tv -PshxNotes="..."
Needs Node 18+ and SHX_URL, SHX_TOKEN (a CI token from the panel's Settings -> Tokens).
Release notes default to NOTES_RU from .env; the version comes from the build's output-metadata.json. */
tasks.register('uploadShx', Exec) {
group = 'publishing'
description = 'Build the release APK and upload it to the SHX VPN panel'
dependsOn 'assembleRelease'
def apkFile = layout.buildDirectory.file('outputs/apk/release/app-release.apk')
def script = rootProject.file('scripts/upload-apk.mjs')
doFirst {
def apk = apkFile.get().asFile
if (!apk.exists()) {
throw new GradleException("${apk} not found: the release APK is unsigned (set KEYSTORE_FILE in .env)")
}
def args = ['node', script.absolutePath, apk.absolutePath,
'--type', (findProperty('shxType') ?: envOr('SHX_TYPE', 'phone')).toString(),
'--channel', (findProperty('shxChannel') ?: 'beta').toString()]
def notes = (findProperty('shxNotes') ?: envOr('NOTES_RU', '')).toString()
if (notes) args += ['--notes', notes]
if (project.hasProperty('shxPublish')) {
args += ['--publish', '--rollout', (findProperty('shxRollout') ?: '100').toString()]
}
commandLine args
}
}
@@ -64,3 +64,6 @@
-keep class * extends androidx.room.RoomDatabase { -keep class * extends androidx.room.RoomDatabase {
<init>(); <init>();
} }
# i2p eddsa (manifest signature check) references sun.security.x509 only for JDK interop, absent on Android
-dontwarn sun.security.x509.**
@@ -27,6 +27,7 @@
<!-- SSID is redacted in the background on Android 10+ without this. --> <!-- SSID is redacted in the background on Android 10+ without this. -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" /> <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
@@ -339,6 +340,17 @@
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- after a reboot or a clock change: re-arm the pause and scenario alarms, evaluate the rules again -->
<receiver
android:name=".logic.BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>
@@ -54,6 +54,16 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
return this; return this;
} }
/**
* A profile keeps the data source it was loaded from, which may be closed
* by the time the profile is updated or deleted, so reopen on demand.
*/
private SQLiteDatabase database()
{
open();
return mDatabase;
}
@Override @Override
public void close() public void close()
{ {
@@ -67,7 +77,7 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
public VpnProfile insertProfile(VpnProfile profile) public VpnProfile insertProfile(VpnProfile profile)
{ {
ContentValues values = ContentValuesFromVpnProfile(profile); ContentValues values = ContentValuesFromVpnProfile(profile);
long insertId = mDatabase.insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values); long insertId = database().insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
if (insertId == -1) if (insertId == -1)
{ {
return null; return null;
@@ -82,14 +92,14 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
{ {
final UUID uuid = profile.getUUID(); final UUID uuid = profile.getUUID();
ContentValues values = ContentValuesFromVpnProfile(profile); ContentValues values = ContentValuesFromVpnProfile(profile);
return mDatabase.update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0; return database().update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
} }
@Override @Override
public boolean deleteVpnProfile(VpnProfile profile) public boolean deleteVpnProfile(VpnProfile profile)
{ {
final UUID uuid = profile.getUUID(); final UUID uuid = profile.getUUID();
return mDatabase.delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0; return database().delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
} }
@Override @Override
@@ -0,0 +1,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"
}
}
@@ -269,7 +269,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
VpnProfile profile = loadProfile(uuid); VpnProfile profile = loadProfile(uuid);
if (profile != null) if (profile != null)
{ {
profile.setPassword(bundle.getString(VpnProfileDataSource.KEY_PASSWORD)); String password = bundle.getString(VpnProfileDataSource.KEY_PASSWORD);
if (password != null)
{ /* an unattended start (watchdog, pause end, FGS retry) carries no
* password: keep the one saved with the profile instead of
* wiping it, or the tunnel never comes back («password unavailable») */
profile.setPassword(password);
}
/* a backup port of F_Profile, for this attempt only */ /* a backup port of F_Profile, for this attempt only */
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0); int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
if (port > 0) if (port > 0)
@@ -25,6 +25,7 @@ import android.net.Network;
import android.net.NetworkRequest; import android.net.NetworkRequest;
import android.os.Build; import android.os.Build;
import java.util.HashSet;
import java.util.LinkedList; import java.util.LinkedList;
public class NetworkManager extends BroadcastReceiver implements Runnable public class NetworkManager extends BroadcastReceiver implements Runnable
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
private volatile boolean mRegistered; private volatile boolean mRegistered;
private ConnectivityManager.NetworkCallback mCallback; private ConnectivityManager.NetworkCallback mCallback;
private Thread mEventNotifier; private Thread mEventNotifier;
private int mConnectedNetworks = 0; /* a set, not a counter: a repeated onAvailable or a missed onLost cannot
* make it drift away from the networks that really exist */
private final HashSet<Network> mConnectedNetworks = new HashSet<>();
private LinkedList<Boolean> mEvents = new LinkedList<>(); private LinkedList<Boolean> mEvents = new LinkedList<>();
public NetworkManager(Context context) public NetworkManager(Context context)
@@ -51,7 +54,7 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
{ {
/* we expect this to be called if connected to at least one network during /* we expect this to be called if connected to at least one network during
* callback registration */ * callback registration */
mConnectedNetworks += 1; mConnectedNetworks.add(network);
mEvents.addLast(true); mEvents.addLast(true);
NetworkManager.this.notifyAll(); NetworkManager.this.notifyAll();
} }
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
synchronized (NetworkManager.this) synchronized (NetworkManager.this)
{ {
/* in particular mobile connections are disconnected overlapping with WiFi */ /* in particular mobile connections are disconnected overlapping with WiFi */
mConnectedNetworks -= 1; mConnectedNetworks.remove(network);
mEvents.addLast(mConnectedNetworks > 0); mEvents.addLast(!mConnectedNetworks.isEmpty());
NetworkManager.this.notifyAll(); NetworkManager.this.notifyAll();
} }
} }
@@ -74,7 +77,11 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
public void Register() public void Register()
{ {
mEvents.clear(); synchronized (this)
{
mEvents.clear();
mConnectedNetworks.clear();
}
mRegistered = true; mRegistered = true;
mEventNotifier = new Thread(this); mEventNotifier = new Thread(this);
mEventNotifier.start(); mEventNotifier.start();
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
break; break;
} }
connected = mEvents.removeFirst(); connected = mEvents.removeFirst();
/* a burst of identical states (several networks coming up at once)
* is one change for charon, not one reconnect attempt each */
while (!mEvents.isEmpty() && mEvents.getFirst() == connected)
{
mEvents.removeFirst();
}
} }
/* call the native parts without holding the lock */ /* call the native parts without holding the lock */
networkChanged(!connected); networkChanged(!connected);
@@ -229,6 +229,32 @@ object PauseManager
alarmManager(ctx).cancel(alarmIntent(ctx)) alarmManager(ctx).cancel(alarmIntent(ctx))
} }
/**
* After a reboot: alarms do not survive it, so a pause that was running would
* never end. Re-arms the alarm, or ends the pause right away if its time is up.
*/
@JvmStatic
fun rearmAfterRestart(context: Context)
{
val ctx = context.applicationContext
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
val until = prefs.getLong(PREF_UNTIL, 0L)
val profile = prefs.getString(PREF_PROFILE, null)
if (profile == null || until == 0L)
{
return
}
if (until > System.currentTimeMillis())
{
DiagLog.i(TAG, "pause survives the restart, re-arming its alarm")
store(ctx, until, profile)
}
else
{
onAlarm(ctx)
}
}
private fun store(ctx: Context, until: Long, profileUuid: String) private fun store(ctx: Context, until: Long, profileUuid: String)
{ {
PreferenceManager.getDefaultSharedPreferences(ctx).edit() PreferenceManager.getDefaultSharedPreferences(ctx).edit()
@@ -0,0 +1,98 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import java.util.PriorityQueue
/**
* The job queue behind [Scheduler]: charon's timers, ordered by due time, and the
* bookkeeping of the single alarm that wakes the device for the earliest one.
* Times are on any monotonic clock (the scheduler passes elapsedRealtime).
*
* Jobs due within [coalesceMs] of each other share one alarm: a job may run up to
* [coalesceMs] early, never late. charon only schedules jobs longer than 3 s ahead,
* so 2 s is harmless, and every alarm saved is one more slot in the Doze quota of
* allow-while-idle alarms that the keepalive needs.
*
* Not thread-safe; the caller synchronises.
*/
class ScheduledJobQueue(private val coalesceMs: Long = COALESCE_MS)
{
private class Job(val id: String, val time: Long) : Comparable<Job>
{
override fun compareTo(other: Job): Int = time.compareTo(other.time)
}
private val jobs = PriorityQueue<Job>()
private var armed = NONE
/** Due time the alarm is currently set for, or [NONE]. */
val armedAt: Long get() = armed
val size: Int get() = jobs.size
/**
* Queues a job.
*
* @return true if the alarm has to be (re)armed for [nextAlarm]; false if the
* armed alarm already covers the job
*/
fun add(id: String, time: Long): Boolean
{
jobs.add(Job(id, time))
if (armed == NONE || time < armed - coalesceMs)
{
armed = time
return true
}
return false
}
/**
* Removes the jobs that are due at [now] (or within the coalescing window).
* The alarm is considered fired: [nextAlarm] says whether to arm another one.
*/
fun popDue(now: Long): List<String>
{
val limit = now + coalesceMs
val due = ArrayList<String>()
while (true)
{
val head = jobs.peek() ?: break
if (head.time > limit)
{
break
}
due.add(jobs.remove().id)
}
armed = jobs.peek()?.time ?: NONE
return due
}
/** Due time of the earliest queued job, or [NONE]; the time to arm after [popDue]. */
fun nextAlarm(): Long = jobs.peek()?.time ?: NONE
fun clear()
{
jobs.clear()
armed = NONE
}
companion object
{
const val NONE = -1L
const val COALESCE_MS = 2000L
}
}
@@ -24,10 +24,10 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter; import android.content.IntentFilter;
import android.os.Build; import android.os.Build;
import android.os.SystemClock;
import android.util.Log; import android.util.Log;
import java.util.ArrayList; import java.util.List;
import java.util.PriorityQueue;
import java.util.UUID; import java.util.UUID;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
private final String TAG = Scheduler.class.getSimpleName(); private final String TAG = Scheduler.class.getSimpleName();
private final Context mContext; private final Context mContext;
private final AlarmManager mManager; private final AlarmManager mManager;
private final PriorityQueue<ScheduledJob> mJobs; /* due times are elapsedRealtime: unlike RTC they do not jump when the clock
* is set (NTP, manual change, time zone), which would delay keepalives and
* rekeyings by hours */
private final ScheduledJobQueue mJobs;
private boolean mExactLogged;
@SuppressLint("UnspecifiedRegisterReceiverFlag") @SuppressLint("UnspecifiedRegisterReceiverFlag")
public Scheduler(Context context) public Scheduler(Context context)
{ {
mContext = context; mContext = context;
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE); mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
mJobs = new PriorityQueue<>(); mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
IntentFilter filter = new IntentFilter(); IntentFilter filter = new IntentFilter();
filter.addAction(EXECUTE_JOB); filter.addAction(EXECUTE_JOB);
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
* alarms instead of crashing with a SecurityException (connectivity then * alarms instead of crashing with a SecurityException (connectivity then
* only degrades slightly while timers may be delayed in doze mode). * only degrades slightly while timers may be delayed in doze mode).
* *
* @param time wakeup time based on RTC * @param time wakeup time based on elapsedRealtime
*/ */
@RequiresApi(api = Build.VERSION_CODES.M) @RequiresApi(api = Build.VERSION_CODES.M)
private void scheduleAlarm(long time) private void scheduleAlarm(long time)
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
{ {
exact = mManager.canScheduleExactAlarms(); exact = mManager.canScheduleExactAlarms();
} }
if (!mExactLogged)
{ /* once per run, for support reports */
mExactLogged = true;
DiagLog.i(TAG, "keepalive alarms are " + (exact ? "exact" : "inexact (exact alarms not allowed)"));
}
try try
{ {
if (exact) if (exact)
{ {
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending); mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
} }
else else
{ {
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending); mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
} }
} }
catch (SecurityException e) catch (SecurityException e)
{ /* permission might have been revoked, or OEM restrictions apply */ { /* permission might have been revoked, or OEM restrictions apply */
Log.w(TAG, "Falling back to inexact alarm after SecurityException"); Log.w(TAG, "Falling back to inexact alarm after SecurityException");
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending); mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
} }
} }
@@ -149,12 +158,10 @@ public class Scheduler extends BroadcastReceiver
{ {
synchronized (this) synchronized (this)
{ {
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms); long time = SystemClock.elapsedRealtime() + ms;
mJobs.add(job); if (mJobs.add(id, time))
{ /* the job is due before the armed alarm: move the alarm */
if (job == mJobs.peek()) scheduleAlarm(time);
{ /* update the alarm if the job has to be executed before all others */
scheduleAlarm(job.Time);
} }
} }
} }
@@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver
@Override @Override
public void onReceive(Context context, Intent intent) public void onReceive(Context context, Intent intent)
{ {
ArrayList<ScheduledJob> jobs = new ArrayList<>(); List<String> jobs;
long now = System.currentTimeMillis();
synchronized (this) synchronized (this)
{ {
ScheduledJob job = mJobs.peek(); jobs = mJobs.popDue(SystemClock.elapsedRealtime());
while (job != null) long next = mJobs.nextAlarm();
if (next != ScheduledJobQueue.NONE)
{ {
if (job.Time > now) scheduleAlarm(next);
{
break;
}
jobs.add(mJobs.remove());
job = mJobs.peek();
}
if (job != null)
{
scheduleAlarm(job.Time);
} }
} }
for (ScheduledJob job : jobs) for (String id : jobs)
{ {
executeJob(job.Id); executeJob(id);
} }
} }
@@ -196,25 +194,4 @@ public class Scheduler extends BroadcastReceiver
* @param id job ID * @param id job ID
*/ */
public native void executeJob(String id); public native void executeJob(String id);
/**
* Keep track of scheduled jobs.
*/
private static class ScheduledJob implements Comparable<ScheduledJob>
{
String Id;
long Time;
ScheduledJob(String id, long time)
{
Id = id;
Time = time;
}
@Override
public int compareTo(ScheduledJob o)
{
return Long.compare(Time, o.Time);
}
}
} }
@@ -232,6 +232,7 @@ public class WatchdogReceiver extends BroadcastReceiver
return; return;
case RESTART: case RESTART:
{ {
boolean autoRestart = uuid == null;
if (uuid == null) if (uuid == null)
{ {
uuid = resolveAutoProfile(context, prefs); uuid = resolveAutoProfile(context, prefs);
@@ -260,6 +261,10 @@ public class WatchdogReceiver extends BroadcastReceiver
* once, which is harmless (retry flag set, same profile). */ * once, which is harmless (retry flag set, same profile). */
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it"); Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
DiagLog.i(TAG, "restarting the VPN process"); DiagLog.i(TAG, "restarting the VPN process");
if (autoRestart)
{ /* this process does not remember the session was auto-started */
AutoConnectManager.getInstance(context).markAutoSessionRestored();
}
Intent start = new Intent(context, CharonVpnService.class); Intent start = new Intent(context, CharonVpnService.class);
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid); start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
start.putExtra(CharonVpnService.KEY_IS_RETRY, true); start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
@@ -91,6 +91,8 @@ class AutoConnectManager private constructor(
private var currentNetwork: NetworkKind? = null private var currentNetwork: NetworkKind? = null
/* marks the session this manager started itself */ /* marks the session this manager started itself */
private val autoSession = AtomicBoolean(false) private val autoSession = AtomicBoolean(false)
/* an auto session was restored by the watchdog and its tunnel is not up yet */
@Volatile private var awaitingRestoredTunnel = false
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */ /* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
private var lastParkAt = 0L private var lastParkAt = 0L
@@ -123,10 +125,28 @@ class AutoConnectManager private constructor(
} }
} }
/** Network scenarios need the network events even when the network rules are off. */
private fun scenariosNeedNetwork(): Boolean =
org.strongswan.android.logic.scenarios.ScenarioEngine.needsNetworkEvents(context)
/** Called when the scenarios changed: starts or stops the network listening to fit. */
fun scenariosChanged()
{
config = loadConfig()
if (config.enabled || scenariosNeedNetwork())
{
start()
}
else
{
stop()
}
}
fun start() fun start()
{ {
config = loadConfig() config = loadConfig()
if (!config.enabled) if (!config.enabled && !scenariosNeedNetwork())
{ {
return return
} }
@@ -186,8 +206,8 @@ class AutoConnectManager private constructor(
} }
else else
{ {
stop()
autoSession.set(false) autoSession.set(false)
scenariosChanged()
} }
} }
@@ -214,8 +234,8 @@ class AutoConnectManager private constructor(
} }
else else
{ {
stop()
autoSession.set(false) autoSession.set(false)
scenariosChanged()
} }
} }
@@ -236,12 +256,28 @@ class AutoConnectManager private constructor(
override fun stateChanged() override fun stateChanged()
{ {
val state = service?.state ?: return val state = service?.state ?: return
if (state == VpnStateService.State.DISABLED) if (state != VpnStateService.State.DISABLED)
{
awaitingRestoredTunnel = false
}
else if (!awaitingRestoredTunnel)
{ {
autoSession.set(false) autoSession.set(false)
} }
} }
/**
* The watchdog brought an auto session back in a fresh process, whose memory
* knows nothing of it. Mark it as auto-started again, so it parks on a trusted
* Wi-Fi instead of running all night; the DISABLED state of the not yet
* started service must not clear the mark.
*/
fun markAutoSessionRestored()
{
awaitingRestoredTunnel = true
autoSession.set(true)
}
/** /**
* Pure decision function, unit-testable. * Pure decision function, unit-testable.
*/ */
@@ -268,8 +304,8 @@ class AutoConnectManager private constructor(
private fun evaluate() private fun evaluate()
{ {
if (!running.get()) if (!running.get() || !config.enabled)
{ { /* running only for the scenarios: they decide, not the network rules */
return return
} }
val svc = service ?: return val svc = service ?: return
@@ -30,6 +30,7 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.os.SystemClock
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.Mutex
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.VpnStateService import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.logic.events.Events import org.strongswan.android.logic.events.Events
@@ -130,6 +132,9 @@ class HealthCheckManager private constructor(
private var tunnelAttempts = 0 private var tunnelAttempts = 0
private var fastPollShifts = 0 private var fastPollShifts = 0
private var powerState = PowerState() 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 */ /* tracks screen/battery state so probing can back off while the device sleeps */
private val powerReceiver = object : BroadcastReceiver() private val powerReceiver = object : BroadcastReceiver()
@@ -360,7 +365,31 @@ class HealthCheckManager private constructor(
return return
} }
val gateway = svc.profile?.gateway val gateway = svc.profile?.gateway
val targets = config.targets 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 mode = config.serverCheckMode
val timeout = config.timeoutMs val timeout = config.timeoutMs
@@ -609,6 +638,21 @@ class HealthCheckManager private constructor(
tunnelAttempts = 0 tunnelAttempts = 0
fastPollShifts = 0 fastPollShifts = 0
pendingDecision = null 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() private fun registerVpnCallback()
@@ -705,6 +749,32 @@ class HealthCheckManager private constructor(
private const val FAST_POLL_MAX_SHIFTS = 3 private const val FAST_POLL_MAX_SHIFTS = 3
private const val AGGRESSIVE_INTERVAL_SEC = 5 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. */ /** Minimum probe interval in adaptive mode while the screen is off. */
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60 internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
@@ -143,6 +143,16 @@ object Events
) )
} }
/** A report went to the panel: the code stays here so the user can find it later. */
@JvmStatic
fun reportSent(context: Context, code: String)
{
EventLog.getInstance(context).record(
EventKind.OK, EventCategory.APP, context.getString(R.string.report_event_title, code),
context.getString(R.string.report_event_text), key = "report:$code",
)
}
/** The watchdog did not bring back [name]: a password would be needed. */ /** The watchdog did not bring back [name]: a password would be needed. */
@JvmStatic @JvmStatic
fun restoreNeedsPassword(context: Context, uuid: String, name: String) fun restoreNeedsPassword(context: Context, uuid: String, name: String)
@@ -542,10 +542,22 @@ class RoutingManager private constructor(private val context: Context)
break break
} }
delay(SITE_REFRESH_MS) 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 * Like [updateConfig], but force prefixes apply on the fly through the
* atomic set swap of the userspace router - no reconnect needed. * atomic set swap of the userspace router - no reconnect needed.
@@ -833,6 +845,9 @@ class RoutingManager private constructor(private val context: Context)
private const val KEY_NOTICE_ONLY = "notice_only_profiles" private const val KEY_NOTICE_ONLY = "notice_only_profiles"
private const val RECONNECT_DEBOUNCE_MS = 2000L private const val RECONNECT_DEBOUNCE_MS = 2000L
const val SITE_REFRESH_MS = 30L * 60 * 1000 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. */ /** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
const val CONFIG_VERSION = 2 const val CONFIG_VERSION = 2
@@ -21,13 +21,16 @@ import android.app.usage.UsageStatsManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.VpnService import android.net.VpnService
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock
import android.telephony.TelephonyManager import android.telephony.TelephonyManager
import android.util.Log import android.util.Log
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@@ -161,6 +164,12 @@ object ScenarioEngine
private const val TAG = "Scenarios" private const val TAG = "Scenarios"
private const val FILE_NAME = "scenarios.json" private const val FILE_NAME = "scenarios.json"
private const val POLL_MS = 4000L private const val POLL_MS = 4000L
/* how long a broadcast waits for the VPN state service of a fresh process */
private const val SERVICE_WAIT_MS = 8000L
private const val SERVICE_POLL_MS = 200L
/* the foreground app did not change for a minute: poll half as often */
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_APPLIED = "pref_scenario_applied"
private const val PREF_WAS_ON = "pref_scenario_was_on" private const val PREF_WAS_ON = "pref_scenario_was_on"
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile" private const val PREF_WAS_PROFILE = "pref_scenario_was_profile"
@@ -174,6 +183,7 @@ object ScenarioEngine
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var appContext: Context? = null private var appContext: Context? = null
private var polling = false private var polling = false
private var unchangedPolls = 0
private var lastForeground: String? = null private var lastForeground: String? = null
fun flow(context: Context): StateFlow<List<Scenario>> fun flow(context: Context): StateFlow<List<Scenario>>
@@ -188,9 +198,20 @@ object ScenarioEngine
_list.value = list _list.value = list
persist(context) persist(context)
start(context) start(context)
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
evaluate(context) evaluate(context)
} }
/** Whether an enabled scenario reacts to Wi-Fi, mobile network or roaming changes. */
@JvmStatic
fun needsNetworkEvents(context: Context): Boolean
{
load(context.applicationContext)
return _list.value.any {
it.enabled && (it.trigger == ScenarioTrigger.WIFI || it.trigger == ScenarioTrigger.MOBILE || it.trigger == ScenarioTrigger.ROAMING)
}
}
/** Starts listening (from the application); idempotent. */ /** Starts listening (from the application); idempotent. */
@JvmStatic @JvmStatic
fun start(context: Context) fun start(context: Context)
@@ -203,36 +224,118 @@ object ScenarioEngine
if (needPoll && !polling) if (needPoll && !polling)
{ {
polling = true polling = true
handler.post(poll) registerScreenReceiver(app)
if (isScreenOn(app))
{
handler.post(poll)
}
} }
else if (!needPoll) else if (!needPoll)
{ {
polling = false polling = false
handler.removeCallbacks(poll) 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 private val poll = object : Runnable
{ {
override fun run() override fun run()
{ {
val app = appContext ?: return val app = appContext ?: return
if (!polling) if (!polling || !isScreenOn(app))
{ { /* resumed by the screen-on broadcast */
return return
} }
val screenOn = (app.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true val fg = foregroundApp(app)
if (screenOn) if (fg != null && fg != lastForeground)
{ {
val fg = foregroundApp(app) lastForeground = fg
if (fg != null && fg != lastForeground) 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
{ {
lastForeground = fg
evaluate(app) evaluate(app)
} }
finally
{
done?.run()
}
} }
handler.postDelayed(this, POLL_MS)
} }
handler.post(check)
} }
/** Network, whitelist or alarm: decide again. */ /** Network, whitelist or alarm: decide again. */
@@ -377,10 +480,12 @@ object ScenarioEngine
{ {
return return
} }
/* without a live service nothing can be put back: keep the record so the
* next evaluation (the receiver waits for the service) still restores */
val service = VpnStateService.getLiveInstance() ?: return
val wasOn = prefs.getBoolean(PREF_WAS_ON, false) val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null) val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply() prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
val service = VpnStateService.getLiveInstance() ?: return
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)") DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
if (PauseManager.isPaused(context)) if (PauseManager.isPaused(context))
{ {
@@ -508,12 +613,22 @@ object ScenarioEngine
{ {
c.add(Calendar.DAY_OF_YEAR, 1) c.add(Calendar.DAY_OF_YEAR, 1)
} }
/* same choice as PauseManager: exact when the grant allows it */
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
try try
{ {
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) if (exact)
{
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
}
else
{
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
}
} }
catch (_: SecurityException) catch (_: SecurityException)
{ { /* revoked in between: an inexact alarm is still better than none */
runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) }
} }
} }
@@ -619,6 +734,7 @@ class ScenarioReceiver : BroadcastReceiver()
override fun onReceive(context: Context, intent: Intent?) override fun onReceive(context: Context, intent: Intent?)
{ {
ScenarioEngine.start(context) ScenarioEngine.start(context)
ScenarioEngine.evaluate(context) val pending = goAsync()
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
} }
} }
@@ -15,6 +15,8 @@
package org.strongswan.android.logic.stats package org.strongswan.android.logic.stats
import android.content.Context import android.content.Context
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -37,7 +39,8 @@ data class DayTraffic(val day: String, val vpn: Long, val direct: Long)
/** /**
* Daily traffic history (F_Stats). While the tunnel is up the daemon * Daily traffic history (F_Stats). While the tunnel is up the daemon
* counters are sampled every [SAMPLE_S] seconds and the growth is added to * counters are sampled every [SAMPLE_S] seconds (every [SAMPLE_OFF_S] with
* the screen off: the counters are cumulative, so nothing is lost) and the growth is added to
* the current day; the counters restart with every connection, so a drop * the current day; the counters restart with every connection, so a drop
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json. * starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
*/ */
@@ -46,12 +49,21 @@ object TrafficHistory
private const val TAG = "TrafficHistory" private const val TAG = "TrafficHistory"
private const val FILE_NAME = "traffic.json" private const val FILE_NAME = "traffic.json"
private const val SAMPLE_S = 15L private const val SAMPLE_S = 15L
private const val SAMPLE_OFF_S = 60L
/* the history lives in memory and is written out this often (not on every
* sample: a full JSON rewrite every 15 s is flash wear for nothing); a
* killed process loses at most this much of the statistics */
internal const val SAVE_EVERY_MS = 5 * 60 * 1000L
private const val KEEP_DAYS = 120 private const val KEEP_DAYS = 120
private val executor = Executors.newSingleThreadScheduledExecutor() private val executor = Executors.newSingleThreadScheduledExecutor()
private var task: ScheduledFuture<*>? = null private var task: ScheduledFuture<*>? = null
private var lastVpn = -1L private var lastVpn = -1L
private var lastDirect = -1L private var lastDirect = -1L
private var lastSampleAt = 0L
private var dirty = false
private var lastSavedAt = 0L
private var lastSavedDay: String? = null
@Volatile private var loaded = false @Volatile private var loaded = false
private val days = LinkedHashMap<String, LongArray>() private val days = LinkedHashMap<String, LongArray>()
private var firstDay: String? = null private var firstDay: String? = null
@@ -86,11 +98,38 @@ object TrafficHistory
val running = task ?: return val running = task ?: return
running.cancel(false) running.cancel(false)
task = null task = null
executor.execute { sample(app) } executor.execute {
sample(app, force = true)
flush(app)
}
} }
private fun sample(context: Context) /** Writes the history out now if it changed since the last write. */
@Synchronized
fun flush(context: Context)
{ {
if (dirty)
{
save(context)
}
}
/** Whether a changed history has to be written now: periodically and when the day turns. */
internal fun saveDue(dirty: Boolean, now: Long, lastSavedAt: Long, dayChanged: Boolean): Boolean =
dirty && (dayChanged || now - lastSavedAt >= SAVE_EVERY_MS)
private fun screenOn(context: Context): Boolean =
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive != false
private fun sample(context: Context, force: Boolean = false)
{
val now = SystemClock.elapsedRealtime()
val screen = screenOn(context)
if (!force && !screen && now - lastSampleAt < SAMPLE_OFF_S * 1000L - 1000L)
{ /* screen off: a sample a minute is enough, the counters accumulate */
return
}
lastSampleAt = now
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull() val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
/* direct bytes only exist with the userspace router (s[3]) */ /* direct bytes only exist with the userspace router (s[3]) */
@@ -121,9 +160,16 @@ object TrafficHistory
firstDay = key firstDay = key
} }
prune() prune()
save(context) dirty = true
if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key))
{
save(context)
}
_changes.value = System.currentTimeMillis() _changes.value = System.currentTimeMillis()
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context) 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. */ /** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
@@ -205,6 +251,9 @@ object TrafficHistory
file.writeText(json.toString()) file.writeText(json.toString())
tmp.delete() tmp.delete()
} }
dirty = false
lastSavedAt = SystemClock.elapsedRealtime()
lastSavedDay = dayKey(System.currentTimeMillis())
} }
catch (e: Exception) catch (e: Exception)
{ {
@@ -0,0 +1,177 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import java.io.File
import java.io.RandomAccessFile
/**
* What goes into a report sent from the log screen: today's part of charon.log and,
* on request, the app's diag.log. No Android types here, so it runs in unit tests.
*/
object ReportContent
{
/* the panel unpacks a report with a 5 MB cap; 3 + 0.75 MB of text leaves room and stays far below 2 MB gzip */
const val CHARON_MAX_BYTES = 3 * 1024 * 1024
const val DIAG_MAX_BYTES = 768 * 1024
const val CUT_MARK = "... (earlier lines cut)\n"
/** Why a report was not sent; each maps to a reason line of the "not sent" sheet */
enum class Failure { NO_NETWORK, SERVER_DOWN, SIGNATURE, TOO_OFTEN, TOO_LARGE, REJECTED }
fun failure(httpCode: Int): Failure = when
{
httpCode == 0 -> Failure.NO_NETWORK
httpCode == 401 -> Failure.SIGNATURE
httpCode == 413 -> Failure.TOO_LARGE
httpCode == 429 -> Failure.TOO_OFTEN
httpCode >= 500 || httpCode == 404 -> Failure.SERVER_DOWN
else -> Failure.REJECTED
}
private val MONTHS = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
/* charon: «Nov 8 14:02:11 06[IKE] ...» (strftime %b %e, C locale) */
private val CHARON_DATE = Regex("""^(\w{3})\s+(\d{1,2})\s+\d{2}:\d{2}:\d{2}\s""")
/**
* Lines of [month] (1-12) / [day] only; lines without a date stay with the dated line before them.
* When nothing is from today the whole text is kept: an old log still helps support.
*/
fun todayOnly(text: String, month: Int, day: Int): String
{
var keep = true
var today = false
val out = StringBuilder()
for (line in text.lineSequence())
{
CHARON_DATE.find(line)?.let { m ->
keep = MONTHS.indexOf(m.groupValues[1]) + 1 == month && m.groupValues[2].toInt() == day
today = today || keep
}
if (keep && line.isNotEmpty())
{
out.append(line).append('\n')
}
}
/* undated lines alone (e.g. the cut mark of readTail) do not count as today's log */
return if (today) out.toString() else text
}
/** The last [maxBytes] of UTF-8 [text], cut at a line start; the cut is marked */
fun tailBytes(text: String, maxBytes: Int): String
{
val bytes = text.toByteArray(Charsets.UTF_8)
if (bytes.size <= maxBytes)
{
return text
}
var start = bytes.size - maxBytes
/* '\n' is never part of a multi-byte UTF-8 sequence, so cutting after it keeps characters whole */
while (start < bytes.size && bytes[start - 1] != '\n'.code.toByte())
{
start++
}
if (start == bytes.size)
{ /* one huge line: cut anywhere, but not inside a character (skip 10xxxxxx continuation bytes) */
start = bytes.size - maxBytes
while (start < bytes.size && (bytes[start].toInt() and 0xC0) == 0x80)
{
start++
}
}
return CUT_MARK + String(bytes, start, bytes.size - start, Charsets.UTF_8)
}
/** Up to [maxBytes] from the end of [file] ("" when missing); a partial first line is dropped */
fun readTail(file: File, maxBytes: Int): String
{
if (!file.exists())
{
return ""
}
return try
{
RandomAccessFile(file, "r").use { f ->
val from = maxOf(0L, f.length() - maxBytes)
val buf = ByteArray((f.length() - from).toInt())
f.seek(from)
f.readFully(buf)
val text = String(buf, Charsets.UTF_8)
if (from > 0) CUT_MARK + text.substringAfter('\n') else text
}
}
catch (e: Exception)
{
""
}
}
/** One network check of the diagnostics screen: the panel shows them as a check list */
data class Check(val title: String, val value: String, val ok: Boolean?)
/** JSON array of the checks (no org.json: this object stays testable without Android) */
fun checksJson(checks: List<Check>): String = checks.joinToString(",", "[", "]") { c ->
"{\"title\":${jsonString(c.title)},\"value\":${jsonString(c.value)},\"ok\":${c.ok?.toString() ?: "null"}}"
}
private fun jsonString(s: String): String = buildString {
append('"')
for (ch in s)
{
when
{
ch == '"' -> append("\\\"")
ch == '\\' -> append("\\\\")
ch == '\n' -> append("\\n")
ch == '\r' -> append("\\r")
ch == '\t' -> append("\\t")
ch < ' ' -> append("\\u%04x".format(ch.code))
else -> append(ch)
}
}
append('"')
}
/** The report text: the connection log, the network checks and diag.log when asked for */
fun build(charon: String, diag: String?, checks: List<Check>? = null): String = buildString {
append("=== charon.log ===\n")
append(tailBytes(charon, CHARON_MAX_BYTES).ifBlank { "(empty)\n" })
if (!checks.isNullOrEmpty())
{
append("\n=== checks.json ===\n")
append(checksJson(checks)).append('\n')
}
if (diag != null)
{
append("\n=== diag.log ===\n")
append(tailBytes(diag, DIAG_MAX_BYTES).ifBlank { "(empty)\n" })
}
}
/** Meta of a report from the log screen; [network] is "Wi-Fi", "mobile" or "none" */
fun meta(
versionName: String, versionCode: Long, manufacturer: String, model: String, release: String, sdk: Int,
network: String, comment: String, profile: String,
) = ReportUploader.Meta(
versionName = versionName,
versionCode = versionCode,
device = if (model.startsWith(manufacturer, ignoreCase = true)) model else "$manufacturer $model",
os = "Android $release (API $sdk)",
network = network,
source = "log",
comment = comment.trim(),
profile = profile,
)
}
@@ -0,0 +1,139 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.GZIPOutputStream
/**
* Sends a diagnostic log to the web panel (POST /api/v1/reports) and returns the report
* code the user can quote to support. The request is signed like the update check.
* The "Send report" sheet of the log screen (LogActivity) calls it, see [ReportContent].
*/
class ReportUploader(
private val baseUrl: String,
private val hmacKey: String,
private val installId: String,
private val clock: () -> Long = { System.currentTimeMillis() },
)
{
data class Meta(
val versionName: String,
val versionCode: Long,
val device: String,
val os: String,
val network: String,
val source: String,
val comment: String,
val profile: String,
)
sealed class Result
{
data class Sent(val code: String) : Result()
data class Failed(val httpCode: Int, val detail: String) : Result()
}
companion object
{
/** Server limit: the whole request body must stay below 2 MB */
const val MAX_BYTES = 2 * 1024 * 1024
const val BOUNDARY = "----shxreport"
private val CODE = Regex("^SHX-[0-9A-Z]{5}$")
/** The panel serves the app API under /api/v1 (the proxy routes only that prefix); the build may give just the host */
fun apiBase(url: String): String
{
val base = url.trim().trimEnd('/')
return if (base.endsWith("/api/v1")) base else "$base/api/v1"
}
/** 201 with {"code":"SHX-XXXXX"} is the only success; a broken 201 is not "no network" */
fun parseResponse(httpCode: Int, body: String): Result
{
if (httpCode != 201)
{
return Result.Failed(httpCode, body.take(200))
}
val code = try { JSONObject(body).optString("code", "") } catch (e: Exception) { "" }
return if (CODE.matches(code)) Result.Sent(code) else Result.Failed(httpCode, "bad response: ${body.take(100)}")
}
fun gzip(text: String): ByteArray
{
val out = ByteArrayOutputStream()
GZIPOutputStream(out).use { it.write(text.toByteArray(Charsets.UTF_8)) }
return out.toByteArray()
}
fun metaJson(m: Meta): String = JSONObject()
.put("versionName", m.versionName).put("versionCode", m.versionCode)
.put("device", m.device.take(120)).put("os", m.os.take(60)).put("network", m.network.take(40))
.put("source", m.source).put("comment", m.comment.take(1000)).put("profile", m.profile.take(200))
.toString()
/** multipart/form-data with the gzip log and the meta JSON */
fun multipartBody(logGz: ByteArray, meta: String): ByteArray
{
val out = ByteArrayOutputStream()
fun w(s: String) = out.write(s.toByteArray(Charsets.UTF_8))
w("--$BOUNDARY\r\nContent-Disposition: form-data; name=\"log\"; filename=\"log.gz\"\r\nContent-Type: application/gzip\r\n\r\n")
out.write(logGz)
/* a plain text field: @fastify/multipart turns an application/json field into an object and the server then reads "[object Object]" */
w("\r\n--$BOUNDARY\r\nContent-Disposition: form-data; name=\"meta\"\r\n\r\n")
w(meta)
w("\r\n--$BOUNDARY--\r\n")
return out.toByteArray()
}
}
fun send(logText: String, meta: Meta): Result
{
val body = multipartBody(gzip(logText), metaJson(meta))
if (body.size > MAX_BYTES)
{
return Result.Failed(413, "report is larger than 2 MB")
}
val url = apiBase(baseUrl) + "/reports"
val ts = clock() / 1000
val conn = URL(url).openConnection() as HttpURLConnection
return try
{
conn.requestMethod = "POST"
conn.doOutput = true
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY")
conn.setRequestProperty("X-Install-Id", installId)
conn.setRequestProperty("X-Timestamp", ts.toString())
conn.setRequestProperty("X-Signature", UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("POST", URL(url).path, ts)))
conn.outputStream.use { it.write(body) }
val code = conn.responseCode
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)?.use { it.readBytes().decodeToString() } ?: ""
parseResponse(code, text)
}
catch (e: Exception)
{
Result.Failed(0, e.message ?: e.javaClass.simpleName)
}
finally
{
conn.disconnect()
}
}
}
@@ -0,0 +1,254 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import net.i2p.crypto.eddsa.EdDSAEngine
import net.i2p.crypto.eddsa.EdDSAPublicKey
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Client of the SHX update API (/api/v1/updates/check).
*
* The wire contract is frozen in the web panel repository: docs/openapi.v1.json
* and packages/contracts/vectors/signing.json. [UpdateSigning] builds exactly
* the strings the server signs; UpdateApiTest checks it against those vectors.
*/
@OptIn(ExperimentalEncodingApi::class)
object UpdateSigning
{
const val SIGNING_VERSION = "shx-manifest-v1"
/** The fields covered by the manifest signature (notes are display-only and not signed) */
data class Fields(
val update: Boolean,
val mandatory: Boolean,
val versionCode: Long,
val versionName: String,
val sha256: String,
val size: Long,
val apkUrl: String,
val issuedAt: Long,
val checkHours: Int,
val wifiOnly: Boolean,
)
/** Fields joined by '\n' in a strict order; absent values are 0 / empty (same as the server) */
fun manifestInput(f: Fields): String
{
return listOf(
SIGNING_VERSION,
if (f.update) "1" else "0",
if (f.mandatory) "1" else "0",
f.versionCode.toString(),
f.versionName,
f.sha256,
f.size.toString(),
f.apkUrl,
f.issuedAt.toString(),
f.checkHours.toString(),
if (f.wifiOnly) "1" else "0",
).joinToString("\n")
}
fun requestInput(method: String, path: String, timestamp: Long): String
{
return "${method.uppercase()}|$path|$timestamp"
}
fun hmacBase64(key: String, input: String): String
{
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key.toByteArray(Charsets.UTF_8), "HmacSHA256"))
return Base64.Default.encode(mac.doFinal(input.toByteArray(Charsets.UTF_8)))
}
/**
* Ed25519 verification on API 24+ (java.security Ed25519 exists only from API 33),
* therefore a small pure-Java implementation is used
*/
fun verifyEd25519(publicKeyRaw: ByteArray, input: String, signatureBase64: String): Boolean
{
return try
{
val spec = EdDSAPublicKeySpec(publicKeyRaw, EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519))
val engine = EdDSAEngine(MessageDigest.getInstance("SHA-512"))
engine.initVerify(EdDSAPublicKey(spec))
engine.update(input.toByteArray(Charsets.UTF_8))
engine.verify(Base64.Default.decode(signatureBase64))
}
catch (e: Exception)
{
false
}
}
}
/** Where the HTTP is delegated to, so the client can be tested without a network */
interface UpdateHttp
{
data class Response(val code: Int, val body: String)
fun get(url: String, headers: Map<String, String>): Response
}
class HttpUrlConnectionHttp(private val timeoutMs: Int = 10_000) : UpdateHttp
{
override fun get(url: String, headers: Map<String, String>): UpdateHttp.Response
{
val conn = URL(url).openConnection(java.net.Proxy.NO_PROXY) as HttpURLConnection
conn.connectTimeout = timeoutMs
conn.readTimeout = timeoutMs
conn.setRequestProperty("Connection", "close")
conn.setRequestProperty("Cache-Control", "no-cache")
for ((k, v) in headers)
{
conn.setRequestProperty(k, v)
}
return try
{
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
UpdateHttp.Response(code, stream?.use { it.readBytes().decodeToString() } ?: "")
}
finally
{
conn.disconnect()
}
}
}
/** Policy delivered inside the signed manifest; the user's own settings stay the default */
data class UpdatePolicy(val checkHours: Int, val wifiOnly: Boolean)
sealed class UpdateApiResult
{
data class Update(val info: UpdateInfo, val mandatory: Boolean, val policy: UpdatePolicy, val issuedAt: Long) : UpdateApiResult()
data class NoUpdate(val policy: UpdatePolicy) : UpdateApiResult()
/** [kind]: NETWORK (try the legacy path), SIGNATURE (never fall back), OTHER (rate limit, bad request) */
data class Failure(val kind: UpdateError, val detail: String) : UpdateApiResult()
}
@OptIn(ExperimentalEncodingApi::class)
class UpdateApiClient(
private val baseUrl: String,
private val hmacKey: String,
private val publicKeyBase64: String,
private val installId: String,
private val http: UpdateHttp = HttpUrlConnectionHttp(),
private val clock: () -> Long = { System.currentTimeMillis() },
)
{
companion object
{
/** The manifest is rejected when its issue time is far from the device clock (replay of a stale "no update") */
const val MAX_SKEW_SECONDS = 6 * 3600L
fun isConfigured(baseUrl: String, hmacKey: String, publicKey: String): Boolean
{
return baseUrl.isNotBlank() && hmacKey.isNotBlank() && publicKey.isNotBlank()
}
}
fun check(packageId: String, versionCode: Long, channel: String, abi: String?, sdk: Int, betaToken: String?): UpdateApiResult
{
val path = URL(ReportUploader.apiBase(baseUrl) + "/updates/check").path
val query = buildString {
append("package=").append(enc(packageId))
append("&versionCode=").append(versionCode)
append("&channel=").append(enc(channel))
if (!abi.isNullOrBlank()) append("&abi=").append(enc(abi))
append("&sdk=").append(sdk)
if (!betaToken.isNullOrBlank()) append("&betaToken=").append(enc(betaToken))
}
val ts = clock() / 1000
val headers = mapOf(
"X-Install-Id" to installId,
"X-Timestamp" to ts.toString(),
"X-Signature" to UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", path, ts)),
)
val response = try
{
http.get(ReportUploader.apiBase(baseUrl) + "/updates/check?" + query, headers)
}
catch (e: Exception)
{
return UpdateApiResult.Failure(UpdateError.NETWORK, e.message ?: e.javaClass.simpleName)
}
return when
{
response.code in 500..599 || response.code == 404 -> UpdateApiResult.Failure(UpdateError.NETWORK, "HTTP ${response.code}")
response.code != 200 -> UpdateApiResult.Failure(UpdateError.OTHER, "HTTP ${response.code}")
else -> parse(response.body)
}
}
private fun parse(body: String): UpdateApiResult
{
val json = try { JSONObject(body) } catch (e: Exception) { return UpdateApiResult.Failure(UpdateError.OTHER, "bad json") }
val update = json.optBoolean("update", false)
val fields = UpdateSigning.Fields(
update = update,
mandatory = json.optBoolean("mandatory", false),
versionCode = if (update) json.optLong("versionCode", 0L) else 0L,
versionName = if (update) json.optString("versionName", "") else "",
sha256 = if (update) json.optString("sha256", "").lowercase() else "",
size = if (update) json.optLong("size", 0L) else 0L,
apkUrl = if (update) json.optString("apkUrl", "") else "",
issuedAt = json.optLong("issuedAt", 0L),
checkHours = json.optInt("checkHours", 12),
wifiOnly = json.optBoolean("wifiOnly", true),
)
val key = try { Base64.Default.decode(publicKeyBase64) } catch (e: Exception) { ByteArray(0) }
if (key.size != 32 || !UpdateSigning.verifyEd25519(key, UpdateSigning.manifestInput(fields), json.optString("signature", "")))
{
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest signature mismatch")
}
val skew = Math.abs(clock() / 1000 - fields.issuedAt)
if (skew > MAX_SKEW_SECONDS)
{
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest is too old or the device clock is off ($skew s)")
}
val policy = UpdatePolicy(fields.checkHours, fields.wifiOnly)
if (!update)
{
return UpdateApiResult.NoUpdate(policy)
}
if (fields.apkUrl.isBlank() || fields.sha256.length != 64)
{
return UpdateApiResult.Failure(UpdateError.OTHER, "incomplete manifest")
}
val notes = HashMap<String, String>()
json.optJSONObject("notes")?.let { n -> for (k in n.keys()) notes[k.lowercase()] = n.optString(k, "") }
val info = UpdateInfo(
versionCode = fields.versionCode, versionName = fields.versionName, apkUrl = fields.apkUrl,
sha256 = fields.sha256, size = fields.size, notes = notes, fromApi = true, issuedAt = fields.issuedAt,
)
return UpdateApiResult.Update(info, fields.mandatory, policy, fields.issuedAt)
}
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
}
@@ -62,6 +62,7 @@ import java.net.Proxy
import java.net.URL import java.net.URL
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.cancellation.CancellationException
/** Categories of update failures, mapped to localized messages by the UI */ /** Categories of update failures, mapped to localized messages by the UI */
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER } enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
@@ -76,6 +77,10 @@ data class UpdateInfo(
val sha256: String, val sha256: String,
val size: Long, val size: Long,
val notes: Map<String, String>, val notes: Map<String, String>,
/* set for manifests from the SHX update API: the file URL is presigned and short-lived */
val fromApi: Boolean = false,
val issuedAt: Long = 0L,
val mandatory: Boolean = false,
) )
sealed class UpdateState sealed class UpdateState
@@ -117,6 +122,17 @@ class UpdateManager private constructor(private val context: Context)
private const val UPDATES_CHANNEL = "org.strongswan.android.updates" private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
private const val UPDATES_NOTIFICATION_ID = 1001 private const val UPDATES_NOTIFICATION_ID = 1001
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002 private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
private const val PREF_INSTALL_ID = "pref_update_install_id"
private const val PREF_UPDATE_CHANNEL = "pref_update_channel"
private const val PREF_BETA_TOKEN = "pref_update_beta_token"
private const val PREF_API_CHECK_HOURS = "pref_update_api_check_hours"
private const val PREF_API_WIFI_ONLY = "pref_update_api_wifi_only"
/* a mandatory update the last check found: keeps the blocking sheet up across restarts and failed checks */
private const val PREF_MANDATORY_CODE = "pref_update_mandatory_code"
private const val PREF_MANDATORY_NAME = "pref_update_mandatory_name"
private const val PREF_MANDATORY_SIZE = "pref_update_mandatory_size"
/* refresh the manifest when it is older than this before downloading (the URL lives 10 minutes) */
private const val URL_MAX_AGE_SECONDS = 8 * 60L
@Volatile @Volatile
private var mInstance: UpdateManager? = null private var mInstance: UpdateManager? = null
@@ -166,6 +182,11 @@ class UpdateManager private constructor(private val context: Context)
* was known in this process: install as soon as the check finds it */ * was known in this process: install as soon as the check finds it */
@Volatile @Volatile
private var mInstallPending = false private var mInstallPending = false
/* set by cancelDownload(); the copy loop and the install step check it */
@Volatile
private var mCancelRequested = false
@Volatile
private var mDownloadConn: HttpURLConnection? = null
private var mVpnService: VpnStateService? = null private var mVpnService: VpnStateService? = null
private val mConnection = object : ServiceConnection private val mConnection = object : ServiceConnection
@@ -191,7 +212,8 @@ class UpdateManager private constructor(private val context: Context)
{ {
cleanup() cleanup()
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE) context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(12, TimeUnit.HOURS) /* the interval comes from the server policy (signed manifest), 12 h until it is known */
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(checkIntervalHours(), TimeUnit.HOURS)
.setConstraints(Constraints.Builder() .setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) .setRequiredNetworkType(NetworkType.CONNECTED)
/* background checks only run while charging - zero battery impact */ /* background checks only run while charging - zero battery impact */
@@ -224,7 +246,7 @@ class UpdateManager private constructor(private val context: Context)
return null return null
} }
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L) val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
if (System.currentTimeMillis() - last >= Constants.UPDATE_CHECK_INTERVAL_MS) if (System.currentTimeMillis() - last >= TimeUnit.HOURS.toMillis(checkIntervalHours()))
{ {
return check() return check()
} }
@@ -244,6 +266,57 @@ class UpdateManager private constructor(private val context: Context)
check() check()
} }
/** True while the update channel is "beta" */
fun isBetaChannel(): Boolean = mPrefs.getString(PREF_UPDATE_CHANNEL, "release") == "beta"
/** Switches the channel (leaving beta forgets the invitation code) and checks right away */
fun setChannel(beta: Boolean, token: String?)
{
val edit = mPrefs.edit().putString(PREF_UPDATE_CHANNEL, if (beta) "beta" else "release")
if (beta) edit.putString(PREF_BETA_TOKEN, token?.trim()) else edit.remove(PREF_BETA_TOKEN)
edit.apply()
check()
}
/** Version name and size of a mandatory update newer than the installed app, or null */
fun pendingMandatory(): Pair<String, Long>?
{
if (mPrefs.getLong(PREF_MANDATORY_CODE, 0L) <= currentVersionCode())
{
return null
}
return (mPrefs.getString(PREF_MANDATORY_NAME, null) ?: "") to mPrefs.getLong(PREF_MANDATORY_SIZE, -1L)
}
/** Interrupts a running download, deletes the partial file and goes back to Idle */
@Synchronized
fun cancelDownload()
{
if (mDownloadJob?.isActive != true)
{
return
}
mInstallPending = false
mCancelRequested = true
mDownloadJob?.cancel()
/* unblocks a read waiting for the network */
try
{
mDownloadConn?.disconnect()
}
catch (_: Exception)
{
}
}
private fun onDownloadCancelled()
{
DiagLog.i(TAG, "update: download cancelled")
cleanup()
cancelDownloadProgress()
mState.value = UpdateState.Idle
}
@Synchronized @Synchronized
private fun check(): Job? private fun check(): Job?
{ {
@@ -257,7 +330,12 @@ class UpdateManager private constructor(private val context: Context)
mInstallPending = false mInstallPending = false
return@launch return@launch
} }
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply() val mandatory = info.mandatory && info.versionCode > currentVersionCode()
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis())
.putLong(PREF_MANDATORY_CODE, if (mandatory) info.versionCode else 0L)
.putString(PREF_MANDATORY_NAME, info.versionName)
.putLong(PREF_MANDATORY_SIZE, info.size)
.apply()
if (info.versionCode > currentVersionCode()) if (info.versionCode > currentVersionCode())
{ {
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " + DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
@@ -300,7 +378,8 @@ class UpdateManager private constructor(private val context: Context)
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install") DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
return return
} }
if (isNetworkMetered()) /* server policy "Wi-Fi only" (default true); a policy of false allows the silent download on metered networks too */
if (mPrefs.getBoolean(PREF_API_WIFI_ONLY, true) && isNetworkMetered())
{ {
DiagLog.i(TAG, "update: silent download deferred (network metered)") DiagLog.i(TAG, "update: silent download deferred (network metered)")
return return
@@ -309,12 +388,17 @@ class UpdateManager private constructor(private val context: Context)
{ {
return return
} }
mCancelRequested = false
mDownloadJob = mScope.launch { mDownloadJob = mScope.launch {
try try
{ {
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)") DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
val apk = downloadApk(info) val apk = downloadApk(info)
verifySignature(apk) verifySignature(apk)
if (mCancelRequested)
{
throw CancellationException("update download cancelled")
}
if (isVpnDown() && canInstallSilently()) if (isVpnDown() && canInstallSilently())
{ {
Log.i(TAG, "Applying the downloaded update silently") Log.i(TAG, "Applying the downloaded update silently")
@@ -340,6 +424,10 @@ class UpdateManager private constructor(private val context: Context)
} }
} }
} }
catch (e: CancellationException)
{
onDownloadCancelled()
}
catch (e: Exception) catch (e: Exception)
{ {
Log.e(TAG, "Silent update download failed", e) Log.e(TAG, "Silent update download failed", e)
@@ -350,6 +438,9 @@ class UpdateManager private constructor(private val context: Context)
} }
} }
/** Check interval from the server policy, kept between 1 hour and a week */
fun checkIntervalHours(): Long = mPrefs.getInt(PREF_API_CHECK_HOURS, 12).coerceIn(1, 168).toLong()
private fun isSilentUpdatesEnabled(): Boolean private fun isSilentUpdatesEnabled(): Boolean
{ {
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false) return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
@@ -408,7 +499,7 @@ class UpdateManager private constructor(private val context: Context)
val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
{ {
Notification.Builder(context, UPDATES_CHANNEL) val builder = Notification.Builder(context, UPDATES_CHANNEL)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .setContentTitle(title)
.setContentText("$progress%") .setContentText("$progress%")
@@ -416,11 +507,16 @@ class UpdateManager private constructor(private val context: Context)
.setStyle(Notification.ProgressStyle() .setStyle(Notification.ProgressStyle()
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100))) .setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
.setProgress(progress)) .setProgress(progress))
.setRequestPromotedOngoing(true)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentIntent(pending) .setContentIntent(pending)
.build() /* setRequestPromotedOngoing appeared in Android 16 QPR2 (API 36.1): on 36.0 it is a
* NoSuchMethodError that killed the app as soon as an update download started */
if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1)
{
builder.setRequestPromotedOngoing(true)
}
builder.build()
} }
else else
{ {
@@ -519,16 +615,25 @@ class UpdateManager private constructor(private val context: Context)
check() check()
return return
} }
mCancelRequested = false
mDownloadJob = mScope.launch { mDownloadJob = mScope.launch {
try try
{ {
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)") DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
val apk = downloadApk(info) val apk = downloadApk(info)
verifySignature(apk) verifySignature(apk)
if (mCancelRequested)
{
throw CancellationException("update download cancelled")
}
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)") DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
UpdateInstaller.install(context, apk) UpdateInstaller.install(context, apk)
mState.value = UpdateState.Idle mState.value = UpdateState.Idle
} }
catch (e: CancellationException)
{
onDownloadCancelled()
}
catch (e: UpdateException) catch (e: UpdateException)
{ {
Log.e(TAG, "Update failed: ${e.kind}", e) Log.e(TAG, "Update failed: ${e.kind}", e)
@@ -560,6 +665,26 @@ class UpdateManager private constructor(private val context: Context)
private fun fetchManifest(): UpdateInfo? private fun fetchManifest(): UpdateInfo?
{ {
if (apiConfigured())
{
when (val r = callApi())
{
is UpdateApiResult.Update -> return r.info.copy(mandatory = r.mandatory)
is UpdateApiResult.NoUpdate -> return upToDateMarker()
is UpdateApiResult.Failure ->
{
/* only a network or server failure falls back to the legacy bucket path; a bad
* signature or a rate limit must not be bypassed by asking the bucket */
if (r.kind != UpdateError.NETWORK || !legacyConfigured())
{
DiagLog.i(TAG, "update: API check failed: ${r.kind} ${r.detail}")
mState.value = UpdateState.Failed(r.kind, r.detail)
return null
}
DiagLog.i(TAG, "update: API unreachable (${r.detail}), using the legacy manifest")
}
}
}
try try
{ {
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE) val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
@@ -592,6 +717,58 @@ class UpdateManager private constructor(private val context: Context)
} }
} }
/** also signs report uploads (ReportUploader) */
internal fun apiHmacKey(): String = decodeUpdateSecret(BuildConfig.UPDATE_API_HMAC_KEY)
private fun apiConfigured(): Boolean =
UpdateApiClient.isConfigured(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY)
/* the legacy path needs the bucket URL; without it there is nothing to fall back to */
private fun legacyConfigured(): Boolean = BuildConfig.UPDATE_BASE_URL.isNotBlank()
/** Stable random installation id (no personal data); the server stores only its hash */
internal fun installId(): String
{
mPrefs.getString(PREF_INSTALL_ID, null)?.let { return it }
val bytes = ByteArray(18).also { java.security.SecureRandom().nextBytes(it) }
val id = android.util.Base64.encodeToString(bytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING)
mPrefs.edit().putString(PREF_INSTALL_ID, id).apply()
return id
}
private fun callApi(): UpdateApiResult
{
val client = UpdateApiClient(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY, installId())
val abi = Build.SUPPORTED_ABIS.firstOrNull()
val result = client.check(context.packageName, currentVersionCode(), mPrefs.getString(PREF_UPDATE_CHANNEL, "release") ?: "release",
abi, Build.VERSION.SDK_INT, mPrefs.getString(PREF_BETA_TOKEN, null))
val policy = when (result)
{
is UpdateApiResult.Update -> result.policy
is UpdateApiResult.NoUpdate -> result.policy
else -> null
}
/* server hints are kept for the scheduler; the user's own settings stay in charge */
policy?.let { mPrefs.edit().putInt(PREF_API_CHECK_HOURS, it.checkHours).putBoolean(PREF_API_WIFI_ONLY, it.wifiOnly).apply() }
return result
}
/** An UpdateInfo that sends check() into the "up to date" branch */
private fun upToDateMarker() = UpdateInfo(currentVersionCode(), currentVersionName(), "", "", -1L, emptyMap(), fromApi = true)
/**
* The presigned file URL lives ~10 minutes while a download may wait for Wi-Fi or charging:
* ask the API again when the manifest is old
*/
private fun freshen(info: UpdateInfo, force: Boolean = false): UpdateInfo
{
if (!info.fromApi || info.apkUrl.isBlank()) return info
val age = System.currentTimeMillis() / 1000 - info.issuedAt
if (!force && age in 0..URL_MAX_AGE_SECONDS) return info
val r = callApi()
return if (r is UpdateApiResult.Update && r.info.versionCode == info.versionCode && r.info.sha256 == info.sha256) r.info.copy(mandatory = r.mandatory) else info
}
private fun parseNotes(notes: JSONObject?): Map<String, String> private fun parseNotes(notes: JSONObject?): Map<String, String>
{ {
if (notes == null) if (notes == null)
@@ -606,8 +783,9 @@ class UpdateManager private constructor(private val context: Context)
return result return result
} }
private fun downloadApk(info: UpdateInfo): File private fun downloadApk(original: UpdateInfo, retried: Boolean = false): File
{ {
val info = freshen(original, force = retried)
val dir = File(context.filesDir, UPDATE_DIR) val dir = File(context.filesDir, UPDATE_DIR)
if (!dir.exists() && !dir.mkdirs()) if (!dir.exists() && !dir.mkdirs())
{ {
@@ -620,36 +798,32 @@ class UpdateManager private constructor(private val context: Context)
} }
apk.delete() apk.delete()
val conn = openConnection(info.apkUrl) /* a presigned URL already carries its own signature: adding S3 auth headers makes S3 reject it */
val conn = openConnection(info.apkUrl, signS3 = !info.fromApi)
conn.readTimeout = 30_000 conn.readTimeout = 30_000
try try
{ {
val code = conn.responseCode val code = conn.responseCode
if (code == HttpURLConnection.HTTP_FORBIDDEN && info.fromApi && !retried)
{ /* the link expired while waiting: fetch a fresh one once */
conn.disconnect()
return downloadApk(original, retried = true)
}
if (code != HttpURLConnection.HTTP_OK) if (code != HttpURLConnection.HTTP_OK)
{ {
throw UpdateException(UpdateError.NETWORK, "HTTP $code") throw UpdateException(UpdateError.NETWORK, "HTTP $code")
} }
val expected = if (info.size > 0) info.size else -1L val expected = if (info.size > 0) info.size else -1L
val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
val buffer = ByteArray(64 * 1024)
var downloaded = 0L
var lastProgress = -1 var lastProgress = -1
conn.inputStream.use { input -> mDownloadConn = conn
val downloaded = conn.inputStream.use { input ->
FileOutputStream(apk).use { output -> FileOutputStream(apk).use { output ->
while (true) copyDownload(input, output, digest, { mCancelRequested }) { done ->
{
val len = input.read(buffer)
if (len < 0)
{
break
}
output.write(buffer, 0, len)
digest.update(buffer, 0, len)
downloaded += len
if (expected > 0) if (expected > 0)
{ {
val progress = (downloaded * 100 / expected).toInt().coerceIn(0, 100) val progress = (done * 100 / expected).toInt().coerceIn(0, 100)
if (progress != lastProgress) if (progress != lastProgress)
{ {
lastProgress = progress lastProgress = progress
@@ -674,6 +848,7 @@ class UpdateManager private constructor(private val context: Context)
} }
finally finally
{ {
mDownloadConn = null
conn.disconnect() conn.disconnect()
cancelDownloadProgress() cancelDownloadProgress()
} }
@@ -771,14 +946,17 @@ class UpdateManager private constructor(private val context: Context)
} }
} }
private fun openConnection(url: String): HttpURLConnection private fun openConnection(url: String, signS3: Boolean = true): HttpURLConnection
{ {
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
conn.connectTimeout = CONNECT_TIMEOUT_MS conn.connectTimeout = CONNECT_TIMEOUT_MS
conn.readTimeout = READ_TIMEOUT_MS conn.readTimeout = READ_TIMEOUT_MS
conn.setRequestProperty("Connection", "close") conn.setRequestProperty("Connection", "close")
conn.setRequestProperty("Cache-Control", "no-cache") conn.setRequestProperty("Cache-Control", "no-cache")
applyS3Auth(conn, url) if (signS3)
{
applyS3Auth(conn, url)
}
return conn return conn
} }
@@ -0,0 +1,71 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
/**
* Release notes of the app language: the full tag ("zh-tw"), then the language ("zh"),
* then Russian, then English. Keys of [notes] are lowercase (see UpdateManager.parseNotes).
*/
fun pickNotes(notes: Map<String, String>, languageTag: String, language: String): String?
{
return listOf(languageTag.lowercase(), language.lowercase(), "ru", "en")
.firstNotNullOfOrNull { key -> notes[key]?.takeIf { it.isNotBlank() } }
}
/** Every non-empty line of the notes is one list item; a leading "-", "*" or "•" is dropped. */
fun noteLines(text: String?): List<String>
{
return text.orEmpty().lines().map { it.trim().trimStart('-', '*', '•').trim() }.filter { it.isNotEmpty() }
}
/**
* Copies the download into [output] and the digest; [isCancelled] is checked between reads and
* when a read fails (cancelling disconnects the connection, which breaks a blocked read).
*/
internal fun copyDownload(input: InputStream, output: OutputStream, digest: MessageDigest,
isCancelled: () -> Boolean, onProgress: (Long) -> Unit): Long
{
val buffer = ByteArray(64 * 1024)
var total = 0L
while (true)
{
if (isCancelled())
{
throw CancellationException("update download cancelled")
}
val len = try
{
input.read(buffer)
}
catch (e: IOException)
{
if (isCancelled()) throw CancellationException("update download cancelled") else throw e
}
if (len < 0)
{
return total
}
output.write(buffer, 0, len)
digest.update(buffer, 0, len)
total += len
onProgress(total)
}
}
@@ -9,7 +9,11 @@
package org.strongswan.android.ui package org.strongswan.android.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
@@ -77,6 +81,7 @@ import org.strongswan.android.R
import org.strongswan.android.data.LogContentProvider import org.strongswan.android.data.LogContentProvider
import org.strongswan.android.logic.CharonVpnService import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.DiagLog import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.ui.compose.SecondaryScaffold import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxTheme import org.strongswan.android.ui.compose.theme.ShxTheme
import java.io.BufferedReader import java.io.BufferedReader
@@ -87,18 +92,49 @@ class LogActivity : AppCompatActivity()
{ {
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1) private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
/* the active profile goes into the report meta */
private var vpn: VpnStateService? = null
private val connection = object : ServiceConnection
{
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
{
vpn = (binder as VpnStateService.LocalBinder).service
}
override fun onServiceDisconnected(name: ComponentName?)
{
vpn = null
}
}
override fun onCreate(savedInstanceState: Bundle?) override fun onCreate(savedInstanceState: Bundle?)
{ {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
bindService(Intent(this, VpnStateService::class.java), connection, Context.BIND_AUTO_CREATE)
setContent { setContent {
ShxTheme { ShxTheme {
LogScreen(onBack = { finish() }, onSend = { sendLog() }, messages = messages) val report: ReportViewModel = viewModel()
/* without the panel API in this build the report goes by email, as before */
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()
{
unbindService(connection)
super.onDestroy()
}
private fun activeProfile(): String
{
val profile = vpn?.profile ?: return ""
return listOfNotNull(profile.name, profile.gateway).filter { it.isNotBlank() }.distinct().joinToString(" · ")
}
private fun sendLog(comment: String)
{ {
/* the daemon log plus the lifecycle log; the latter survives process /* the daemon log plus the lifecycle log; the latter survives process
* deaths and documents watchdog/reconnect decisions */ * deaths and documents watchdog/reconnect decisions */
@@ -124,6 +160,10 @@ class LogActivity : AppCompatActivity()
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL)) intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version)) intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
intent.type = "text/plain" intent.type = "text/plain"
if (comment.isNotBlank())
{
intent.putExtra(Intent.EXTRA_TEXT, comment)
}
if (uris.size > 1) if (uris.size > 1)
{ {
intent.putExtra(Intent.EXTRA_STREAM, uris) intent.putExtra(Intent.EXTRA_STREAM, uris)
@@ -0,0 +1,380 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui
import android.app.Application
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.strongswan.android.ui.compose.runDiagnostics
import org.strongswan.android.BuildConfig
import org.strongswan.android.R
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
import org.strongswan.android.logic.events.Events
import org.strongswan.android.logic.updates.ReportContent
import org.strongswan.android.logic.updates.ReportUploader
import org.strongswan.android.logic.updates.UpdateManager
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxFormField
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxIntro
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.design.ShxSheetTitle
import org.strongswan.android.ui.compose.design.ShxToggleRow
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import java.io.File
import java.util.Calendar
/** State of the "Send report" sheet (mockups App8-App10); the upload survives a rotation */
class ReportViewModel(app: Application) : AndroidViewModel(app)
{
sealed class Ui
{
data object Hidden : Ui()
data class Form(val sending: Boolean = false) : Ui()
data class Sent(val code: String) : Ui()
data class Failed(val failure: ReportContent.Failure) : Ui()
}
var ui by mutableStateOf<Ui>(Ui.Hidden)
private set
var comment by mutableStateOf("")
var attachDiag by mutableStateOf(true)
/* a swipe may hide the sheet mid-upload: the result then opens it again */
private var job: Job? = null
fun open()
{
ui = Ui.Form(sending = job?.isActive == true)
}
fun close()
{
ui = Ui.Hidden
}
fun send(profile: String)
{
if (job?.isActive == true)
{
return
}
ui = Ui.Form(sending = true)
val app = getApplication<Application>()
val note = comment
val diag = attachDiag
job = viewModelScope.launch {
/* the same checks as the Diagnostics screen (~3 s); a hang must not block the report */
val checks = if (!diag) null else withTimeoutOrNull(10_000) {
runCatching { runDiagnostics(app) }.getOrNull()?.steps?.map { ReportContent.Check(it.title, it.value, it.ok) }
}
val result = withContext(Dispatchers.IO) {
val now = Calendar.getInstance()
val charon = ReportContent.todayOnly(
ReportContent.readTail(File(app.filesDir, CharonVpnService.LOG_FILE), ReportContent.CHARON_MAX_BYTES * 2),
now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH),
)
val text = ReportContent.build(charon, if (diag) ReportContent.readTail(DiagLog.file(app), ReportContent.DIAG_MAX_BYTES) else null, checks)
val meta = ReportContent.meta(
BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toLong(), Build.MANUFACTURER, Build.MODEL,
Build.VERSION.RELEASE, Build.VERSION.SDK_INT, networkLabel(app), note, profile,
)
val updates = UpdateManager.getInstance(app)
ReportUploader(BuildConfig.UPDATE_API_URL, updates.apiHmacKey(), updates.installId()).send(text, meta)
}
when (result)
{
is ReportUploader.Result.Sent ->
{
Events.reportSent(app, result.code)
comment = ""
ui = Ui.Sent(result.code)
}
is ReportUploader.Result.Failed ->
{
DiagLog.i("Report", "upload failed: HTTP ${result.httpCode} ${result.detail}")
ui = Ui.Failed(ReportContent.failure(result.httpCode))
}
}
}
}
companion object
{
/** Only the address and the HMAC key are needed (the manifest key is for updates) */
fun apiReady(context: Context): Boolean =
BuildConfig.UPDATE_API_URL.isNotBlank() && UpdateManager.getInstance(context).apiHmacKey().isNotBlank()
/** The network under the tunnel: with the VPN up the active network is the VPN itself */
private fun networkLabel(context: Context): String
{
val network = UnderlyingNetwork.resolve(context) ?: return "none"
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "none"
val caps = try { cm.getNetworkCapabilities(network) } catch (_: Exception) { null } ?: return "none"
return when
{
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "mobile"
else -> "other"
}
}
}
}
@Composable
fun ReportSheet(model: ReportViewModel, profile: () -> String, onMail: (String) -> Unit)
{
val ui = model.ui
if (ui == ReportViewModel.Ui.Hidden)
{
return
}
ShxSheet(onDismiss = model::close)
{
when (ui)
{
is ReportViewModel.Ui.Form -> ReportForm(model, ui.sending, onSend = { model.send(profile()) }, onMail = { onMail(model.comment) })
is ReportViewModel.Ui.Sent -> ReportSent(ui.code, onDone = model::close)
is ReportViewModel.Ui.Failed -> ReportFailed(ui.failure, onRetry = model::open, onMail = { onMail(model.comment) })
ReportViewModel.Ui.Hidden -> Unit
}
}
}
/* App8 */
@Composable
private fun ReportForm(model: ReportViewModel, sending: Boolean, onSend: () -> Unit, onMail: () -> Unit)
{
val c = LocalShxColors.current
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
ShxSheetTitle(stringResource(R.string.log_send_report))
ShxIntro(stringResource(R.string.report_intro))
}
ReasonCard(
stringResource(R.string.report_contents), c.accent,
listOf(R.string.report_item_log, R.string.report_item_device, R.string.report_item_network, R.string.report_item_ips).map { stringResource(it) to null },
)
ShxPanel()
{
ShxToggleRow(
stringResource(R.string.report_diag_title), model.attachDiag, { model.attachDiag = it },
subtitle = stringResource(R.string.report_diag_sub), enabled = !sending,
)
}
ShxFormField(
label = stringResource(R.string.report_comment_label),
value = model.comment,
onValueChange = { model.comment = it.take(1000) },
singleLine = false,
enabled = !sending,
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.report_cancel), model::close, style = ShxButtonStyle.OUTLINE, enabled = !sending, modifier = Modifier.weight(1f))
Box(contentAlignment = Alignment.Center, modifier = Modifier.weight(2f))
{
ShxButton(if (sending) "" else stringResource(R.string.report_send), { if (!sending) onSend() }, modifier = Modifier.fillMaxWidth())
if (sending)
{
CircularProgressIndicator(color = c.onAccent, strokeWidth = 2.5.dp, modifier = Modifier.size(22.dp))
}
}
}
if (!sending)
{
TextLink(stringResource(R.string.report_by_mail), onMail)
}
}
/* App9 */
@Composable
private fun ReportSent(code: String, onDone: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
val copied = stringResource(R.string.report_code_copied)
ResultHeader(ShxIcons.Check, c.successContainer, c.success, stringResource(R.string.report_sent_title), stringResource(R.string.report_sent_sub))
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(16.dp))
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
{
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(stringResource(R.string.report_code_label).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
Text(code, style = ShxType.mono.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, letterSpacing = 0.9.sp), color = c.text)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile).clickable(role = Role.Button) {
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(ClipData.newPlainText("report", code))
Toast.makeText(context, copied, Toast.LENGTH_SHORT).show()
},
)
{
Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.report_copy_code), tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
}
}
}
ShxNote(stringResource(R.string.report_saved_note))
ShxButton(stringResource(R.string.report_done), onDone, modifier = Modifier.fillMaxWidth())
}
/* App10 */
@Composable
private fun ReportFailed(failure: ReportContent.Failure, onRetry: () -> Unit, onMail: () -> Unit)
{
val c = LocalShxColors.current
val noAnswer = failure == ReportContent.Failure.NO_NETWORK || failure == ReportContent.Failure.SERVER_DOWN
ResultHeader(
ShxIcons.Alert, c.errorContainer, c.error, stringResource(R.string.report_failed_title),
stringResource(if (noAnswer) R.string.report_failed_no_answer else R.string.report_failed_refused),
)
val reasons = when (failure)
{
ReportContent.Failure.NO_NETWORK -> listOf(R.string.report_reason_offline to R.string.report_reason_offline_d, R.string.report_reason_server to R.string.report_reason_server_d)
ReportContent.Failure.SERVER_DOWN -> listOf(R.string.report_reason_server to R.string.report_reason_server_d)
ReportContent.Failure.SIGNATURE -> listOf(R.string.report_reason_clock to R.string.report_reason_clock_d)
ReportContent.Failure.TOO_OFTEN -> listOf(R.string.report_reason_often to R.string.report_reason_often_d)
ReportContent.Failure.TOO_LARGE -> listOf(R.string.report_reason_large to R.string.report_reason_large_d)
ReportContent.Failure.REJECTED -> listOf(R.string.report_reason_rejected to R.string.report_reason_rejected_d)
}
ReasonCard(stringResource(R.string.report_reasons), c.error, reasons.map { (t, d) -> stringResource(t) to stringResource(d) })
ShxNote(stringResource(R.string.report_mail_note))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.report_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.report_by_letter), onMail, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
}
}
/** 52dp round icon, 22/800 title, 13 sub (as F_ErrImport) */
@Composable
private fun ResultHeader(icon: ImageVector, background: Color, tint: Color, title: String, sub: String)
{
val c = LocalShxColors.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(CircleShape).background(background))
{
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text)
Text(sub, style = ShxType.sub, color = c.textSub)
}
}
}
/** White card with a section label and dotted lines: «bold — muted» when [items] carry a second part */
@Composable
private fun ReasonCard(title: String, dot: Color, items: List<Pair<String, String?>>)
{
val c = LocalShxColors.current
val shape = RoundedCornerShape(22.dp)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp),
)
{
Text(title.uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
items.forEach { (head, tail) ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
Text(
buildAnnotatedString {
if (tail == null)
{
append(head)
}
else
{
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(head) }
append(" — ")
withStyle(SpanStyle(color = c.textSub)) { append(tail) }
}
},
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
)
}
}
}
}
@Composable
private fun TextLink(text: String, onClick: () -> Unit)
{
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
{
Text(
text, style = ShxType.chip, color = LocalShxColors.current.accentText,
modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onClick).padding(8.dp),
)
}
}
@@ -71,6 +71,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions import com.journeyapps.barcodescanner.ScanOptions
@@ -193,19 +194,24 @@ private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle() val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
val extra = extras[uuid] ?: ProfileExtra(uuid) val extra = extras[uuid] ?: ProfileExtra(uuid)
/* the live state of the tunnel and a direct ping of the server */ /* the live state of the tunnel and a direct ping of the server */
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null) { val vpn by produceState<Pair<VpnStateService.State, String?>?>(null, owner) {
while (true) /* only while the page is on screen */
{ owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
val service = VpnStateService.getLiveInstance() while (true)
value = service?.let { it.state to it.profile?.getUUID()?.toString() } {
delay(1500) val service = VpnStateService.getLiveInstance()
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
delay(1500)
}
} }
} }
val rtt by produceState<Long?>(null, profile.gateway) { val rtt by produceState<Long?>(null, profile.gateway, owner) {
while (true) owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
{ while (true)
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first } {
delay(30_000) value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
delay(30_000)
}
} }
} }
val active = vpn?.second == uuid val active = vpn?.second == uuid
@@ -55,6 +55,7 @@ class SettingsActivity : AppCompatActivity()
Box(Modifier.padding(padding)) { Box(Modifier.padding(padding)) {
SettingsScreen(profiles = profiles, showTitle = false) SettingsScreen(profiles = profiles, showTitle = false)
} }
org.strongswan.android.ui.compose.UpdateSheetHost()
} }
} }
} }
@@ -92,7 +92,7 @@ data class DiagReport(val steps: List<DiagStep>, val at: Long, val vpnUp: Boolea
val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true } val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true }
} }
private suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope { internal suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
val vpn = VpnStateService.getLiveInstance() val vpn = VpnStateService.getLiveInstance()
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
val gateway = (vpn?.profile)?.gateway val gateway = (vpn?.profile)?.gateway
@@ -199,7 +199,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
{ {
synchronized(underlyingNetworks) synchronized(underlyingNetworks)
{ {
underlyingNetworks[network] = capabilities val previous = underlyingNetworks.put(network, capabilities)
/* fired on every signal-strength tick: the published state (network
* kinds, SSID) only depends on the transports, and reading the SSID
* is a system call, so unchanged transports end here */
if (previous != null && sameTransports(previous, capabilities))
{
return
}
publishNetworks() publishNetworks()
} }
} }
@@ -214,6 +221,10 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
} }
} }
private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
private fun publishNetworks() private fun publishNetworks()
{ {
_networkAvailable.value = underlyingNetworks.isNotEmpty() _networkAvailable.value = underlyingNetworks.isNotEmpty()
@@ -124,10 +124,23 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
var result by remember { mutableStateOf<CheckResult?>(null) } var result by remember { mutableStateOf<CheckResult?>(null) }
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) } var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) } val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
val apps by produceState<List<InstalledApp>>(emptyList(), mode) { var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
if (mode == RouteCheckMode.APP && value.isEmpty())
/** The installed apps; loads them on first use, so a check never runs against a list that is still empty. */
suspend fun loadApps(): List<InstalledApp>
{
if (apps.isEmpty())
{ {
value = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) } apps = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
}
return apps
}
LaunchedEffect(mode)
{
if (mode == RouteCheckMode.APP)
{
loadApps()
} }
} }
@@ -150,7 +163,7 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
{ {
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive) RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive) RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
RouteCheckMode.APP -> apps.firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) } RouteCheckMode.APP -> loadApps().firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
?.let { CheckResult.App(it) } ?: CheckResult.BadInput ?.let { CheckResult.App(it) } ?: CheckResult.BadInput
} }
if (result !is CheckResult.BadInput) if (result !is CheckResult.BadInput)
@@ -173,10 +186,6 @@ fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteChec
{ {
if (initialQuery.isNotBlank()) if (initialQuery.isNotBlank())
{ {
if (mode == RouteCheckMode.APP)
{
delay(300)
}
run() run()
} }
} }
@@ -52,7 +52,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.strongswan.android.R import org.strongswan.android.R
import org.strongswan.android.logic.updates.UpdateState
import org.strongswan.android.ui.compose.design.ShxButton import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons import org.strongswan.android.ui.compose.design.ShxIcons
@@ -221,91 +220,6 @@ fun KillSwitchPage(lockdown: Boolean)
} }
} }
/** Update card (F_Update): app icon, version, what is new, progress, Later / Install. */
@Composable
fun UpdateCard(state: UpdateState, installed: String, onCheck: () -> Unit, onInstall: () -> Unit)
{
val c = LocalShxColors.current
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
val shape = RoundedCornerShape(24.dp)
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(16.dp),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
{
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(
if (info != null) "SHX VPN ${info.versionName}" else stringResource(R.string.update_card_uptodate),
style = ShxType.cardHeading.copy(fontSize = 20.sp), color = c.text,
)
Text(
when
{
info != null -> stringResource(R.string.update_card_have, installed, formatBytes(info.size))
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
else -> stringResource(R.string.update_card_version, installed)
},
style = ShxType.sub, color = c.textSub,
)
}
}
if (info != null)
{
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull()
if (!notes.isNullOrBlank())
{
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
notes.split(Regex("(?<=[.;])\\s+")).map { it.trim().trimEnd('.', ';') }.filter { it.isNotEmpty() }.forEach { line ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.accent))
Text(line, style = ShxType.note.copy(fontSize = 14.sp), color = c.text)
}
}
}
}
if (state is UpdateState.Downloading)
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
Row(Modifier.fillMaxWidth())
{
Text(stringResource(R.string.update_card_downloading), style = ShxType.meta, color = c.textSub, modifier = Modifier.weight(1f))
Text("${state.progress}%", style = ShxType.meta, color = c.textSub)
}
Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.tile))
{
Box(Modifier.fillMaxWidth(state.progress.coerceIn(0, 100) / 100f).height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.accent))
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
if (info != null)
{
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.update_card_install), onInstall, modifier = Modifier.weight(2f), enabled = state !is UpdateState.Downloading)
}
else
{
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth(), enabled = state !is UpdateState.Checking)
}
}
Text(stringResource(R.string.update_card_footer), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
}
}
/** /**
* «Help» (F_Help): two big buttons (check the connection, contact support), * «Help» (F_Help): two big buttons (check the connection, contact support),
* the frequent questions as an accordion, and «About the app». * the frequent questions as an accordion, and «About the app».
@@ -566,14 +480,7 @@ internal fun InstallPermissionSheet(onOpen: () -> Unit, onDismiss: () -> Unit)
Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft) Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
} }
} }
Row( UpdateSafeNote(stringResource(R.string.install_perm_safe))
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = 12.dp),
)
{
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
Text(stringResource(R.string.install_perm_safe), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onSuccess)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{ {
ShxButton(stringResource(R.string.install_perm_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f)) ShxButton(stringResource(R.string.install_perm_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
@@ -105,8 +105,6 @@ import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.WatchdogReceiver import org.strongswan.android.logic.WatchdogReceiver
import org.strongswan.android.logic.connectivity.WhiteListManager import org.strongswan.android.logic.connectivity.WhiteListManager
import org.strongswan.android.logic.shortcuts.VpnShortcutManager import org.strongswan.android.logic.shortcuts.VpnShortcutManager
import org.strongswan.android.logic.updates.UpdateError
import org.strongswan.android.logic.updates.UpdateInstaller
import org.strongswan.android.logic.updates.UpdateManager import org.strongswan.android.logic.updates.UpdateManager
import org.strongswan.android.logic.updates.UpdateState import org.strongswan.android.logic.updates.UpdateState
import org.strongswan.android.ui.LogActivity import org.strongswan.android.ui.LogActivity
@@ -151,43 +149,9 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
val updateManager = remember { UpdateManager.getInstance(context) } val updateManager = remember { UpdateManager.getInstance(context) }
val updateState by updateManager.state.collectAsStateWithLifecycle() val updateState by updateManager.state.collectAsStateWithLifecycle()
var showUpdateConfirm by remember { mutableStateOf(false) }
var showInstallPermission by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val unknownSourcesLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (UpdateInstaller.canInstall(context))
{
updateManager.downloadAndInstall()
}
else
{
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.update_permission_denied)) }
}
}
val startUpdate: () -> Unit = {
if (UpdateInstaller.canInstall(context))
{
updateManager.downloadAndInstall()
}
else
{ /* F_UpdateInstall explains the system page first */
showInstallPermission = true
}
}
if (showInstallPermission)
{
InstallPermissionSheet(
onOpen = {
showInstallPermission = false
unknownSourcesLauncher.launch(UpdateInstaller.createInstallPermissionIntent(context))
},
onDismiss = { showInstallPermission = false },
)
}
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) } var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle() val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle()
@@ -419,18 +383,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
if (page == SettingsPage.UPDATES) if (page == SettingsPage.UPDATES)
{ {
UpdateCard( UpdatesPage()
state = updateState,
installed = versionName,
onCheck = {
when (updateState)
{
is UpdateState.Checking, is UpdateState.Downloading -> Unit
else -> updateManager.checkNow()
}
},
onInstall = { showUpdateConfirm = true },
)
} }
if (page == SettingsPage.KILLSWITCH) if (page == SettingsPage.KILLSWITCH)
@@ -445,75 +398,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() }) KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() })
} }
if (page == SettingsPage.UPDATES) if (page == SettingsPage.ROOT) Text(
{
ShxSection(title = stringResource(R.string.update_settings))
{
var autoUpdate by remember {
mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true))
}
ShxSettingToggleRow(
icon = ShxIcons.Refresh,
title = stringResource(R.string.update_auto_check_title),
subtitle = stringResource(R.string.update_auto_check_summary),
checked = autoUpdate,
onCheckedChange = {
autoUpdate = it
prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply()
},
)
ShxSectionDivider()
var silentUpdates by remember {
mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false))
}
ShxSettingToggleRow(
icon = ShxIcons.Refresh,
title = stringResource(R.string.update_silent_title),
subtitle = stringResource(R.string.update_silent_summary),
checked = silentUpdates,
onCheckedChange = {
silentUpdates = it
prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply()
},
)
ShxSectionDivider()
ShxSettingRow(
icon = ShxIcons.Download,
title = stringResource(R.string.update_check_title),
subtitle = updateStatusText(updateState),
onClick = {
when (updateState)
{
is UpdateState.Checking, is UpdateState.Downloading -> Unit
else -> updateManager.checkNow()
}
},
)
val updateInfo = (updateState as? UpdateState.Available)?.info
?: (updateState as? UpdateState.Downloading)?.info
if (updateInfo != null)
{
ShxSectionDivider()
ShxSettingRow(
icon = ShxIcons.Download,
title = stringResource(R.string.update_download_install),
subtitle = when (val state = updateState)
{
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
else -> stringResource(R.string.update_available_fmt, updateInfo.versionName)
},
onClick = {
if (updateState !is UpdateState.Downloading)
{
showUpdateConfirm = true
}
},
)
}
}
}
if (page == SettingsPage.ROOT || page == SettingsPage.UPDATES) Text(
text = stringResource(R.string.app_version_fmt, versionName), text = stringResource(R.string.app_version_fmt, versionName),
style = ShxType.meta, style = ShxType.meta,
color = LocalShxColors.current.textSub, color = LocalShxColors.current.textSub,
@@ -600,61 +485,6 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
) )
} }
if (showUpdateConfirm && updateState is UpdateState.Available)
{
val info = (updateState as UpdateState.Available).info
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull() ?: ""
ShxAlertDialog(
onDismissRequest = { showUpdateConfirm = false },
title = { Text(stringResource(R.string.update_confirm_title, info.versionName)) },
text = { if (notes.isNotEmpty()) { Text(notes) } },
confirmButton = {
ShxDialogButton(primary = true, onClick = {
showUpdateConfirm = false
startUpdate()
}) { Text(stringResource(R.string.update_action)) }
},
dismissButton = {
ShxDialogButton(onClick = { showUpdateConfirm = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@Composable
private fun updateStatusText(state: UpdateState): String
{
return when (state)
{
is UpdateState.Checking -> stringResource(R.string.update_checking)
is UpdateState.Idle -> stringResource(R.string.update_status_idle)
is UpdateState.UpToDate -> stringResource(R.string.update_status_uptodate)
is UpdateState.Available ->
{
if (state.info.size > 0)
{
stringResource(R.string.update_available_fmt, state.info.versionName) + " • " +
stringResource(R.string.update_size_mb_fmt, (state.info.size / (1024 * 1024)).toInt())
}
else
{
stringResource(R.string.update_available_fmt, state.info.versionName)
}
}
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
is UpdateState.Failed -> stringResource(
when (state.error)
{
UpdateError.NETWORK -> R.string.update_error_network
UpdateError.HASH -> R.string.update_error_hash
UpdateError.SIGNATURE -> R.string.update_error_signature
UpdateError.INSTALL -> R.string.update_error_install
UpdateError.OTHER -> R.string.update_error_generic
}
)
}
} }
/** /**
@@ -260,6 +260,8 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
{ {
AddServerSheet(onDismiss = { addSheetOpen = false }) AddServerSheet(onDismiss = { addSheetOpen = false })
} }
/* update sheets, the mandatory update included, over every tab */
UpdateSheetHost()
} }
} }
} }
@@ -0,0 +1,658 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.text.format.DateUtils
import android.widget.Toast
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.preference.PreferenceManager
import kotlinx.coroutines.flow.MutableStateFlow
import org.strongswan.android.BuildConfig
import org.strongswan.android.R
import org.strongswan.android.logic.updates.UpdateError
import org.strongswan.android.logic.updates.UpdateInfo
import org.strongswan.android.logic.updates.UpdateInstaller
import org.strongswan.android.logic.updates.UpdateManager
import org.strongswan.android.logic.updates.UpdateState
import org.strongswan.android.logic.updates.noteLines
import org.strongswan.android.logic.updates.pickNotes
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxChoiceCard
import org.strongswan.android.ui.compose.design.ShxFormField
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxIntro
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.design.ShxSheetTitle
import org.strongswan.android.ui.compose.design.ShxTone
import org.strongswan.android.ui.compose.design.ShxToggleRow
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.utils.Constants
/** Which update sheet the user opened: «What is new» (App2) or the install attempt (App3 / App5). */
enum class UpdateSheet { REVIEW, ATTEMPT }
/** Shared between the Updates page and the one sheet host of the activity. */
object UpdateSheets
{
val shown = MutableStateFlow<UpdateSheet?>(null)
fun show(sheet: UpdateSheet?)
{
shown.value = sheet
}
}
/**
* All update sheets of an activity (ShxApp and SettingsActivity call it once):
* «What is new» (App2), the download (App3), the mandatory update (App4),
* the failure (App5) and the install permission (App6).
*/
@Composable
fun UpdateSheetHost()
{
val context = LocalContext.current
val manager = remember { UpdateManager.getInstance(context) }
val state by manager.state.collectAsStateWithLifecycle()
val sheet by UpdateSheets.shown.collectAsStateWithLifecycle()
val mandatory = remember(state) { manager.pendingMandatory() }
var askPermission by remember { mutableStateOf(false) }
val install = {
UpdateSheets.show(UpdateSheet.ATTEMPT)
manager.downloadAndInstall()
}
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult())
{
if (UpdateInstaller.canInstall(context)) install()
else Toast.makeText(context, R.string.update_permission_denied, Toast.LENGTH_LONG).show()
}
val start: () -> Unit = {
if (UpdateInstaller.canInstall(context)) install() else askPermission = true
}
LaunchedEffect(Unit)
{ /* a mandatory update found earlier (e.g. before a restart): make sure it is still current */
if (manager.pendingMandatory() != null && state !is UpdateState.Available && state !is UpdateState.Downloading)
{
manager.checkNow()
}
}
LaunchedEffect(state)
{ /* the installer took over, or the download was cancelled */
if (sheet == UpdateSheet.ATTEMPT && (state is UpdateState.Idle || state is UpdateState.UpToDate))
{
UpdateSheets.show(null)
}
}
when
{
askPermission -> InstallPermissionSheet(
onOpen = {
askPermission = false
launcher.launch(UpdateInstaller.createInstallPermissionIntent(context))
},
onDismiss = { askPermission = false },
)
sheet == UpdateSheet.ATTEMPT -> ShxSheet(onDismiss = { UpdateSheets.show(null) })
{
when (val s = state)
{
is UpdateState.Failed -> UpdateFailedContent(s, onRetry = start)
else -> UpdateProgressContent(s, onCancel = { manager.cancelDownload() }, onInstall = start)
}
}
mandatory != null -> MandatoryUpdateSheet(mandatory.first, mandatory.second, onUpdate = start)
sheet == UpdateSheet.REVIEW ->
{
val info = (state as? UpdateState.Available)?.info
if (info != null) UpdateReviewSheet(info, onInstall = start, onDismiss = { UpdateSheets.show(null) })
else androidx.compose.runtime.SideEffect { UpdateSheets.show(null) }
}
}
}
/** «Updates» page (App1): the available version, automatic mode, channel, safety note, manual check. */
@Composable
fun UpdatesPage()
{
val context = LocalContext.current
val c = LocalShxColors.current
val manager = remember { UpdateManager.getInstance(context) }
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
val state by manager.state.collectAsStateWithLifecycle()
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
val busy = state is UpdateState.Checking || state is UpdateState.Downloading
var beta by remember { mutableStateOf(manager.isBetaChannel()) }
var askCode by remember { mutableStateOf(false) }
Column(Modifier.padding(horizontal = ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(c.accentContainer).padding(ShxSpacing.lg),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
{
Icon(ShxIcons.Refresh, contentDescription = null, tint = c.onAccent, modifier = Modifier.size(26.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(
when
{
info != null -> stringResource(R.string.update_available_fmt, info.versionName)
state is UpdateState.UpToDate -> stringResource(R.string.update_card_uptodate)
else -> "SHX VPN ${BuildConfig.VERSION_NAME}"
},
style = ShxType.cardHeading, color = c.onAccentContainerStrong,
)
Text(
when
{
info != null -> stringResource(R.string.updates_have_size, BuildConfig.VERSION_NAME, sizeText(info.size))
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
state is UpdateState.UpToDate -> stringResource(R.string.update_card_version, BuildConfig.VERSION_NAME)
else -> stringResource(R.string.update_status_idle)
},
style = ShxType.sub, color = c.onAccentContainer,
)
}
}
if (info != null)
{
val downloading = state as? UpdateState.Downloading
ShxButton(
if (downloading != null) stringResource(R.string.update_downloading_fmt, downloading.progress) else stringResource(R.string.updates_review),
{ UpdateSheets.show(if (downloading != null) UpdateSheet.ATTEMPT else UpdateSheet.REVIEW) },
height = 52.dp, modifier = Modifier.fillMaxWidth(),
)
}
}
ShxSectionHeader(stringResource(R.string.updates_section_auto))
ShxPanel()
{
var autoCheck by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) }
var silent by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) }
val hours = remember { manager.checkIntervalHours().toInt() }
ShxToggleRow(
stringResource(R.string.updates_auto_check), autoCheck,
{ autoCheck = it; prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply() },
subtitle = pluralStringResource(R.plurals.updates_auto_check_sub, hours, hours), divider = true,
)
ShxToggleRow(
stringResource(R.string.updates_silent), silent,
{ silent = it; prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply() },
subtitle = stringResource(R.string.updates_silent_sub),
)
}
ShxSectionHeader(stringResource(R.string.updates_section_channel))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxChoiceCard(
stringResource(R.string.updates_channel_stable), stringResource(R.string.updates_channel_stable_sub), !beta,
{
if (beta)
{
beta = false
manager.setChannel(beta = false, token = null)
}
},
modifier = Modifier.weight(1f),
)
ShxChoiceCard(
stringResource(R.string.updates_channel_beta), stringResource(R.string.updates_channel_beta_sub), beta,
{ if (!beta) askCode = true },
modifier = Modifier.weight(1f),
)
}
Text(stringResource(R.string.updates_channel_hint), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
UpdateSafeNote(stringResource(R.string.install_perm_safe))
ShxButton(
stringResource(R.string.updates_check_now), { manager.checkNow() },
style = ShxButtonStyle.SOFT, height = 52.dp, enabled = !busy, modifier = Modifier.fillMaxWidth(),
)
val last = remember(state) { prefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L) }
Text(
when (val s = state)
{
is UpdateState.Checking -> stringResource(R.string.update_card_checking)
is UpdateState.Failed -> stringResource(updateErrorText(s.error))
else -> if (last > 0) stringResource(R.string.updates_last_check, lastCheckText(context, last))
else stringResource(R.string.updates_never_checked)
},
style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
)
}
if (askCode)
{
BetaCodeSheet(
onEnable = { code ->
askCode = false
beta = true
manager.setChannel(beta = true, token = code)
},
onDismiss = { askCode = false },
)
}
}
/** Invitation code of the beta channel. */
@Composable
private fun BetaCodeSheet(onEnable: (String) -> Unit, onDismiss: () -> Unit)
{
var code by remember { mutableStateOf("") }
ShxSheet(onDismiss = onDismiss)
{
ShxSheetTitle(stringResource(R.string.updates_beta_title))
ShxIntro(stringResource(R.string.updates_beta_text))
ShxFormField(label = stringResource(R.string.updates_beta_code), value = code, onValueChange = { code = it }, mono = true)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.updates_beta_enable), { onEnable(code.trim()) }, enabled = code.isNotBlank(), modifier = Modifier.weight(2f))
}
}
}
/** «A new version is available» (App2): version, what is new from the release notes, Later / Download and install. */
@Composable
private fun UpdateReviewSheet(info: UpdateInfo, onInstall: () -> Unit, onDismiss: () -> Unit)
{
val c = LocalShxColors.current
val locale = LocalConfiguration.current.locales[0]
val lines = noteLines(pickNotes(info.notes, "${locale.language}-${locale.country}", locale.language))
ShxSheet(onDismiss = onDismiss)
{
UpdateSheetHeader(
"SHX VPN ${info.versionName}",
stringResource(R.string.updates_review_sub, BuildConfig.VERSION_NAME, sizeText(info.size)),
)
if (lines.isNotEmpty())
{
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(ShxSpacing.lg))
{
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
{
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section, color = c.accentText)
lines.forEach { Bullet(it, c.accent) }
}
}
}
UpdateSafeNote(stringResource(R.string.updates_review_safe))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.cert_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.update_download_install), onInstall, modifier = Modifier.weight(2f))
}
}
}
/** Download (App3): progress and the four steps of the update. */
@Composable
private fun UpdateProgressContent(state: UpdateState, onCancel: () -> Unit, onInstall: () -> Unit)
{
val c = LocalShxColors.current
val info = (state as? UpdateState.Downloading)?.info ?: (state as? UpdateState.Available)?.info
val progress = (state as? UpdateState.Downloading)?.progress ?: 0
val downloading = state is UpdateState.Downloading
UpdateSheetHeader(
stringResource(R.string.updates_downloading_title, info?.versionName ?: ""),
stringResource(R.string.updates_downloading_sub),
)
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
{
Row(Modifier.fillMaxWidth())
{
Text(stringResource(R.string.update_card_downloading), style = ShxType.chip, color = c.textSoft, modifier = Modifier.weight(1f))
if (info != null && info.size > 0)
{
Text(
stringResource(R.string.updates_progress, progress, sizeText(info.size * progress / 100), sizeText(info.size)),
style = ShxType.chip, color = c.textSoft,
)
}
}
Box(Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.cardBorder))
{
Box(Modifier.fillMaxWidth(progress / 100f).height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.accent))
}
}
/* steps: the list is known once a version is shown; at 100% the file is being checked */
val verifying = downloading && progress >= 100
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 6.dp))
{
UpdateStep(StepState.DONE, stringResource(R.string.updates_step_list), stringResource(R.string.updates_step_list_sub), divider = true)
UpdateStep(
if (verifying) StepState.DONE else StepState.RUNNING, stringResource(R.string.updates_step_download),
if (info != null && info.size > 0) sizeText(info.size) else stringResource(R.string.update_card_downloading), divider = true,
)
UpdateStep(if (verifying) StepState.RUNNING else StepState.WAITING, stringResource(R.string.updates_step_verify), stringResource(R.string.updates_step_verify_sub), divider = true)
UpdateStep(StepState.WAITING, stringResource(R.string.updates_step_install), stringResource(R.string.updates_step_install_sub), divider = false)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.updates_cancel), onCancel, style = ShxButtonStyle.OUTLINE, enabled = downloading, modifier = Modifier.weight(1f))
/* while downloading the app installs on its own; Available here means a background download
* finished or failed without an install request, so the button starts it */
ShxButton(stringResource(R.string.update_card_install), onInstall, enabled = state is UpdateState.Available, modifier = Modifier.weight(2f))
}
}
private enum class StepState { DONE, RUNNING, WAITING }
@Composable
private fun UpdateStep(step: StepState, title: String, sub: String, divider: Boolean)
{
val c = LocalShxColors.current
Column()
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier.fillMaxWidth().padding(vertical = ShxSpacing.md),
)
{
val shape = CircleShape
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(24.dp).clip(shape).then(
when (step)
{
StepState.DONE -> Modifier.background(c.success)
StepState.RUNNING -> Modifier.background(c.accentContainer).border(2.dp, c.accent, shape)
StepState.WAITING -> Modifier.background(c.card).border(1.dp, c.outline, shape)
}
),
)
{
when (step)
{
StepState.DONE -> Icon(ShxIcons.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp))
StepState.RUNNING -> Text("…", style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
StepState.WAITING -> Unit
}
}
Column(verticalArrangement = Arrangement.spacedBy(1.dp))
{
Text(title, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (step == StepState.WAITING) c.iconMuted else c.text)
Text(sub, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
}
}
if (divider)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
/** Mandatory update (App4): cannot be closed; update now or leave the app. */
@Composable
private fun MandatoryUpdateSheet(versionName: String, size: Long, onUpdate: () -> Unit)
{
val c = LocalShxColors.current
val activity = LocalActivity.current
ShxSheet(onDismiss = {}, dismissible = false)
{
UpdateSheetHeader(
stringResource(R.string.updates_mandatory_title),
stringResource(R.string.updates_mandatory_sub, BuildConfig.VERSION_NAME),
icon = ShxIcons.Alert, iconBackground = c.warningContainer, iconTint = c.onWarningContainer, round = true,
)
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
{
Text(stringResource(R.string.updates_mandatory_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
Bullet(stringResource(R.string.updates_mandatory_reason), c.warning)
Bullet(stringResource(R.string.updates_mandatory_install, versionName, sizeText(size)), c.accent)
}
}
ShxNote(stringResource(R.string.updates_mandatory_locked), tone = ShxTone.WARNING)
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.cert_update_now), onUpdate, modifier = Modifier.fillMaxWidth())
ShxButton(stringResource(R.string.updates_exit), { activity?.finishAndRemoveTask() }, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.fillMaxWidth())
}
}
}
/** Update not installed (App5): reasons by error kind, retry, the download page, details for support. */
@Composable
private fun UpdateFailedContent(state: UpdateState.Failed, onRetry: () -> Unit)
{
val c = LocalShxColors.current
val context = LocalContext.current
val checkFailed = state.error == UpdateError.HASH || state.error == UpdateError.SIGNATURE
UpdateSheetHeader(
stringResource(R.string.updates_failed_title),
stringResource(
when (state.error)
{
UpdateError.HASH, UpdateError.SIGNATURE -> R.string.updates_failed_sub_check
UpdateError.NETWORK -> R.string.updates_failed_sub_network
UpdateError.INSTALL -> R.string.updates_failed_sub_install
UpdateError.OTHER -> R.string.updates_failed_sub_other
}
),
icon = ShxIcons.Shield, iconBackground = c.errorContainer, iconTint = c.error, round = true,
)
val (title, detail) = when (state.error)
{
UpdateError.HASH -> R.string.updates_reason_hash to R.string.updates_reason_hash_d
UpdateError.SIGNATURE -> R.string.updates_reason_signature to R.string.updates_reason_signature_d
UpdateError.NETWORK -> R.string.updates_reason_network to R.string.updates_reason_network_d
UpdateError.INSTALL -> R.string.updates_reason_install to R.string.updates_reason_install_d
UpdateError.OTHER -> R.string.updates_reason_other to R.string.updates_reason_other_d
}
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
{
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
{
Text(stringResource(R.string.updates_failed_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
Reason(stringResource(title), stringResource(detail), if (checkFailed) c.error else c.accent)
Reason(stringResource(R.string.updates_reason_same), stringResource(R.string.updates_reason_same_d, BuildConfig.VERSION_NAME), c.accent)
Reason(stringResource(R.string.updates_reason_later), stringResource(R.string.updates_reason_later_d), c.accent)
}
}
ShxNote(stringResource(R.string.updates_manual_note))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.updates_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
ShxButton(
stringResource(R.string.updates_from_site),
{ runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.UPDATE_DOWNLOAD_PAGE_URL))) } },
style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f),
)
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
{
Text(
stringResource(R.string.updates_support),
style = ShxType.chip, color = c.accentText,
modifier = Modifier.clip(RoundedCornerShape(8.dp))
.clickable(role = Role.Button) { copySupportDetails(context, state) }.padding(horizontal = ShxSpacing.sm, vertical = 6.dp),
)
}
}
/** Error kind, message and versions for support; the beta code stays out. */
private fun copySupportDetails(context: Context, state: UpdateState.Failed)
{
val manager = UpdateManager.getInstance(context)
val text = buildString {
appendLine("SHX VPN update error: ${state.error}")
appendLine("Detail: ${state.detail ?: "-"}")
appendLine("Installed: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
appendLine("Channel: ${if (manager.isBetaChannel()) "beta" else "release"}")
appendLine("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}), ${Build.MANUFACTURER} ${Build.MODEL}, ${Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}")
}
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
clipboard.setPrimaryClip(ClipData.newPlainText("SHX VPN", text))
/* Android 13+ confirms the copy itself */
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
{
Toast.makeText(context, R.string.updates_copied, Toast.LENGTH_SHORT).show()
}
}
/** Icon well, sheet title and subtitle of the update sheets. */
@Composable
private fun UpdateSheetHeader(
title: String,
subtitle: String,
icon: ImageVector? = null,
iconBackground: Color = LocalShxColors.current.accent,
iconTint: Color = Color.White,
round: Boolean = false,
)
{
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp),
)
{
val size = if (round) 52.dp else 56.dp
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(if (round) CircleShape else RoundedCornerShape(16.dp)).background(iconBackground))
{
if (icon != null) Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(24.dp))
else Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = iconTint, modifier = Modifier.size(36.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(title, style = ShxType.pageTitle, color = c.text)
Text(subtitle, style = ShxType.sub, color = c.textSub)
}
}
}
/** Green «safe» box with a shield (App1, App2, App6). */
@Composable
internal fun UpdateSafeNote(text: String)
{
val c = LocalShxColors.current
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = ShxSpacing.md),
)
{
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
Text(text, style = ShxType.note, color = c.onSuccess)
}
}
@Composable
private fun Bullet(text: String, dot: Color)
{
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.text)
}
}
/** «Title — detail» line of App5. */
@Composable
private fun Reason(title: String, detail: String, dot: Color)
{
val c = LocalShxColors.current
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = c.text)) { append(title) }
append(" — ")
withStyle(SpanStyle(color = c.textSoft)) { append(detail) }
},
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
)
}
}
private fun sizeText(bytes: Long): String = if (bytes > 0) formatBytes(bytes) else "—"
private fun lastCheckText(context: Context, time: Long): String =
DateUtils.getRelativeDateTimeString(context, time, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
internal fun updateErrorText(error: UpdateError): Int = when (error)
{
UpdateError.NETWORK -> R.string.update_error_network
UpdateError.HASH -> R.string.update_error_hash
UpdateError.SIGNATURE -> R.string.update_error_signature
UpdateError.INSTALL -> R.string.update_error_install
UpdateError.OTHER -> R.string.update_error_generic
}
@@ -331,7 +331,7 @@ fun ShxButton(
.background(if (enabled) bg else bg.copy(alpha = 0.38f)) .background(if (enabled) bg else bg.copy(alpha = 0.38f))
.then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier) .then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier)
.clickable(enabled = enabled, role = Role.Button, onClick = onClick) .clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(horizontal = if (height >= 44.dp) 16.dp else 14.dp), .padding(horizontal = if (height >= 44.dp) 10.dp else 14.dp),
) )
{ {
if (icon != null) if (icon != null)
@@ -339,7 +339,17 @@ fun ShxButton(
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp)) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
Text(text, style = textStyle, color = if (enabled) fg else fg.copy(alpha = 0.6f), maxLines = 1, overflow = TextOverflow.Ellipsis) /* two lines on large buttons: on 320dp screens «Скачать и установить» beside «Позже» was cut to «Скачать и установ…» */
Text(
text,
style = textStyle,
color = if (enabled) fg else fg.copy(alpha = 0.6f),
/* a single word must not wrap mid-word («Отмени / ть»): it shrinks instead */
maxLines = if (height >= 52.dp && text.trim().contains(' ')) 2 else 1,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
autoSize = androidx.compose.foundation.text.TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = textStyle.fontSize),
)
} }
} }
@@ -555,14 +565,18 @@ fun ShxTag(text: String, background: Color, color: Color)
) )
} }
/** Bottom sheet of the mockup: radius 32, 36x4 handle, page background. */ /**
* Bottom sheet of the mockup: radius 32, 36x4 handle, page background.
* [dismissible] = false: swipe, scrim tap and «Back» do not close it (a mandatory update).
*/
@Composable @Composable
fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) fun ShxSheet(onDismiss: () -> Unit, dismissible: Boolean = true, content: @Composable ColumnScope.() -> Unit)
{ {
val c = LocalShxColors.current val c = LocalShxColors.current
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = { if (dismissible) onDismiss() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true, confirmValueChange = { dismissible || it != androidx.compose.material3.SheetValue.Hidden }),
properties = androidx.compose.material3.ModalBottomSheetProperties(shouldDismissOnBackPress = dismissible, shouldDismissOnClickOutside = dismissible),
containerColor = c.background, containerColor = c.background,
scrimColor = c.scrim, scrimColor = c.scrim,
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
@@ -571,6 +585,8 @@ fun ShxSheet(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit)
}, },
) )
{ {
/* belt and braces for a mandatory sheet: «Back» is swallowed even if the dialog forwards it */
androidx.activity.compose.BackHandler(enabled = !dismissible) {}
Column( Column(
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp), modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
@@ -40,6 +40,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.strongswan.android.R import org.strongswan.android.R
import org.strongswan.android.logic.PauseManager import org.strongswan.android.logic.PauseManager
@@ -103,12 +106,16 @@ internal fun HomeProblem.titleRes(): Int = when (this)
internal fun rememberNow(): Long internal fun rememberNow(): Long
{ {
var now by remember { mutableLongStateOf(System.currentTimeMillis()) } var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) val lifecycle = LocalLifecycleOwner.current.lifecycle
{ LaunchedEffect(lifecycle)
while (true) { /* no ticking while the app is in the background */
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
{ {
now = System.currentTimeMillis() while (true)
delay(1000L - now % 1000L) {
now = System.currentTimeMillis()
delay(1000L - now % 1000L)
}
} }
} }
return now return now
@@ -17,6 +17,9 @@
*/ */
#include <errno.h> #include <errno.h>
#include <poll.h>
#include <stdint.h>
#include <sys/eventfd.h>
#include <unistd.h> #include <unistd.h>
#include "android_jni.h" #include "android_jni.h"
@@ -70,6 +73,12 @@ struct private_android_service_t {
*/ */
int tunfd; int tunfd;
/**
* eventfd that wakes the TUN reader whenever tunfd is replaced or closed
* (-1 if it could not be created, then the reader falls back to a 1 s poll)
*/
int wakefd;
/** /**
* MTU of TUN device * MTU of TUN device
*/ */
@@ -136,6 +145,21 @@ static void engine_write_packet(void *ctx, ip_packet_t *packet)
deliver_plain(ctx, packet); deliver_plain(ctx, packet);
} }
/**
* Wake the TUN reader so it picks up a replaced or closed tunfd. Call it
* after this->tunfd was changed (the eventfd counter stays readable until the
* reader drains it, so a wake-up before the reader polls is not lost).
*/
static void wake_reader(private_android_service_t *this)
{
uint64_t one = 1;
if (this->wakefd >= 0 && write(this->wakefd, &one, sizeof(one)) < 0)
{
DBG1(DBG_DMN, "waking the TUN reader failed: %s", strerror(errno));
}
}
/** /**
* Job handling outbound plaintext packets * Job handling outbound plaintext packets
*/ */
@@ -143,16 +167,10 @@ static job_requeue_t handle_plain(private_android_service_t *this)
{ {
ip_packet_t *packet; ip_packet_t *packet;
chunk_t raw; chunk_t raw;
fd_set set; struct pollfd fds[2];
ssize_t len; ssize_t len;
int tunfd; int tunfd, wakefd, nfds = 1, timeout;
bool old, dns_proxy; bool old, dns_proxy;
timeval_t tv = {
/* check every second if tunfd is still valid */
.tv_sec = 1,
};
FD_ZERO(&set);
this->lock->read_lock(this->lock); this->lock->read_lock(this->lock);
if (this->tunfd < 0) if (this->tunfd < 0)
@@ -161,28 +179,60 @@ static job_requeue_t handle_plain(private_android_service_t *this)
return JOB_REQUEUE_NONE; return JOB_REQUEUE_NONE;
} }
tunfd = this->tunfd; tunfd = this->tunfd;
FD_SET(tunfd, &set); wakefd = this->wakefd;
/* cache this while we have the lock */ /* cache this while we have the lock */
dns_proxy = this->use_dns_proxy; dns_proxy = this->use_dns_proxy;
this->lock->unlock(this->lock); this->lock->unlock(this->lock);
fds[0].fd = tunfd;
fds[0].events = POLLIN;
fds[0].revents = 0;
if (wakefd >= 0)
{ /* a replaced or closed tunfd wakes us, so the timeout is only a
* safety net; without the eventfd re-check every second as before */
fds[1].fd = wakefd;
fds[1].events = POLLIN;
fds[1].revents = 0;
nfds = 2;
timeout = 60000;
}
else
{
timeout = 1000;
}
old = thread_cancelability(TRUE); old = thread_cancelability(TRUE);
len = select(tunfd + 1, &set, NULL, NULL, &tv); len = poll(fds, nfds, timeout);
thread_cancelability(old); thread_cancelability(old);
if (len < 0) if (len < 0)
{ {
if (errno == EBADF) if (errno == EBADF)
{ /* the TUN device got closed just before calling select(), retry */ { /* the TUN device got closed just before calling poll(), retry */
return JOB_REQUEUE_FAIR; return JOB_REQUEUE_FAIR;
} }
DBG1(DBG_DMN, "select on TUN device failed: %s", strerror(errno)); DBG1(DBG_DMN, "poll on TUN device failed: %s", strerror(errno));
return JOB_REQUEUE_NONE; return JOB_REQUEUE_NONE;
} }
else if (len == 0) else if (len == 0)
{ /* timeout, check again right away */ { /* timeout, check again right away */
return JOB_REQUEUE_DIRECT; return JOB_REQUEUE_DIRECT;
} }
if (nfds == 2 && (fds[1].revents & POLLIN))
{ /* tunfd was replaced or closed: drain the counter and start over with
* the current fd (pending TUN data stays, poll is level-triggered) */
uint64_t cnt;
if (read(wakefd, &cnt, sizeof(cnt)) < 0 && errno != EAGAIN)
{
DBG1(DBG_DMN, "reading the wake-up fd failed: %s", strerror(errno));
}
return JOB_REQUEUE_DIRECT;
}
if (fds[0].revents & POLLNVAL)
{ /* the fd was closed while polling, retry with the current one */
return JOB_REQUEUE_FAIR;
}
raw = chunk_alloc(this->mtu); raw = chunk_alloc(this->mtu);
len = read(tunfd, raw.ptr, raw.len); len = read(tunfd, raw.ptr, raw.len);
@@ -375,6 +425,8 @@ static bool setup_tun_device(private_android_service_t *this,
} }
this->tunfd = tunfd; this->tunfd = tunfd;
this->lock->unlock(this->lock); this->lock->unlock(this->lock);
/* a reader polling the previous fd must switch to the new one */
wake_reader(this);
DBG1(DBG_DMN, "successfully created TUN device"); DBG1(DBG_DMN, "successfully created TUN device");
@@ -422,6 +474,7 @@ static bool setup_tun_device_without_dns(private_android_service_t *this)
} }
this->tunfd = tunfd; this->tunfd = tunfd;
this->lock->unlock(this->lock); this->lock->unlock(this->lock);
wake_reader(this);
/* the fd changed, re-bind the userspace router to the new TUN */ /* the fd changed, re-bind the userspace router to the new TUN */
direct_engine_set_tun(this, engine_write_packet, this->mtu); direct_engine_set_tun(this, engine_write_packet, this->mtu);
@@ -446,6 +499,8 @@ static void close_tun_device(private_android_service_t *this)
tunfd = this->tunfd; tunfd = this->tunfd;
this->tunfd = -1; this->tunfd = -1;
this->lock->unlock(this->lock); this->lock->unlock(this->lock);
/* let the reader notice tunfd < 0 and quit instead of polling a dead fd */
wake_reader(this);
/* drop the remembered binding too, so a lazily created engine does not /* drop the remembered binding too, so a lazily created engine does not
* attach to the dead TUN, and flush the engine if it exists */ * attach to the dead TUN, and flush the engine if it exists */
@@ -977,6 +1032,10 @@ METHOD(android_service_t, destroy, void,
close_tun_device(this); close_tun_device(this);
this->dns_proxy->destroy(this->dns_proxy); this->dns_proxy->destroy(this->dns_proxy);
this->lock->destroy(this->lock); this->lock->destroy(this->lock);
if (this->wakefd >= 0)
{
close(this->wakefd);
}
this->settings->destroy(this->settings); this->settings->destroy(this->settings);
free(this); free(this);
} }
@@ -1005,6 +1064,7 @@ android_service_t *android_service_create(android_creds_t *creds,
.settings = settings, .settings = settings,
.creds = creds, .creds = creds,
.tunfd = -1, .tunfd = -1,
.wakefd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC),
.mtu = settings->get_int(settings, "global.mtu", ANDROID_DEFAULT_MTU), .mtu = settings->get_int(settings, "global.mtu", ANDROID_DEFAULT_MTU),
); );
/* only allow queries for the VPN gateway */ /* only allow queries for the VPN gateway */
@@ -797,10 +797,9 @@ static void *engine_thread(private_direct_engine_t *this)
{ {
active = TRUE; active = TRUE;
this->udp->reprotect(this->udp); this->udp->reprotect(this->udp);
if (this->tcp_active) /* also while lwIP is dormant: this clears the negative cache of
{ * failed direct connects, which must not survive a roam */
this->tcp->reprotect(this->tcp); this->tcp->reprotect(this->tcp);
}
/* the network changed: whatever was dead for direct connects /* the network changed: whatever was dead for direct connects
* may work again - drop the tunnel fallbacks with the rest of * may work again - drop the tunnel fallbacks with the rest of
* the failure state (tcp->reprotect clears the neg table) */ * the failure state (tcp->reprotect clears the neg table) */
@@ -812,8 +811,22 @@ static void *engine_thread(private_direct_engine_t *this)
this->bind_lock->unlock(this->bind_lock); this->bind_lock->unlock(this->bind_lock);
if (!bound && this->unbound_flushed) if (!bound && this->unbound_flushed)
{ /* TUN still gone and already flushed */ { /* TUN still gone and already flushed: nothing to do until the
* re-bind wakes us, so back off like an idle round instead of
* polling at the active cadence all through a re-authentication */
drain_queue_nobind(this); drain_queue_nobind(this);
if (active)
{
poll_ms = ENGINE_POLL_MS;
}
else
{
poll_ms *= 2;
if (poll_ms > ENGINE_IDLE_POLL_MS)
{
poll_ms = ENGINE_IDLE_POLL_MS;
}
}
continue; continue;
} }
@@ -862,6 +875,12 @@ static void *engine_thread(private_direct_engine_t *this)
{ {
this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n); this->tcp->process_poll(this->tcp, fds + 1 + udp_n, tcp_n);
this->tcp->check_timers(this->tcp); this->tcp->check_timers(this->tcp);
if (!active && this->tcp->flow_count(this->tcp) == 0)
{ /* a full idle round with no TCP flow left: let lwIP go dormant
* again (the next TCP packet wakes it, its timers catch up),
* so the idle cadence stretches back to ENGINE_IDLE_POLL_MS */
this->tcp_active = FALSE;
}
} }
{ /* adapt the cadence after the work of this round is known */ { /* adapt the cadence after the work of this round is known */
@@ -966,6 +966,12 @@ METHOD(direct_tcp_t, check_timers, void,
enumerator->destroy(enumerator); enumerator->destroy(enumerator);
} }
METHOD(direct_tcp_t, flow_count, size_t,
private_direct_tcp_t *this)
{
return this->relays->get_count(this->relays);
}
METHOD(direct_tcp_t, set_mtu, void, METHOD(direct_tcp_t, set_mtu, void,
private_direct_tcp_t *this, uint16_t mtu) private_direct_tcp_t *this, uint16_t mtu)
{ {
@@ -1290,6 +1296,7 @@ direct_tcp_t *direct_tcp_create(uint16_t mtu, direct_engine_t *engine,
.public = { .public = {
.handle_packet = _handle_packet, .handle_packet = _handle_packet,
.check_timers = _check_timers, .check_timers = _check_timers,
.flow_count = _flow_count,
.set_mtu = _set_mtu, .set_mtu = _set_mtu,
.reprotect = _reprotect, .reprotect = _reprotect,
.fill_poll = _fill_poll, .fill_poll = _fill_poll,
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Der Bericht geht über einen geschützten Kanal an das SHX-Team. Mit dem Berichtscode findet der Support Ihr Protokoll.</string>
<string name="report_contents">Inhalt des Berichts</string>
<string name="report_item_log">Verbindungsprotokoll von heute</string>
<string name="report_item_device">App-Version und Telefonmodell</string>
<string name="report_item_network">Netzwerktyp und aktiver Server</string>
<string name="report_item_ips">IP-Adressen der Server aus dem Protokoll</string>
<string name="report_diag_title">Diagnose anhängen</string>
<string name="report_diag_sub">Ergebnisse der Netzwerkprüfung und erweitertes App-Protokoll</string>
<string name="report_comment_label">Was ist passiert? (optional)</string>
<string name="report_cancel">Abbrechen</string>
<string name="report_send">Senden</string>
<string name="report_by_mail">Per E-Mail senden</string>
<string name="report_sent_title">Bericht gesendet</string>
<string name="report_sent_sub">Nennen Sie diesen Code dem Support</string>
<string name="report_code_label">Berichtscode</string>
<string name="report_copy_code">Code kopieren</string>
<string name="report_code_copied">Code kopiert</string>
<string name="report_saved_note">Der Code ist unter „Ereignisse“ gespeichert und lässt sich später finden.</string>
<string name="report_done">Fertig</string>
<string name="report_failed_title">Bericht nicht gesendet</string>
<string name="report_failed_no_answer">Der Server hat nicht geantwortet</string>
<string name="report_failed_refused">Der Server hat den Bericht abgelehnt</string>
<string name="report_reasons">Mögliche Ursachen</string>
<string name="report_reason_offline">Kein Internet</string>
<string name="report_reason_offline_d">Netzwerk prüfen und wiederholen</string>
<string name="report_reason_server">Server nicht erreichbar</string>
<string name="report_reason_server_d">in ein paar Minuten wiederholen</string>
<string name="report_reason_clock">Telefonuhr geht falsch</string>
<string name="report_reason_clock_d">automatische Zeit einschalten und wiederholen</string>
<string name="report_reason_often">Zu häufig</string>
<string name="report_reason_often_d">in einer Stunde erneut versuchen</string>
<string name="report_reason_large">Protokoll zu groß</string>
<string name="report_reason_large_d">per E-Mail senden</string>
<string name="report_reason_rejected">Anfrage abgelehnt</string>
<string name="report_reason_rejected_d">Protokoll per E-Mail senden</string>
<string name="report_mail_note">Das Protokoll lässt sich wie bisher auch per E-Mail senden.</string>
<string name="report_retry">Wiederholen</string>
<string name="report_by_letter">Per E-Mail senden</string>
<string name="report_event_title">Bericht gesendet, Code %1$s</string>
<string name="report_event_text">Nennen Sie diesen Code dem Support, damit er Ihr Protokoll findet.</string>
</resources>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">Installiert: %1$s · %2$s</string>
<string name="updates_review">Ansehen und installieren</string>
<string name="updates_section_auto">Automatisch</string>
<string name="updates_auto_check">Selbst prüfen</string>
<string name="updates_silent">Still installieren</string>
<string name="updates_silent_sub">Über WLAN laden und installieren, wenn das VPN aus ist</string>
<string name="updates_section_channel">Kanal</string>
<string name="updates_channel_stable">Stabil</string>
<string name="updates_channel_stable_sub">Geprüfte Versionen</string>
<string name="updates_channel_beta">Beta</string>
<string name="updates_channel_beta_sub">Früher, aber mit Fehlern</string>
<string name="updates_channel_hint">Die Beta wird mit einem Einladungscode des Administrators freigeschaltet.</string>
<string name="updates_beta_title">Einladungscode für die Beta</string>
<string name="updates_beta_text">Gib den Code ein, den du vom Administrator erhalten hast. Ohne gültigen Code bleibt die App bei stabilen Versionen.</string>
<string name="updates_beta_code">Einladungscode</string>
<string name="updates_beta_enable">Beta einschalten</string>
<string name="updates_check_now">Jetzt prüfen</string>
<string name="updates_last_check">Letzte Prüfung: %1$s</string>
<string name="updates_never_checked">Noch nicht geprüft</string>
<string name="updates_review_sub">Installiert: %1$s · %2$s · die Datei wird vor der Installation geprüft</string>
<string name="updates_review_safe">Die App prüft die Prüfsumme und die SHX-Signatur. Eine gefälschte Datei wird nicht installiert.</string>
<string name="updates_downloading_title">%1$s wird geladen</string>
<string name="updates_downloading_sub">Du kannst dieses Fenster schließen: Der Download läuft weiter</string>
<string name="updates_progress">%1$d%% · %2$s von %3$s</string>
<string name="updates_step_list">Versionsliste erhalten</string>
<string name="updates_step_list_sub">Über einen geschützten Kanal</string>
<string name="updates_step_download">Datei wird geladen</string>
<string name="updates_step_verify">Datei wird geprüft</string>
<string name="updates_step_verify_sub">Prüfsumme und SHX-Signatur</string>
<string name="updates_step_install">Installation</string>
<string name="updates_step_install_sub">Android fragt nach einer Bestätigung</string>
<string name="updates_cancel">Abbrechen</string>
<string name="updates_mandatory_title">SHX VPN muss aktualisiert werden</string>
<string name="updates_mandatory_sub">Version %1$s wird nicht mehr unterstützt</string>
<string name="updates_mandatory_what">Was das bedeutet</string>
<string name="updates_mandatory_reason">Der Server nimmt keine Verbindungen der alten Version mehr an</string>
<string name="updates_mandatory_install">Installiere %1$s (%2$s), deine Server und Einstellungen bleiben erhalten</string>
<string name="updates_mandatory_locked">Dieses Fenster lässt sich erst nach dem Update schließen.</string>
<string name="updates_exit">App beenden</string>
<string name="updates_failed_title">Update nicht installiert</string>
<string name="updates_failed_sub_check">Die Datei hat die Prüfung nicht bestanden</string>
<string name="updates_failed_sub_network">Die Datei wurde nicht geladen</string>
<string name="updates_failed_sub_install">Android hat die Datei nicht installiert</string>
<string name="updates_failed_sub_other">Etwas ist schiefgelaufen</string>
<string name="updates_failed_what">Was passiert ist</string>
<string name="updates_reason_hash">Prüfsumme stimmt nicht</string>
<string name="updates_reason_hash_d">die Datei wurde unterwegs beschädigt, die Kopie ist gelöscht</string>
<string name="updates_reason_signature">Signatur stimmt nicht</string>
<string name="updates_reason_signature_d">die Datei ist nicht von SHX signiert, die Kopie ist gelöscht</string>
<string name="updates_reason_network">Keine Verbindung zum Update-Server</string>
<string name="updates_reason_network_d">der Download wurde unterbrochen</string>
<string name="updates_reason_install">Android hat die Installation abgelehnt</string>
<string name="updates_reason_install_d">das Installationsprogramm meldete einen Fehler</string>
<string name="updates_reason_other">Unerwarteter Fehler</string>
<string name="updates_reason_other_d">Details für den Support findest du unten</string>
<string name="updates_reason_same">Version unverändert</string>
<string name="updates_reason_same_d">du hast weiterhin %1$s</string>
<string name="updates_reason_later">Versuche es später erneut</string>
<string name="updates_reason_later_d">oder wechsle das Netz: WLAN statt Mobilfunk</string>
<string name="updates_manual_note">Du kannst die App manuell über die Download-Seite installieren. Deine Server und Einstellungen bleiben erhalten.</string>
<string name="updates_retry">Wiederholen</string>
<string name="updates_from_site">Von der Website laden</string>
<string name="updates_support">Details für den Support</string>
<string name="updates_copied">Kopiert</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Alle %d Stunde, während das Telefon lädt</item>
<item quantity="other">Alle %d Stunden, während das Telefon lädt</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Raport trafi do zespołu SHX bezpiecznym kanałem. Po kodzie raportu wsparcie znajdzie Twój dziennik.</string>
<string name="report_contents">Co trafi do raportu</string>
<string name="report_item_log">Dziennik połączenia z dzisiaj</string>
<string name="report_item_device">Wersja aplikacji i model telefonu</string>
<string name="report_item_network">Typ sieci i aktywny serwer</string>
<string name="report_item_ips">Adresy IP serwerów z dziennika</string>
<string name="report_diag_title">Dołącz diagnostykę</string>
<string name="report_diag_sub">Wyniki sprawdzenia sieci i rozszerzony dziennik aplikacji</string>
<string name="report_comment_label">Co się stało (opcjonalnie)</string>
<string name="report_cancel">Anuluj</string>
<string name="report_send">Wyślij</string>
<string name="report_by_mail">Wyślij e-mailem</string>
<string name="report_sent_title">Raport wysłany</string>
<string name="report_sent_sub">Podaj ten kod wsparciu</string>
<string name="report_code_label">Kod raportu</string>
<string name="report_copy_code">Kopiuj kod</string>
<string name="report_code_copied">Kod skopiowany</string>
<string name="report_saved_note">Kod zapisano w „Zdarzeniach”, można go znaleźć później.</string>
<string name="report_done">Gotowe</string>
<string name="report_failed_title">Raport nie został wysłany</string>
<string name="report_failed_no_answer">Serwer nie odpowiedział</string>
<string name="report_failed_refused">Serwer odrzucił raport</string>
<string name="report_reasons">Możliwe przyczyny</string>
<string name="report_reason_offline">Brak internetu</string>
<string name="report_reason_offline_d">sprawdź sieć i ponów</string>
<string name="report_reason_server">Serwer niedostępny</string>
<string name="report_reason_server_d">ponów za kilka minut</string>
<string name="report_reason_clock">Zły czas w telefonie</string>
<string name="report_reason_clock_d">włącz automatyczny czas i ponów</string>
<string name="report_reason_often">Zbyt często</string>
<string name="report_reason_often_d">spróbuj za godzinę</string>
<string name="report_reason_large">Dziennik jest za duży</string>
<string name="report_reason_large_d">wyślij go e-mailem</string>
<string name="report_reason_rejected">Żądanie odrzucone</string>
<string name="report_reason_rejected_d">wyślij dziennik e-mailem</string>
<string name="report_mail_note">Dziennik można też wysłać e-mailem, jak dotąd.</string>
<string name="report_retry">Ponów</string>
<string name="report_by_letter">Wyślij e-mailem</string>
<string name="report_event_title">Raport wysłany, kod %1$s</string>
<string name="report_event_text">Podaj ten kod wsparciu, aby znalazło Twój dziennik.</string>
</resources>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">Masz %1$s · %2$s</string>
<string name="updates_review">Zobacz i zainstaluj</string>
<string name="updates_section_auto">Automatycznie</string>
<string name="updates_auto_check">Sprawdzaj samodzielnie</string>
<string name="updates_silent">Instaluj po cichu</string>
<string name="updates_silent_sub">Pobierz przez Wi-Fi i zainstaluj, gdy VPN jest wyłączony</string>
<string name="updates_section_channel">Kanał</string>
<string name="updates_channel_stable">Stabilny</string>
<string name="updates_channel_stable_sub">Sprawdzone wersje</string>
<string name="updates_channel_beta">Beta</string>
<string name="updates_channel_beta_sub">Wcześniej, ale z błędami</string>
<string name="updates_channel_hint">Beta jest dostępna z kodem zaproszenia od administratora.</string>
<string name="updates_beta_title">Kod zaproszenia do bety</string>
<string name="updates_beta_text">Wpisz kod od administratora. Bez prawidłowego kodu aplikacja zostanie na stabilnych wersjach.</string>
<string name="updates_beta_code">Kod zaproszenia</string>
<string name="updates_beta_enable">Włącz betę</string>
<string name="updates_check_now">Sprawdź teraz</string>
<string name="updates_last_check">Ostatnie sprawdzenie: %1$s</string>
<string name="updates_never_checked">Jeszcze nie sprawdzano</string>
<string name="updates_review_sub">Masz %1$s · %2$s · plik sprawdzimy przed instalacją</string>
<string name="updates_review_safe">Aplikacja sprawdzi sumę kontrolną i podpis SHX. Podrobiony plik się nie zainstaluje.</string>
<string name="updates_downloading_title">Pobieramy %1$s</string>
<string name="updates_downloading_sub">Możesz zamknąć to okno: pobieranie będzie trwać</string>
<string name="updates_progress">%1$d%% · %2$s z %3$s</string>
<string name="updates_step_list">Lista wersji pobrana</string>
<string name="updates_step_list_sub">Przez chroniony kanał</string>
<string name="updates_step_download">Pobieranie pliku</string>
<string name="updates_step_verify">Sprawdzanie pliku</string>
<string name="updates_step_verify_sub">Suma kontrolna i podpis SHX</string>
<string name="updates_step_install">Instalacja</string>
<string name="updates_step_install_sub">Android poprosi o potwierdzenie</string>
<string name="updates_cancel">Anuluj</string>
<string name="updates_mandatory_title">SHX VPN wymaga aktualizacji</string>
<string name="updates_mandatory_sub">Wersja %1$s nie jest już obsługiwana</string>
<string name="updates_mandatory_what">Co to oznacza</string>
<string name="updates_mandatory_reason">Serwer nie przyjmuje już połączeń ze starej wersji</string>
<string name="updates_mandatory_install">Zainstaluj %1$s (%2$s), Twoje serwery i ustawienia zostaną zachowane</string>
<string name="updates_mandatory_locked">Tego okna nie można zamknąć, dopóki aplikacja nie zostanie zaktualizowana.</string>
<string name="updates_exit">Zamknij aplikację</string>
<string name="updates_failed_title">Aktualizacja nie została zainstalowana</string>
<string name="updates_failed_sub_check">Plik nie przeszedł weryfikacji</string>
<string name="updates_failed_sub_network">Nie udało się pobrać pliku</string>
<string name="updates_failed_sub_install">Android nie zainstalował pliku</string>
<string name="updates_failed_sub_other">Coś poszło nie tak</string>
<string name="updates_failed_what">Co się stało</string>
<string name="updates_reason_hash">Suma kontrolna się nie zgadza</string>
<string name="updates_reason_hash_d">plik uszkodził się po drodze, kopia została usunięta</string>
<string name="updates_reason_signature">Podpis się nie zgadza</string>
<string name="updates_reason_signature_d">plik nie jest podpisany przez SHX, kopia została usunięta</string>
<string name="updates_reason_network">Brak połączenia z serwerem aktualizacji</string>
<string name="updates_reason_network_d">pobieranie zostało przerwane</string>
<string name="updates_reason_install">Android odrzucił instalację</string>
<string name="updates_reason_install_d">instalator systemowy zwrócił błąd</string>
<string name="updates_reason_other">Nieoczekiwany błąd</string>
<string name="updates_reason_other_d">szczegóły dla wsparcia znajdziesz poniżej</string>
<string name="updates_reason_same">Wersja się nie zmieniła</string>
<string name="updates_reason_same_d">nadal masz %1$s</string>
<string name="updates_reason_later">Spróbuj później</string>
<string name="updates_reason_later_d">lub zmień sieć: Wi-Fi zamiast komórkowej</string>
<string name="updates_manual_note">Możesz zainstalować ręcznie ze strony pobierania. Twoje serwery i ustawienia pozostaną bez zmian.</string>
<string name="updates_retry">Ponów</string>
<string name="updates_from_site">Pobierz ze strony</string>
<string name="updates_support">Szczegóły dla wsparcia</string>
<string name="updates_copied">Skopiowano</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Co %d godzinę, gdy telefon się ładuje</item>
<item quantity="few">Co %d godziny, gdy telefon się ładuje</item>
<item quantity="many">Co %d godzin, gdy telefon się ładuje</item>
<item quantity="other">Co %d godziny, gdy telefon się ładuje</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Отчёт уйдёт команде SHX по защищённому каналу. По коду отчёта поддержка найдёт ваш журнал.</string>
<string name="report_contents">Что войдёт в отчёт</string>
<string name="report_item_log">Журнал подключения за сегодня</string>
<string name="report_item_device">Версия приложения и модель телефона</string>
<string name="report_item_network">Тип сети и активный сервер</string>
<string name="report_item_ips">IP-адреса серверов из журнала</string>
<string name="report_diag_title">Приложить диагностику</string>
<string name="report_diag_sub">Результаты проверки сети и расширенный журнал приложения</string>
<string name="report_comment_label">Что случилось (необязательно)</string>
<string name="report_cancel">Отмена</string>
<string name="report_send">Отправить</string>
<string name="report_by_mail">Отправить по почте</string>
<string name="report_sent_title">Отчёт отправлен</string>
<string name="report_sent_sub">Назовите этот код поддержке</string>
<string name="report_code_label">Код отчёта</string>
<string name="report_copy_code">Скопировать код</string>
<string name="report_code_copied">Код скопирован</string>
<string name="report_saved_note">Код сохранён в «Событиях», его можно найти позже.</string>
<string name="report_done">Готово</string>
<string name="report_failed_title">Отчёт не отправлен</string>
<string name="report_failed_no_answer">Сервер не ответил</string>
<string name="report_failed_refused">Сервер отклонил отчёт</string>
<string name="report_reasons">Возможные причины</string>
<string name="report_reason_offline">Нет интернета</string>
<string name="report_reason_offline_d">проверьте сеть и повторите</string>
<string name="report_reason_server">Сервер недоступен</string>
<string name="report_reason_server_d">повторите через несколько минут</string>
<string name="report_reason_clock">Часы телефона сбиты</string>
<string name="report_reason_clock_d">включите автоматическое время и повторите</string>
<string name="report_reason_often">Слишком часто</string>
<string name="report_reason_often_d">попробуйте через час</string>
<string name="report_reason_large">Журнал слишком большой</string>
<string name="report_reason_large_d">отправьте его письмом</string>
<string name="report_reason_rejected">Запрос отклонён</string>
<string name="report_reason_rejected_d">отправьте журнал письмом</string>
<string name="report_mail_note">Журнал можно отправить и письмом, как раньше.</string>
<string name="report_retry">Повторить</string>
<string name="report_by_letter">Отправить письмом</string>
<string name="report_event_title">Отчёт отправлен, код %1$s</string>
<string name="report_event_text">Назовите этот код поддержке, чтобы она нашла ваш журнал.</string>
</resources>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">У вас %1$s · %2$s</string>
<string name="updates_review">Посмотреть и установить</string>
<string name="updates_section_auto">Автоматически</string>
<string name="updates_auto_check">Проверять сами</string>
<string name="updates_silent">Ставить тихо</string>
<string name="updates_silent_sub">Скачать по Wi-Fi и установить, когда VPN выключен</string>
<string name="updates_section_channel">Канал</string>
<string name="updates_channel_stable">Стабильный</string>
<string name="updates_channel_stable_sub">Проверенные версии</string>
<string name="updates_channel_beta">Бета</string>
<string name="updates_channel_beta_sub">Раньше, но с ошибками</string>
<string name="updates_channel_hint">Бета открывается по коду приглашения от администратора.</string>
<string name="updates_beta_title">Код приглашения в бету</string>
<string name="updates_beta_text">Введите код, который дал администратор. Без верного кода приложение останется на стабильных версиях.</string>
<string name="updates_beta_code">Код приглашения</string>
<string name="updates_beta_enable">Включить бету</string>
<string name="updates_check_now">Проверить сейчас</string>
<string name="updates_last_check">Последняя проверка: %1$s</string>
<string name="updates_never_checked">Ещё не проверяли</string>
<string name="updates_review_sub">У вас %1$s · %2$s · файл проверим перед установкой</string>
<string name="updates_review_safe">Приложение сверит контрольную сумму и подпись SHX. Подделанный файл не установится.</string>
<string name="updates_downloading_title">Скачиваем %1$s</string>
<string name="updates_downloading_sub">Можно закрыть шторку: загрузка продолжится</string>
<string name="updates_progress">%1$d%% · %2$s из %3$s</string>
<string name="updates_step_list">Список версий получен</string>
<string name="updates_step_list_sub">По защищённому каналу</string>
<string name="updates_step_download">Скачивание файла</string>
<string name="updates_step_verify">Проверка файла</string>
<string name="updates_step_verify_sub">Контрольная сумма и подпись SHX</string>
<string name="updates_step_install">Установка</string>
<string name="updates_step_install_sub">Android спросит подтверждение</string>
<string name="updates_cancel">Отменить</string>
<string name="updates_mandatory_title">Нужно обновить SHX VPN</string>
<string name="updates_mandatory_sub">Версия %1$s больше не поддерживается</string>
<string name="updates_mandatory_what">Что это значит</string>
<string name="updates_mandatory_reason">Сервер больше не принимает подключения со старой версии</string>
<string name="updates_mandatory_install">Установите %1$s (%2$s), ваши серверы и настройки сохранятся</string>
<string name="updates_mandatory_locked">Закрыть эту шторку нельзя, пока приложение не обновлено.</string>
<string name="updates_exit">Выйти из приложения</string>
<string name="updates_failed_title">Обновление не установлено</string>
<string name="updates_failed_sub_check">Файл не прошёл проверку</string>
<string name="updates_failed_sub_network">Файл не скачался</string>
<string name="updates_failed_sub_install">Android не установил файл</string>
<string name="updates_failed_sub_other">Что-то пошло не так</string>
<string name="updates_failed_what">Что произошло</string>
<string name="updates_reason_hash">Контрольная сумма не совпала</string>
<string name="updates_reason_hash_d">файл повреждён по дороге, копия удалена</string>
<string name="updates_reason_signature">Подпись не совпала</string>
<string name="updates_reason_signature_d">файл подписан не SHX, копия удалена</string>
<string name="updates_reason_network">Нет связи с сервером обновлений</string>
<string name="updates_reason_network_d">загрузка прервалась</string>
<string name="updates_reason_install">Android отклонил установку</string>
<string name="updates_reason_install_d">системный установщик вернул ошибку</string>
<string name="updates_reason_other">Непредвиденная ошибка</string>
<string name="updates_reason_other_d">подробности для поддержки — ниже</string>
<string name="updates_reason_same">Версия не изменилась</string>
<string name="updates_reason_same_d">у вас по-прежнему %1$s</string>
<string name="updates_reason_later">Попробуйте позже</string>
<string name="updates_reason_later_d">или смените сеть: Wi-Fi вместо мобильной</string>
<string name="updates_manual_note">Установить вручную можно со страницы скачивания. Ваши серверы и настройки останутся на месте.</string>
<string name="updates_retry">Повторить</string>
<string name="updates_from_site">Скачать с сайта</string>
<string name="updates_support">Подробности для поддержки</string>
<string name="updates_copied">Скопировано</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Раз в %d час, когда телефон на зарядке</item>
<item quantity="few">Раз в %d часа, когда телефон на зарядке</item>
<item quantity="many">Раз в %d часов, когда телефон на зарядке</item>
<item quantity="other">Раз в %d часа, когда телефон на зарядке</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">Звіт піде команді SHX захищеним каналом. За кодом звіту підтримка знайде ваш журнал.</string>
<string name="report_contents">Що увійде у звіт</string>
<string name="report_item_log">Журнал підключення за сьогодні</string>
<string name="report_item_device">Версія застосунку й модель телефона</string>
<string name="report_item_network">Тип мережі й активний сервер</string>
<string name="report_item_ips">IP-адреси серверів із журналу</string>
<string name="report_diag_title">Додати діагностику</string>
<string name="report_diag_sub">Результати перевірки мережі та розширений журнал застосунку</string>
<string name="report_comment_label">Що сталося (необов’язково)</string>
<string name="report_cancel">Скасувати</string>
<string name="report_send">Надіслати</string>
<string name="report_by_mail">Надіслати поштою</string>
<string name="report_sent_title">Звіт надіслано</string>
<string name="report_sent_sub">Назвіть цей код підтримці</string>
<string name="report_code_label">Код звіту</string>
<string name="report_copy_code">Скопіювати код</string>
<string name="report_code_copied">Код скопійовано</string>
<string name="report_saved_note">Код збережено в «Подіях», його можна знайти пізніше.</string>
<string name="report_done">Готово</string>
<string name="report_failed_title">Звіт не надіслано</string>
<string name="report_failed_no_answer">Сервер не відповів</string>
<string name="report_failed_refused">Сервер відхилив звіт</string>
<string name="report_reasons">Можливі причини</string>
<string name="report_reason_offline">Немає інтернету</string>
<string name="report_reason_offline_d">перевірте мережу й повторіть</string>
<string name="report_reason_server">Сервер недоступний</string>
<string name="report_reason_server_d">повторіть за кілька хвилин</string>
<string name="report_reason_clock">Годинник телефона збитий</string>
<string name="report_reason_clock_d">увімкніть автоматичний час і повторіть</string>
<string name="report_reason_often">Надто часто</string>
<string name="report_reason_often_d">спробуйте за годину</string>
<string name="report_reason_large">Журнал завеликий</string>
<string name="report_reason_large_d">надішліть його листом</string>
<string name="report_reason_rejected">Запит відхилено</string>
<string name="report_reason_rejected_d">надішліть журнал листом</string>
<string name="report_mail_note">Журнал можна надіслати й листом, як раніше.</string>
<string name="report_retry">Повторити</string>
<string name="report_by_letter">Надіслати листом</string>
<string name="report_event_title">Звіт надіслано, код %1$s</string>
<string name="report_event_text">Назвіть цей код підтримці, щоб вона знайшла ваш журнал.</string>
</resources>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">У вас %1$s · %2$s</string>
<string name="updates_review">Переглянути й установити</string>
<string name="updates_section_auto">Автоматично</string>
<string name="updates_auto_check">Перевіряти самостійно</string>
<string name="updates_silent">Установлювати тихо</string>
<string name="updates_silent_sub">Завантажити через Wi-Fi й установити, коли VPN вимкнено</string>
<string name="updates_section_channel">Канал</string>
<string name="updates_channel_stable">Стабільний</string>
<string name="updates_channel_stable_sub">Перевірені версії</string>
<string name="updates_channel_beta">Бета</string>
<string name="updates_channel_beta_sub">Раніше, але з помилками</string>
<string name="updates_channel_hint">Бета відкривається за кодом запрошення від адміністратора.</string>
<string name="updates_beta_title">Код запрошення до бети</string>
<string name="updates_beta_text">Введіть код, який дав адміністратор. Без правильного коду застосунок залишиться на стабільних версіях.</string>
<string name="updates_beta_code">Код запрошення</string>
<string name="updates_beta_enable">Увімкнути бету</string>
<string name="updates_check_now">Перевірити зараз</string>
<string name="updates_last_check">Остання перевірка: %1$s</string>
<string name="updates_never_checked">Ще не перевіряли</string>
<string name="updates_review_sub">У вас %1$s · %2$s · файл перевіримо перед установленням</string>
<string name="updates_review_safe">Застосунок звірить контрольну суму й підпис SHX. Підроблений файл не встановиться.</string>
<string name="updates_downloading_title">Завантажуємо %1$s</string>
<string name="updates_downloading_sub">Можна закрити шторку: завантаження триватиме</string>
<string name="updates_progress">%1$d%% · %2$s з %3$s</string>
<string name="updates_step_list">Список версій отримано</string>
<string name="updates_step_list_sub">Через захищений канал</string>
<string name="updates_step_download">Завантаження файлу</string>
<string name="updates_step_verify">Перевірка файлу</string>
<string name="updates_step_verify_sub">Контрольна сума й підпис SHX</string>
<string name="updates_step_install">Установлення</string>
<string name="updates_step_install_sub">Android попросить підтвердження</string>
<string name="updates_cancel">Скасувати</string>
<string name="updates_mandatory_title">Потрібно оновити SHX VPN</string>
<string name="updates_mandatory_sub">Версія %1$s більше не підтримується</string>
<string name="updates_mandatory_what">Що це означає</string>
<string name="updates_mandatory_reason">Сервер більше не приймає підключення зі старої версії</string>
<string name="updates_mandatory_install">Установіть %1$s (%2$s), ваші сервери й налаштування збережуться</string>
<string name="updates_mandatory_locked">Цю шторку не можна закрити, доки застосунок не оновлено.</string>
<string name="updates_exit">Вийти із застосунку</string>
<string name="updates_failed_title">Оновлення не встановлено</string>
<string name="updates_failed_sub_check">Файл не пройшов перевірку</string>
<string name="updates_failed_sub_network">Файл не завантажився</string>
<string name="updates_failed_sub_install">Android не встановив файл</string>
<string name="updates_failed_sub_other">Щось пішло не так</string>
<string name="updates_failed_what">Що сталося</string>
<string name="updates_reason_hash">Контрольна сума не збіглася</string>
<string name="updates_reason_hash_d">файл пошкоджено дорогою, копію видалено</string>
<string name="updates_reason_signature">Підпис не збігся</string>
<string name="updates_reason_signature_d">файл підписано не SHX, копію видалено</string>
<string name="updates_reason_network">Немає зв’язку із сервером оновлень</string>
<string name="updates_reason_network_d">завантаження перервалося</string>
<string name="updates_reason_install">Android відхилив установлення</string>
<string name="updates_reason_install_d">системний установник повернув помилку</string>
<string name="updates_reason_other">Непередбачена помилка</string>
<string name="updates_reason_other_d">подробиці для підтримки — нижче</string>
<string name="updates_reason_same">Версія не змінилася</string>
<string name="updates_reason_same_d">у вас і далі %1$s</string>
<string name="updates_reason_later">Спробуйте пізніше</string>
<string name="updates_reason_later_d">або змініть мережу: Wi-Fi замість мобільної</string>
<string name="updates_manual_note">Установити вручну можна зі сторінки завантаження. Ваші сервери й налаштування залишаться на місці.</string>
<string name="updates_retry">Повторити</string>
<string name="updates_from_site">Завантажити із сайту</string>
<string name="updates_support">Подробиці для підтримки</string>
<string name="updates_copied">Скопійовано</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Раз на %d годину, коли телефон заряджається</item>
<item quantity="few">Раз на %d години, коли телефон заряджається</item>
<item quantity="many">Раз на %d годин, коли телефон заряджається</item>
<item quantity="other">Раз на %d години, коли телефон заряджається</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">报告将通过加密通道发送给 SHX 团队。客服凭报告代码即可找到你的日志。</string>
<string name="report_contents">报告包含的内容</string>
<string name="report_item_log">今天的连接日志</string>
<string name="report_item_device">应用版本和手机型号</string>
<string name="report_item_network">网络类型和当前服务器</string>
<string name="report_item_ips">日志中的服务器 IP 地址</string>
<string name="report_diag_title">附加诊断信息</string>
<string name="report_diag_sub">网络检查结果和应用的详细日志</string>
<string name="report_comment_label">发生了什么(可选)</string>
<string name="report_cancel">取消</string>
<string name="report_send">发送</string>
<string name="report_by_mail">通过邮件发送</string>
<string name="report_sent_title">报告已发送</string>
<string name="report_sent_sub">请把此代码告诉客服</string>
<string name="report_code_label">报告代码</string>
<string name="report_copy_code">复制代码</string>
<string name="report_code_copied">代码已复制</string>
<string name="report_saved_note">代码已保存在“事件”中,之后可以在那里找到。</string>
<string name="report_done">完成</string>
<string name="report_failed_title">报告未发送</string>
<string name="report_failed_no_answer">服务器没有响应</string>
<string name="report_failed_refused">服务器拒绝了报告</string>
<string name="report_reasons">可能的原因</string>
<string name="report_reason_offline">没有网络</string>
<string name="report_reason_offline_d">检查网络后重试</string>
<string name="report_reason_server">服务器不可用</string>
<string name="report_reason_server_d">几分钟后重试</string>
<string name="report_reason_clock">手机时间不准</string>
<string name="report_reason_clock_d">开启自动时间后重试</string>
<string name="report_reason_often">过于频繁</string>
<string name="report_reason_often_d">一小时后再试</string>
<string name="report_reason_large">日志过大</string>
<string name="report_reason_large_d">请通过邮件发送</string>
<string name="report_reason_rejected">请求被拒绝</string>
<string name="report_reason_rejected_d">请通过邮件发送日志</string>
<string name="report_mail_note">也可以像以前一样通过邮件发送日志。</string>
<string name="report_retry">重试</string>
<string name="report_by_letter">通过邮件发送</string>
<string name="report_event_title">报告已发送,代码 %1$s</string>
<string name="report_event_text">把此代码告诉客服,以便找到你的日志。</string>
</resources>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">当前 %1$s · %2$s</string>
<string name="updates_review">查看并安装</string>
<string name="updates_section_auto">自动</string>
<string name="updates_auto_check">自动检查</string>
<string name="updates_silent">静默安装</string>
<string name="updates_silent_sub">通过 Wi-Fi 下载,并在 VPN 关闭时安装</string>
<string name="updates_section_channel">渠道</string>
<string name="updates_channel_stable">稳定版</string>
<string name="updates_channel_stable_sub">经过测试的版本</string>
<string name="updates_channel_beta">测试版</string>
<string name="updates_channel_beta_sub">更早获得,但可能有错误</string>
<string name="updates_channel_hint">测试版需要管理员提供的邀请码。</string>
<string name="updates_beta_title">测试版邀请码</string>
<string name="updates_beta_text">输入管理员提供的邀请码。没有有效的邀请码时,应用仍使用稳定版。</string>
<string name="updates_beta_code">邀请码</string>
<string name="updates_beta_enable">启用测试版</string>
<string name="updates_check_now">立即检查</string>
<string name="updates_last_check">上次检查:%1$s</string>
<string name="updates_never_checked">尚未检查</string>
<string name="updates_review_sub">当前 %1$s · %2$s · 安装前会校验文件</string>
<string name="updates_review_safe">应用会校验校验和与 SHX 签名,伪造的文件无法安装。</string>
<string name="updates_downloading_title">正在下载 %1$s</string>
<string name="updates_downloading_sub">可以关闭此面板,下载会继续</string>
<string name="updates_progress">%1$d%% · %2$s / %3$s</string>
<string name="updates_step_list">已获取版本列表</string>
<string name="updates_step_list_sub">通过受保护的通道</string>
<string name="updates_step_download">下载文件</string>
<string name="updates_step_verify">校验文件</string>
<string name="updates_step_verify_sub">校验和与 SHX 签名</string>
<string name="updates_step_install">安装</string>
<string name="updates_step_install_sub">Android 会请求确认</string>
<string name="updates_cancel">取消</string>
<string name="updates_mandatory_title">需要更新 SHX VPN</string>
<string name="updates_mandatory_sub">版本 %1$s 已不再受支持</string>
<string name="updates_mandatory_what">这意味着什么</string>
<string name="updates_mandatory_reason">服务器不再接受旧版本的连接</string>
<string name="updates_mandatory_install">安装 %1$s(%2$s),你的服务器和设置会保留</string>
<string name="updates_mandatory_locked">应用更新前无法关闭此面板。</string>
<string name="updates_exit">退出应用</string>
<string name="updates_failed_title">更新未安装</string>
<string name="updates_failed_sub_check">文件未通过校验</string>
<string name="updates_failed_sub_network">文件未能下载</string>
<string name="updates_failed_sub_install">Android 未安装该文件</string>
<string name="updates_failed_sub_other">出现问题</string>
<string name="updates_failed_what">发生了什么</string>
<string name="updates_reason_hash">校验和不匹配</string>
<string name="updates_reason_hash_d">文件在传输中损坏,副本已删除</string>
<string name="updates_reason_signature">签名不匹配</string>
<string name="updates_reason_signature_d">文件不是由 SHX 签名的,副本已删除</string>
<string name="updates_reason_network">无法连接更新服务器</string>
<string name="updates_reason_network_d">下载已中断</string>
<string name="updates_reason_install">Android 拒绝了安装</string>
<string name="updates_reason_install_d">系统安装程序返回了错误</string>
<string name="updates_reason_other">意外错误</string>
<string name="updates_reason_other_d">可在下方复制给支持人员的详细信息</string>
<string name="updates_reason_same">版本未变化</string>
<string name="updates_reason_same_d">当前仍为 %1$s</string>
<string name="updates_reason_later">请稍后再试</string>
<string name="updates_reason_later_d">或切换网络:用 Wi-Fi 代替移动数据</string>
<string name="updates_manual_note">也可以从下载页面手动安装,你的服务器和设置会保留。</string>
<string name="updates_retry">重试</string>
<string name="updates_from_site">从网站下载</string>
<string name="updates_support">复制给支持人员的详细信息</string>
<string name="updates_copied">已复制</string>
<plurals name="updates_auto_check_sub">
<item quantity="other">手机充电时每 %d 小时检查一次</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">報告將透過加密通道傳送給 SHX 團隊。客服憑報告代碼即可找到你的日誌。</string>
<string name="report_contents">報告包含的內容</string>
<string name="report_item_log">今天的連線日誌</string>
<string name="report_item_device">應用程式版本和手機型號</string>
<string name="report_item_network">網路類型和目前伺服器</string>
<string name="report_item_ips">日誌中的伺服器 IP 位址</string>
<string name="report_diag_title">附加診斷資訊</string>
<string name="report_diag_sub">網路檢查結果和應用程式的詳細日誌</string>
<string name="report_comment_label">發生了什麼(選填)</string>
<string name="report_cancel">取消</string>
<string name="report_send">傳送</string>
<string name="report_by_mail">透過電子郵件傳送</string>
<string name="report_sent_title">報告已傳送</string>
<string name="report_sent_sub">請把此代碼告訴客服</string>
<string name="report_code_label">報告代碼</string>
<string name="report_copy_code">複製代碼</string>
<string name="report_code_copied">代碼已複製</string>
<string name="report_saved_note">代碼已儲存在「事件」中,之後可以在那裡找到。</string>
<string name="report_done">完成</string>
<string name="report_failed_title">報告未傳送</string>
<string name="report_failed_no_answer">伺服器沒有回應</string>
<string name="report_failed_refused">伺服器拒絕了報告</string>
<string name="report_reasons">可能的原因</string>
<string name="report_reason_offline">沒有網路</string>
<string name="report_reason_offline_d">檢查網路後重試</string>
<string name="report_reason_server">伺服器無法使用</string>
<string name="report_reason_server_d">幾分鐘後重試</string>
<string name="report_reason_clock">手機時間不準</string>
<string name="report_reason_clock_d">開啟自動時間後重試</string>
<string name="report_reason_often">過於頻繁</string>
<string name="report_reason_often_d">一小時後再試</string>
<string name="report_reason_large">日誌過大</string>
<string name="report_reason_large_d">請透過電子郵件傳送</string>
<string name="report_reason_rejected">要求被拒絕</string>
<string name="report_reason_rejected_d">請透過電子郵件傳送日誌</string>
<string name="report_mail_note">也可以像以前一樣透過電子郵件傳送日誌。</string>
<string name="report_retry">重試</string>
<string name="report_by_letter">透過電子郵件傳送</string>
<string name="report_event_title">報告已傳送,代碼 %1$s</string>
<string name="report_event_text">把此代碼告訴客服,以便找到你的日誌。</string>
</resources>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">目前 %1$s · %2$s</string>
<string name="updates_review">檢視並安裝</string>
<string name="updates_section_auto">自動</string>
<string name="updates_auto_check">自動檢查</string>
<string name="updates_silent">靜默安裝</string>
<string name="updates_silent_sub">透過 Wi-Fi 下載,並在 VPN 關閉時安裝</string>
<string name="updates_section_channel">頻道</string>
<string name="updates_channel_stable">穩定版</string>
<string name="updates_channel_stable_sub">經過測試的版本</string>
<string name="updates_channel_beta">測試版</string>
<string name="updates_channel_beta_sub">更早取得,但可能有錯誤</string>
<string name="updates_channel_hint">測試版需要管理員提供的邀請碼。</string>
<string name="updates_beta_title">測試版邀請碼</string>
<string name="updates_beta_text">輸入管理員提供的邀請碼。沒有有效的邀請碼時,應用程式仍使用穩定版。</string>
<string name="updates_beta_code">邀請碼</string>
<string name="updates_beta_enable">啟用測試版</string>
<string name="updates_check_now">立即檢查</string>
<string name="updates_last_check">上次檢查:%1$s</string>
<string name="updates_never_checked">尚未檢查</string>
<string name="updates_review_sub">目前 %1$s · %2$s · 安裝前會驗證檔案</string>
<string name="updates_review_safe">應用程式會驗證檢查碼與 SHX 簽章,偽造的檔案無法安裝。</string>
<string name="updates_downloading_title">正在下載 %1$s</string>
<string name="updates_downloading_sub">可以關閉此面板,下載會繼續</string>
<string name="updates_progress">%1$d%% · %2$s / %3$s</string>
<string name="updates_step_list">已取得版本清單</string>
<string name="updates_step_list_sub">透過受保護的通道</string>
<string name="updates_step_download">下載檔案</string>
<string name="updates_step_verify">驗證檔案</string>
<string name="updates_step_verify_sub">檢查碼與 SHX 簽章</string>
<string name="updates_step_install">安裝</string>
<string name="updates_step_install_sub">Android 會要求確認</string>
<string name="updates_cancel">取消</string>
<string name="updates_mandatory_title">需要更新 SHX VPN</string>
<string name="updates_mandatory_sub">版本 %1$s 已不再支援</string>
<string name="updates_mandatory_what">這代表什麼</string>
<string name="updates_mandatory_reason">伺服器不再接受舊版本的連線</string>
<string name="updates_mandatory_install">安裝 %1$s(%2$s),你的伺服器和設定會保留</string>
<string name="updates_mandatory_locked">應用程式更新前無法關閉此面板。</string>
<string name="updates_exit">結束應用程式</string>
<string name="updates_failed_title">更新未安裝</string>
<string name="updates_failed_sub_check">檔案未通過驗證</string>
<string name="updates_failed_sub_network">檔案未能下載</string>
<string name="updates_failed_sub_install">Android 未安裝該檔案</string>
<string name="updates_failed_sub_other">發生問題</string>
<string name="updates_failed_what">發生了什麼</string>
<string name="updates_reason_hash">檢查碼不相符</string>
<string name="updates_reason_hash_d">檔案在傳輸中損壞,副本已刪除</string>
<string name="updates_reason_signature">簽章不相符</string>
<string name="updates_reason_signature_d">檔案不是由 SHX 簽署,副本已刪除</string>
<string name="updates_reason_network">無法連線到更新伺服器</string>
<string name="updates_reason_network_d">下載已中斷</string>
<string name="updates_reason_install">Android 拒絕了安裝</string>
<string name="updates_reason_install_d">系統安裝程式傳回了錯誤</string>
<string name="updates_reason_other">非預期的錯誤</string>
<string name="updates_reason_other_d">可在下方複製給支援人員的詳細資訊</string>
<string name="updates_reason_same">版本未變更</string>
<string name="updates_reason_same_d">目前仍為 %1$s</string>
<string name="updates_reason_later">請稍後再試</string>
<string name="updates_reason_later_d">或切換網路:用 Wi-Fi 取代行動數據</string>
<string name="updates_manual_note">也可以從下載頁面手動安裝,你的伺服器和設定會保留。</string>
<string name="updates_retry">重試</string>
<string name="updates_from_site">從網站下載</string>
<string name="updates_support">複製給支援人員的詳細資訊</string>
<string name="updates_copied">已複製</string>
<plurals name="updates_auto_check_sub">
<item quantity="other">手機充電時每 %d 小時檢查一次</item>
</plurals>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- "Send report" sheet of the log screen (mockups App7-App10) -->
<resources>
<string name="report_intro">The report goes to the SHX team over a secure channel. With the report code, support will find your log.</string>
<string name="report_contents">What the report includes</string>
<string name="report_item_log">Today’s connection log</string>
<string name="report_item_device">App version and phone model</string>
<string name="report_item_network">Network type and active server</string>
<string name="report_item_ips">Server IP addresses from the log</string>
<string name="report_diag_title">Attach diagnostics</string>
<string name="report_diag_sub">Network check results and the app’s extended log</string>
<string name="report_comment_label">What happened (optional)</string>
<string name="report_cancel">Cancel</string>
<string name="report_send">Send</string>
<string name="report_by_mail">Send by email</string>
<string name="report_sent_title">Report sent</string>
<string name="report_sent_sub">Tell this code to support</string>
<string name="report_code_label">Report code</string>
<string name="report_copy_code">Copy code</string>
<string name="report_code_copied">Code copied</string>
<string name="report_saved_note">The code is saved in “Events”, you can find it there later.</string>
<string name="report_done">Done</string>
<string name="report_failed_title">Report not sent</string>
<string name="report_failed_no_answer">The server did not answer</string>
<string name="report_failed_refused">The server refused the report</string>
<string name="report_reasons">Possible reasons</string>
<string name="report_reason_offline">No internet</string>
<string name="report_reason_offline_d">check the network and retry</string>
<string name="report_reason_server">Server unavailable</string>
<string name="report_reason_server_d">retry in a few minutes</string>
<string name="report_reason_clock">Phone clock is off</string>
<string name="report_reason_clock_d">turn on automatic time and retry</string>
<string name="report_reason_often">Too often</string>
<string name="report_reason_often_d">try again in an hour</string>
<string name="report_reason_large">Log is too large</string>
<string name="report_reason_large_d">send it by email</string>
<string name="report_reason_rejected">Request refused</string>
<string name="report_reason_rejected_d">send the log by email</string>
<string name="report_mail_note">The log can also be sent by email, as before.</string>
<string name="report_retry">Retry</string>
<string name="report_by_letter">Send by email</string>
<string name="report_event_title">Report sent, code %1$s</string>
<string name="report_event_text">Tell this code to support so they can find your log.</string>
</resources>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- App self-update: the Updates page and the update sheets -->
<resources>
<string name="updates_have_size">You have %1$s · %2$s</string>
<string name="updates_review">View and install</string>
<string name="updates_section_auto">Automatically</string>
<string name="updates_auto_check">Check by itself</string>
<string name="updates_silent">Install quietly</string>
<string name="updates_silent_sub">Download over Wi-Fi and install while the VPN is off</string>
<string name="updates_section_channel">Channel</string>
<string name="updates_channel_stable">Stable</string>
<string name="updates_channel_stable_sub">Tested versions</string>
<string name="updates_channel_beta">Beta</string>
<string name="updates_channel_beta_sub">Earlier, but with bugs</string>
<string name="updates_channel_hint">Beta opens with an invitation code from the administrator.</string>
<string name="updates_beta_title">Beta invitation code</string>
<string name="updates_beta_text">Enter the code the administrator gave you. Without a valid code the app stays on stable versions.</string>
<string name="updates_beta_code">Invitation code</string>
<string name="updates_beta_enable">Turn on beta</string>
<string name="updates_check_now">Check now</string>
<string name="updates_last_check">Last check: %1$s</string>
<string name="updates_never_checked">Not checked yet</string>
<string name="updates_review_sub">You have %1$s · %2$s · the file is checked before installing</string>
<string name="updates_review_safe">The app checks the checksum and the SHX signature. A forged file will not install.</string>
<string name="updates_downloading_title">Downloading %1$s</string>
<string name="updates_downloading_sub">You can close this sheet: the download goes on</string>
<string name="updates_progress">%1$d%% · %2$s of %3$s</string>
<string name="updates_step_list">Version list received</string>
<string name="updates_step_list_sub">Over a protected channel</string>
<string name="updates_step_download">Downloading the file</string>
<string name="updates_step_verify">Checking the file</string>
<string name="updates_step_verify_sub">Checksum and SHX signature</string>
<string name="updates_step_install">Installation</string>
<string name="updates_step_install_sub">Android will ask for confirmation</string>
<string name="updates_cancel">Cancel</string>
<string name="updates_mandatory_title">SHX VPN needs an update</string>
<string name="updates_mandatory_sub">Version %1$s is no longer supported</string>
<string name="updates_mandatory_what">What this means</string>
<string name="updates_mandatory_reason">The server no longer accepts connections from the old version</string>
<string name="updates_mandatory_install">Install %1$s (%2$s): your servers and settings stay</string>
<string name="updates_mandatory_locked">This sheet can’t be closed until the app is updated.</string>
<string name="updates_exit">Exit the app</string>
<string name="updates_failed_title">Update not installed</string>
<string name="updates_failed_sub_check">The file did not pass the check</string>
<string name="updates_failed_sub_network">The file did not download</string>
<string name="updates_failed_sub_install">Android did not install the file</string>
<string name="updates_failed_sub_other">Something went wrong</string>
<string name="updates_failed_what">What happened</string>
<string name="updates_reason_hash">Checksum mismatch</string>
<string name="updates_reason_hash_d">the file was damaged on the way, the copy is deleted</string>
<string name="updates_reason_signature">Signature mismatch</string>
<string name="updates_reason_signature_d">the file is not signed by SHX, the copy is deleted</string>
<string name="updates_reason_network">No connection to the update server</string>
<string name="updates_reason_network_d">the download was interrupted</string>
<string name="updates_reason_install">Android rejected the installation</string>
<string name="updates_reason_install_d">the system installer returned an error</string>
<string name="updates_reason_other">Unexpected error</string>
<string name="updates_reason_other_d">the details for support are below</string>
<string name="updates_reason_same">Version unchanged</string>
<string name="updates_reason_same_d">you still have %1$s</string>
<string name="updates_reason_later">Try again later</string>
<string name="updates_reason_later_d">or switch networks: Wi-Fi instead of mobile data</string>
<string name="updates_manual_note">You can install it manually from the download page. Your servers and settings stay in place.</string>
<string name="updates_retry">Retry</string>
<string name="updates_from_site">Download from the site</string>
<string name="updates_support">Details for support</string>
<string name="updates_copied">Copied</string>
<plurals name="updates_auto_check_sub">
<item quantity="one">Every %d hour while the phone is charging</item>
<item quantity="other">Every %d hours while the phone is charging</item>
</plurals>
</resources>
@@ -0,0 +1,100 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
class ScheduledJobQueueTest
{
@Test
fun firstJobArmsTheAlarm()
{
val q = ScheduledJobQueue(2000)
assertThat(q.add("a", 10_000)).isTrue()
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun laterJobDoesNotRearm()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.add("b", 50_000)).isFalse()
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun jobJustBeforeTheArmedAlarmReusesIt()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.add("b", 8_500)).isFalse()
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun clearlyEarlierJobRearms()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.add("b", 5_000)).isTrue()
assertThat(q.armedAt).isEqualTo(5_000)
}
@Test
fun popDueRunsJobsInOrderIncludingTheCoalescingWindow()
{
val q = ScheduledJobQueue(2000)
q.add("c", 11_900)
q.add("a", 10_000)
q.add("b", 10_500)
q.add("late", 30_000)
assertThat(q.popDue(10_000)).containsExactly("a", "b", "c")
assertThat(q.nextAlarm()).isEqualTo(30_000)
assertThat(q.armedAt).isEqualTo(30_000)
}
@Test
fun earlyOrSpuriousWakeupKeepsTheQueue()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.popDue(5_000)).isEmpty()
assertThat(q.nextAlarm()).isEqualTo(10_000)
assertThat(q.armedAt).isEqualTo(10_000)
}
@Test
fun emptyQueueLeavesNothingArmed()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
assertThat(q.popDue(10_000)).containsExactly("a")
assertThat(q.nextAlarm()).isEqualTo(ScheduledJobQueue.NONE)
assertThat(q.armedAt).isEqualTo(ScheduledJobQueue.NONE)
assertThat(q.add("b", 20_000)).isTrue()
}
@Test
fun clearForgetsTheArmedAlarm()
{
val q = ScheduledJobQueue(2000)
q.add("a", 10_000)
q.clear()
assertThat(q.size).isEqualTo(0)
assertThat(q.add("b", 40_000)).isTrue()
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.strongswan.android.logic.stats.TrafficHistory
class TrafficHistoryTest
{
private val every = TrafficHistory.SAVE_EVERY_MS
@Test
fun cleanHistoryIsNeverWritten()
{
assertThat(TrafficHistory.saveDue(false, 10 * every, 0, true)).isFalse()
}
@Test
fun changedHistoryWaitsForTheInterval()
{
assertThat(TrafficHistory.saveDue(true, every - 1, 0, false)).isFalse()
assertThat(TrafficHistory.saveDue(true, every, 0, false)).isTrue()
}
@Test
fun dayChangeWritesAtOnce()
{
assertThat(TrafficHistory.saveDue(true, 1000, 900, true)).isTrue()
}
}
@@ -242,4 +242,30 @@ class HealthCheckManagerTest
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 3)).isTrue() assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 3)).isTrue()
assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse() assertThat(HealthCheckManager.shouldProbeServer(ServerCheckMode.WITH_TUNNEL, 0, 0)).isFalse()
} }
@Test
fun inboundTrafficSkipsProbesInAdaptiveMode()
{
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, 60_000)).isTrue()
}
@Test
fun noPassiveSkipWithoutInboundOrAfterCap()
{
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 100, 60_000)).isFalse()
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, false, -1, 60_000)).isFalse()
assertThat(
HealthCheckManager.trafficProvesAlive(config, 0, 0, false, 10_000, HealthCheckManager.PASSIVE_MAX_PROBE_AGE_MS)
).isFalse()
}
@Test
fun noPassiveSkipWhileFailureIsObservedOrNotAdaptive()
{
assertThat(HealthCheckManager.trafficProvesAlive(config, 1, 0, false, 10_000, 1000)).isFalse()
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 2, false, 10_000, 1000)).isFalse()
assertThat(HealthCheckManager.trafficProvesAlive(config, 0, 0, true, 10_000, 1000)).isFalse()
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(adaptiveMode = false), 0, 0, false, 10_000, 1000)).isFalse()
assertThat(HealthCheckManager.trafficProvesAlive(config.copy(aggressiveMode = true), 0, 0, false, 10_000, 1000)).isFalse()
}
} }
@@ -0,0 +1,172 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import org.assertj.core.api.Assertions.assertThat
import org.json.JSONObject
import org.junit.Test
import java.io.File
class ReportContentTest
{
@Test
fun checksGoBetweenTheLogAndDiagAsAJsonArray()
{
val checks = listOf(
ReportContent.Check("Интернет", "Wi-Fi", true),
ReportContent.Check("Сервер \"nl1\"", "нет ответа\n15 с", false),
ReportContent.Check("DNS", "проверяется", null),
)
val text = ReportContent.build("a\n", "d\n", checks)
assertThat(text).isEqualTo(
"=== charon.log ===\na\n\n=== checks.json ===\n" +
"[{\"title\":\"Интернет\",\"value\":\"Wi-Fi\",\"ok\":true}," +
"{\"title\":\"Сервер \\\"nl1\\\"\",\"value\":\"нет ответа\\n15 с\",\"ok\":false}," +
"{\"title\":\"DNS\",\"value\":\"проверяется\",\"ok\":null}]\n" +
"\n=== diag.log ===\nd\n"
)
assertThat(ReportContent.build("a\n", null, emptyList())).doesNotContain("checks.json")
}
@Test
fun apiBaseAddsTheApiPrefixOnlyWhenMissing()
{
/* the proxy routes only /api/v1/ and the HMAC covers the full path */
assertThat(ReportUploader.apiBase("https://get.shnt.top")).isEqualTo("https://get.shnt.top/api/v1")
assertThat(ReportUploader.apiBase("https://get.shnt.top/")).isEqualTo("https://get.shnt.top/api/v1")
assertThat(ReportUploader.apiBase("https://get.shnt.top/api/v1/")).isEqualTo("https://get.shnt.top/api/v1")
assertThat(java.net.URL(ReportUploader.apiBase("http://127.0.0.1:4000") + "/reports").path).isEqualTo("/api/v1/reports")
}
@Test
fun parseResponseAcceptsOnlyA201WithAValidCode()
{
assertThat(ReportUploader.parseResponse(201, """{"code":"SHX-7F3K2"}""")).isEqualTo(ReportUploader.Result.Sent("SHX-7F3K2"))
val broken = ReportUploader.parseResponse(201, "<html>proxy</html>")
assertThat(broken).isInstanceOf(ReportUploader.Result.Failed::class.java)
assertThat((broken as ReportUploader.Result.Failed).httpCode).isEqualTo(201)
assertThat(ReportUploader.parseResponse(201, """{"code":"nope"}""")).isInstanceOf(ReportUploader.Result.Failed::class.java)
val limited = ReportUploader.parseResponse(429, """{"error":{"code":"rate_limited"}}""") as ReportUploader.Result.Failed
assertThat(limited.httpCode).isEqualTo(429)
assertThat(limited.detail).contains("rate_limited")
}
@Test
fun failureMapsServerAnswersToReasons()
{
assertThat(ReportContent.failure(0)).isEqualTo(ReportContent.Failure.NO_NETWORK)
assertThat(ReportContent.failure(401)).isEqualTo(ReportContent.Failure.SIGNATURE)
assertThat(ReportContent.failure(413)).isEqualTo(ReportContent.Failure.TOO_LARGE)
assertThat(ReportContent.failure(429)).isEqualTo(ReportContent.Failure.TOO_OFTEN)
assertThat(ReportContent.failure(502)).isEqualTo(ReportContent.Failure.SERVER_DOWN)
assertThat(ReportContent.failure(404)).isEqualTo(ReportContent.Failure.SERVER_DOWN)
assertThat(ReportContent.failure(400)).isEqualTo(ReportContent.Failure.REJECTED)
assertThat(ReportContent.failure(201)).isEqualTo(ReportContent.Failure.REJECTED)
}
@Test
fun todayOnlyKeepsTodaysLinesWithTheirContinuations()
{
val log = """
Nov 7 23:59:58 06[IKE] old line
old continuation
Nov 8 00:00:01 06[IKE] initiating IKE_SA
payload continuation
Nov 8 14:02:30 07[IKE] CHILD_SA established
""".trimIndent()
assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo(
"Nov 8 00:00:01 06[IKE] initiating IKE_SA\n payload continuation\nNov 8 14:02:30 07[IKE] CHILD_SA established\n"
)
assertThat(ReportContent.todayOnly("Dec 18 10:00:00 01[IKE] two-digit day\n", 12, 18)).contains("two-digit day")
}
@Test
fun todayOnlyFallsBackToTheWholeLogWhenNothingIsFromToday()
{
val log = "Nov 7 10:00:00 06[IKE] yesterday\n"
assertThat(ReportContent.todayOnly(log, 11, 8)).isEqualTo(log)
/* a cut log starts with an undated mark: it alone must not count as "today" */
val cut = ReportContent.CUT_MARK + log
assertThat(ReportContent.todayOnly(cut, 11, 8)).isEqualTo(cut)
}
@Test
fun tailBytesCutsTheStartAtALineBoundaryAndKeepsCyrillicWhole()
{
val text = (1..200).joinToString("") { "строка $it: нет ответа\n" }
val cut = ReportContent.tailBytes(text, 1000)
assertThat(cut).startsWith(ReportContent.CUT_MARK)
val body = cut.removePrefix(ReportContent.CUT_MARK)
assertThat(body.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(1000)
assertThat(body).startsWith("строка ").endsWith("строка 200: нет ответа\n")
assertThat(body).doesNotContain("�")
assertThat(ReportContent.tailBytes("short\n", 1000)).isEqualTo("short\n")
}
@Test
fun tailBytesOfOneHugeLineDoesNotSplitACharacter()
{
val cut = ReportContent.tailBytes("ж".repeat(1000), 101).removePrefix(ReportContent.CUT_MARK)
assertThat(cut).doesNotContain("�")
assertThat(cut).isEqualTo("ж".repeat(50))
}
@Test
fun readTailDropsThePartialFirstLine()
{
val file = File.createTempFile("charon", ".log")
try
{
file.writeText("first line\nsecond line\nthird line\n")
assertThat(ReportContent.readTail(file, 1000)).isEqualTo("first line\nsecond line\nthird line\n")
assertThat(ReportContent.readTail(file, 15)).isEqualTo(ReportContent.CUT_MARK + "third line\n")
assertThat(ReportContent.readTail(File(file.path + ".missing"), 100)).isEmpty()
}
finally
{
file.delete()
}
}
@Test
fun buildAddsDiagOnlyWhenAskedAndStaysWithinTheLimits()
{
val plain = ReportContent.build("Nov 8 14:02:31 08[IKE] tunnel up\n", null)
assertThat(plain).isEqualTo("=== charon.log ===\nNov 8 14:02:31 08[IKE] tunnel up\n")
val withDiag = ReportContent.build("", "11-08 14:02:00.000 Watchdog: ok\n")
assertThat(withDiag).contains("=== charon.log ===\n(empty)").contains("=== diag.log ===\n11-08 14:02:00.000 Watchdog: ok")
val huge = "x".repeat(99) + "\n"
val big = ReportContent.build(huge.repeat(40_000), huge.repeat(10_000))
assertThat(big.toByteArray(Charsets.UTF_8).size).isLessThanOrEqualTo(ReportContent.CHARON_MAX_BYTES + ReportContent.DIAG_MAX_BYTES + 200)
/* what the panel unpacks for the report card (5 MB cap) */
assertThat(big.length).isLessThan(5 * 1024 * 1024)
}
@Test
fun metaDescribesThePhoneAndPassesTheServerSchema()
{
val meta = ReportContent.meta("2.19.2", 170, "Google", "Pixel 7", "14", 34, "Wi-Fi", " Не подключается в метро ", "nl1 · nl1.shx.one")
assertThat(meta.device).isEqualTo("Google Pixel 7")
assertThat(ReportContent.meta("2.19.2", 170, "samsung", "SAMSUNG SM-S911B", "14", 34, "mobile", "", "").device).isEqualTo("SAMSUNG SM-S911B")
assertThat(meta.os).isEqualTo("Android 14 (API 34)")
assertThat(meta.comment).isEqualTo("Не подключается в метро")
val json = JSONObject(ReportUploader.metaJson(meta))
/* ReportMetaSchema: source is "log" | "diagnostics", versionCode an integer */
assertThat(json.getString("source")).isEqualTo("log")
assertThat(json.getInt("versionCode")).isEqualTo(170)
assertThat(json.getString("network")).isEqualTo("Wi-Fi")
assertThat(json.getString("profile")).isEqualTo("nl1 · nl1.shx.one")
}
}
@@ -0,0 +1,69 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import org.assertj.core.api.Assertions.assertThat
import org.json.JSONObject
import org.junit.Test
import java.io.ByteArrayInputStream
import java.util.zip.GZIPInputStream
class ReportUploaderTest
{
private val meta = ReportUploader.Meta("2.19.1", 169, "Pixel 7", "Android 14", "LTE", "log", "Не подключается", "nl1")
@Test
fun gzipRoundTripsTheLogAndStartsWithTheGzipMagic()
{
val gz = ReportUploader.gzip("09:38:11 IKE_SA_INIT → nl1 :4500\n")
assertThat(gz[0]).isEqualTo(0x1f.toByte())
assertThat(gz[1]).isEqualTo(0x8b.toByte())
assertThat(GZIPInputStream(ByteArrayInputStream(gz)).readBytes().decodeToString()).isEqualTo("09:38:11 IKE_SA_INIT → nl1 :4500\n")
}
@Test
fun metaJsonCarriesTheFieldsTheServerValidatesAndTruncatesLongOnes()
{
val json = JSONObject(ReportUploader.metaJson(meta.copy(comment = "x".repeat(2000), device = "d".repeat(500))))
assertThat(json.getString("versionName")).isEqualTo("2.19.1")
assertThat(json.getInt("versionCode")).isEqualTo(169)
assertThat(json.getString("comment")).hasSize(1000)
assertThat(json.getString("device")).hasSize(120)
assertThat(json.getString("source")).isEqualTo("log")
}
@Test
fun multipartBodyHasTheLogAndMetaPartsInTheExpectedShape()
{
val body = ReportUploader.multipartBody(byteArrayOf(0x1f, 0x8b.toByte(), 1, 2), "{\"a\":1}").decodeToString(throwOnInvalidSequence = false)
assertThat(body).contains("name=\"log\"; filename=\"log.gz\"")
assertThat(body).contains("name=\"meta\"")
assertThat(body).contains("{\"a\":1}")
/* the meta part must stay a plain field (see multipartBody) */
assertThat(body.substringAfter("name=\"meta\"").substringBefore("{")).doesNotContain("Content-Type")
assertThat(body).endsWith("--${ReportUploader.BOUNDARY}--\r\n")
}
@Test
fun anOversizedReportIsRefusedBeforeAnyRequest()
{
/* 62 symbols are ~6 bits each: 4 MB of text stays above 2 MB after gzip */
val alphabet = ('a'..'z') + ('A'..'Z') + ('0'..'9')
val huge = java.util.Random(1).let { r -> buildString { repeat(4 * 1024 * 1024) { append(alphabet[r.nextInt(alphabet.size)]) } } }
val result = ReportUploader("https://api.example/api/v1", "k", "install-0001").send(huge, meta)
assertThat(result).isInstanceOf(ReportUploader.Result.Failed::class.java)
assertThat((result as ReportUploader.Result.Failed).httpCode).isEqualTo(413)
}
}
@@ -0,0 +1,196 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic.updates
import org.assertj.core.api.Assertions.assertThat
import org.json.JSONObject
import org.junit.Test
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Checks the client against the shared vectors of the web panel
* (packages/contracts/vectors/signing.json). If the server contract changes,
* the vectors are regenerated there and copied to src/test/resources.
*/
@OptIn(ExperimentalEncodingApi::class)
class UpdateApiTest
{
private val vectors = JSONObject(
javaClass.classLoader!!.getResourceAsStream("shx-signing-vectors.json")!!.use { it.readBytes().decodeToString() })
private val publicKeyRaw = vectors.getJSONObject("ed25519").getString("publicKeyRawBase64")
private val manifest = vectors.getJSONObject("manifest")
private val hmacKey = vectors.getJSONObject("request").getString("hmacKey")
private fun fields(o: JSONObject) = UpdateSigning.Fields(
update = o.getBoolean("update"),
mandatory = o.getBoolean("mandatory"),
versionCode = o.optLong("versionCode", 0L),
versionName = o.optString("versionName", ""),
sha256 = o.optString("sha256", ""),
size = o.optLong("size", 0L),
apkUrl = o.optString("apkUrl", ""),
issuedAt = o.getLong("issuedAt"),
checkHours = o.getInt("checkHours"),
wifiOnly = o.getBoolean("wifiOnly"),
)
private fun responseJson(v: JSONObject, patch: (JSONObject) -> Unit = {}): String
{
val f = v.getJSONObject("fields")
val body = JSONObject(f.toString())
body.put("signature", v.getString("signatureBase64"))
if (f.getBoolean("update"))
{
body.put("notes", JSONObject().put("ru", "Исправления").put("en", "Fixes"))
}
patch(body)
return body.toString()
}
private class FakeHttp(private val response: UpdateHttp.Response) : UpdateHttp
{
var lastUrl: String? = null
var lastHeaders: Map<String, String> = emptyMap()
override fun get(url: String, headers: Map<String, String>): UpdateHttp.Response
{
lastUrl = url
lastHeaders = headers
return response
}
}
private fun client(http: UpdateHttp, nowSeconds: Long = manifest.getJSONObject("fields").getLong("issuedAt")) =
UpdateApiClient("https://api.example/api/v1", hmacKey, publicKeyRaw, "install-0001", http) { nowSeconds * 1000 }
@Test
fun manifestSigningInputMatchesTheServerVector()
{
assertThat(UpdateSigning.manifestInput(fields(manifest.getJSONObject("fields")))).isEqualTo(manifest.getString("signingInput"))
val none = vectors.getJSONObject("noUpdate")
assertThat(UpdateSigning.manifestInput(fields(none.getJSONObject("fields")))).isEqualTo(none.getString("signingInput"))
}
@Test
fun ed25519SignatureFromTheServerVerifiesAndTamperingIsRejected()
{
val key = Base64.Default.decode(publicKeyRaw)
val input = manifest.getString("signingInput")
val signature = manifest.getString("signatureBase64")
assertThat(UpdateSigning.verifyEd25519(key, input, signature)).isTrue()
assertThat(UpdateSigning.verifyEd25519(key, input + "x", signature)).isFalse()
assertThat(UpdateSigning.verifyEd25519(key, input.replace("250003", "250004"), signature)).isFalse()
assertThat(UpdateSigning.verifyEd25519(ByteArray(32), input, signature)).isFalse()
}
@Test
fun requestSignatureMatchesTheServerVector()
{
val r = vectors.getJSONObject("request")
val input = UpdateSigning.requestInput(r.getString("method"), r.getString("path"), r.getString("timestamp").toLong())
assertThat(input).isEqualTo(r.getString("signingInput"))
assertThat(UpdateSigning.hmacBase64(hmacKey, input)).isEqualTo(r.getString("signatureBase64"))
}
@Test
fun aBareHostGetsTheApiPrefix()
{
/* UPDATE_API_URL=https://get.shnt.top (no /api/v1) used to hit /updates/check -> 404 -> silent legacy fallback */
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
val bare = UpdateApiClient("https://api.example/", hmacKey, publicKeyRaw, "install-0001", http) {
manifest.getJSONObject("fields").getLong("issuedAt") * 1000
}
assertThat(bare.check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null)).isInstanceOf(UpdateApiResult.Update::class.java)
assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?")
}
@Test
fun clientReturnsTheOfferedUpdateAndSendsASignedRequest()
{
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
val result = client(http).check("one.shx.strongswan.ext", 168, "release", "arm64-v8a", 34, null)
assertThat(result).isInstanceOf(UpdateApiResult.Update::class.java)
val update = result as UpdateApiResult.Update
assertThat(update.info.versionName).isEqualTo("2.5.0-beta.3")
assertThat(update.info.versionCode).isEqualTo(250003L)
assertThat(update.info.fromApi).isTrue()
assertThat(update.info.notes["ru"]).isEqualTo("Исправления")
assertThat(update.policy).isEqualTo(UpdatePolicy(12, true))
assertThat(http.lastUrl).startsWith("https://api.example/api/v1/updates/check?package=one.shx.strongswan.ext&versionCode=168")
assertThat(http.lastHeaders["X-Install-Id"]).isEqualTo("install-0001")
val ts = http.lastHeaders["X-Timestamp"]!!.toLong()
assertThat(http.lastHeaders["X-Signature"]).isEqualTo(
UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", "/api/v1/updates/check", ts)))
}
@Test
fun clientAcceptsASignedNoUpdateAnswer()
{
val http = FakeHttp(UpdateHttp.Response(200, responseJson(vectors.getJSONObject("noUpdate"))))
val result = client(http).check("one.shx.strongswan.ext", 169, "release", null, 34, null)
assertThat(result).isInstanceOf(UpdateApiResult.NoUpdate::class.java)
}
@Test
fun tamperedFieldsAreRejectedAsASignatureFailureAndNeverFallBack()
{
for (field in listOf("sha256", "apkUrl", "versionCode", "versionName", "size", "mandatory", "checkHours", "wifiOnly"))
{
val body = responseJson(manifest) { o ->
when (field)
{
"mandatory", "wifiOnly" -> o.put(field, !o.getBoolean(field))
"versionCode", "size", "checkHours" -> o.put(field, o.getLong(field) + 1)
"sha256" -> o.put(field, "cd".repeat(32))
else -> o.put(field, o.getString(field) + "x")
}
}
val result = client(FakeHttp(UpdateHttp.Response(200, body))).check("one.shx.strongswan.ext", 1, "release", null, 34, null)
assertThat(result).describedAs(field).isInstanceOf(UpdateApiResult.Failure::class.java)
assertThat((result as UpdateApiResult.Failure).kind).describedAs(field).isEqualTo(UpdateError.SIGNATURE)
}
}
@Test
fun aStaleManifestIsRejectedEvenWithAValidSignature()
{
val issued = manifest.getJSONObject("fields").getLong("issuedAt")
val result = client(FakeHttp(UpdateHttp.Response(200, responseJson(manifest))), nowSeconds = issued + UpdateApiClient.MAX_SKEW_SECONDS + 60)
.check("one.shx.strongswan.ext", 1, "release", null, 34, null)
assertThat((result as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE)
}
@Test
fun aWrongPublicKeyFailsAndServerErrorsAreClassified()
{
val http = FakeHttp(UpdateHttp.Response(200, responseJson(manifest)))
val wrongKey = UpdateApiClient("https://api.example/api/v1", hmacKey, Base64.Default.encode(ByteArray(32) { 7 }), "install-0001", http) { 1760438401_000L }
assertThat((wrongKey.check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.SIGNATURE)
// 404/5xx: сеть или сервер, допускается запасной путь; 401/429: нет
assertThat((client(FakeHttp(UpdateHttp.Response(503, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK)
assertThat((client(FakeHttp(UpdateHttp.Response(404, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.NETWORK)
assertThat((client(FakeHttp(UpdateHttp.Response(401, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER)
assertThat((client(FakeHttp(UpdateHttp.Response(429, ""))).check("p", 1, "release", null, 34, null) as UpdateApiResult.Failure).kind).isEqualTo(UpdateError.OTHER)
}
@Test
fun theClientIsConfiguredOnlyWithAllThreeValues()
{
assertThat(UpdateApiClient.isConfigured("https://api", "key", "pub")).isTrue()
assertThat(UpdateApiClient.isConfigured("", "key", "pub")).isFalse()
assertThat(UpdateApiClient.isConfigured("https://api", "", "pub")).isFalse()
assertThat(UpdateApiClient.isConfigured("https://api", "key", "")).isFalse()
}
}
@@ -0,0 +1,84 @@
package org.strongswan.android.logic.updates
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
class UpdateNotesTest
{
private val notes = mapOf("ru" to "Русский", "en" to "English", "zh-tw" to "繁體", "zh" to "中文")
@Test
fun picksAppLanguageThenRussianThenEnglish()
{
assertThat(pickNotes(notes, "en-US", "en")).isEqualTo("English")
assertThat(pickNotes(notes, "zh-TW", "zh")).isEqualTo("繁體")
assertThat(pickNotes(notes, "zh-CN", "zh")).isEqualTo("中文")
assertThat(pickNotes(notes, "de-DE", "de")).isEqualTo("Русский")
assertThat(pickNotes(mapOf("en" to "English", "ru" to " "), "de-DE", "de")).isEqualTo("English")
assertThat(pickNotes(emptyMap(), "ru-RU", "ru")).isNull()
}
@Test
fun everyNonEmptyLineIsAnItem()
{
assertThat(noteLines("Серверы\n\n - История трафика \r\n• Исправлен сбой\n")).containsExactly("Серверы", "История трафика", "Исправлен сбой")
assertThat(noteLines("Одна строка. Без переносов.")).containsExactly("Одна строка. Без переносов.")
assertThat(noteLines(null)).isEmpty()
}
@Test
fun copiesWithDigestAndProgress()
{
val data = ByteArray(200_000) { it.toByte() }
val out = ByteArrayOutputStream()
val digest = MessageDigest.getInstance("SHA-256")
var last = 0L
val total = copyDownload(ByteArrayInputStream(data), out, digest, { false }) { last = it }
assertThat(total).isEqualTo(data.size.toLong())
assertThat(last).isEqualTo(data.size.toLong())
assertThat(out.toByteArray()).isEqualTo(data)
assertThat(digest.digest()).isEqualTo(MessageDigest.getInstance("SHA-256").digest(data))
}
@Test
fun cancelStopsBetweenReads()
{
var cancelled = false
val out = ByteArrayOutputStream()
assertThatThrownBy {
copyDownload(ByteArrayInputStream(ByteArray(300_000)), out, MessageDigest.getInstance("SHA-256"), { cancelled }) { cancelled = true }
}.isInstanceOf(CancellationException::class.java)
/* one 64 KiB chunk, then the loop stopped */
assertThat(out.size()).isEqualTo(64 * 1024)
}
@Test
fun failedReadAfterCancelIsACancellation()
{
var cancelled = false
/* what disconnect() does to a blocked read */
val broken = object : InputStream()
{
override fun read(): Int = throw IOException("socket closed")
override fun read(b: ByteArray, off: Int, len: Int): Int
{
cancelled = true
throw IOException("socket closed")
}
}
assertThatThrownBy {
copyDownload(broken, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { cancelled }) {}
}.isInstanceOf(CancellationException::class.java)
/* without a cancel the network error stays a network error */
assertThatThrownBy {
copyDownload(object : InputStream() { override fun read(): Int = throw IOException("reset") }, ByteArrayOutputStream(), MessageDigest.getInstance("SHA-256"), { false }) {}
}.isInstanceOf(IOException::class.java)
}
}
@@ -0,0 +1,43 @@
{
"note": "ТЕСТОВЫЙ ключ, только для проверки формата подписи.",
"ed25519": {
"privateKeyPem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIA9Mac/nzlc6O6s6/5p7z3BfaggTulVsGYSLJyiG9/H8\n-----END PRIVATE KEY-----\n",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEASE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY=\n-----END PUBLIC KEY-----\n",
"publicKeyRawBase64": "SE5f2zN0uBApPHejtQ7Ud0+AcNN+UjvbBgHYIo/lmnY="
},
"manifest": {
"fields": {
"update": true,
"mandatory": false,
"versionCode": 250003,
"versionName": "2.5.0-beta.3",
"checkHours": 12,
"wifiOnly": true,
"sha256": "abababababababababababababababababababababababababababababababab",
"size": 38900000,
"apkUrl": "https://s3.example/app.apk?sig=1",
"issuedAt": 1760438401
},
"signingInput": "shx-manifest-v1\n1\n0\n250003\n2.5.0-beta.3\nabababababababababababababababababababababababababababababababab\n38900000\nhttps://s3.example/app.apk?sig=1\n1760438401\n12\n1",
"signatureBase64": "KkPqAQX4DwqhgNWdJecOePHsnPzOGCYAGNG25mQganJGQiD2MTl9YSrcHHeO5hoGeLoOa4rEU40rV++YtODGCw=="
},
"noUpdate": {
"fields": {
"update": false,
"mandatory": false,
"issuedAt": 1760438401,
"checkHours": 12,
"wifiOnly": true
},
"signingInput": "shx-manifest-v1\n0\n0\n0\n\n\n0\n\n1760438401\n12\n1",
"signatureBase64": "8Q5+fb7t6ot5iXYBPbv9CCspNzJOEVmQqHLh/6k2lNNtMxeyuTfbldKaKFzQV4orknVKijbf5ChgYDlC5pNvDQ=="
},
"request": {
"hmacKey": "test-hmac-key",
"method": "GET",
"path": "/api/v1/updates/check",
"timestamp": "1760438400",
"signingInput": "GET|/api/v1/updates/check|1760438400",
"signatureBase64": "m6eQoNLWfDApDH2E1/q3aW/qG06t01/2zvxL1RD+Ckw="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# SHX VPN для Android # SHX VPN для Android
Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.18.0 (versionCode 165)**. Форк Android-клиента strongSwan (IKEv2/IPsec), пакет `one.shx.strongswan.ext`. Текущая версия — **2.19.1 (versionCode 169)**.
Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само. Распространяется вне Google Play: APK и манифест обновлений лежат в приватном бакете S3, приложение обновляется само.
- Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду. - Дизайн: [design/README.md](design/README.md) — макет «Финал 2026» и соответствие артбордов коду.
+182
View File
@@ -0,0 +1,182 @@
# План оптимизации энергопотребления SHX VPN (Android)
Основа — разбор кода версии 2.18.0 (165). Пункты, помеченные «проверить», в коде не подтверждены и требуют замера
или чтения кода перед правкой. Цель: меньше пробуждений CPU и радио при подключённом туннеле с погашенным экраном.
## 1. Что уже сделано (не трогать без замеров)
- `HealthCheckManager`: адаптивный режим (экран выключен → интервал не меньше 60 с, экономия заряда или низкий заряд → 300 с, без быстрых опросов),
экспоненциальный бэкофф при сбое, без будильников и wakelock'ов.
- `WatchdogReceiver`: будильники растягиваются — «припаркован» 5 мин / 30 мин (wakeup), «соединён» 5 мин / 60 мин.
- `HomeViewModel`: опрос трафика (1 с) и проб (10 с) только пока видна вкладка «Главная» и VPN соединён (`repeatOnLifecycle` + `whileHomeConnected`).
- `CharonVpnService`: диагностика маршрутов раз в 5 мин только в расширенном режиме; уведомление не чаще раза в 5 с (`NOTIFICATION_MIN_INTERVAL_MS`).
- Фоновая работа: `WorkManager` (обновления 12 ч — с условиями сети и зарядки, профили 24 ч, итоги недели 7 дн.), wakelock'ов в коде нет.
- `DiagLog`: писатель открыт, ротация по счётчику байт, а не по `stat` на каждую строку.
## 1a. Главный упор: лишняя работа CPU
Приоритет всего плана — убрать работу, которую телефон делает без пользы (пробуждения, холостые циклы, повторные вычисления).
Критерий: меньше CPU-времени и пробуждений в час на подключённом туннеле, при погашенном экране — близко к нулю сверх IKE/ESP.
**Подтверждено по коду:**
- **Холостое пробуждение раз в секунду в нативе.** `backend/android_service.c`: цикл чтения TUN делает `select()` с таймаутом 1 с
(«check every second if tunfd is still valid») и по таймауту сразу возвращается в очередь заданий — поток просыпается каждую секунду
даже без трафика. Заменить на ожидание без таймера: `poll()` по TUN и `eventfd`/pipe, который будит закрытие TUN (или таймаут 30–60 с как страховка).
Это самый дешёвый и самый бесспорный выигрыш: один поток, ~3600 пробуждений в час → единицы.
- Движок прямых потоков (`routing/direct_engine.c`) уже адаптивен: простой растягивает `poll` до 4 с (1 с, пока есть TCP-потоки).
Остаётся: не вызывать `tcp->check_timers` / `sys_check_timeouts` и не пересобирать `pollfd` в холостых раундах, довести холостой интервал
до 10–30 с при погашенном экране (будить через `wake`-fd при новом пакете — он уже есть).
- Java: `ScenarioEngine.poll` (4 с, IPC в `UsageStatsManager`), `rememberNow()` (1 с), опрос трафика 1 с — только при видимой «Главной», это нормально.
- `RoutingManager.refreshSites` каждые 30 мин резолвит все домены — разовая нагрузка на радио и CPU, но без пользы при погашенном экране.
- `DiagLog`: каждая строка — `format.format(Date())` + `toByteArray` + `flush()` (системный вызов). При частых событиях сети это лишний CPU и запись во флеш.
**Проверить замером (гипотезы):**
- сколько CPU съедает разбор/поиск по базе адресов РФ (`RuIpListManager`, `IPRangeSet`) при загрузке и обновлении, и не пересобирается ли набор без изменений
(сравнивать хэш до применения, не вызывать `pushUserspaceRouting` без реального диффа);
- Compose: лишние рекомпозиции «Главной» (счётчик трафика раз в секунду трогает большое дерево?) — Layout Inspector / recomposition counts;
- число одновременных `NetworkCallback` (см. C) — каждое событие сети запускает дублирующую работу в семи местах.
## 2. Фаза 0 — замер базовой линии (до любых правок)
Без цифр правки ниже — гипотезы. Сценарии (эмулятор не годится, нужен реальный телефон):
1. Ночь: VPN соединён, экран выключен, Wi-Fi, 8 ч. То же на мобильной сети.
2. Те же условия с включёнными: проверкой здоровья, сценарием «приложение открыто», сайтами в правилах, «сайты РФ напрямую».
3. День: экран включён, вкладки «Главная» / «Маршруты» / «Диагностика».
Инструменты: `adb shell dumpsys batterystats --reset` → прогон → `dumpsys batterystats one.shx.strongswan.ext`,
`dumpsys alarm` (число и тип будильников пакета), `dumpsys jobscheduler`, Perfetto (пробуждения CPU, радио), Battery Historian.
Фиксировать: mAh за час, число wakeup-будильников в час, время активного радио, CPU нативного роутера.
## 3. Кандидаты на оптимизацию (по убыванию ожидаемого эффекта)
### A. Проверка здоровья: пассивные сигналы вместо проб (сделано в этой ветке)
`HealthCheckManager` каждые 30 с (по умолчанию в `HealthConfig`; проверить фактический дефолт в UI настроек) шлёт HTTP/ICMP-пробы
через туннель — каждая держит радио. Идеи:
- пропускать цикл, если по `TrafficStats` за интервал был входящий трафик через туннель (туннель очевидно жив);
- при выключенном экране: один таргет вместо трёх, интервал 5 мин;
- не стартовать цикл вообще, пока устройство в Doze — не делалось: корутинные таймеры и так не будят спящий CPU.
Реализовано: в адаптивном режиме цикл пропускает пробы, если за интервал через туннель пришло ≥ 4 КБ входящих данных
(`CharonVpnService.queryTraffic()`), но реальная проба идёт не реже раза в 5 минут; при наблюдаемом сбое, ожидающем решении,
агрессивном режиме или неизвестном счётчике пропуска нет. При выключенном экране проверяется один адрес вместо трёх.
Сама проверка здоровья включается пользователем (`HealthConfig.enabled = false` по умолчанию), поэтому выигрыш — для тех, кто её включил.
### B. Keepalive и будильники strongSwan — проверено, код не менялся
Умолчание уже экономное: `charonservice.c` ставит `charon.keep_alive = 45` с (`ANDROID_KEEPALIVE_INTERVAL`, у upstream-strongSwan 20 с)
и DPD-запас 20 с; профильный `nat_keepalive` (10–120 с) его переопределяет. Keepalive уходит только когда исходящего трафика не было.
Поднимать 45 с выше без замеров нельзя: UDP-таймауты NAT операторов бывают 30–60 с, туннель «жив», но молчит — это прямое нарушение условия раздела 5.
Что остаётся: замерить число будильников (`dumpsys alarm`) за ночь и, если вклад велик, добавить в профиль **необязательную** настройку,
не меняя умолчание; рекомендовать значения по результатам теста на конкретной сети пользователя.
### C. Сетевые колбэки — единый хаб отклонён, исправлен один шумный
Разбор показал, что хаб не нужен: `NetworkMonitor` и `WhiteListManager` уже отсекают тики уровня сигнала в `onCapabilitiesChanged`,
`PauseManager` регистрируется только на время паузы, проверка здоровья — только при включённой функции и соединённом VPN,
а `NetworkManager` и `UnderlyingNetworkTracker` в `CharonVpnService` — часть самого туннеля (трогать нельзя по разделу 5).
Выигрыш от объединения — единицы вызовов Binder на смену сети при заметном риске для автоподключения и белых списков.
Исправлено единственное реальное место: `HomeViewModel.onCapabilitiesChanged` на каждый тик сигнала Wi-Fi заново читал SSID (системный вызов);
теперь состояние пересчитывается только при смене транспорта.
### D. Опрос переднего приложения в сценариях (средний эффект, низкая сложность)
`ScenarioEngine.poll` — каждые 4 с `UsageStatsManager.queryEvents` (IPC), пока есть сценарий с триггером «приложение».
При выключенном экране выборка пропускается, но `postDelayed` продолжает тикать.
- остановить цикл по `ACTION_SCREEN_OFF`, запускать по `ACTION_SCREEN_ON`;
- увеличить интервал до 8–10 с, если foreground-приложение не менялось несколько циклов.
### E. Обновление адресов сайтов из правил (средний эффект, низкая сложность)
`RoutingManager.refreshSites` каждые 30 мин резолвит все домены правил, пока соединён VPN, независимо от экрана.
- пока экран выключен — не резолвить; обновить один раз при `ACTION_SCREEN_ON`, если прошло больше `SITE_REFRESH_MS`;
- резолвить только домены, чьи TTL истекли.
### F. UI-циклы после ухода с экрана (низкий эффект, низкая сложность)
Проверить, что все циклы отвязаны от жизненного цикла (через `repeatOnLifecycle(STARTED)`):
`RoutingScreen` (5 с, уже на `repeatOnLifecycle`), `DiagnosticsScreen` (5 с), `ProfilesScreen` и `ServerActivity` (30 с, 1,5 с),
`RecentAppsScreen` (10 с), `LogActivity` (800 мс), `rememberNow()` в `HomeStatus.kt` (тик каждую секунду, пока composable жив).
Отдельно: `rememberNow()` должен тикать только если на экране есть обратный отсчёт паузы или время подключения.
### G. Нативный роутер «сайты РФ напрямую» (неизвестный эффект, замер)
Пользовательский роутер (`getRoutingStats`, relay UDP/TCP) обрабатывает пакеты в процессе. Замерить CPU-время потоков
в Perfetto в режимах «Стабильно» и «Гибко» на типовой нагрузке (видео, мессенджер). Если вклад заметный — смотреть размеры очередей,
размер буферов, пакетную обработку и спин-ожидание в нативной части (`app/src/main/jni`).
### H. Мелочи
- `DiagLog`: flush на каждую строку нужен для живучести; при выключенном экране можно понизить подробность (watchdog, network-change)
или не логировать повторы.
- Проверить `WhiteListManager` (перепроверки `recheckJob`, `refreshIfStale`) на периодические пробы при выключенном экране.
- Подсказка пользователю на экране «Батарея» (`BatteryActivity`): показывать, какие функции (проверка здоровья, сценарии по приложению)
заметно расходуют заряд и как их смягчить.
## 4. Порядок работ
1. Фаза 0: базовая линия на реальном телефоне (раздел 2); для CPU добавить `adb shell top -H -p <pid>` / Perfetto по потокам (счёт пробуждений потока чтения TUN).
2. Быстрые правки с низким риском: секундный `select` TUN (раздел 1a), D, E, F, `DiagLog` — один релиз, сравнить с базой.
3. A (пассивная проверка здоровья) и B (keepalive) — отдельные релизы, каждый со своим замером; B только после проверки на сетях операторов.
4. C (хаб колбэков) — рефакторинг, после A/B; регрессия по сценариям смены сети (Wi-Fi ↔ мобильная, роуминг, белые списки, пауза).
5. G — по результатам Perfetto.
6. После каждой правки: тот же прогон из фазы 0; принимается, если mAh/ч и число будильников не растут, а надёжность не падает.
## 5. Жёсткое условие: туннель не должен падать в фоне
Экономия не оправдывает потери туннеля при свёрнутом приложении, погашенном экране или Doze. Правила для каждой правки:
- **Не трогать** то, что держит процесс и туннель живыми: foreground-сервис `CharonVpnService` (`startForegroundSafe`), будильники `Scheduler`
(NAT-keepalive, rekey), `FgsStartRetry`, страховочные будильники `WatchdogReceiver`, DPD charon. Их интервалы не растягивать «заодно».
- **Растягивать можно только** вторичные проверки поверх IKE DPD (пробы здоровья, опросы UI/сценариев, диагностика, DNS сайтов).
Любое растяжение обязано иметь быстрый выход по событию: смена сети, `ACTION_SCREEN_ON`, потеря валидации VPN-сети (`vpnCallback`).
- **TUN `select` (1а):** секундный таймаут сейчас ещё и ловит закрытие TUN (`EBADF`). Замена обязана будить поток при закрытии
(`eventfd` пишется в `close`/реконнекте/реавторизации) — иначе поток зависнет на закрытом дескрипторе, и туннель не переподнимется.
Страховочный таймаут оставить (30–60 с), не убирать совсем.
- **Keepalive (B):** менять умолчание только после проверки на реальных операторах; слишком большой интервал — NAT закрывает соединение, туннель «жив», но молчит.
- **Пробы здоровья (A):** пассивный сигнал «трафик идёт» не должен подавлять проверку, если исходящий трафик есть, а входящего нет.
- **Приёмка каждого релиза** (кроме замеров): ночь с погашенным экраном — туннель не разорван, входящий пуш/пинг проходит утром без ручных действий;
`dumpsys deviceidle force-idle` → выход из Doze → трафик идёт; смена Wi-Fi ↔ мобильная сеть при погашенном экране; убийство процесса
(`adb shell am kill`) → watchdog поднимает туннель; `adb logcat -b crash` чист. Не проходит хотя бы один пункт — правка откатывается.
## 6. Риски и ограничения
- Надёжность туннеля важнее экономии: правки A, B, E не должны ухудшать восстановление после смены сети и Doze
(смоделировать `adb shell dumpsys deviceidle force-idle`, отключение и возврат сети, подмену SSID).
- minSdk 24: `setExactAndAllowWhileIdle` и ограничения точных будильников (Android 12+/13+) уже учтены в `Scheduler` и `WatchdogReceiver`; не ломать.
- Релиз — по процедуре из `docs/README.md` (smoke на AVD `shx36`, выгрузка в S3 только с подтверждения).
- Строки UI, если появятся (подсказки в «Батарее»), добавлять сразу во все семь `strings.xml`.
## 7. Версия 2.19.0: надёжность и лишняя работа (по результатам разбора багов)
**Стабильность соединения**
- `Scheduler` (будильники keepalive и rekey): `ELAPSED_REALTIME_WAKEUP` вместо `RTC_WAKEUP` — сдвиг часов (NTP, ручная смена, пояс)
больше не уводит срабатывание на часы вперёд. Очередь вынесена в `ScheduledJobQueue` (тесты): задания в окне 2 с делят один будильник
(раньше срабатывает не более чем на 2 с, поздно — никогда; charon ставит задания дольше 3 с). Каждый сэкономленный будильник — слот в квоте
allow-while-idle в Doze. Интервалы и keepalive 45 с не менялись. В `diag.log` один раз пишется, точные ли будильники.
- `BootReceiver` (`BOOT_COMPLETED`, `TIME_SET`, `TIMEZONE_CHANGED`): после перезагрузки и смены времени заново взводит будильник пауз
(раньше пауза после перезагрузки не заканчивалась никогда) и будильник окон сценариев, правила решают заново. Туннель сам не стартует;
профиль без сохранённого пароля по-прежнему не запускается.
- `ScenarioReceiver` ждёт привязки `VpnStateService` (до 8 с) через `goAsync`; раньше срабатывание свежего процесса терялось. `restoreIfApplied`
стирает запись о применённом сценарии только когда сервис есть.
- Будильник окон сценариев: точный, если разрешено (как у паузы).
- Сетевые сценарии (Wi-Fi, мобильная сеть, роуминг) слушают сеть и без включённых правил сетей (`AutoConnectManager.scenariosChanged`).
- Watchdog, поднявший туннель авто-сессии в свежем процессе, помечает сессию авто (`markAutoSessionRestored`): туннель паркуется на доверенной
Wi-Fi, а не работает всю ночь. Постоянный флаг не вводился сознательно: устаревший флаг запарковал бы туннель, запущенный вручную.
- `NetworkManager`: набор сетей вместо счётчика (повтор `onAvailable` или пропуск `onLost` не сдвигают состояние); подряд идущие одинаковые
события схлопываются в одно.
**Расход ресурсов**
- `direct_engine.c`: `tcp_active` сбрасывается после холостого раунда без TCP-потоков (`flow_count` в `direct_tcp.c` объявлен, но не был
реализован — теперь реализован), холостой интервал снова растягивается до 4 с; ветка «TUN не привязан» наращивает интервал, а не опрашивает
4 раза в секунду; `reprotect` очищает отрицательный кэш и при спящем lwIP.
- `TrafficHistory`: история в памяти, на диск раз в 5 мин, при остановке и смене дня; с погашенным экраном опрос раз в минуту; виджет обновляется
только при включённом экране. Теряется не более 5 мин статистики при убийстве процесса.
**Сознательно не делали (нет выигрыша или он не стоит риска)**
- Проверка здоровья уже растягивает паузы до штатного интервала и выключена по умолчанию, отдельная «остановка на мёртвом туннеле» не нужна.
- Проверка белых списков: цепочка идёт только пока белый список обнаружен, шаг не больше 5 мин; удлинение при погашенном экране отложило бы
возврат туннеля на минуты ради долей процента.
- Сеть без процесса (`registerNetworkCallback` на `PendingIntent`) — гипотеза не проверена на устройстве.
- `UnderlyingNetworkTracker` и SSID для сценариев из кэша `NetworkMonitor` — нужен лог с реальных сетей.
**Известное ограничение:** без «Будильников и напоминаний» keepalive в глубоком Doze остаётся под квотой; склейка заданий лишь освобождает слоты.
**Найдено при smoke-тесте 2.19.0:** `CharonVpnService` при старте по `Intent` без пароля (watchdog, конец паузы, `FgsStartRetry`) затирал сохранённый в профиле пароль значением `null`;
туннель после смерти процесса не возвращался («Password unavailable»). Теперь пароль из `Intent` подставляется, только если он там есть.
Проверено на эмуляторе `shx36` (профиль на 192.0.2.1, туннель не поднимается, проверялась логика): `am crash` → watchdog поднимает сервис (~80 с, окно неточного
будильника); сценарий «Wi-Fi → включить» срабатывает по событию сети при выключенных правилах сетей (~10 с); будильник сценария по времени будит убитый процесс,
`ScenarioReceiver` ждёт сервис и возвращает прежнее состояние VPN. Без реального сервера не проверены keepalive/rekey, NAT-таймауты и энергопотребление.
@@ -0,0 +1,107 @@
#!/usr/bin/env node
// Загрузка собранного APK в панель SHX VPN с компьютера сборки. Нужен только Node 18+, без зависимостей.
//
// node upload-apk.mjs app-release.apk --type phone --channel beta [--publish] [--rollout 100] [--notes "Что нового"]
//
// Окружение:
// SHX_URL адрес консоли, например https://apk.shnt.top
// SHX_TOKEN токен CI из «Настройки → Токены» (upload — только черновик, publish — можно публиковать)
//
// versionName и versionCode берутся из output-metadata.json, который Gradle кладёт рядом с APK,
// либо из --version-name / --version-code.
//
// Шаги: билет на загрузку → файл напрямую в S3 по presigned PUT → сервер проверяет SHA-256 и оформляет версию.
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { parseArgs } from "node:util";
const { values: o, positionals } = parseArgs({
allowPositionals: true,
options: {
type: { type: "string" },
channel: { type: "string", default: "beta" },
"version-name": { type: "string" },
"version-code": { type: "string" },
notes: { type: "string", default: "" },
"notes-en": { type: "string", default: "" },
publish: { type: "boolean", default: false },
rollout: { type: "string", default: "100" },
help: { type: "boolean", short: "h", default: false },
},
});
const die = (msg) => { console.error("✗ " + msg); process.exit(1); };
if (o.help || positionals.length !== 1) {
console.log("node upload-apk.mjs <файл.apk> --type <id типа> [--channel beta|release] [--publish] [--rollout 1-100] [--notes текст] [--version-name 2.20.0 --version-code 220]");
process.exit(o.help ? 0 : 1);
}
// Windows: переменные, заданные через [Environment]::SetEnvironmentVariable(..., "User"), видны только новым
// процессам. Если терминал (или Gradle daemon) открыт раньше, читаем их из реестра пользователя.
function userEnv(name) {
if (process.env[name]) return process.env[name];
if (process.platform !== "win32") return "";
try {
const out = execFileSync("reg", ["query", "HKCU\\Environment", "/v", name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
return /REG_(?:EXPAND_)?SZ\s+(.*)$/m.exec(out)?.[1]?.trim() ?? "";
} catch {
return "";
}
}
const base = userEnv("SHX_URL").replace(/\/$/, "");
const token = userEnv("SHX_TOKEN");
if (!base) die("Задайте SHX_URL (адрес консоли)");
if (!token) die("Задайте SHX_TOKEN (Настройки → Токены в консоли)");
if (!o.type) die("Укажите --type (id типа приложения из раздела «Типы приложений»)");
if (!["beta", "release"].includes(o.channel)) die("--channel: beta или release");
const rolloutPct = Number(o.rollout);
if (!Number.isInteger(rolloutPct) || rolloutPct < 1 || rolloutPct > 100) die("--rollout: целое от 1 до 100");
const file = positionals[0];
if (!existsSync(file)) die("Нет файла " + file);
let versionName = o["version-name"];
let versionCode = o["version-code"] ? Number(o["version-code"]) : undefined;
const meta = join(dirname(file), "output-metadata.json");
if ((!versionName || !versionCode) && existsSync(meta)) {
const el = JSON.parse(readFileSync(meta, "utf8")).elements?.[0];
versionName ??= el?.versionName;
versionCode ??= el?.versionCode;
}
if (!versionName || !Number.isInteger(versionCode)) die("Не удалось определить версию: передайте --version-name и --version-code");
const bytes = readFileSync(file);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const mb = (statSync(file).size / 1048576).toFixed(1);
async function api(path, body) {
const r = await fetch(base + path, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) die(`${path}: ${r.status} ${data?.error?.message ?? r.statusText}`);
return data;
}
console.log(`→ ${o.type} / ${o.channel} / ${versionName} (${versionCode}), ${mb} МБ, sha256 ${sha256.slice(0, 12)}…`);
const ticket = await api("/api/admin/versions/uploads", { typeId: o.type, channel: o.channel, versionName, versionCode });
if (bytes.length > ticket.maxBytes) die("Файл больше допустимого размера");
const put = await fetch(ticket.url, { method: ticket.method, headers: ticket.headers, body: bytes });
if (!put.ok) die(`Загрузка в хранилище: ${put.status} ${await put.text().catch(() => "")}`.slice(0, 300));
console.log("✓ файл в хранилище");
const v = await api("/api/admin/versions", {
uploadId: ticket.uploadId,
sha256,
notes: { ru: o.notes, en: o["notes-en"] },
publish: o.publish,
rolloutPct,
});
console.log(`✓ версия оформлена: ${v.versionName}, статус ${v.status}${o.publish ? `, раскатка ${rolloutPct}%` : " (черновик: опубликуйте в консоли)"}`);
console.log(` ${base}/versions/${v.id}`);